Files
Ransom-s-wake/tests/integration/run_site_terrain_integration.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

338 lines
16 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 SeabedFloraActor = preload("res://game/world/sites/seabed_flora_actor.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 := 49
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 holds the steep bump, so it is rendered subdivided; the second tile
# (cells 64..80) is flat and stays on the grid.
var subdivision := actor.tile_subdivision(0)
_expect(subdivision == BathymetricTerrainActor.STEEP_TILE_SUBDIVISION, "the steep tile is subdivided (%d)" % subdivision)
_expect(actor.tile_subdivision(1) == 1, "the flat tile keeps one vertex per cell")
# The first tile spans cells 0..64 on both axes: vertex index = (row * n) * (64 n + 1) + column * n.
var points_per_row := 64 * subdivision + 1
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 * subdivision) * points_per_row + cell.x * subdivision]
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 on grid nodes sit on their field cells (worst %.4f m)" % worst_vertex_error)
var mid_vertex := first_tile.global_transform * tile_vertices[(50 * subdivision + 1) * points_per_row + 10 * subdivision + 1]
var mid_expected := field.smooth_elevation_at(mid_vertex.x, mid_vertex.z)
_expect(absf(mid_vertex.y - mid_expected) < 0.01, "subdivided vertices sit on the smooth surface (%.3f vs %.3f)" % [mid_vertex.y, mid_expected])
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 flora := site.flora()
var grass_count := flora.instance_count(SeabedFloraActor.Kind.SEAGRASS)
var fan_count := flora.instance_count(SeabedFloraActor.Kind.SEA_FAN)
_expect(grass_count > 0 and fan_count > 0, "calibration site grows seagrass and sea fans (%d, %d)" % [grass_count, fan_count])
var field_for_flora := definition.height_field()
var grass_on_sand := true
var grass_in_band := true
for transform in flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS):
var at := transform.origin
grass_on_sand = grass_on_sand and field_for_flora.material_at(at.x, at.z) == SonarMaterial.Value.SAND
grass_in_band = grass_in_band and -at.y < SeabedFloraActor.GRASS_DEPTH_NONE_M and absf(at.y - field_for_flora.elevation_at(at.x, at.z)) < 0.1
_expect(grass_on_sand, "seagrass stands on sand")
_expect(grass_in_band, "seagrass stays in the lit band and on the seabed")
var fans_on_rock := true
for transform in flora.sample_transforms(SeabedFloraActor.Kind.SEA_FAN):
var at := transform.origin
fans_on_rock = fans_on_rock and field_for_flora.material_at(at.x, at.z) == SonarMaterial.Value.ROCK
_expect(fans_on_rock, "sea fans stand on rock")
var first_pass := flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS)
flora.configure(field_for_flora, hash(String(definition.site_id)))
_expect(first_pass == flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS), "flora placement is deterministic for a seed")
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)