diff --git a/content/sites/vehicle_test_terrain.res b/content/sites/vehicle_test_terrain.res new file mode 100644 index 0000000..496f48d Binary files /dev/null and b/content/sites/vehicle_test_terrain.res differ diff --git a/docs/briefs/captures/vehicle_test_terrain_preview.png b/docs/briefs/captures/vehicle_test_terrain_preview.png new file mode 100644 index 0000000..5367085 Binary files /dev/null and b/docs/briefs/captures/vehicle_test_terrain_preview.png differ diff --git a/docs/briefs/captures/vehicle_test_terrain_preview.png.import b/docs/briefs/captures/vehicle_test_terrain_preview.png.import new file mode 100644 index 0000000..c5bc213 --- /dev/null +++ b/docs/briefs/captures/vehicle_test_terrain_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crrgsnvuf3ttv" +path="res://.godot/imported/vehicle_test_terrain_preview.png-4e68e296dd4625902c8d2c03244ab133.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/vehicle_test_terrain_preview.png" +dest_files=["res://.godot/imported/vehicle_test_terrain_preview.png-4e68e296dd4625902c8d2c03244ab133.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index 2ffbf89..cf8a391 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -123,6 +123,14 @@ check_godot_log \ "production site application unit tests" \ "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +printf 'Quality gate: site baker unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-baker-unit.log" \ + --path . \ + --script tests/unit/run_site_baker_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +check_godot_log "site baker unit tests" "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" + printf 'Quality gate: production foundation integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ diff --git a/tests/run_load_checks.gd b/tests/run_load_checks.gd index 26f868c..672bd65 100644 --- a/tests/run_load_checks.gd +++ b/tests/run_load_checks.gd @@ -4,6 +4,7 @@ const SEARCH_ROOTS := [ "res://src", "res://game", "res://content", + "res://tools", "res://scenes", "res://tests", ] diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd new file mode 100644 index 0000000..3713530 --- /dev/null +++ b/tests/unit/run_site_baker_tests.gd @@ -0,0 +1,71 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const VehicleTestRecipe = preload("res://tools/sites/vehicle_test_terrain_recipe.gd") + +const TEST_COUNT := 10 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_plane_and_blob() + _test_materials_and_determinism() + + if _failures.is_empty(): + print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site baker unit tests: %d failed." % _failures.size()) + quit(1) + + +func _small_recipe() -> SiteTerrainBaker.Recipe: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 21 + recipe.rows = 21 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -20.0 + recipe.origin_z_m = -20.0 + recipe.floor_elevation_m = -80.0 + recipe.rock_slope_degrees = 20.0 + return recipe + + +func _test_plane_and_blob() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.1, 0.0, 0.0)) + var data := SiteTerrainBaker.bake(recipe) + _expect(data != null and data.validation_error().is_empty(), "plane recipe bakes valid data") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -30.0), "plane depth at the reference point") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 20)], -28.0), "plane slopes along +Z") + _expect(data.materials[data.index_of(10, 10)] == SonarMaterial.Value.SAND, "gentle plane is sand") + + recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(0.0, 0.0), 4.0, 4.0, 10.0)) + data = SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -20.0), "blob adds its amplitude at the centre") + _expect(data.materials[data.index_of(12, 10)] == SonarMaterial.Value.ROCK, "steep blob flank is rock") + + +func _test_materials_and_determinism() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + recipe.primitives.append(SiteTerrainBaker.Roughness.new(1933, 0.05, 0.5)) + recipe.material_patches.append( + SiteTerrainBaker.MaterialPatch.new(Vector2(10.0, 10.0), 3.0, SonarMaterial.Value.ROCK) + ) + var first := SiteTerrainBaker.bake(recipe) + var second := SiteTerrainBaker.bake(recipe) + _expect(first.elevations_m == second.elevations_m, "same recipe bakes identical elevations") + _expect(first.materials == second.materials, "same recipe bakes identical materials") + _expect(first.materials[first.index_of(15, 15)] == SonarMaterial.Value.ROCK, "material patch overrides the slope rule") + var calibration := SiteTerrainBaker.bake(VehicleTestRecipe.recipe()) + _expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tools/bake_site_terrain.gd b/tools/bake_site_terrain.gd new file mode 100644 index 0000000..6a69b3f --- /dev/null +++ b/tools/bake_site_terrain.gd @@ -0,0 +1,58 @@ +extends SceneTree + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + +const RECIPES := { + "vehicle_test": "res://tools/sites/vehicle_test_terrain_recipe.gd", + "zone1_vigilant": "res://tools/sites/zone1_vigilant_recipe.gd", +} +const OUTPUT_DIRECTORY := "res://content/sites" +const PREVIEW_DIRECTORY := "res://docs/briefs/captures" + + +func _init() -> void: + var arguments := OS.get_cmdline_user_args() + if arguments.is_empty() or not RECIPES.has(arguments[0]): + push_error("usage: -- <%s>" % " | ".join(RECIPES.keys())) + quit(1) + return + var name: String = arguments[0] + var recipe_script := load(RECIPES[name]) as GDScript + if recipe_script == null: + push_error("recipe script %s is missing" % RECIPES[name]) + quit(1) + return + var recipe: SiteTerrainBaker.Recipe = recipe_script.recipe() + var data := SiteTerrainBaker.bake(recipe) + if data == null: + push_error("recipe %s did not bake valid terrain" % name) + quit(1) + return + DirAccess.make_dir_recursive_absolute(OUTPUT_DIRECTORY) + DirAccess.make_dir_recursive_absolute(PREVIEW_DIRECTORY) + var output_path := "%s/%s_terrain.res" % [OUTPUT_DIRECTORY, name] + var save_error := ResourceSaver.save(data, output_path) + if save_error != OK: + push_error("cannot save %s (%d)" % [output_path, save_error]) + quit(1) + return + var preview_path := "%s/%s_terrain_preview.png" % [PREVIEW_DIRECTORY, name] + var preview_error := SiteTerrainBaker.write_preview_png(data, preview_path) + if preview_error != OK: + push_error("cannot write %s (%d)" % [preview_path, preview_error]) + quit(1) + return + var rock_cells := 0 + for material: int in data.materials: + if material == SiteTerrainBaker.SonarMaterial.Value.ROCK: + rock_cells += 1 + var minimum := data.elevations_m[0] + var maximum := data.elevations_m[0] + for elevation: float in data.elevations_m: + minimum = minf(minimum, elevation) + maximum = maxf(maximum, elevation) + print("Baked %s: %d x %d cells, elevation %.1f .. %.1f m, rock %.0f %%" % [ + name, data.columns, data.rows, minimum, maximum, + 100.0 * float(rock_cells) / float(data.materials.size()) + ]) + quit(0) diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd new file mode 100644 index 0000000..4de46a9 --- /dev/null +++ b/tools/site_terrain_baker.gd @@ -0,0 +1,258 @@ +class_name SiteTerrainBaker +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + + +class Primitive extends RefCounted: + func elevation_delta(_x_m: float, _z_m: float) -> float: + return 0.0 + + static func distance_to_segment(point: Vector2, start: Vector2, end: Vector2) -> float: + var segment := end - start + var length_squared := segment.length_squared() + if length_squared == 0.0: + return point.distance_to(start) + var t := clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return point.distance_to(start + segment * t) + + static func end_taper(point: Vector2, start: Vector2, end: Vector2, taper_m: float) -> float: + if taper_m <= 0.0: + return 1.0 + var segment := end - start + var length := segment.length() + if length == 0.0: + return 1.0 + var along := clampf((point - start).dot(segment) / length, 0.0, length) + return smoothstep(0.0, taper_m, along) * smoothstep(0.0, taper_m, length - along) + + +class SlopedPlane extends Primitive: + var depth_m: float + var slope_x: float + var slope_z: float + var reference_x_m: float + var reference_z_m: float + + func _init(p_depth_m: float, p_slope_x: float, p_slope_z: float, p_reference_x_m: float, p_reference_z_m: float) -> void: + depth_m = p_depth_m + slope_x = p_slope_x + slope_z = p_slope_z + reference_x_m = p_reference_x_m + reference_z_m = p_reference_z_m + + func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m + slope_x * (x_m - reference_x_m) + slope_z * (z_m - reference_z_m) + + +class Blob extends Primitive: + var centre: Vector2 + var sigma_x_m: float + var sigma_z_m: float + var amplitude_m: float + + func _init(p_centre: Vector2, p_sigma_x_m: float, p_sigma_z_m: float, p_amplitude_m: float) -> void: + centre = p_centre + sigma_x_m = p_sigma_x_m + sigma_z_m = p_sigma_z_m + amplitude_m = p_amplitude_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / sigma_x_m + var dz := (z_m - centre.y) / sigma_z_m + return amplitude_m * exp(-(dx * dx + dz * dz)) + + +class Ridge extends Primitive: + var start: Vector2 + var end: Vector2 + var sigma_m: float + var amplitude_m: float + var taper_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_sigma_m: float, p_amplitude_m: float, p_taper_m: float) -> void: + start = p_start + end = p_end + sigma_m = p_sigma_m + amplitude_m = p_amplitude_m + taper_m = p_taper_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := distance_to_segment(point, start, end) + var profile := exp(-pow(distance / sigma_m, 2.0)) + return amplitude_m * profile * end_taper(point, start, end, taper_m) + + +class Groove extends Primitive: + var start: Vector2 + var end: Vector2 + var half_width_m: float + var depth_m: float + var wall_softness_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_half_width_m: float, p_depth_m: float, p_wall_softness_m: float) -> void: + start = p_start + end = p_end + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := distance_to_segment(point, start, end) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + return -depth_m * inside * end_taper(point, start, end, half_width_m) + + +class Step extends Primitive: + var point: Vector2 + var normal: Vector2 + var drop_m: float + var softness_m: float + var slope_beyond: float + + func _init(p_point: Vector2, p_normal: Vector2, p_drop_m: float, p_softness_m: float, p_slope_beyond: float) -> void: + point = p_point + normal = p_normal.normalized() + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + + func elevation_delta(x_m: float, z_m: float) -> float: + var signed_distance := (Vector2(x_m, z_m) - point).dot(normal) + var step := drop_m * smoothstep(-softness_m, softness_m, signed_distance) + var slope := maxf(signed_distance - softness_m, 0.0) * slope_beyond + return -(step + slope) + + +class Ripples extends Primitive: + var direction: Vector2 + var wavelength_m: float + var amplitude_m: float + var centre: Vector2 + var radius_m: float + + func _init(p_direction: Vector2, p_wavelength_m: float, p_amplitude_m: float, p_centre: Vector2, p_radius_m: float) -> void: + direction = p_direction.normalized() + wavelength_m = p_wavelength_m + amplitude_m = p_amplitude_m + centre = p_centre + radius_m = p_radius_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var phase := point.dot(direction) * TAU / wavelength_m + var falloff := 1.0 + if radius_m > 0.0: + falloff = 1.0 - smoothstep(radius_m * 0.6, radius_m, point.distance_to(centre)) + return amplitude_m * sin(phase) * falloff + + +class Roughness extends Primitive: + var amplitude_m: float + var _noise: FastNoiseLite + + func _init(p_seed: int, p_frequency: float, p_amplitude_m: float) -> void: + amplitude_m = p_amplitude_m + _noise = FastNoiseLite.new() + _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _noise.seed = p_seed + _noise.frequency = p_frequency + _noise.fractal_type = FastNoiseLite.FRACTAL_FBM + _noise.fractal_octaves = 3 + + func elevation_delta(x_m: float, z_m: float) -> float: + return _noise.get_noise_2d(x_m, z_m) * amplitude_m + + +class MaterialPatch extends RefCounted: + var centre: Vector2 + var radius_m: float + var material: int + + func _init(p_centre: Vector2, p_radius_m: float, p_material: int) -> void: + centre = p_centre + radius_m = p_radius_m + material = p_material + + +class Recipe extends RefCounted: + var columns: int = 2 + var rows: int = 2 + var cell_size_m: float = 1.0 + var origin_x_m: float = 0.0 + var origin_z_m: float = 0.0 + var floor_elevation_m: float = -1000.0 + var rock_slope_degrees: float = 20.0 + var rock_reflectivity: float = 0.72 + var sand_reflectivity: float = 0.45 + var primitives: Array[Primitive] = [] + var material_patches: Array[MaterialPatch] = [] + + +static func bake(recipe: Recipe) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = recipe.columns + data.rows = recipe.rows + data.cell_size_m = recipe.cell_size_m + data.origin_x_m = recipe.origin_x_m + data.origin_z_m = recipe.origin_z_m + data.rock_reflectivity = recipe.rock_reflectivity + data.sand_reflectivity = recipe.sand_reflectivity + var cell_count := recipe.columns * recipe.rows + # Packed arrays are passed by value in GDScript: fill locals, assign once. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(cell_count) + materials.resize(cell_count) + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + var elevation := 0.0 + for primitive: Primitive in recipe.primitives: + elevation += primitive.elevation_delta(x_m, z_m) + elevations[data.index_of(column, row)] = maxf(elevation, recipe.floor_elevation_m) + materials[data.index_of(column, row)] = SonarMaterial.Value.SAND + data.elevations_m = elevations + data.materials = materials + var field := TerrainHeightField.from_data(data) + if field == null: + return null + var rock_cosine := cos(deg_to_rad(recipe.rock_slope_degrees)) + for row in recipe.rows: + for column in recipe.columns: + if field.normal_at_cell(column, row).y < rock_cosine: + materials[data.index_of(column, row)] = SonarMaterial.Value.ROCK + for patch: MaterialPatch in recipe.material_patches: + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + if Vector2(x_m, z_m).distance_to(patch.centre) <= patch.radius_m: + materials[data.index_of(column, row)] = patch.material + data.materials = materials + return data + + +static func write_preview_png(data: SiteTerrainData, path: String) -> Error: + var field := TerrainHeightField.from_data(data) + if field == null: + return ERR_INVALID_DATA + var image := Image.create(data.columns, data.rows, false, Image.FORMAT_RGB8) + var span := maxf(field.maximum_elevation_m() - field.minimum_elevation_m(), 0.001) + for row in data.rows: + for column in data.columns: + var ratio := (field.elevation_at_cell(column, row) - field.minimum_elevation_m()) / span + var color := Color(ratio, ratio, ratio) + if field.material_at_cell(column, row) == SonarMaterial.Value.ROCK: + color.r = minf(color.r + 0.16, 1.0) + image.set_pixel(column, row, color) + return image.save_png(path) diff --git a/tools/sites/vehicle_test_terrain_recipe.gd b/tools/sites/vehicle_test_terrain_recipe.gd new file mode 100644 index 0000000..b5baf29 --- /dev/null +++ b/tools/sites/vehicle_test_terrain_recipe.gd @@ -0,0 +1,25 @@ +class_name VehicleTestTerrainRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 61 + result.rows = 69 + result.cell_size_m = 3.0 + result.origin_x_m = -90.0 + result.origin_z_m = -176.0 + result.floor_elevation_m = -95.0 + result.rock_slope_degrees = 20.0 + result.primitives = [ + SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.19, 0.0, 28.0), + SiteTerrainBaker.Blob.new(Vector2(-24.0, -48.0), sqrt(210.0), sqrt(980.0), 13.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, -58.0), sqrt(250.0), sqrt(1150.0), 15.0), + SiteTerrainBaker.Blob.new(Vector2(0.0, -66.0), sqrt(120.0), sqrt(2500.0), -8.5), + SiteTerrainBaker.Blob.new(Vector2(-5.0, -125.0), sqrt(900.0), sqrt(620.0), 8.0), + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.0), TAU / 0.075, 0.9, Vector2.ZERO, 0.0), + SiteTerrainBaker.Ripples.new(Vector2(0.0, 1.0), TAU / 0.048, 0.9, Vector2.ZERO, 0.0), + ] + return result