From aae9bcd3014f805f3e2cc4a471eb4868b28d14c7 Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 21:37:17 +0200 Subject: [PATCH] feat(tools): polyline Canyon and Escarpment primitives with outline noise Co-Authored-By: Claude Fable 5.1 --- tests/unit/run_site_baker_tests.gd | 33 +++++++- tools/site_terrain_baker.gd | 124 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd index d3714da..6103717 100644 --- a/tests/unit/run_site_baker_tests.gd +++ b/tests/unit/run_site_baker_tests.gd @@ -8,7 +8,7 @@ const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res" -const TEST_COUNT := 16 +const TEST_COUNT := 26 var _failures: Array[String] = [] @@ -18,6 +18,8 @@ func _init() -> void: _test_materials_and_determinism() _test_modifiers_and_primitive_materials() _test_zone1_artefact_is_stable() + _test_canyon() + _test_escarpment() if _failures.is_empty(): print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) @@ -113,6 +115,35 @@ func _test_modifiers_and_primitive_materials() -> void: _expect(flat_data.materials[flat_data.index_of(0, 0)] == SonarMaterial.Value.SAND, "primitive material is local") +func _test_canyon() -> void: + var canyon := SiteTerrainBaker.Canyon.new( + PackedVector2Array([Vector2(-20.0, 0.0), Vector2(0.0, 0.0), Vector2(0.0, 20.0)]), + 4.0, 10.0, 1.0, 0, 0.0 + ) + _expect(is_equal_approx(canyon.elevation_delta(-10.0, 0.0), -10.0), "canyon floor sits at depth on the first leg") + _expect(is_equal_approx(canyon.elevation_delta(1.5, 10.0), -10.0), "canyon floor is flat across its width on the second leg") + _expect(is_equal_approx(canyon.elevation_delta(-10.0, 6.0), 0.0), "canyon stops past half width plus softness") + _expect(is_equal_approx(canyon.elevation_delta(15.0, -15.0), 0.0), "canyon is zero far from the path") + _expect(is_equal_approx(canyon.elevation_delta(0.0, 20.0), 0.0), "canyon closes at its last vertex") + _expect(canyon.elevation_delta(-10.0, 4.0) < -4.0 and canyon.elevation_delta(-10.0, 4.0) > -6.0, "canyon wall is half way down at half width") + var rough := SiteTerrainBaker.Canyon.new( + PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0 + ) + var wall_values: Dictionary = {} + for x in range(-100, 101, 10): + wall_values[snappedf(rough.elevation_delta(float(x), 8.0), 0.01)] = true + _expect(wall_values.size() > 3, "outline noise makes the canyon wall irregular along the path") + + +func _test_escarpment() -> void: + var cliff := SiteTerrainBaker.Escarpment.new( + PackedVector2Array([Vector2(-50.0, 0.0), Vector2(50.0, 0.0)]), 10.0, 2.0, 0.5, 0, 0.0 + ) + _expect(is_equal_approx(cliff.elevation_delta(0.0, -10.0), 0.0), "escarpment leaves the left (north) side alone") + _expect(is_equal_approx(cliff.elevation_delta(0.0, 2.0), -10.0), "escarpment drops by drop_m on the right (south) side") + _expect(is_equal_approx(cliff.elevation_delta(0.0, 12.0), -15.0), "escarpment keeps falling at slope_beyond") + + func _test_zone1_artefact_is_stable() -> void: var versioned := load(ZONE1_TERRAIN_PATH) as SiteTerrainData _expect(versioned != null, "versioned zone 1 terrain loads") diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd index 9f011f0..94d46bd 100644 --- a/tools/site_terrain_baker.gd +++ b/tools/site_terrain_baker.gd @@ -7,6 +7,8 @@ const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field ## A primitive returning this from material_at leaves the slope rule in charge. const NO_MATERIAL := -1 +## Outline noise wavelength of about 20 m: visible irregularity, no cell-scale jitter. +const EDGE_NOISE_FREQUENCY := 0.05 class Primitive extends RefCounted: @@ -36,6 +38,52 @@ class Primitive extends RefCounted: 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) + static func path_length(path: PackedVector2Array) -> float: + var total := 0.0 + for index in range(1, path.size()): + total += path[index - 1].distance_to(path[index]) + return total + + ## Nearest point of a polyline: x = distance to the path, y = arc length of the nearest + ## point from the first vertex, z = +1 when the point lies to the right of the travel + ## direction (south of an eastbound path, with +X east and +Z south), -1 to the left. + static func path_sample(point: Vector2, path: PackedVector2Array) -> Vector3: + if path.size() == 1: + return Vector3(point.distance_to(path[0]), 0.0, 1.0) + var best := Vector3(INF, 0.0, 1.0) + var arc_start := 0.0 + for index in range(1, path.size()): + var start := path[index - 1] + var end := path[index] + var segment := end - start + var length := segment.length() + var t := 0.0 + if length > 0.0: + t = clampf((point - start).dot(segment) / (length * length), 0.0, 1.0) + var distance := point.distance_to(start + segment * t) + if distance < best.x: + var cross := segment.x * (point.y - start.y) - segment.y * (point.x - start.x) + best = Vector3(distance, arc_start + t * length, 1.0 if cross >= 0.0 else -1.0) + arc_start += length + return best + + ## Deterministic low-frequency noise that roughens an outline; null when amplitude is 0. + static func edge_noise(seed: int, amplitude_m: float) -> FastNoiseLite: + if amplitude_m <= 0.0: + return null + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = EDGE_NOISE_FREQUENCY + noise.fractal_type = FastNoiseLite.FRACTAL_FBM + noise.fractal_octaves = 3 + return noise + + static func edge_offset(noise: FastNoiseLite, amplitude_m: float, x_m: float, z_m: float) -> float: + if noise == null: + return 0.0 + return noise.get_noise_2d(x_m, z_m) * amplitude_m + class SlopedPlane extends Primitive: var depth_m: float @@ -119,6 +167,82 @@ class Groove extends Primitive: return -depth_m * inside * end_taper(point, start, end, half_width_m) +## Flat-floored trench along a polyline. The floor sits depth_m below the surroundings +## wherever the (noise-perturbed) distance to the path is under half_width_m minus +## wall_softness_m; the walls climb over 2 * wall_softness_m; the trench closes over one +## half-width at each end. +class Canyon extends Primitive: + var path: PackedVector2Array + var half_width_m: float + var depth_m: float + var wall_softness_m: float + var edge_amplitude_m: float + var _noise: FastNoiseLite + var _length_m: float + + func _init( + p_path: PackedVector2Array, + p_half_width_m: float, + p_depth_m: float, + p_wall_softness_m: float, + p_edge_seed: int, + p_edge_amplitude_m: float + ) -> void: + path = p_path + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + edge_amplitude_m = p_edge_amplitude_m + _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) + _length_m = path_length(p_path) + + func elevation_delta(x_m: float, z_m: float) -> float: + var sample := path_sample(Vector2(x_m, z_m), path) + var distance := sample.x + edge_offset(_noise, edge_amplitude_m, x_m, z_m) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + var taper := smoothstep(0.0, half_width_m, sample.y) * smoothstep(0.0, half_width_m, _length_m - sample.y) + return -depth_m * inside * taper + + +## Step along a polyline: the terrain drops by drop_m on the right of the travel direction +## (south of an eastbound path), over 2 * softness_m, then keeps falling at slope_beyond. +## The crest line is roughened by outline noise. Extend the path past the field edges: +## beyond its ends the contour wraps around the end vertex. +class Escarpment extends Primitive: + var path: PackedVector2Array + var drop_m: float + var softness_m: float + var slope_beyond: float + var edge_amplitude_m: float + var _noise: FastNoiseLite + + func _init( + p_path: PackedVector2Array, + p_drop_m: float, + p_softness_m: float, + p_slope_beyond: float, + p_edge_seed: int, + p_edge_amplitude_m: float + ) -> void: + path = p_path + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + edge_amplitude_m = p_edge_amplitude_m + _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) + + func elevation_delta(x_m: float, z_m: float) -> float: + var sample := path_sample(Vector2(x_m, z_m), path) + var signed_distance := sample.x * sample.z + edge_offset(_noise, edge_amplitude_m, x_m, z_m) + 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 Step extends Primitive: var point: Vector2 var normal: Vector2