feat(sites): typed terrain data and height field sampler

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Haiku 4.5 committed 2026-10-06 19:45:21 +02:00
1 parent fdbfd773e2
commit 1c1f0b87da
4 files changed
+287

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
class_name SiteTerrainData
extends Resource
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
## Regular single-surface heightfield. Column grows with world +X (east),
## row grows with world +Z (south). Elevations are world Y in metres.
@export var columns: int = 0
@export var rows: int = 0
@export var cell_size_m: float = 1.0
@export var origin_x_m: float = 0.0
@export var origin_z_m: float = 0.0
@export var elevations_m := PackedFloat32Array()
@export var materials := PackedByteArray()
@export var rock_reflectivity: float = 0.72
@export var sand_reflectivity: float = 0.45
func index_of(column: int, row: int) -> int:
return row * columns + column
func validation_error() -> String:
if columns < 2 or rows < 2:
return "terrain needs at least 2 columns and 2 rows"
if not is_finite(cell_size_m) or cell_size_m <= 0.0:
return "terrain cell size must be positive"
if not is_finite(origin_x_m) or not is_finite(origin_z_m):
return "terrain origin must be finite"
var cell_count := columns * rows
if elevations_m.size() != cell_count:
return "terrain elevation count does not match the grid"
if materials.size() != cell_count:
return "terrain material count does not match the grid"
for elevation: float in elevations_m:
if not is_finite(elevation):
return "terrain elevations must be finite"
for material: int in materials:
if not SonarMaterial.is_terrain(material):
return "terrain materials must be rock or sand"
for reflectivity: float in [rock_reflectivity, sand_reflectivity]:
if not is_finite(reflectivity) or reflectivity <= 0.0 or reflectivity > 1.0:
return "terrain reflectivity must be within ]0, 1]"
return ""
+133
View File
@@ -0,0 +1,133 @@
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
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:
var left := elevation_at_cell(column - 1, row)
var right := elevation_at_cell(column + 1, row)
var north := elevation_at_cell(column, row - 1)
var south := elevation_at_cell(column, row + 1)
var span_x := float(_clamp_column(column + 1) - _clamp_column(column - 1)) * _data.cell_size_m
var span_z := float(_clamp_row(row + 1) - _clamp_row(row - 1)) * _data.cell_size_m
var slope_x := (right - left) / span_x
var slope_z := (south - north) / span_z
return Vector3(-slope_x, 1.0, -slope_z).normalized()
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)
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
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)