Files
Ransom-s-wake/tools/site_terrain_baker.gd
T
OlivierandClaude Fable 5.1 d91e6bd02f fix(tools): canyons impose a floor material through rock outcrops; tighten showcase contracts
Review findings: the mesa forced rock onto the canyon bed, two runners
declared the wrong TEST_COUNT, and the escarpment check could pass on the
slope alone. Canyon gains floor_material, the showcase is re-baked with sand
beds, the contract measures the step across the crest band, counts distinct
ledges, and probes the crevasse against the open plateau. Degenerate recipe
parameters now fail an assert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:00:24 +02:00

601 lines
21 KiB
GDScript

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")
## 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:
func elevation_delta(_x_m: float, _z_m: float) -> float:
return 0.0
## Material imposed by this primitive at (x, z), or NO_MATERIAL. Applied after the
## slope rule, in recipe order, before the material patches.
func material_at(_x_m: float, _z_m: float) -> int:
return NO_MATERIAL
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)
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
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)
## 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. floor_material (a SonarMaterial value, or NO_MATERIAL) is
## imposed on the flat floor, so a canyon cut through a rock outcrop keeps a sand bed.
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 floor_material: int
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,
p_floor_material: int
) -> void:
assert(p_path.size() >= 2, "a canyon path needs at least two vertices")
assert(p_half_width_m > 0.0, "a canyon needs a positive half width")
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
floor_material = p_floor_material
_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:
return -depth_m * _presence(x_m, z_m)
func material_at(x_m: float, z_m: float) -> int:
# Full presence means the flat floor: inside the walls and clear of the end tapers.
if floor_material != NO_MATERIAL and _presence(x_m, z_m) >= 0.999:
return floor_material
return NO_MATERIAL
## 1 on the flat floor, 0 outside the trench, in between on the walls and end tapers.
func _presence(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 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. The path must extend past the field
## edges: beyond an end vertex the drop is decided by the half-plane of the last segment,
## which leaves a seam along the path's extension.
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:
assert(p_path.size() >= 2, "an escarpment path needs at least two vertices")
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)
## Flat-topped elliptical mound (or, with a negative height, a flat-bottomed pit) whose
## flank spans wall_width_m inside a noise-roughened outline. With rock_top the whole
## mound above 30 % of its height is rock, sommet plat compris.
class Outcrop extends Primitive:
var centre: Vector2
var radius_x_m: float
var radius_z_m: float
var height_m: float
var wall_width_m: float
var edge_amplitude_m: float
var rock_top: bool
var _noise: FastNoiseLite
var _mean_radius_m: float
func _init(
p_centre: Vector2,
p_radius_x_m: float,
p_radius_z_m: float,
p_height_m: float,
p_wall_width_m: float,
p_edge_seed: int,
p_edge_amplitude_m: float,
p_rock_top: bool
) -> void:
assert(p_radius_x_m > 0.0 and p_radius_z_m > 0.0, "an outcrop needs positive radii")
centre = p_centre
radius_x_m = p_radius_x_m
radius_z_m = p_radius_z_m
height_m = p_height_m
wall_width_m = p_wall_width_m
edge_amplitude_m = p_edge_amplitude_m
rock_top = p_rock_top
_noise = edge_noise(p_edge_seed, p_edge_amplitude_m)
_mean_radius_m = (p_radius_x_m + p_radius_z_m) * 0.5
func elevation_delta(x_m: float, z_m: float) -> float:
var dx := (x_m - centre.x) / radius_x_m
var dz := (z_m - centre.y) / radius_z_m
var edge_distance := (sqrt(dx * dx + dz * dz) - 1.0) * _mean_radius_m
edge_distance += edge_offset(_noise, edge_amplitude_m, x_m, z_m)
return height_m * (1.0 - smoothstep(-wall_width_m, 0.0, edge_distance))
func material_at(x_m: float, z_m: float) -> int:
if rock_top and absf(elevation_delta(x_m, z_m)) > 0.3 * absf(height_m):
return SonarMaterial.Value.ROCK
return NO_MATERIAL
## count half-ellipsoid boulders scattered uniformly in a disc by a seeded generator.
## Boulders combine by maximum, so overlapping blocks never pile into one mound, and every
## cell they cover is rock.
class BoulderField extends Primitive:
var centre: Vector2
var radius_m: float
var height_ratio: float
var _boulders := PackedVector3Array() # x, z, radius
var _reach_m: float
func _init(
p_centre: Vector2,
p_radius_m: float,
p_seed: int,
p_count: int,
p_boulder_radius_min_m: float,
p_boulder_radius_max_m: float,
p_height_ratio: float
) -> void:
centre = p_centre
radius_m = p_radius_m
height_ratio = p_height_ratio
_reach_m = p_radius_m + p_boulder_radius_max_m
var generator := RandomNumberGenerator.new()
generator.seed = p_seed
for _index in p_count:
var distance := p_radius_m * sqrt(generator.randf())
var angle := TAU * generator.randf()
var boulder_radius := generator.randf_range(p_boulder_radius_min_m, p_boulder_radius_max_m)
_boulders.append(Vector3(
p_centre.x + distance * cos(angle),
p_centre.y + distance * sin(angle),
boulder_radius
))
func boulders() -> PackedVector3Array:
return _boulders
func elevation_delta(x_m: float, z_m: float) -> float:
if Vector2(x_m, z_m).distance_to(centre) > _reach_m:
return 0.0
var highest := 0.0
for boulder in _boulders:
var dx := x_m - boulder.x
var dz := z_m - boulder.y
var squared := dx * dx + dz * dz
var radius_squared := boulder.z * boulder.z
if squared < radius_squared:
highest = maxf(highest, boulder.z * height_ratio * sqrt(1.0 - squared / radius_squared))
return highest
func material_at(x_m: float, z_m: float) -> int:
if elevation_delta(x_m, z_m) > 0.0:
return SonarMaterial.Value.ROCK
return NO_MATERIAL
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
## Reshapes the summed elevation once every primitive has been added. gradient is the
## local slope Vector2(dE/dx, dE/dz) of the field being modified, by central differences.
class Modifier extends RefCounted:
func modify(_x_m: float, _z_m: float, elevation_m: float, _gradient: Vector2) -> float:
return elevation_m
## Quantises steep ground into horizontal ledges layer_height_m apart: the stratified
## walls of a canyon or cliff. Flat floors and summits are below minimum_slope_degrees and
## stay untouched; the effect is full from full_slope_degrees. sharpness 0 leaves the
## field unchanged, 1 cuts hard steps. radius_m 0 applies everywhere.
class Strata extends Modifier:
var layer_height_m: float
var sharpness: float
var centre: Vector2
var radius_m: float
var _minimum_tangent: float
var _full_tangent: float
func _init(
p_layer_height_m: float,
p_sharpness: float,
p_minimum_slope_degrees: float,
p_full_slope_degrees: float,
p_centre: Vector2,
p_radius_m: float
) -> void:
assert(p_layer_height_m > 0.0, "strata need a positive layer height")
layer_height_m = p_layer_height_m
sharpness = clampf(p_sharpness, 0.0, 0.999)
centre = p_centre
radius_m = p_radius_m
_minimum_tangent = tan(deg_to_rad(p_minimum_slope_degrees))
_full_tangent = tan(deg_to_rad(p_full_slope_degrees))
func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float:
var weight := smoothstep(_minimum_tangent, _full_tangent, gradient.length())
if radius_m > 0.0:
weight *= 1.0 - smoothstep(radius_m * 0.8, radius_m, Vector2(x_m, z_m).distance_to(centre))
if weight <= 0.0:
return elevation_m
var layer := floorf(elevation_m / layer_height_m)
var fraction := elevation_m / layer_height_m - layer
var sharpened := clampf((fraction - 0.5) / (1.0 - sharpness) + 0.5, 0.0, 1.0)
return lerpf(elevation_m, (layer + sharpened) * layer_height_m, weight)
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 modifiers: Array[Modifier] = []
var material_patches: Array[MaterialPatch] = []
## Bake order: primitive sum, modifiers in order, floor clamp, slope material,
## primitive materials in order, material patches.
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)] = elevation
for modifier: Modifier in recipe.modifiers:
elevations = _apply_modifier(recipe, modifier, elevations)
for index in cell_count:
elevations[index] = maxf(elevations[index], recipe.floor_elevation_m)
materials[index] = 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 primitive: Primitive in recipe.primitives:
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 material := primitive.material_at(x_m, z_m)
if material != NO_MATERIAL:
materials[data.index_of(column, row)] = material
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
## Returns the modified copy of elevations; the gradient of each cell is read from the
## unmodified input so the modifier sees one consistent field.
static func _apply_modifier(recipe: Recipe, modifier: Modifier, source: PackedFloat32Array) -> PackedFloat32Array:
var columns := recipe.columns
var rows := recipe.rows
var cell_size := recipe.cell_size_m
var result := PackedFloat32Array()
result.resize(source.size())
for row in rows:
var z_m := recipe.origin_z_m + float(row) * cell_size
var north := maxi(row - 1, 0)
var south := mini(row + 1, rows - 1)
for column in columns:
var x_m := recipe.origin_x_m + float(column) * cell_size
var west := maxi(column - 1, 0)
var east := mini(column + 1, columns - 1)
var gradient := Vector2(
(source[row * columns + east] - source[row * columns + west]) / (float(east - west) * cell_size),
(source[south * columns + column] - source[north * columns + column]) / (float(south - north) * cell_size)
)
var index := row * columns + column
result[index] = modifier.modify(x_m, z_m, source[index], gradient)
return result
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)