feat(world): tiled heightfield terrain actor with per-cell sonar materials
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ab1289d10
commit
0e590f2bc7
4 files changed
+286
No files matched your search
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -141,6 +141,16 @@ check_godot_log \
|
|||||||
"production foundation integration" \
|
"production foundation integration" \
|
||||||
"$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log"
|
"$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'
|
printf 'Quality gate: POC B integration\n'
|
||||||
"$GODOT_EXECUTABLE" --headless \
|
"$GODOT_EXECUTABLE" --headless \
|
||||||
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
|
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user