Terrain des sites de plongée : champ de hauteur authoré, Banc du Vigilant, formes variées et rendu sous-marin #4
No files matched your search
@@ -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 ""
|
||||||
@@ -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)
|
||||||
@@ -103,6 +103,16 @@ check_godot_log \
|
|||||||
"production sonar application unit tests" \
|
"production sonar application unit tests" \
|
||||||
"$QUALITY_TEMP_DIRECTORY/sonar-application-unit-console.log"
|
"$QUALITY_TEMP_DIRECTORY/sonar-application-unit-console.log"
|
||||||
|
|
||||||
|
printf 'Quality gate: production site domain unit tests\n'
|
||||||
|
"$GODOT_EXECUTABLE" --headless \
|
||||||
|
--log-file "$QUALITY_TEMP_DIRECTORY/site-domain-unit.log" \
|
||||||
|
--path . \
|
||||||
|
--script tests/unit/run_site_domain_tests.gd \
|
||||||
|
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log"
|
||||||
|
check_godot_log \
|
||||||
|
"production site domain unit tests" \
|
||||||
|
"$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log"
|
||||||
|
|
||||||
printf 'Quality gate: production foundation integration\n'
|
printf 'Quality gate: production foundation integration\n'
|
||||||
"$GODOT_EXECUTABLE" --headless \
|
"$GODOT_EXECUTABLE" --headless \
|
||||||
--log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \
|
--log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
||||||
|
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
|
||||||
|
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
|
||||||
|
|
||||||
|
const TEST_COUNT := 20
|
||||||
|
|
||||||
|
var _failures: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_test_terrain_data_validation()
|
||||||
|
_test_height_field_sampling()
|
||||||
|
|
||||||
|
if _failures.is_empty():
|
||||||
|
print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for failure: String in _failures:
|
||||||
|
push_error(failure)
|
||||||
|
print("Site domain unit tests: %d failed." % _failures.size())
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_terrain(columns: int, rows: int) -> SiteTerrainData:
|
||||||
|
var data := SiteTerrainData.new()
|
||||||
|
data.columns = columns
|
||||||
|
data.rows = rows
|
||||||
|
data.cell_size_m = 2.0
|
||||||
|
data.origin_x_m = -10.0
|
||||||
|
data.origin_z_m = 100.0
|
||||||
|
# Packed arrays are passed by value in GDScript: build locally, then assign.
|
||||||
|
var elevations := PackedFloat32Array()
|
||||||
|
var materials := PackedByteArray()
|
||||||
|
elevations.resize(columns * rows)
|
||||||
|
materials.resize(columns * rows)
|
||||||
|
for row in rows:
|
||||||
|
for column in columns:
|
||||||
|
var index := row * columns + column
|
||||||
|
# Plane sloping down toward +Z (south): -20 at row 0, -2 m per row.
|
||||||
|
elevations[index] = -20.0 - 2.0 * float(row)
|
||||||
|
materials[index] = (
|
||||||
|
SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND
|
||||||
|
)
|
||||||
|
data.elevations_m = elevations
|
||||||
|
data.materials = materials
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
func _test_terrain_data_validation() -> void:
|
||||||
|
var data := _make_terrain(3, 4)
|
||||||
|
_expect(data.validation_error().is_empty(), "regular terrain data is valid")
|
||||||
|
_expect(data.index_of(2, 3) == 11, "index is row-major with columns along X")
|
||||||
|
|
||||||
|
var tiny := _make_terrain(1, 4)
|
||||||
|
_expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns")
|
||||||
|
|
||||||
|
var mismatched := _make_terrain(3, 4)
|
||||||
|
mismatched.elevations_m.resize(5)
|
||||||
|
_expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid")
|
||||||
|
|
||||||
|
var non_finite := _make_terrain(3, 4)
|
||||||
|
non_finite.elevations_m[0] = INF
|
||||||
|
_expect(not non_finite.validation_error().is_empty(), "elevations must be finite")
|
||||||
|
|
||||||
|
var metal_terrain := _make_terrain(3, 4)
|
||||||
|
metal_terrain.materials[1] = SonarMaterial.Value.METAL
|
||||||
|
_expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only")
|
||||||
|
|
||||||
|
var bad_cell := _make_terrain(3, 4)
|
||||||
|
bad_cell.cell_size_m = 0.0
|
||||||
|
_expect(not bad_cell.validation_error().is_empty(), "cell size must be positive")
|
||||||
|
|
||||||
|
var bad_reflectivity := _make_terrain(3, 4)
|
||||||
|
bad_reflectivity.sand_reflectivity = 1.5
|
||||||
|
_expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1")
|
||||||
|
_expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_height_field_sampling() -> void:
|
||||||
|
var field := TerrainHeightField.from_data(_make_terrain(3, 4))
|
||||||
|
_expect(field != null, "valid data yields a field")
|
||||||
|
if field == null:
|
||||||
|
return
|
||||||
|
_expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells")
|
||||||
|
_expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size")
|
||||||
|
_expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact")
|
||||||
|
_expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices")
|
||||||
|
_expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge")
|
||||||
|
_expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell")
|
||||||
|
_expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell")
|
||||||
|
_expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction")
|
||||||
|
_expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds")
|
||||||
|
_expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data")
|
||||||
|
|
||||||
|
|
||||||
|
func _expect(condition: bool, label: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
_failures.append(label)
|
||||||
Reference in new issue
Block a user