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 ## Tiles holding a cell steeper than this are rendered on the smooth surface at a finer ## step, which removes the facets of the heightfield grid on cliffs and ledges. The ## collision stays on the grid: the smooth surface passes through every grid node. const STEEP_TILE_SLOPE_DEGREES := 35.0 const STEEP_TILE_SUBDIVISION := 2 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() ## Mesh points per cell edge of a tile: 1 for a flat tile, STEEP_TILE_SUBDIVISION for a ## steep one. func tile_subdivision(tile_index: int) -> int: if _tiles_root == null or tile_index < 0 or tile_index >= _tiles_root.get_child_count(): return 0 return int(_tiles_root.get_child(tile_index).get_meta(&"subdivision", 1)) 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 ## The geometry of one tile, computed off the main thread, then turned into a mesh. class TileGeometry extends RefCounted: var cells := Rect2i() # position = first column / row, end = last column / row var subdivision := 1 var centre := Vector3.ZERO var vertices := PackedVector3Array() var normals := PackedVector3Array() func _build_tiles() -> void: _tiles_root = Node3D.new() _tiles_root.name = "Tiles" add_child(_tiles_root) var tiles: Array[TileGeometry] = [] 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) var tile := TileGeometry.new() tile.cells = Rect2i(start_column, start_row, end_column - start_column, end_row - start_row) tiles.append(tile) start_column = end_column start_row = end_row # The per-tile sampling is pure (reads the field only), so the tiles are computed in # parallel; meshes and nodes are then built on the main thread in tile order. var group := WorkerThreadPool.add_group_task( _compute_tile_geometry.bind(tiles), tiles.size(), -1, true, "seabed tiles" ) WorkerThreadPool.wait_for_group_task_completion(group) for tile in tiles: _tiles_root.add_child(_build_tile(tile)) func _compute_tile_geometry(tile_index: int, tiles: Array[TileGeometry]) -> void: var tile := tiles[tile_index] var start_column := tile.cells.position.x var start_row := tile.cells.position.y var end_column := tile.cells.end.x var end_row := tile.cells.end.y var corner_min := _field.cell_world_position(start_column, start_row) var corner_max := _field.cell_world_position(end_column, end_row) tile.centre = Vector3( (corner_min.x + corner_max.x) * 0.5, 0.0, (corner_min.z + corner_max.z) * 0.5 ) var normals := _field.cell_block_normals(start_column, end_column, start_row, end_row) tile.subdivision = STEEP_TILE_SUBDIVISION if _is_steep(normals) else 1 var vertices: PackedVector3Array if tile.subdivision > 1: var block := _field.cell_block_subdivided(start_column, end_column, start_row, end_row, tile.subdivision) vertices = block.positions normals = block.normals else: vertices = _field.cell_block_positions(start_column, end_column, start_row, end_row) for index in vertices.size(): vertices[index] -= tile.centre tile.vertices = vertices tile.normals = normals func _build_tile(tile: TileGeometry) -> MeshInstance3D: var subdivision := tile.subdivision var indices := PackedInt32Array() var columns_in_tile := tile.cells.size.x * subdivision + 1 for row in range(tile.cells.size.y * subdivision): for column in range(tile.cells.size.x * subdivision): 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] = tile.vertices arrays[Mesh.ARRAY_NORMAL] = tile.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" % [tile.cells.position.x / TILE_CELLS, tile.cells.position.y / TILE_CELLS] instance.mesh = mesh instance.set_meta(&"subdivision", subdivision) instance.material_override = _material instance.position = tile.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 static func _is_steep(normals: PackedVector3Array) -> bool: var steep_cosine := cos(deg_to_rad(STEEP_TILE_SLOPE_DEGREES)) for normal in normals: if normal.y < steep_cosine: return true return false 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)