Files
Ransom-s-wake/game/domain/sites/terrain_height_field.gd
T
OlivierandClaude Fable 5.1 0f9918224a
Quality Gate / Quality Gate (pull_request) Failing after 2m58s
feat(world): seabed flora and encrusting growth on rock
SeabedFloraActor scatters seagrass tufts on flat sand in the lit band and
sea fans on rock between 15 and 60 m, deterministically from the site id,
one MultiMesh per tile and kind, with procedural alpha-cut meshes and
textures (SeabedFloraAssets), vertex sway and wrapped lighting, no
collision and no sonar presence. Placement reads the field arrays
directly and runs on the worker thread pool. The seabed shader grows
turf algae on sunlit rock, coralline crusts in patches and encrusting
sponges in the shade, fading out between 40 and 75 m. The water-column
optics move to a shared shader include.

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

384 lines
14 KiB
GDScript

class_name TerrainHeightField
extends RefCounted
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
var _data: SiteTerrainData
var _minimum_elevation_m := 0.0
var _maximum_elevation_m := 0.0
static func from_data(data: SiteTerrainData) -> TerrainHeightField:
if data == null or not data.validation_error().is_empty():
return null
var field := TerrainHeightField.new()
field._data = data
field._minimum_elevation_m = data.elevations_m[0]
field._maximum_elevation_m = data.elevations_m[0]
for elevation: float in data.elevations_m:
field._minimum_elevation_m = minf(field._minimum_elevation_m, elevation)
field._maximum_elevation_m = maxf(field._maximum_elevation_m, elevation)
return field
func columns() -> int:
return _data.columns
func rows() -> int:
return _data.rows
func cell_size_m() -> float:
return _data.cell_size_m
func origin_m() -> Vector2:
return Vector2(_data.origin_x_m, _data.origin_z_m)
func bounds_m() -> Rect2:
return Rect2(
_data.origin_x_m,
_data.origin_z_m,
float(_data.columns - 1) * _data.cell_size_m,
float(_data.rows - 1) * _data.cell_size_m
)
func contains(x_m: float, z_m: float) -> bool:
return bounds_m().has_point(Vector2(x_m, z_m))
func minimum_elevation_m() -> float:
return _minimum_elevation_m
func maximum_elevation_m() -> float:
return _maximum_elevation_m
## Row-major elevations (index = row * columns + column), the HeightMapShape3D layout.
func elevations_m() -> PackedFloat32Array:
return _data.elevations_m
## Row-major sonar materials, same layout as elevations_m.
func materials() -> PackedByteArray:
return _data.materials
func elevation_at_cell(column: int, row: int) -> float:
return _data.elevations_m[_data.index_of(_clamp_column(column), _clamp_row(row))]
func material_at_cell(column: int, row: int) -> int:
return _data.materials[_data.index_of(_clamp_column(column), _clamp_row(row))]
func cell_world_position(column: int, row: int) -> Vector3:
return Vector3(
_data.origin_x_m + float(column) * _data.cell_size_m,
elevation_at_cell(column, row),
_data.origin_z_m + float(row) * _data.cell_size_m
)
func normal_at_cell(column: int, row: int) -> Vector3:
return _normal_at(_data.elevations_m, _clamp_column(column), _clamp_row(row))
## World positions of the cells [start_column..end_column] x [start_row..end_row]
## (bounds included, clamped to the grid), row-major. Same values as cell_world_position.
func cell_block_positions(start_column: int, end_column: int, start_row: int, end_row: int) -> PackedVector3Array:
var first_column := _clamp_column(start_column)
var last_column := _clamp_column(end_column)
var first_row := _clamp_row(start_row)
var last_row := _clamp_row(end_row)
var elevations := _data.elevations_m
var columns := _data.columns
var cell_size := _data.cell_size_m
var positions := PackedVector3Array()
positions.resize((last_column - first_column + 1) * (last_row - first_row + 1))
var index := 0
for row in range(first_row, last_row + 1):
var z := _data.origin_z_m + float(row) * cell_size
var row_start := row * columns
for column in range(first_column, last_column + 1):
positions[index] = Vector3(
_data.origin_x_m + float(column) * cell_size,
elevations[row_start + column],
z
)
index += 1
return positions
## Normals of the same cell block, row-major. Same values as normal_at_cell.
func cell_block_normals(start_column: int, end_column: int, start_row: int, end_row: int) -> PackedVector3Array:
var first_column := _clamp_column(start_column)
var last_column := _clamp_column(end_column)
var first_row := _clamp_row(start_row)
var last_row := _clamp_row(end_row)
var elevations := _data.elevations_m
var normals := PackedVector3Array()
normals.resize((last_column - first_column + 1) * (last_row - first_row + 1))
var index := 0
for row in range(first_row, last_row + 1):
for column in range(first_column, last_column + 1):
normals[index] = _normal_at(elevations, column, row)
index += 1
return normals
func elevation_at(x_m: float, z_m: float) -> float:
var column_f := clampf(
(x_m - _data.origin_x_m) / _data.cell_size_m,
0.0,
float(_data.columns - 1)
)
var row_f := clampf(
(z_m - _data.origin_z_m) / _data.cell_size_m,
0.0,
float(_data.rows - 1)
)
var column := mini(int(floorf(column_f)), _data.columns - 2)
var row := mini(int(floorf(row_f)), _data.rows - 2)
var tx := column_f - float(column)
var tz := row_f - float(row)
var top := lerpf(elevation_at_cell(column, row), elevation_at_cell(column + 1, row), tx)
var bottom := lerpf(
elevation_at_cell(column, row + 1),
elevation_at_cell(column + 1, row + 1),
tx
)
return lerpf(top, bottom, tz)
## Smooth (Catmull-Rom bicubic) elevation: passes through every cell value, so it
## agrees with elevation_at on the grid nodes and bends between them instead of
## creasing. Reproduces planes exactly, including at the grid edges, where the field
## is extrapolated linearly. Each pass is clamped between its two bracketing nodes,
## so the surface never overshoots a step (no dip at the foot of a block, no bump
## above a ledge). Used to render steep tiles at a finer step.
func smooth_elevation_at(x_m: float, z_m: float) -> float:
var column_f := clampf((x_m - _data.origin_x_m) / _data.cell_size_m, 0.0, float(_data.columns - 1))
var row_f := clampf((z_m - _data.origin_z_m) / _data.cell_size_m, 0.0, float(_data.rows - 1))
var column := mini(int(floorf(column_f)), _data.columns - 2)
var row := mini(int(floorf(row_f)), _data.rows - 2)
var weights_x := _catmull_rom_weights(column_f - float(column))
var weights_z := _catmull_rom_weights(row_f - float(row))
var along := PackedFloat32Array()
along.resize(4)
for offset_z in 4:
var sample_row := row - 1 + offset_z
var p1 := _extrapolated_elevation(column, sample_row)
var p2 := _extrapolated_elevation(column + 1, sample_row)
var along_x := (
weights_x[0] * _extrapolated_elevation(column - 1, sample_row)
+ weights_x[1] * p1
+ weights_x[2] * p2
+ weights_x[3] * _extrapolated_elevation(column + 2, sample_row)
)
along[offset_z] = clampf(along_x, minf(p1, p2), maxf(p1, p2))
var result := weights_z[0] * along[0] + weights_z[1] * along[1] + weights_z[2] * along[2] + weights_z[3] * along[3]
return clampf(result, minf(along[1], along[2]), maxf(along[1], along[2]))
## Positions and normals of one cell block on the smooth surface.
class SurfaceBlock extends RefCounted:
var positions := PackedVector3Array()
var normals := PackedVector3Array()
## The cell block [start_column..end_column] x [start_row..end_row] sampled on the smooth
## surface every 1 / subdivision cell, row-major: (cells * subdivision + 1) points per
## axis. The points on the grid nodes equal cell_world_position; the normals are
## central differences on the smooth surface, continuous across tile borders thanks to
## a one-cell margin. The smooth grid is built once for both arrays.
func cell_block_subdivided(
start_column: int, end_column: int, start_row: int, end_row: int, subdivision: int
) -> SurfaceBlock:
var block := SurfaceBlock.new()
if subdivision <= 1:
block.positions = cell_block_positions(start_column, end_column, start_row, end_row)
block.normals = cell_block_normals(start_column, end_column, start_row, end_row)
return block
var first_column := _clamp_column(start_column)
var last_column := _clamp_column(end_column)
var first_row := _clamp_row(start_row)
var last_row := _clamp_row(end_row)
var fine := _fine_block(first_column, last_column, first_row, last_row, subdivision)
var points_x := (last_column - first_column) * subdivision + 1
var points_z := (last_row - first_row) * subdivision + 1
var padded_x := points_x + 2 * subdivision
var step_m := _data.cell_size_m / float(subdivision)
var x0 := _data.origin_x_m + float(first_column) * _data.cell_size_m
var z0 := _data.origin_z_m + float(first_row) * _data.cell_size_m
var twice_step_m := 2.0 * step_m
var positions := PackedVector3Array()
var normals := PackedVector3Array()
positions.resize(points_x * points_z)
normals.resize(points_x * points_z)
var index := 0
for sub_row in points_z:
var fine_row_start := (sub_row + subdivision) * padded_x + subdivision
var z := z0 + float(sub_row) * step_m
for sub_column in points_x:
var at := fine_row_start + sub_column
positions[index] = Vector3(x0 + float(sub_column) * step_m, fine[at], z)
var slope_x := (fine[at + 1] - fine[at - 1]) / twice_step_m
var slope_z := (fine[at + padded_x] - fine[at - padded_x]) / twice_step_m
normals[index] = Vector3(-slope_x, 1.0, -slope_z).normalized()
index += 1
block.positions = positions
block.normals = normals
return block
func cell_block_positions_subdivided(
start_column: int, end_column: int, start_row: int, end_row: int, subdivision: int
) -> PackedVector3Array:
return cell_block_subdivided(start_column, end_column, start_row, end_row, subdivision).positions
func cell_block_normals_subdivided(
start_column: int, end_column: int, start_row: int, end_row: int, subdivision: int
) -> PackedVector3Array:
return cell_block_subdivided(start_column, end_column, start_row, end_row, subdivision).normals
## Smooth heights of the block padded by one cell on every side, row-major with
## (cells + 2) * subdivision + 1 points per axis. Separable Catmull-Rom with the
## weights of each sub-cell phase computed once: no per-point function calls.
func _fine_block(first_column: int, last_column: int, first_row: int, last_row: int, subdivision: int) -> PackedFloat32Array:
var cells_x := last_column - first_column + 2
var cells_z := last_row - first_row + 2
var source_x := cells_x + 4
var source_z := cells_z + 4
var source_column0 := first_column - 2
var source_row0 := first_row - 2
var fine_x := cells_x * subdivision + 1
var fine_z := cells_z * subdivision + 1
var phase_weights: Array[PackedFloat32Array] = []
for phase in subdivision:
phase_weights.append(_catmull_rom_weights(float(phase) / float(subdivision)))
# Source samples with linear extrapolation outside the grid.
var source := PackedFloat32Array()
source.resize(source_x * source_z)
for sz in source_z:
for sx in source_x:
source[sz * source_x + sx] = _extrapolated_elevation(source_column0 + sx, source_row0 + sz)
# Pass 1: along x, for every source row.
var along := PackedFloat32Array()
along.resize(fine_x * source_z)
for sz in source_z:
var row_start := sz * source_x
var out_start := sz * fine_x
for cell in cells_x:
var p0 := source[row_start + cell]
var p1 := source[row_start + cell + 1]
var p2 := source[row_start + cell + 2]
var p3 := source[row_start + cell + 3]
var low := minf(p1, p2)
var high := maxf(p1, p2)
for phase in subdivision:
var w := phase_weights[phase]
along[out_start + cell * subdivision + phase] = clampf(w[0] * p0 + w[1] * p1 + w[2] * p2 + w[3] * p3, low, high)
along[out_start + cells_x * subdivision] = source[row_start + cells_x + 1]
# Pass 2: along z, for every fine column.
var fine := PackedFloat32Array()
fine.resize(fine_x * fine_z)
for cell in cells_z:
for phase in subdivision:
var w := phase_weights[phase]
var out_row := (cell * subdivision + phase) * fine_x
var r0 := cell * fine_x
var r1 := r0 + fine_x
var r2 := r1 + fine_x
var r3 := r2 + fine_x
for fx in fine_x:
var a1 := along[r1 + fx]
var a2 := along[r2 + fx]
fine[out_row + fx] = clampf(
w[0] * along[r0 + fx] + w[1] * a1 + w[2] * a2 + w[3] * along[r3 + fx],
minf(a1, a2),
maxf(a1, a2)
)
var last_out := cells_z * subdivision * fine_x
var last_source := (cells_z + 1) * fine_x
for fx in fine_x:
fine[last_out + fx] = along[last_source + fx]
return fine
## Cell elevation, extended past the grid by linear extrapolation so the smooth surface
## keeps reproducing planes at the edges.
func _extrapolated_elevation(column: int, row: int) -> float:
var columns := _data.columns
var rows := _data.rows
var c := clampi(column, 0, columns - 1)
var r := clampi(row, 0, rows - 1)
var value := _data.elevations_m[r * columns + c]
if column < 0:
value += float(column) * (_data.elevations_m[r * columns + 1] - _data.elevations_m[r * columns])
elif column > columns - 1:
value += float(column - columns + 1) * (_data.elevations_m[r * columns + columns - 1] - _data.elevations_m[r * columns + columns - 2])
if row < 0:
value += float(row) * (_data.elevations_m[columns + c] - _data.elevations_m[c])
elif row > rows - 1:
value += float(row - rows + 1) * (_data.elevations_m[(rows - 1) * columns + c] - _data.elevations_m[(rows - 2) * columns + c])
return value
static func _catmull_rom_weights(t: float) -> PackedFloat32Array:
var t2 := t * t
var t3 := t2 * t
return PackedFloat32Array([
0.5 * (-t + 2.0 * t2 - t3),
0.5 * (2.0 - 5.0 * t2 + 3.0 * t3),
0.5 * (t + 4.0 * t2 - 3.0 * t3),
0.5 * (-t2 + t3),
])
func material_at(x_m: float, z_m: float) -> int:
var column := int(roundf((x_m - _data.origin_x_m) / _data.cell_size_m))
var row := int(roundf((z_m - _data.origin_z_m) / _data.cell_size_m))
return material_at_cell(column, row)
func reflectivity_for(material: int) -> float:
match material:
SonarMaterial.Value.SAND:
return _data.sand_reflectivity
_:
return _data.rock_reflectivity
## Central differences, one-sided on the grid edges. column and row must be in range.
func _normal_at(elevations: PackedFloat32Array, column: int, row: int) -> Vector3:
var columns := _data.columns
var left := maxi(column - 1, 0)
var right := mini(column + 1, columns - 1)
var north := maxi(row - 1, 0)
var south := mini(row + 1, _data.rows - 1)
var slope_x := (
(elevations[row * columns + right] - elevations[row * columns + left])
/ (float(right - left) * _data.cell_size_m)
)
var slope_z := (
(elevations[south * columns + column] - elevations[north * columns + column])
/ (float(south - north) * _data.cell_size_m)
)
return Vector3(-slope_x, 1.0, -slope_z).normalized()
func _clamp_column(column: int) -> int:
return clampi(column, 0, _data.columns - 1)
func _clamp_row(row: int) -> int:
return clampi(row, 0, _data.rows - 1)