feat(tools): polyline Canyon and Escarpment primitives with outline noise

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-06 21:37:17 +02:00
1 parent 520a62863b
commit aae9bcd301
2 files changed
+156 -1

No files matched your search

+32 -1
View File
@@ -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")
+124
View File
@@ -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