Quality Gate / Quality Gate (pull_request) Failing after 2m58s
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>
338 lines
16 KiB
GDScript
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)
|