diff --git a/game/world/sites/bathymetric_terrain_actor.gd b/game/world/sites/bathymetric_terrain_actor.gd new file mode 100644 index 0000000..e4a9305 --- /dev/null +++ b/game/world/sites/bathymetric_terrain_actor.gd @@ -0,0 +1,135 @@ +class_name BathymetricTerrainActor +extends StaticBody3D + +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader") + +const TILE_CELLS := 64 +const VISIBILITY_RANGE_END_M := 260.0 +const VISIBILITY_RANGE_MARGIN_M := 40.0 + +var _field: TerrainHeightField +var _material: ShaderMaterial +var _tiles_root: Node3D +var _collision: CollisionShape3D + + +func configure(field: TerrainHeightField) -> String: + if field == null: + return "terrain field is missing" + _clear() + _field = field + _material = ShaderMaterial.new() + _material.shader = TERRAIN_SHADER + _build_tiles() + _build_collision() + return "" + + +func height_field() -> TerrainHeightField: + return _field + + +func tile_count() -> int: + return 0 if _tiles_root == null else _tiles_root.get_child_count() + + +func sonar_material_at(world_position: Vector3) -> int: + return _field.material_at(world_position.x, world_position.z) + + +func sonar_reflectivity_at(world_position: Vector3) -> float: + return _field.reflectivity_for(sonar_material_at(world_position)) + + +func _clear() -> void: + if _tiles_root != null: + remove_child(_tiles_root) + _tiles_root.free() + _tiles_root = null + if _collision != null: + remove_child(_collision) + _collision.free() + _collision = null + + +func _build_tiles() -> void: + _tiles_root = Node3D.new() + _tiles_root.name = "Tiles" + add_child(_tiles_root) + var last_column := _field.columns() - 1 + var last_row := _field.rows() - 1 + var start_row := 0 + while start_row < last_row: + var end_row := mini(start_row + TILE_CELLS, last_row) + var start_column := 0 + while start_column < last_column: + var end_column := mini(start_column + TILE_CELLS, last_column) + _tiles_root.add_child(_build_tile(start_column, end_column, start_row, end_row)) + start_column = end_column + start_row = end_row + + +func _build_tile(start_column: int, end_column: int, start_row: int, end_row: int) -> MeshInstance3D: + var corner_min := _field.cell_world_position(start_column, start_row) + var corner_max := _field.cell_world_position(end_column, end_row) + var centre := Vector3( + (corner_min.x + corner_max.x) * 0.5, + 0.0, + (corner_min.z + corner_max.z) * 0.5 + ) + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var indices := PackedInt32Array() + var columns_in_tile := end_column - start_column + 1 + for row in range(start_row, end_row + 1): + for column in range(start_column, end_column + 1): + vertices.append(_field.cell_world_position(column, row) - centre) + normals.append(_field.normal_at_cell(column, row)) + for row in range(end_row - start_row): + for column in range(end_column - start_column): + var top_left := row * columns_in_tile + column + var top_right := top_left + 1 + var bottom_left := top_left + columns_in_tile + var bottom_right := bottom_left + 1 + indices.append_array([top_left, bottom_left, top_right]) + indices.append_array([top_right, bottom_left, bottom_right]) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var instance := MeshInstance3D.new() + instance.name = "Tile_%d_%d" % [start_column / TILE_CELLS, start_row / TILE_CELLS] + instance.mesh = mesh + instance.material_override = _material + instance.position = centre + instance.visibility_range_end = VISIBILITY_RANGE_END_M + instance.visibility_range_end_margin = VISIBILITY_RANGE_MARGIN_M + instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + return instance + + +func _build_collision() -> void: + var shape := HeightMapShape3D.new() + shape.map_width = _field.columns() + shape.map_depth = _field.rows() + var heights := PackedFloat32Array() + heights.resize(_field.columns() * _field.rows()) + for row in _field.rows(): + for column in _field.columns(): + heights[row * _field.columns() + column] = _field.elevation_at_cell(column, row) + shape.map_data = heights + _collision = CollisionShape3D.new() + _collision.name = "Collision" + _collision.shape = shape + var bounds := _field.bounds_m() + _collision.position = Vector3( + bounds.position.x + bounds.size.x * 0.5, + 0.0, + bounds.position.y + bounds.size.y * 0.5 + ) + _collision.scale = Vector3(_field.cell_size_m(), 1.0, _field.cell_size_m()) + add_child(_collision) diff --git a/game/world/sites/seabed_terrain.gdshader b/game/world/sites/seabed_terrain.gdshader new file mode 100644 index 0000000..dafb296 --- /dev/null +++ b/game/world/sites/seabed_terrain.gdshader @@ -0,0 +1,35 @@ +shader_type spatial; +render_mode cull_disabled, depth_draw_opaque; + +uniform vec3 sand_color : source_color = vec3(0.50, 0.46, 0.34); +uniform vec3 rock_color : source_color = vec3(0.21, 0.20, 0.18); +uniform vec3 deep_tint : source_color = vec3(0.04, 0.09, 0.15); +uniform float sand_slope_cosine = 0.9397; // cos(20 deg) +uniform float rock_slope_cosine = 0.7660; // cos(40 deg) +uniform float depth_tint_start_m = 20.0; +uniform float depth_tint_end_m = 90.0; +uniform float ripple_wavelength_m = 1.6; +uniform float ripple_contrast = 0.12; + +varying vec3 world_position; +varying vec3 world_normal; + +void vertex() { + world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); +} + +void fragment() { + float up = clamp(world_normal.y, 0.0, 1.0); + float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up); + vec3 albedo = mix(sand_color, rock_color, rockness); + float phase = (world_position.x * 0.8 + world_position.z * 0.6) * TAU / ripple_wavelength_m; + float ripple = sin(phase) * ripple_contrast * (1.0 - rockness); + albedo *= 1.0 + ripple; + float depth_m = -world_position.y; + float tint = smoothstep(depth_tint_start_m, depth_tint_end_m, depth_m); + albedo = mix(albedo, deep_tint, tint * 0.6); + ALBEDO = albedo; + ROUGHNESS = mix(0.95, 0.82, rockness); + METALLIC = 0.0; +} diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index cf8a391..c3583bf 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -141,6 +141,16 @@ check_godot_log \ "production foundation integration" \ "$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log" +printf 'Quality gate: production site terrain integration\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-terrain-integration.log" \ + --path . \ + --script tests/integration/run_site_terrain_integration.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" +check_godot_log \ + "production site terrain integration" \ + "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" + printf 'Quality gate: POC B integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \ diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd new file mode 100644 index 0000000..399c1cc --- /dev/null +++ b/tests/integration/run_site_terrain_integration.gd @@ -0,0 +1,106 @@ +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 TEST_COUNT := 12 + +var _failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _test_actor_matches_field() + + 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 = 81 + 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 in the north-west corner only: any front/back flip shows up. + SiteTerrainBaker.Blob.new(Vector2(-60.0, -60.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() == 4, "80 cells per side split into 2 x 2 tiles of 64") + 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, 80), Vector2i(80, 80), + Vector2i(10, 10), 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 north_west := _collision_height(space, -60.0, -60.0) + var south_east := _collision_height(space, 60.0, 60.0) + _expect(north_west > -30.0, "north-west bump is where the field puts it (collision)") + _expect(south_east < -39.0, "south-east stays flat (collision)") + _expect(absf(north_west - field.elevation_at(-60.0, -60.0)) < 0.05, "bump height matches the field") + + _expect(actor.sonar_material_at(Vector3(-52.0, -30.0, -60.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() == 4, "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)