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. 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 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 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)