Files
Ransom-s-wake/tests/integration/run_site_terrain_integration.gd
T
OlivierandClaude Fable 5.1 d7e50f8879 feat(world): depth-coloured water column fog and de-tiled sand ripples
A FogVolume covering the site carries a fog shader whose scattering albedo
follows the same spectral transmittance as the seabed and whose density
grows below the reference depth, so haze darkens and turns indigo with
depth around every object; the global volumetric density moves into the
volume and the far depth fog only covers what lies beyond 128 m. Sand
ripples are bent by a slow domain warp, blended with a larger ripple
scale and faded out between 40 and 60 m, which hides the 3.2 m tiling.

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

307 lines
14 KiB
GDScript

extends SceneTree
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd")
const BathymetricTerrainActor = preload(
"res://game/world/sites/bathymetric_terrain_actor.gd"
)
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
const DiveSite = preload("res://game/world/sites/dive_site.gd")
const SeabedDetailTextures = preload("res://game/world/sites/seabed_detail_textures.gd")
const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd")
const ReferenceBathymetryAcquisition = preload(
"res://game/world/sonar/reference_bathymetry_acquisition.gd"
)
const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn")
const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres"
const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres"
const GROOVE_PROBE_OFFSET_M := 25.0
const TEST_COUNT := 41
var _failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
await _test_actor_matches_field()
await _test_dive_site_scene()
await _test_zone1_site()
await _test_showcase_site()
var exit_code := 0
if _failures.is_empty():
print("Site terrain integration: %d passed, 0 failed." % TEST_COUNT)
else:
for failure: String in _failures:
push_error(failure)
print("Site terrain integration: %d failed." % _failures.size())
exit_code = 1
await process_frame
quit(exit_code)
func _asymmetric_field() -> TerrainHeightField:
var recipe := SiteTerrainBaker.Recipe.new()
recipe.columns = 81
recipe.rows = 65
recipe.cell_size_m = 2.0
recipe.origin_x_m = -80.0
recipe.origin_z_m = -80.0
recipe.floor_elevation_m = -90.0
recipe.primitives = [
SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.0, 0.0, 0.0),
# Tall bump off the diagonal on a non-square grid: any front/back flip or
# row/column transposition shows up.
SiteTerrainBaker.Blob.new(Vector2(-60.0, 20.0), 10.0, 10.0, 18.0),
]
return TerrainHeightField.from_data(SiteTerrainBaker.bake(recipe))
func _test_actor_matches_field() -> void:
var field := _asymmetric_field()
_expect(field != null, "asymmetric test field bakes")
if field == null:
return
var actor := BathymetricTerrainActor.new()
root.add_child(actor)
_expect(actor.configure(field).is_empty(), "actor accepts a valid field")
await physics_frame
await physics_frame
_expect(actor.tile_count() == 2, "80 x 64 cells split into 2 x 1 tiles of at most 64 cells")
var material := actor.terrain_material()
_expect(
material != null
and material.get_shader_parameter(&"sand_normal_map") is ImageTexture
and material.get_shader_parameter(&"rock_normal_map") is ImageTexture
and material.get_shader_parameter(&"rock_grain_map") is ImageTexture,
"terrain material carries the three procedural detail maps"
)
var first_textures := SeabedDetailTextures.build(7)
var second_textures := SeabedDetailTextures.build(7)
_expect(
first_textures.rock_normal.get_image().get_data() == second_textures.rock_normal.get_image().get_data()
and first_textures.sand_normal.get_image().get_data() == second_textures.sand_normal.get_image().get_data(),
"detail maps are deterministic for a seed"
)
_expect(
first_textures.rock_normal.get_width() == SeabedDetailTextures.TEXTURE_SIZE
and first_textures.rock_normal.get_image().has_mipmaps(),
"detail maps are tile-sized with mipmaps"
)
var sand_image := first_textures.sand_normal.get_image()
var left := sand_image.get_pixel(0, 100)
var right := sand_image.get_pixel(SeabedDetailTextures.TEXTURE_SIZE - 1, 100)
_expect(absf(left.r - right.r) < 0.25 and absf(left.g - right.g) < 0.25, "sand ripple map wraps across its seam")
var first_tile := actor.get_node("Tiles").get_child(0) as MeshInstance3D
var arrays: Array = first_tile.mesh.surface_get_arrays(0)
var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var tile_indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var first_face := Plane(
tile_vertices[tile_indices[0]],
tile_vertices[tile_indices[1]],
tile_vertices[tile_indices[2]]
)
_expect(first_face.normal.y > 0.0, "tile triangles are wound clockwise from above (front face up)")
# The first tile spans cells 0..64 on both axes, so vertex index = row * 65 + column.
var worst_vertex_error := 0.0
for cell: Vector2i in [Vector2i(0, 0), Vector2i(10, 50), Vector2i(64, 64)]:
var vertex := first_tile.global_transform * tile_vertices[cell.y * 65 + cell.x]
worst_vertex_error = maxf(worst_vertex_error, vertex.distance_to(field.cell_world_position(cell.x, cell.y)))
_expect(worst_vertex_error < 0.01, "tile vertices sit on their field cells (worst %.4f m)" % worst_vertex_error)
var space := actor.get_world_3d().direct_space_state
var worst_error := 0.0
for cell: Vector2i in [
Vector2i(0, 0), Vector2i(80, 0), Vector2i(0, 64), Vector2i(80, 64),
Vector2i(10, 50), Vector2i(40, 40), Vector2i(64, 20),
]:
var expected := field.cell_world_position(cell.x, cell.y)
var query := PhysicsRayQueryParameters3D.create(
Vector3(expected.x, 50.0, expected.z),
Vector3(expected.x, -200.0, expected.z)
)
var hit := space.intersect_ray(query)
if hit.is_empty():
_failures.append("collision ray at cell %s hits nothing" % cell)
continue
worst_error = maxf(worst_error, absf(float(hit["position"].y) - expected.y))
_expect(worst_error < 0.05, "collision height equals the field height at vertices (worst %.3f m)" % worst_error)
var bump := _collision_height(space, -60.0, 20.0)
var north_east := _collision_height(space, 60.0, -60.0)
_expect(bump > -30.0, "south-west bump is where the field puts it (collision)")
_expect(north_east < -39.0, "north-east stays flat (collision)")
_expect(absf(bump - field.elevation_at(-60.0, 20.0)) < 0.05, "bump height matches the field")
var mid_cell := _collision_height(space, -59.0, 21.0)
_expect(absf(mid_cell - field.elevation_at(-59.0, 21.0)) < 0.3, "collision between vertices follows the field (%.2f m)" % absf(mid_cell - field.elevation_at(-59.0, 21.0)))
_expect(actor.sonar_material_at(Vector3(-52.0, -30.0, 20.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock")
_expect(actor.sonar_material_at(Vector3(40.0, -40.0, 40.0)) == SonarMaterial.Value.SAND, "flat plain reports sand")
_expect(is_equal_approx(actor.sonar_reflectivity_at(Vector3(40.0, -40.0, 40.0)), 0.45), "sand reflectivity from the data")
_expect(actor.configure(field).is_empty() and actor.tile_count() == 2, "reconfiguring rebuilds without duplicating tiles")
_expect(not actor.configure(null).is_empty(), "null field is refused")
actor.queue_free()
func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float:
var hit := space.intersect_ray(
PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z))
)
if hit.is_empty():
return INF
return float(hit["position"].y)
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)
func _test_dive_site_scene() -> void:
var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition
_expect(definition != null, "calibration site definition loads")
if definition == null:
return
_expect(definition.validation_error().is_empty(), "calibration site definition is valid")
var started_usec := Time.get_ticks_usec()
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
root.add_child(site)
_expect(site.configure(definition).is_empty(), "dive site scene accepts the definition")
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
_expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms)
await physics_frame
await physics_frame
_expect(site.terrain().tile_count() > 0, "site builds its terrain tiles")
var water_column := site.get_node_or_null("WaterColumn") as FogVolume
_expect(
water_column != null and water_column.material is ShaderMaterial
and (water_column.material as ShaderMaterial).shader != null,
"site carries a water-column fog volume with its depth shader"
)
_expect(site.placeholder_count() == 2, "calibration target and beacon are placed")
var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D
_expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body")
_expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition")
var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0))
sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z)
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
var request := SonarCaptureRequest.new(
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
)
var result = acquisition.capture(request)
_expect(result.accepted, "reference acquisition scans the baked terrain")
var materials: Dictionary = {}
for sample in result.samples:
materials[sample.material] = true
_expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock")
_expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder")
site.queue_free()
func _test_zone1_site() -> void:
# Let the calibration site queued for deletion leave the physics space first.
await process_frame
var definition := load(ZONE1_SITE_PATH) as DiveSiteDefinition
if definition == null:
_failures.append("zone 1 site definition loads")
return
var started_usec := Time.get_ticks_usec()
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
root.add_child(site)
_expect(site.configure(definition).is_empty(), "dive site scene accepts the Banc du Vigilant")
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
_expect(configure_ms < 3000.0, "zone 1 configures under the 3 s load budget (%.0f ms)" % configure_ms)
await physics_frame
await physics_frame
var space := site.get_world_3d().direct_space_state
var terrain := site.terrain()
var hull := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET)[0]
var hull_body := site.get_node_or_null("%%Placements/%s" % hull.placement_id) as StaticBody3D
var excluded: Array[RID] = []
if hull_body != null:
excluded.append(hull_body.get_rid())
var under_hull := _terrain_height(space, terrain, hull.position_m.x, hull.position_m.z, excluded)
_expect(under_hull < hull.position_m.y, "a ray at the target hits the seabed under the hull centre (%.1f m)" % under_hull)
var entrance := definition.placement(&"saignee_entrance").position_m
var groove_floor := _terrain_height(space, terrain, entrance.x, entrance.z, [])
var beside_groove := _terrain_height(space, terrain, entrance.x + GROOVE_PROBE_OFFSET_M, entrance.z, [])
_expect(groove_floor < beside_groove, "la Saignee is a trench at its entrance (%.1f m vs %.1f m)" % [groove_floor, beside_groove])
var sensor := definition.spawn_transform()
sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z)
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
var request := SonarCaptureRequest.new(
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
)
var result = acquisition.capture(request)
var has_sand := false
for sample in result.samples:
has_sand = has_sand or sample.material == SonarMaterial.Value.SAND
_expect(result.accepted and has_sand, "a reference scan from the zone 1 spawn reads the sand plateau")
site.queue_free()
func _test_showcase_site() -> void:
await process_frame
var definition := load(SHOWCASE_SITE_PATH) as DiveSiteDefinition
_expect(definition != null, "showcase site definition loads")
if definition == null:
return
var started_usec := Time.get_ticks_usec()
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
root.add_child(site)
_expect(site.configure(definition).is_empty(), "dive site scene accepts the Jardin des Formes")
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
_expect(configure_ms < 3000.0, "showcase configures under the 3 s load budget (%.0f ms)" % configure_ms)
await physics_frame
await physics_frame
var space := site.get_world_3d().direct_space_state
var terrain := site.terrain()
var canyon_floor := _terrain_height(space, terrain, -130.0, -135.0, [])
var mesa_top := _terrain_height(space, terrain, -80.0, -90.0, [])
_expect(mesa_top - canyon_floor >= 18.0, "collision rays find the canyon under the mesa (%.1f m vs %.1f m)" % [canyon_floor, mesa_top])
# Sensor above the canyon looking along its second leg (the acquisition probes along
# local +Z): rock walls and sand floor.
var sensor := Transform3D(Basis.IDENTITY, Vector3(-130.0, -15.0, -135.0))
sensor.basis = Basis.looking_at(-Vector3(40.0, 0.0, -30.0).normalized(), Vector3.UP)
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
var request := SonarCaptureRequest.new(
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
)
var result = acquisition.capture(request)
var materials: Dictionary = {}
for sample in result.samples:
materials[sample.material] = true
_expect(
result.accepted and materials.has(SonarMaterial.Value.ROCK) and materials.has(SonarMaterial.Value.SAND),
"a scan along the canyon reads rock walls and a sand floor"
)
site.queue_free()
## Seabed height under (x, z), or NAN (every comparison fails) when the ray misses the terrain actor.
func _terrain_height(
space: PhysicsDirectSpaceState3D,
terrain: Node,
x: float,
z: float,
excluded: Array[RID]
) -> float:
var query := PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z))
query.exclude = excluded
var hit := space.intersect_ray(query)
if hit.is_empty() or hit["collider"] != terrain:
return NAN
return float(hit["position"].y)