Files
Ransom-s-wake/tools/site_terrain_baker.gd
T
OlivierandClaude Fable 5.1 f22a5e69aa feat(sites): strata on the Banc du Vigilant drop-off and multi-scale seabed relief
Strata now take a world rectangle, so the 4 m ledges cover the whole break
and talus south of z = 118 m without touching la Saignée or the landmarks.
Roughness gains octave and fractal-type parameters; a SlopeRoughness
modifier roughens rock faces only. Both sites are re-baked with hummocks
from 250 m down to 5 m. The secondary item, the first debris piece and one
safe-route waypoint move a few metres to keep their contracts.

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

643 lines
23 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
## Seeded fractal noise added everywhere. frequency is 1 / wavelength of the largest
## octave; each further octave halves the wavelength and the amplitude, so a low
## frequency with five octaves gives hummocks, swales and ripple-scale detail at once.
## FRACTAL_RIDGED makes sharp crests (rock), FRACTAL_FBM rolling ground (sediment).
class Roughness extends Primitive:
var amplitude_m: float
var _noise: FastNoiseLite
func _init(
p_seed: int,
p_frequency: float,
p_amplitude_m: float,
p_octaves: int = 3,
p_fractal_type: FastNoiseLite.FractalType = FastNoiseLite.FRACTAL_FBM
) -> 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 = p_fractal_type
_noise.fractal_octaves = p_octaves
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. region_m is a world-space (x, z) rectangle with a
## hard edge; an empty rectangle applies everywhere. Ledges need two to three cells of
## wall per layer to be resolved: at 1.5 m per cell, 4 m layers on a 50 degree wall.
class Strata extends Modifier:
var layer_height_m: float
var sharpness: float
var region_m: Rect2
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_region_m: Rect2
) -> 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)
region_m = p_region_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:
if region_m.has_area() and not region_m.has_point(Vector2(x_m, z_m)):
return elevation_m
var weight := smoothstep(_minimum_tangent, _full_tangent, gradient.length())
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)
## Seeded noise weighted by the local slope: rock faces and ridge flanks get crumbly
## while flat sand stays smooth. Zero below minimum_slope_degrees, full amplitude from
## full_slope_degrees.
class SlopeRoughness extends Modifier:
var amplitude_m: float
var _noise: FastNoiseLite
var _minimum_tangent: float
var _full_tangent: float
func _init(
p_seed: int,
p_frequency: float,
p_amplitude_m: float,
p_minimum_slope_degrees: float,
p_full_slope_degrees: 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_RIDGED
_noise.fractal_octaves = 3
_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 weight <= 0.0:
return elevation_m
return elevation_m + _noise.get_noise_2d(x_m, z_m) * amplitude_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)