Files
Ransom-s-wake/game/world/sites/bathymetric_terrain_actor.gd
OlivierandClaude Fable 5.1 122f3f5800 feat(world): render steep tiles on a smooth bicubic surface, sampled in parallel
Tiles holding a slope over 35 degrees are meshed at two vertices per cell
on a clamped Catmull-Rom surface that passes through every grid node, so
cliffs and ledges lose the facets of the heightfield grid while the
collision stays on the grid. The domain field exposes the smooth
elevation and a separable, per-phase-weighted block sampler with linear
edge extrapolation and a one-cell margin for continuous normals; the actor
samples tiles on the worker thread pool and builds meshes afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:09:42 +02:00

201 lines
7.0 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
## 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)