Rock is normal-mapped triplanar in world space at two scales, sand carries an oriented ripple normal map on the xz plane, and a grain map drives detail occlusion, albedo mottling and roughness. The three maps are seamless, seeded, mipmapped textures built synchronously by SeabedDetailTextures, so no asset is needed and builds are deterministic. The mesh has no tangents: perturbed normals are computed in world space and written to NORMAL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
141 lines
4.6 KiB
GDScript
141 lines
4.6 KiB
GDScript
class_name BathymetricTerrainActor
|
|
extends StaticBody3D
|
|
|
|
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
|
|
const SeabedDetailTextures = preload("res://game/world/sites/seabed_detail_textures.gd")
|
|
const TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader")
|
|
|
|
const TILE_CELLS := 64
|
|
const VISIBILITY_RANGE_END_M := 420.0
|
|
const VISIBILITY_RANGE_MARGIN_M := 60.0
|
|
const DETAIL_TEXTURE_SEED := 4102
|
|
|
|
var _field: TerrainHeightField
|
|
var _material: ShaderMaterial
|
|
var _detail_textures: SeabedDetailTextures
|
|
var _tiles_root: Node3D
|
|
var _collision: CollisionShape3D
|
|
|
|
|
|
func configure(field: TerrainHeightField) -> String:
|
|
if field == null:
|
|
return "terrain field is missing"
|
|
_clear()
|
|
_field = field
|
|
if _detail_textures == null:
|
|
_detail_textures = SeabedDetailTextures.build(DETAIL_TEXTURE_SEED)
|
|
_material = ShaderMaterial.new()
|
|
_material.shader = TERRAIN_SHADER
|
|
_material.set_shader_parameter(&"sand_normal_map", _detail_textures.sand_normal)
|
|
_material.set_shader_parameter(&"rock_normal_map", _detail_textures.rock_normal)
|
|
_material.set_shader_parameter(&"rock_grain_map", _detail_textures.rock_grain)
|
|
_build_tiles()
|
|
_build_collision()
|
|
return ""
|
|
|
|
|
|
func height_field() -> TerrainHeightField:
|
|
return _field
|
|
|
|
|
|
func terrain_material() -> ShaderMaterial:
|
|
return _material
|
|
|
|
|
|
func tile_count() -> int:
|
|
return 0 if _tiles_root == null else _tiles_root.get_child_count()
|
|
|
|
|
|
func sonar_material_at(world_position: Vector3) -> int:
|
|
return _field.material_at(world_position.x, world_position.z)
|
|
|
|
|
|
func sonar_reflectivity_at(world_position: Vector3) -> float:
|
|
return _field.reflectivity_for(sonar_material_at(world_position))
|
|
|
|
|
|
func _clear() -> void:
|
|
if _tiles_root != null:
|
|
remove_child(_tiles_root)
|
|
_tiles_root.free()
|
|
_tiles_root = null
|
|
if _collision != null:
|
|
remove_child(_collision)
|
|
_collision.free()
|
|
_collision = null
|
|
|
|
|
|
func _build_tiles() -> void:
|
|
_tiles_root = Node3D.new()
|
|
_tiles_root.name = "Tiles"
|
|
add_child(_tiles_root)
|
|
var last_column := _field.columns() - 1
|
|
var last_row := _field.rows() - 1
|
|
var start_row := 0
|
|
while start_row < last_row:
|
|
var end_row := mini(start_row + TILE_CELLS, last_row)
|
|
var start_column := 0
|
|
while start_column < last_column:
|
|
var end_column := mini(start_column + TILE_CELLS, last_column)
|
|
_tiles_root.add_child(_build_tile(start_column, end_column, start_row, end_row))
|
|
start_column = end_column
|
|
start_row = end_row
|
|
|
|
|
|
func _build_tile(start_column: int, end_column: int, start_row: int, end_row: int) -> MeshInstance3D:
|
|
var corner_min := _field.cell_world_position(start_column, start_row)
|
|
var corner_max := _field.cell_world_position(end_column, end_row)
|
|
var centre := Vector3(
|
|
(corner_min.x + corner_max.x) * 0.5,
|
|
0.0,
|
|
(corner_min.z + corner_max.z) * 0.5
|
|
)
|
|
var vertices := _field.cell_block_positions(start_column, end_column, start_row, end_row)
|
|
for index in vertices.size():
|
|
vertices[index] -= centre
|
|
var normals := _field.cell_block_normals(start_column, end_column, start_row, end_row)
|
|
var indices := PackedInt32Array()
|
|
var columns_in_tile := end_column - start_column + 1
|
|
for row in range(end_row - start_row):
|
|
for column in range(end_column - start_column):
|
|
var top_left := row * columns_in_tile + column
|
|
var top_right := top_left + 1
|
|
var bottom_left := top_left + columns_in_tile
|
|
var bottom_right := bottom_left + 1
|
|
indices.append_array([top_left, top_right, bottom_left])
|
|
indices.append_array([top_right, bottom_right, bottom_left])
|
|
var arrays: Array = []
|
|
arrays.resize(Mesh.ARRAY_MAX)
|
|
arrays[Mesh.ARRAY_VERTEX] = vertices
|
|
arrays[Mesh.ARRAY_NORMAL] = normals
|
|
arrays[Mesh.ARRAY_INDEX] = indices
|
|
var mesh := ArrayMesh.new()
|
|
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
|
var instance := MeshInstance3D.new()
|
|
instance.name = "Tile_%d_%d" % [start_column / TILE_CELLS, start_row / TILE_CELLS]
|
|
instance.mesh = mesh
|
|
instance.material_override = _material
|
|
instance.position = centre
|
|
instance.visibility_range_end = VISIBILITY_RANGE_END_M
|
|
instance.visibility_range_end_margin = VISIBILITY_RANGE_MARGIN_M
|
|
instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
|
|
return instance
|
|
|
|
|
|
func _build_collision() -> void:
|
|
var shape := HeightMapShape3D.new()
|
|
shape.map_width = _field.columns()
|
|
shape.map_depth = _field.rows()
|
|
shape.map_data = _field.elevations_m()
|
|
_collision = CollisionShape3D.new()
|
|
_collision.name = "Collision"
|
|
_collision.shape = shape
|
|
var bounds := _field.bounds_m()
|
|
_collision.position = Vector3(
|
|
bounds.position.x + bounds.size.x * 0.5,
|
|
0.0,
|
|
bounds.position.y + bounds.size.y * 0.5
|
|
)
|
|
_collision.scale = Vector3(_field.cell_size_m(), 1.0, _field.cell_size_m())
|
|
add_child(_collision)
|