extends "res://scenes/maps/map_dc_level.gd"
## DC Erosion — mountain ranges at two scales, for judging erosion AND view distance.
##
## WHY ADDING OCTAVES DID NOT GIVE MOUNTAIN RANGES BEFORE.
##
## Octaves go UP in frequency and DOWN in amplitude (lacunarity 2.0, gain 0.5 in
## fractal_noise_2d). Octave k has wavelength base/2^k and amplitude 1/2^k. They
## add DETAIL BENEATH the base wavelength and can never add structure above it —
## so raising mountain_octaves makes terrain busier, not grander.
##
## Worse, at the old terrain_height of 1000 m the base wavelength was 2 km, so:
##
## octave 0 2.00 km
## octave 1 1.00 km
## octave 2 0.50 km <- below Nyquist at LOD 8 (voxel 256 m), INVISIBLE at range
## octave 3 0.25 km <- invisible at range
##
## The octaves were there. You could not see them from far away, and nothing in the
## field was range-sized to begin with. That is the whole failure.
##
## THE FIX IS THE BASE WAVELENGTH, NOT THE OCTAVE COUNT. terrain_height 20000 puts
## octave 0 at 20 km, and all four octaves (20 / 10 / 5 / 2.5 km) stay above Nyquist
## at the LODs you actually view a range from.
##
## AND MOUNTAIN DISTANCE IS ITS OWN KNOB. Peak spacing is
## terrain_height / mountain_scale, independent of height. That is what makes a
## range read as a range rather than as noise with tall spikes.
##
## TWO SCALES IN ONE SCENE NEEDED A CODE CHANGE. mountain_height is documented as
## "amplitude as fraction of [floor_y, ceiling_y]" — ONE SCALAR for the whole world,
## so every region got identical relief and an Everest-scale range could not coexist
## with a Vermont-scale one. relief_scale / relief_floor (added to
## DCTerrainGeneratorProcNodes for this map) modulate that amplitude with a much
## lower-frequency field, so "how mountainous is it here" varies across the map.
## relief_scale = 0 keeps the old uniform behaviour, so nothing else changes.
const VOXEL_SIZE := 1.0
## 14, not 12. The view distance complaint was not fog, not the far plane
## (200 km, player.tscn) and not a clamp -- octree_depth simply IS the world, and
## 12 gave 57.3 km, so from the middle the horizon was 28.6 km and there was
## barely more than one 20 km mountain to see. Measured with
## dc-terrain/tests/test_leaf_count_vs_depth.cpp: leaf count grows LINEARLY at a
## flat 224 per level, because a clipmap adds a shell rather than a volume, so
## 12 -> 14 quadruples the range for about 21% more chunks (2080 -> 2528).
## MAX_LOD_MATERIALS went 12 -> 16 alongside, or LODs 12 and 13 would share a
## material slot and lose their fade_t crossfade at exactly these distances.
const OCTREE_DEPTH := 16 # root span = 28 * 2^15 * 1.0 = 917.5 km
## Vertical range. floor/ceiling are a HARD CLIP — solid below, air above — so
## this is the total relief available, not a suggestion.
##
## THE BAND IS CENTRED ON y = 0, AND THAT IS A PHYSICS CONSTRAINT, NOT A TASTE.
## Godot's character physics degrades with ALTITUDE — the magnitude of y, not
## distance from the origin, which measures clean out to 100 km. Measured with
## scenes/test/slope_travel_gate.gd, driving the shipping capsule up a
## triangulated slope:
##
## altitude 5deg 10deg 15deg 20deg 25deg 30deg
## 0..6000 99.6% 98.4% 96.5% 93.9% 90.6% 86.5% clean, no stalls
## 7000 98.2% 98.4% 96.5% 21.2% 90.7% 86.6% one cell stalls
## 8000 99.0% 98.4% 96.6% 93.9% 89.5% 86.6% clean
## 9700 87.3% 50.2% 18.9% 12.9% 18.1% 68.6% stalls everywhere
##
## It is not a cliff: isolated failures appear around 7 km and are pervasive by
## 9.7 km, with backward corrections up to -0.66 m in a single frame. THAT is
## "the player gets stuck and cannot travel". Raising CharacterBody3D.safe_margin
## was tried and FALSIFIED — it fixes the mid slopes and makes 5 deg worse.
##
## The surface was MEASURED at 4600..15200 m (erosion_range_gate, 576/576
## columns), 10600 m of relief. Centring that on zero puts every walkable point
## within |y| <= 5300, below the first observed failure. Centring is optimal —
## any other placement has a larger max|y|, and the relief is what it is.
##
## THE SHIFT IS A PURE TRANSLATION AND CHANGES NOTHING ELSE. The density mapping
## is y_mapped = (y - floor_y) * (height_scale / y_range) - mtn_offset * scale,
## so moving floor and ceiling together leaves y_range — and therefore every
## wavelength, every slope, MOUNTAIN_HEIGHT's fraction and DETAIL_HEIGHT's
## fraction — untouched. The terrain translates and keeps its shape. WATER_Y
## moves with them so the waterline fraction is unchanged too (0.2381 either way).
##
## Was -1000 / 20000 with the surface at 4600..15200; now -10900 / 10100 with
## the surface at -5300..5300.
##
## 9 km -> 20 km alongside the TERRAIN_HEIGHT change. Slope is amplitude over
## half-wavelength, so tripling the wavelength needs roughly triple the vertical
## room to keep the terrain from flattening. Measured at 60 km wavelength: a 10 km
## range gives 0.33 RMS slope at 2 m, a 21 km range gives 0.50 -- against 0.63
## before. Gentler underfoot AND steeper at range, which is what a bigger world
## with visible distant peaks needs.
##
## NOTE this is now the dominant term in mountain amplitude: MOUNTAIN_HEIGHT is a
## FRACTION of it, so 0.55 * 21 km = ~11.5 km peaks.
## RE-CENTRED +1800 m after MOUNTAIN_SCALE 1.0 -> 0.1.
##
## Lowering the mountain frequency grew total relief 10600 -> 13800 m and put
## the surface at -8700..5100 (erosion_range_gate, 576/576 columns) -- centre
## -1800, and a floor PAST the 7 km altitude where slope-travel stalls begin.
## The band was -10900/10100 when the surface was -5300..+5300.
##
## Moving floor and ceiling TOGETHER leaves y_range at 21000, and y_mapped is
## (y - floor_y) * (height_scale / y_range), so only the offset moves: every
## wavelength, every slope, MOUNTAIN_HEIGHT's fraction and DETAIL_HEIGHT's
## fraction are untouched. The terrain translates and keeps its shape. Verified
## by re-running the gate, not assumed.
##
## -> surface -6900..+6900, centred. Centring is optimal: any other placement
## has a larger max|y|, and the relief is what it is until the height remap
## lands (which is the real fix -- it lifts peaks without deepening valleys, so
## relief stops being one scalar spent on both ends).
const FLOOR_Y := -9100.0
const CEILING_Y := 11900.0
## World metres that one cycle of the noise covers. THE most important number
## here: it sets EVERY wavelength in the field simultaneously -- mountain,
## erosion and detail all evaluate at p = world / TERRAIN_HEIGHT.
##
## 20 km -> 60 km, because view distance was the binding complaint and extent was
## not what limited it. Measured: LOD cell size is 28 * 2^k and the split rule
## puts LOD k at ~56 * 2^k metres, so VOXEL SIZE IS ROUGHLY DISTANCE / 56. A
## 20 km mountain is down to ten voxels by 112 km and stops reading; a 60 km one
## survives to ~336 km. Making the features bigger buys distance for free, where
## raising lod_factor to buy the same distance costs 4x the leaves (2528 ->
## 10032, measured with test_leaf_count_vs_depth).
##
## It is a UNIFORM zoom, so DETAIL_SCALE is tripled below to hold the detail
## band at a 100 m base, and CEILING_Y is raised because tripling the wavelength
## at a fixed amplitude divides every slope by three -- measured, the whole
## profile fell from 0.63/0.38/0.27 to 0.33/0.20/0.15 and read as rolling hills.
const TERRAIN_HEIGHT := 60000.0
## Absolute waterline, in metres, converted to the colour shader's normalised
## fraction at setup. 4000 m sits below the measured surface minimum (5500 m), so
## nothing floods -- this map is about mountains, and a sea at 8.7 km, which is
## what the old 0.465 fraction became once the ceiling doubled, is not a feature.
## Moved with FLOOR_Y and CEILING_Y (was 4000 against a 4600 m surface minimum,
## now -5900 against -5300), which is what keeps the waterline FRACTION identical
## at 0.2381 — the number the colour shader actually consumes.
## Moves with the band, which is what keeps the waterline FRACTION identical
## at 0.2381 -- the number the colour shader actually consumes.
## MEASURED, not chosen. HYPSO_SEA 0.20 targets 33.7% land, so the water
## level that DELIVERS it is the matching percentile of surface heights --
## erosion_range_gate over 576 columns:
## p10 -6900 p25 -6500 p50 -5300 | p66 +700 p75 +1200 p90 +4600
## => WATER_Y 700 m, land 32.1%
## The 6000 m gap between p50 and p66 IS the continental slope; the two
## hypsometric modes are real in metres, not just in curve units.
## ⚠ Re-measure when any HYPSO_* or mountain constant moves.
const WATER_Y := 700.0
## COLOUR MODE — 1 is the lookdev material, the SAME one map_dc_diffusion uses.
##
## This map set `terrain_params` and nothing else, so `color_mode` fell through
## to the shader default of 0 -- a completely different material from the one
## the diffusion map renders. That is why the two maps looked unrelated.
##
## Mode 0 was also mis-calibrated here, and that is the "colour does not follow
## the terrain" part. It drives every band off
## h = (world_y - floor_y) / (ceiling_y - floor_y)
## normalised over the FULL 21 km band, while the surface occupies only part of
## it -- so h never left a middle slice, `water_height` 0.2381 sat below the
## terrain's minimum and never triggered, and snow only reached the top ~1.5 km.
## The ramp was calibrated to a band twice the size of the terrain in it.
const LOOKDEV_COLOUR_MODE := 1
## THE RANGE THE SURFACE ACTUALLY OCCUPIES, which is what mode 1 normalises
## altitude over. MEASURED, not chosen:
##
## "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/erosion_range_gate.gd \
## -- --ms 0.1
## -> surface spans -8700 m .. 5100 m (relief 13800 m), 576/576 columns
##
## ⚠ RE-MEASURE WHEN ANY GENERATOR CONSTANT MOVES. The shader comment records
## what a too-wide range costs: terrain lands in a thin slice of the ramp and
## "everything reads as one shade". The diffusion map uses its BLOCK's range for
## the same reason rather than the field's.
##
## ⚠ THIS RANGE VIOLATES THE PHYSICS BAND. -8700 m is past the 7 km altitude
## where slope-travel stalls begin and approaching the 9.7 km where they are
## pervasive (the table above FLOOR_Y). It was -5300..+5300 at MOUNTAIN_SCALE
## 1.0. Recorded here rather than silently coloured over.
const SURFACE_RANGE := Vector2(-7800.0, 10600.0)
## THE LAND/SEA CURVE (proc-nodes/src/shared/hypso_curve.inc).
##
## A plain fBm is UNIMODAL, so intersecting it with a water plane at any level
## gives a coastline of uniform fractal character everywhere -- no shelves, no
## basins, no reason for land to clump. This curve remaps the mountain layer to
## a BIMODAL distribution (abyssal plain + continental platform, with a steep
## continental slope between) so the waterline falls in the trough where few
## samples sit, which is what makes a coastline sharp.
##
## Gated by dc-terrain/tests/test_hypso_curve.cpp: monotonic (min dout/dh = 0),
## C1 by a scaling test (ratio 2.00), 2 output modes with dip 0.95, and the
## summit lift measured to leave everything below its onset untouched.
##
## SEA sets the land fraction, measured over the REAL multifractal field with
## this map's constants (176400 columns, +/-458.8 km):
## sea 0.00 -> 53.7% 0.10 -> 45.7% 0.20 -> 33.7% 0.30 -> 22.9%
## 0.20 is close to Earth's ~29% and leaves ocean for the volumetric seafloor.
##
## ⚠ relief_scale/relief_floor DO NOT modulate this. The generator uses
## mountain_height alone on the hypso path, because relief_amp varies 12.5x
## across the world and scaling the curve by it would put sea level at a
## different world Y in every region -- one water plane could then match the
## shore nowhere. See the note at the emission site.
const HYPSO_ENABLED := 1.0
const HYPSO_SEA := 0.20
const HYPSO_SHELF_WIDTH := 0.12 # gentle shelf below sea level
const HYPSO_SLOPE_WIDTH := 0.05 # steep drop half-width, SUBMERGED
const HYPSO_ABYSS := -0.62
const HYPSO_PLAT_SLOPE := 0.13
const HYPSO_MTN_ONSET := 0.35
const HYPSO_MTN_WIDTH := 0.10
const HYPSO_MTN_SLOPE := 1.30
const HYPSO_PEAK_ONSET := 0.60
const HYPSO_PEAK_GAIN := 0.30
## Peak spacing = TERRAIN_HEIGHT / MOUNTAIN_SCALE. At 60 km terrain height
## that is 600 km between major peaks, against a 917 km world -- roughly one
## and a half ranges in view rather than fifteen.
##
## 1.0 -> 0.1, because the map read as "too many high frequency steep
## mountains" and this is the knob that owns that, not the erosion filter.
##
## WHY THIS ONE AND NOT GAIN OR OCTAVE COUNT. M_MSCALE multiplies the SAMPLE
## COORDINATE (erosion_wrapper_glsl.h:811-812), so it is frequency; and the
## returned slope components are multiplied by it as well (:820, the chain
## rule), so lowering it widens spacing AND reduces gradient proportionally.
## Gain was already tuned twice and its comment records both failures (0.55
## leaves the mid octaves negligible, 0.75 returns to cliffs-everywhere), and
## the octave COUNT was measured to do nothing -- 5, 7 and 9 gave identical
## structure functions to two decimals.
##
## WHAT WAS ACTUALLY WRONG AT 1.0. The eight octaves ran
## 60 / 32 / 17 / 9.2 / 4.9 / 2.6 / 1.4 / 0.74 km, and at MOUNTAIN_GAIN 0.68
## the last still carried 0.68^7 = 6.7% of ~5.5 km, i.e. ~370 m of relief at a
## 740 m wavelength -- a ~50% slope, from the MOUNTAIN layer's own gradient.
## That is why no amount of erosion tuning removed it.
##
## ⚠ NOT 0.0. It appears as `M_TH / M_MSCALE` in nyquist_octaves
## (erosion_wrapper_glsl.h:809), so zero is a divide-by-zero, not "no
## mountains". "Nearly zero" is a floor, not a figure of speech.
const MOUNTAIN_SCALE := 0.1
## 600 / 321 / 172 / 92 / 49 / 26 / 14 / 7.4 km at MOUNTAIN_SCALE 0.1 and
## NOISE_LACUNARITY 1.87. The finest mountain octave moves 0.74 -> 7.4 km,
## which is the whole point of the change: the steep high-frequency content is
## gone and RELIEF_SCALE's 120 km lobes become the dominant mid-scale
## structure, which is what makes "how mountainous is it here" read.
const MOUNTAIN_OCTAVES := 8
## Fraction of the 10 km vertical range at FULL relief. WAS 0.8, i.e. 8 km of
## relief at a 20 km wavelength -- steeper than the Himalaya, and measured as a
## flat 0.43 RMS slope at EVERY scale from 2 m to 100 m. That constant slope was
## the floor under the "everything is a cliff" complaint: no amount of tuning
## detail or erosion could get below it, because it is the mountain layer's own
## gradient. 0.55 -> ~5.5 km, still a very large mountain.
const MOUNTAIN_HEIGHT := 0.55
## Relief lobes every TERRAIN_HEIGHT / RELIEF_SCALE = 40 km. The world is 57 km, so
## you get roughly one high region and one low one — which is the point: stand in
## the middle and both are in view at once.
const RELIEF_SCALE := 0.5
## Amplitude in the flattest regions. 0.08 * 8 km = ~640 m of relief: Vermont.
const RELIEF_FLOOR := 0.08
## PERSISTENCE — was hardcoded 0.5, and that is why the ground was glassy.
## Octave k has amplitude gain^k, so at 0.5 the amplitude at player-scale
## wavelengths is metres and no octave count fixes it: measured, raising
## mountain_octaves 4 -> 12 moved local roughness 0.72 -> 0.98 m per 2 m stride.
## 0.55 adds mid-scale structure without turning ridges into cliffs.
## 0.68, not 0.55. Octave k of an fBm is worth gain^k, so at 0.55 the mid
## octaves are already negligible and the field is a huge base wavelength plus
## fine detail with little between -- "no progressive mountain range buildup".
## Raising the octave COUNT does nothing about that and was measured not to:
## erosion octaves 5, 7 and 9 gave identical structure functions to two decimals.
## PERSISTENCE is the knob. Measured RMS slope at 100 m: 0.20 at gain 0.55,
## 0.32 at 0.68. Higher still (0.75) returns to the cliff-everywhere problem.
const MOUNTAIN_GAIN := 0.68
## DETAIL LAYER — its own amplitude and falloff, and NOT scaled by the relief
## field, so the low-relief lobes have rock underfoot too. That matters because
## the spawn sits in one: baseline roughness there was 0.69 m per 2 m stride
## against 9.78 m in the mountains, a 14x difference you could feel.
##
## dscale 200 over terrain_height 20000 = a 100 m base, six octaves down to
## ~3 m. dh is a NOMINAL amplitude — grad_noised's output range is about +/-0.1,
## so the effective figure is roughly a tenth of it. Measured, not derived.
## DETAIL_HEIGHT was 120 m and it was 77% of the roughness underfoot, in
## quadrature: 30 m of amplitude spent entirely between 15 and 100 m wavelength
## is a 47 degree RMS slope on its own, while contributing nothing at mid scale
## (its structure function is flat at ~30 m from 250 m outward). 30 m halves the
## 2 m slope; below 30 the returns vanish because the floor is the mountain.
## 600, not 200: DETAIL_SCALE is a divisor of TERRAIN_HEIGHT, so tripling that
## would have tripled the detail base wavelength from 100 m to 300 m and smoothed
## the ground underfoot. Tripled in step, the detail band stays where it was.
const DETAIL_SCALE := 600.0
const DETAIL_HEIGHT := 30.0
## 4, NOT 6. dscale 200 over terrain_height 20000 is a 100 m base, so six octaves
## reach 3.1 m — at or under the mesher's Nyquist for a 1.0 m voxel, which cannot
## be represented and aliases into hard glitter. Four stops at 12.5 m. Measured
## cost of dropping the two: roughness at spawn 2.45 -> 2.13 m per 2 m stride.
const DETAIL_OCTAVES := 4
const DETAIL_GAIN := 0.5
## NOT 2.0. With frequency doubling and constant gain every octave is a scaled
## copy of the last and they all land on harmonics — which is what reads as
## self-similar. A non-integer ratio stops them lining up. Applied to both the
## mountain and detail fBm.
const NOISE_LACUNARITY := 1.87
## SPAWN IS NO LONGER A HEIGHT. It is an XZ column, and the Y is found from the
## terrain at load time.
##
## The hardcoded height was wrong four times in one session -- 9600, 5200, 4200,
## 9700 -- every time for the same reason: it encodes a MEASUREMENT of a field
## that keeps moving, so any change to TERRAIN_HEIGHT, CEILING_Y, MOUNTAIN_HEIGHT
## or the erosion parameters silently invalidates it. One of those values also
## came from measuring the broken CPU field and was ~5 km low. A constant that
## has to be re-derived every time the thing it describes changes is a bug with a
## slow fuse.
##
## _surface_y() bisects DCTerrain.sample_density instead. That is only
## trustworthy because sample_density now returns the SAME surface the mesher
## draws -- it used to differ by a mean of 4 km, which is precisely how the
## 5200 m figure survived -- and dc-terrain/tests/test_erosion_parity.cpp is the
## regression guard on that.
## NOT (0,0). Gradient noise is identically ZERO at every integer lattice point --
## grad_noised_2d computes f = p - floor(p), so at a lattice point f = 0 and the
## value is dot(gradient, 0) -- and the world origin is a lattice point of EVERY
## octave simultaneously. Spawning there put the player on a guaranteed zero of
## the mountain field, sitting in a bowl that only rises to real mountains 45-100
## km out: measured 0 m at the origin, -1833 m at 10 km, +1672 m at 100 km. It
## reads as a vast empty impact crater, and it is the single least representative
## column in the world.
##
## (31337, -21500) is an ordinary column: -473 m against a world mean, with 699 m
## of relief across a 4 km window. Any off-lattice point would do; this one was
## measured rather than picked.
const SPAWN_XZ := Vector2(31337.0, -21500.0)
## Metres above the found surface. Enough not to spawn inside rock on a slope
## the bisection sampled slightly off, small enough not to be a fall.
const SPAWN_CLEARANCE := 3.0
## The teleporter sits beside the spawn, on its own column -- 6 m away can be
## several metres different in height on this terrain, so it gets its own query
## rather than reusing the player's.
const TELEPORTER_OFFSET_X := 6.0
const TELEPORTER_CLEARANCE := 0.0
var _gen: DCTerrainGeneratorProcNodes
func _ready() -> void:
super._ready()
_setup_terrain()
_place_player()
_setup_water()
## A FLAT SEA AT WATER_Y, so the coastline is visible at all.
##
## Deliberately the simplest thing that shows a shoreline: one plane, no
## waves, no FFT. GodotOceanWaves (cloned at ../GodotOceanWaves) is the real
## surface and brings a compute pipeline, cascades and global uniforms with
## it -- none of which can be judged until there IS a waterline to look at.
##
## It rides the camera in XZ. Y never moves: a sea whose height follows
## anything is not a sea.
##
## ⚠ SIZE IT FROM THE FAR PLANE, NOT FROM TASTE. 40 km was the first guess and
## the plane's straight edge was plainly visible across the middle of the view
## from any altitude. player.tscn's far plane is 200 km, so the WORST CASE is
## the view corner at 200*sqrt(2) = 283 km from the camera, and the plane has
## to reach that in every direction -> 566 km minimum. 600 km with margin.
##
## It costs two triangles. The thing that would genuinely cost is a displaced
## ocean surface at this span, which is why GodotOceanWaves uses a CLIPMAP
## rather than a plane -- that is the shape to adopt when the FFT surface goes
## in, not before.
const WATER_PLANE_SPAN := 600000.0
var _water: MeshInstance3D = null
var _far_water: MeshInstance3D = null
## LAYER 1 of the ocean: an FFT wave field on a camera-following warped sheet.
## false falls back to the flat plate below, which is the A/B if the ocean
## misbehaves -- one constant, no code change.
## Metres of 3D perturbation on the seabed. 0 disables it entirely and the
## density is bit-identical to a pure heightfield.
## ⚠ AMPLITUDE MUST BE COMPARABLE TO WAVELENGTH, or there are no caves.
##
## The density is d = H(x,z) - y in world metres, so d(d)/dy = -1 per metre. The
## 3D term contributes at most amp/wavelength to that gradient, and the surface
## can only FOLD OVER -- which is what a cave, arch or overhang is -- where the
## 3D term outruns the base. 250 m at a 3000 m wavelength is a ratio of 0.083:
## twelve times too small to fold anything, so it produced a bumpier heightfield
## and nothing else.
##
## It is not fixable by adding octaves either. Octave i has ratio
## (amp/wavelength) * (roughness * lacunarity)^i, and with roughness 0.5 and
## lacunarity 2.0 that product is exactly 1 -- every octave carries the SAME
## ratio, so no scale folds.
##
## 300 m at 300 m is a ratio of 1.0 per octave. Raise the wavelength for a
## smoother seabed, lower it for more caves.
## Deep-water values. The emitted density caps the amplitude at half the local
## depth, so these are what a deep basin gets; shallow water scales down and
## keeps its ratio below 1, which is why the shallows stay smooth.
const SUBMARINE_AMP := 1500.0
const SUBMARINE_WAVELENGTH := 1500.0
const SUBMARINE_OCTAVES := 4
## ⚠ PULLED IN FOR TESTING. At 50 -> 400 m of depth the volumetric seabed
## starts several kilometres offshore -- the measured shore profile puts 20 m of
## depth at ~1460 m out -- so from a beach there is nothing to see and no way to
## tell it from a bug. 5 -> 60 m puts it just past the surf. Push it back out
## once it has been confirmed working.
const SUBMARINE_BUFFER_START := 5.0
const SUBMARINE_BUFFER_END := 200.0
const USE_FFT_OCEAN := true
## Ocean debug channel, applied to the wave sheet at startup.
## 0 shaded · 1 world normal · 2 foam_factor · 3 wave height · 4 depth · 5 fresnel
## White water, white snow, and water painted white by AtmFog all look the same
## in a screenshot. These do not.
const OCEAN_DEBUG := 0
## Turn Sky3D's AtmFog off. It is the only thing in the project that blends over
## the WHOLE scene -- a spatial shader with `blend_mix, cull_front, unshaded`
## that reads DEPTH_TEXTURE and tints by reconstructed distance. If the wave
## shading comes back with this true, something was drawing on top of it; if it
## does not, nothing was and the fault is inside the ocean shader. One flag,
## one answer, no more reading screenshots for it.
const ATMFOG_OFF := false
func _setup_water() -> void:
if USE_FFT_OCEAN:
var ocean := Ocean.new()
ocean.name = "SeaLevel"
ocean.debug_channel = OCEAN_DEBUG
ocean.water_y = WATER_Y
# ⚠ DO NOT stretch the sheet to the plate's span. It was set to
# WATER_PLANE_SPAN * 0.5 (300 km) back when the sheet WAS the whole
# sea, and left there it puts ~50 m between vertices at the camera --
# one whole cascade tile, so no wave can be represented at all. The
# sheet covers the wave zone; the plate covers the horizon.
_water = ocean
add_child(ocean)
ocean.submerged_changed.connect(_on_submerged_changed)
_register_water_panel.call_deferred(ocean)
if ATMFOG_OFF:
_kill_atmfog.call_deferred()
print("[MapDCErosion] FFT ocean at y=%.0f (%.1f km wave sheet + flat plate "
% [WATER_Y, ocean.extent / 1000.0]
+ "to the horizon, shore-attenuated against the real seabed)")
# The Ocean node owns the horizon plate too, running the SAME shader --
# a StandardMaterial3D behind the sheet put a visible tonal step at its
# rim, which one material cannot do.
return
_water = _make_flat_plate()
add_child(_water)
_water.global_position = Vector3(0.0, WATER_Y, 0.0)
print("[MapDCErosion] sea plane at y=%.0f (%.0f km span, camera-following in XZ)"
% [WATER_Y, WATER_PLANE_SPAN / 1000.0])
## UNDERWATER, DON'T RENDER WHAT THE WATER HIDES.
##
## Submerged, the volume pass leaves at most exp(-k_min * d) of a surface d
## metres away, so past Ocean.visibility_distance() (1200 m at default water)
## nothing can show. The camera's far plane was still 1000 km, so the whole
## world behind the murk was drawn and shaded -- and the terrain material's
## per-pixel detail normal made that the dominant cost of the frame (hyper-games
## #108 perf, 2026-10-07: GPU 19 ms above water -> 55-69 ms below it).
##
## The far plane comes down to the visibility distance while submerged and is
## put back on surfacing. A pixel the far plane now drops reads as sky, which the
## pass already treats as max_path of water: the image differs by at most 0.8%.
## Re-checked every frame because the active camera can change while under.
var _submerged := false
var _sub_vis := 0.0
var _far_cams: Array[Camera3D] = []
const _SUB_FAR_META := &"_submerged_orig_far"
func _on_submerged_changed(is_sub: bool) -> void:
_submerged = is_sub
if is_sub:
_sub_vis = (_water as Ocean).visibility_distance()
_clamp_far_submerged()
else:
for c in _far_cams:
if is_instance_valid(c) and c.has_meta(_SUB_FAR_META):
c.far = float(c.get_meta(_SUB_FAR_META))
c.remove_meta(_SUB_FAR_META)
_far_cams.clear()
print("[MapDCErosion] %s: far plane %s" % ["submerged" if is_sub else "surfaced",
"%.0f m (water visibility)" % _sub_vis if is_sub else "restored"])
func _clamp_far_submerged() -> void:
var cam := get_viewport().get_camera_3d()
if cam == null or cam.far <= _sub_vis:
return
if not cam.has_meta(_SUB_FAR_META):
cam.set_meta(_SUB_FAR_META, cam.far)
_far_cams.append(cam)
cam.far = _sub_vis
## Zero Sky3D's atmospheric fog. Deferred: the sky lives in the Game scene and
## may not be reachable while the map is still entering the tree.
func _kill_atmfog() -> void:
var sky := get_node_or_null("/root/Game/Sky3DExtended/SkyDome")
if sky == null:
print("[MapDCErosion] ATMFOG_OFF: no SkyDome found — fog left alone")
return
sky.set("fog_density", 0.0)
sky.set("fog_rayleigh_depth", 0.0)
sky.set("fog_mie_depth", 0.0)
print("[MapDCErosion] ATMFOG_OFF: atmospheric fog zeroed (diagnostic)")
## A dock panel to switch the water off, because the seabed is underneath it.
## Volumetric submarine terrain -- caves, arches, overhangs -- is invisible from
## above the waterline and hidden by absorption from below, so there was no way
## to look at the thing that had just been built.
##
## BuildDock.register is idempotent on id, so re-entering the map replaces the
## panel rather than stacking a second one.
func _register_water_panel(ocean) -> void:
if ocean == null or not is_instance_valid(ocean):
return
var dock := get_node_or_null("/root/BuildDock")
if dock == null:
return
var panel := VBoxContainer.new()
var rows := [
["Wave surface", true, func(v): ocean.set_surface_visible(v)],
["Horizon plate", true, func(v): ocean.set_horizon_visible(v)],
["Underwater tint", true, func(v): ocean.set_underwater_enabled(v)],
]
# LOOK A/B for the seabed detail-normal stopgap (dc_erosion_color.gdshader,
# undersea_detail_off): ticked = the old smooth look (erosion + old fBm normals,
# ~49 ms GPU at the seabed), unticked = the raw Geiss mesh (~16 ms).
var terrain := get_node_or_null("DCTerrain")
var mat: Variant = terrain.get("custom_material") if terrain != null else null
if mat is ShaderMaterial:
rows.append(["Seabed smooth normals (old field, slow)", false,
func(v): (mat as ShaderMaterial).set_shader_parameter("undersea_detail_off", not v)])
for r in rows:
var cb := CheckBox.new()
cb.text = r[0]
cb.button_pressed = r[1]
cb.toggled.connect(r[2])
panel.add_child(cb)
var all := Button.new()
all.text = "Hide all water"
all.pressed.connect(func():
ocean.set_surface_visible(false)
ocean.set_horizon_visible(false)
ocean.set_underwater_enabled(false)
for c in panel.get_children():
if c is CheckBox: (c as CheckBox).set_pressed_no_signal(false))
panel.add_child(all)
dock.register("water", "Water", panel, true, dock.SIDE_LEFT)
## The flat sea plate: the far field behind the wave sheet, and the whole sea
## when USE_FFT_OCEAN is off.
func _make_flat_plate() -> MeshInstance3D:
var mesh := PlaneMesh.new()
mesh.size = Vector2(WATER_PLANE_SPAN, WATER_PLANE_SPAN)
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.name = "SeaLevel"
# Never culled: its AABB is the plane, but the camera sits inside it.
mi.extra_cull_margin = WATER_PLANE_SPAN * 0.5
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.02, 0.09, 0.16, 0.82)
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.roughness = 0.08
m.metallic = 0.25
m.cull_mode = BaseMaterial3D.CULL_DISABLED # visible from underwater too
mi.material_override = m
return mi
func _process(_delta: float) -> void:
# FIRST: the brush editor's per-frame work (aim outline, operator preview, fill
# ghost) lives in map_dc_level._process; the early returns below would skip it.
super._process(_delta)
if _water == null:
return
# The Ocean node follows the camera itself, on its own snap lattice.
if _water is Ocean:
if _submerged:
_clamp_far_submerged()
return
var cam := get_viewport().get_camera_3d()
if cam == null:
return
# XZ only. Snapped to a coarse grid so the plane does not jitter against
# the terrain every frame at 917 km coordinates, where float32 precision
# in the vertex shader is already thin.
var step := 2000.0
_water.global_position = Vector3(
snappedf(cam.global_position.x, step), WATER_Y,
snappedf(cam.global_position.z, step))
func _setup_terrain() -> void:
var terrain := get_node_or_null("DCTerrain")
if terrain == null or not is_instance_valid(terrain):
push_warning("[MapDCErosion] no DCTerrain child — nothing to generate")
return
# The erosion graph, built in code: the graph IS the parameter set, and six
# literals in a script diff better than a .tres full of node ids. Socket indices
# follow scenes/test/proc_erosion_sunshine_test.gd, the working reference.
var g := ProcNodeGraph.new()
var noise := g.add_node(GradientNoiseNode.new())
g.get_node(noise).set_input_default(1, 3.0) # scale
g.get_node(noise).set_input_default(2, 3) # detail octaves
g.get_node(noise).set_input_default(3, 0.1) # gain
var erosion := g.add_node(ErosionNoiseNode.new())
g.get_node(erosion).set_input_default(1, 5.0) # height scale
# STRENGTH AND GAIN ARE COUPLED, and the relation is exact rather than tuned.
# Per-octave slope is amplitude/wavelength; with gain g and lacunarity L that
# scales as (g/(1/L))^k. At gain 0.68 against lacunarity 2.0 the slope GREW
# 1.36x per octave -- measured 0.056 at the 6.3 km octave (a 3 degree drape,
# the giant smears) rising to 0.354 at 98 m (19 degrees, reads as grit). Every
# octave had a different character, which is the opposite of self-similar.
#
# gain = 1/lacunarity = 0.5 makes slope CONSTANT across octaves, and the whole
# thing collapses to slope = 2 * strength, independent of scale. 0.15 -> 0.30
# at every octave: large channels and small ones with the same character.
#
# The previous 0.028 was also 8x too shallow to cut anything -- 257 m of
# erosion against 11550 m of mountain is a 2.2% perturbation, i.e. surface
# texture rather than a landform. That was an overcorrection to "too extreme".
g.get_node(erosion).set_input_default(2, 0.15) # erosion strength
g.get_node(erosion).set_input_default(3, 0.6) # slope power
g.get_node(erosion).set_input_default(4, 0.7) # cell scale
g.get_node(erosion).set_input_default(5, 7) # octaves
g.get_node(erosion).set_input_default(6, 0.5) # gain = 1/lacunarity, see above
# Sockets 7/8 carry the gradient noise's VALUE and its DERIVATIVE. The erosion
# filter is slope-aware and needs both — that is what makes it look eroded
# rather than merely bumpy, and it is why these two connections are not optional.
g.connect_sockets(noise, 0, erosion, 7)
g.connect_sockets(noise, 1, erosion, 8)
_gen = DCTerrainGeneratorProcNodes.new()
_gen.set_terrain_height(TERRAIN_HEIGHT)
_gen.set_floor_y(FLOOR_Y)
_gen.set_ceiling_y(CEILING_Y)
_gen.set_mountain_scale(MOUNTAIN_SCALE)
_gen.set_mountain_height(MOUNTAIN_HEIGHT)
_gen.set_mountain_octaves(MOUNTAIN_OCTAVES)
_gen.set_relief_scale(RELIEF_SCALE)
_gen.set_relief_floor(RELIEF_FLOOR)
_gen.set_mountain_gain(MOUNTAIN_GAIN)
_gen.set_detail_scale(DETAIL_SCALE)
_gen.set_detail_height(DETAIL_HEIGHT)
_gen.set_detail_octaves(DETAIL_OCTAVES)
_gen.set_detail_gain(DETAIL_GAIN)
_gen.set_noise_lacunarity(NOISE_LACUNARITY)
# The land/sea curve. See the HYPSO_* block above.
# ── SUBMARINE TERRAIN ───────────────────────────────────────────────
# Below the coastal buffer the seabed becomes volumetric: a 3D fBm added to
# the density, so it can hold caves, arches and overhangs. A heightfield
# cannot, and the whole world is meant to be reachable.
#
# The buffer keeps the first 50 m of depth a pure heightfield and reaches
# full strength by 400 m. That is not arbitrary -- the measured shore
# profile runs 1460 m from the waterline to 20 m of depth, so 50 m of depth
# is roughly 4 km offshore: beaches, the waterline and the ocean's own depth
# query all stay in single-valued terrain, and the caves start well out.
_gen.set_submarine_amp(SUBMARINE_AMP)
_gen.set_submarine_wavelength(SUBMARINE_WAVELENGTH)
_gen.set_submarine_octaves(SUBMARINE_OCTAVES)
_gen.set_submarine_buffer(SUBMARINE_BUFFER_START, SUBMARINE_BUFFER_END)
_gen.set_submarine_water_y(WATER_Y)
_gen.set_hypso_enabled(HYPSO_ENABLED)
_gen.set_hypso_sea(HYPSO_SEA)
_gen.set_hypso_shelf_width(HYPSO_SHELF_WIDTH)
_gen.set_hypso_slope_width(HYPSO_SLOPE_WIDTH)
_gen.set_hypso_abyss(HYPSO_ABYSS)
_gen.set_hypso_plat_slope(HYPSO_PLAT_SLOPE)
_gen.set_hypso_mtn_onset(HYPSO_MTN_ONSET)
_gen.set_hypso_mtn_width(HYPSO_MTN_WIDTH)
_gen.set_hypso_mtn_slope(HYPSO_MTN_SLOPE)
_gen.set_hypso_peak_onset(HYPSO_PEAK_ONSET)
_gen.set_hypso_peak_gain(HYPSO_PEAK_GAIN)
# height_space_sqrt stays OFF. The transform is implemented and correct, but it
# pivots on floor_y, so squaring compresses the whole world onto the floor —
# measured elevations collapsed to about -650..-990 m against a -1000 m floor,
# and the roughness-follows-elevation effect it exists for did NOT appear
# (highest transect was the SMOOTHEST). It needs the vertical mapping
# re-derived around a real sea level before it is worth enabling.
_gen.set_graph(g)
# OUR GEISS-STYLE UNDERSEA TERM (§4 P3): multi-level floors, rotated octaves and
# shelves below the coast band (the scene's coast_start / coast_full knobs, 30 / 50 m
# below WATER_Y by default), capped at half the water depth. The term is cut out of
# the bridged scene blender/undersea_geiss.tscn (UnderseaGeissGraph.install_detail).
# It REPLACES the plain submarine fBm above (dc-terrain skips that block when this
# is set). A refusal is logged and the fBm stays, so the seabed never goes flat.
var undersea_why := UnderseaGeissGraph.install_detail(_gen)
if undersea_why != "":
push_warning("[MapDCErosion] undersea graph not used: %s" % undersea_why)
# Assert the graph compiled to the erosion function rather than silently
# producing a bare noise field. A generator that fails to compile still renders
# something plausible, which is the worst outcome for a map whose only purpose
# is judging what the erosion looks like.
#
# CHECK THE CALL, NOT THE NAME. This used to test for "erosion_filter", which
# was a real check while that function was emitted only when the erosion node
# was in the graph. It is not one any more: erosion_filter now lives in the
# shared noise LIBRARY, which GpuCompiler attaches whenever any node mentions
# noise at all — so a graph containing nothing but GradientNoise would have
# passed. The string below appears only in ErosionNoiseNode's own emitted
# snippet, so it is present exactly when the node actually ran.
var glsl: String = g.compile_to_glsl_function("terrain_density", true)
#
# ⚠ THIS CHECK WAS ITSELF WRONG, AND FIRED FOR WEEKS. It tested for
# "vec2 en_result = erosion_node_eval(" while the node emits
# erosion_node_eval_FULL( -- the call was renamed and the guard was not.
# Dumping the shipping GLSL (dump_map_glsl.gd) showed the call present at
# line 1030 the whole time. A guard that cries wolf is worse than no guard:
# it trains you to ignore it, and it cost several hours of treating a
# working generator as broken.
#
# So key on a line that carries NO function name and is emitted only by
# ErosionNoiseNode. Renaming erosion_node_eval_full again cannot silently
# break this the way it broke the last one.
#
# ⚠ AND IT WRITES OUT WHAT IT LOOKED AT. This guard was wrong twice and
# both times the message asserted a conclusion without showing evidence,
# which is what made it cost hours instead of minutes. A check that can
# fail must name the artifact it examined (hyper-games #44).
#
# NOTE the string examined here is compile_to_glsl_function's, which is a
# SEPARATE compile from the generator's cached_glsl_ (different function
# name, clamp_octaves=true). If the two disagree, THAT is the finding.
if not glsl.contains("float en_erosion = en_result.y;"):
var dump := "/tmp/erosion_guard_glsl.txt"
var f := FileAccess.open(dump, FileAccess.WRITE)
if f != null:
f.store_string(glsl)
f.close()
push_warning("[MapDCErosion] the graph's compiled GLSL does not contain " +
"ErosionNoiseNode's emitted line. Either the node did not emit, " +
"or this check's key is stale AGAIN. The exact %d bytes examined " +
"are at %s — grep it before believing this message."
% [glsl.length(), dump])
else:
print("[MapDCErosion] erosion node emitted (%d bytes of graph GLSL)" % glsl.length())
terrain.set("generator", _gen)
# THE COLOUR SHADER NORMALISES HEIGHT BY THE SAME VERTICAL RANGE, so it has to
# be told what that range is. It was authored in the .tscn as (-1000, 9000)
# and stayed there when CEILING_Y moved to 20000, which silently broke every
# material band: h = (y - floor)/(ceiling - floor) read 0.978 at the spawn
# instead of 0.466, putting the whole world past the snow threshold (0.70)
# so it rendered as featureless white with no rock, no cliffs and no
# variation. It looked like missing terrain rather than a broken ramp.
#
# Set from the map's own constants rather than authored in the scene: a
# duplicated constant is a constant that goes stale, which is the same defect
# as the hardcoded spawn height above.
var mat: Variant = terrain.get("custom_material")
if mat != null and mat is ShaderMaterial:
var water_frac: float = (WATER_Y - FLOOR_Y) / (CEILING_Y - FLOOR_Y)
(mat as ShaderMaterial).set_shader_parameter(
"terrain_params", Vector3(FLOOR_Y, CEILING_Y, water_frac))
# THE TWO THE DIFFUSION MAP SETS AND THIS ONE DID NOT.
(mat as ShaderMaterial).set_shader_parameter("color_mode", LOOKDEV_COLOUR_MODE)
(mat as ShaderMaterial).set_shader_parameter("field_range", SURFACE_RANGE)
# THE DETAIL NORMAL'S FREQUENCY, from the SAME constants the generator
# uses. Set here rather than defaulted in the shader so the material and
# the terrain cannot drift: the whole premise is that the shader
# evaluates the SAME field, and a duplicated frequency is the first
# thing that would quietly stop being true.
print("[MapDCErosion] detail normal: freq %.5f (base %.0f m), %d oct, gain %.2f"
% [DETAIL_SCALE / TERRAIN_HEIGHT, TERRAIN_HEIGHT / DETAIL_SCALE,
DETAIL_OCTAVES, DETAIL_GAIN])
# Read back, so a wrong name or a material that is not the one being
# drawn shows up here rather than as "the colours did not change".
var read_back: Variant = (mat as ShaderMaterial).get_shader_parameter("color_mode")
print("[MapDCErosion] color_mode %d, reads back %s (%s); field_range %.0f..%.0f"
% [LOOKDEV_COLOUR_MODE, str(read_back),
"MATCH" if int(read_back) == LOOKDEV_COLOUR_MODE else "MISMATCH",
SURFACE_RANGE.x, SURFACE_RANGE.y])
print("[MapDCErosion] colour ramp: floor %.0f ceiling %.0f water %.0f m (h=%.3f)"
% [FLOOR_Y, CEILING_Y, WATER_Y, water_frac])
# STOPGAP: the detail normal's field has no undersea term, so it switches off
# where the generator's undersea term switches on (dc_erosion_color.gdshader,
# undersea_detail_off). Same coast knobs install_detail handed the generator.
# Only when the term installed: on a refusal the seabed keeps the plain fBm,
# which IS the field the include evaluates.
if undersea_why == "":
var kn := UnderseaGeissGraph.knobs_for(UnderseaGeissGraph.load_graph())
var band := Vector3(WATER_Y, float(kn["coast_start"]), float(kn["coast_full"]))
(mat as ShaderMaterial).set_shader_parameter("undersea_detail_off", true)
(mat as ShaderMaterial).set_shader_parameter("undersea_band", band)
print("[MapDCErosion] detail normal OFF below the coast band (water %.0f, coast %.0f..%.0f m)"
% [band.x, band.y, band.z])
else:
push_warning("[MapDCErosion] no ShaderMaterial on DCTerrain — colour ramp " +
"still on whatever the scene authored, which will not match " +
"floor_y/ceiling_y.")
# The colour material is declared in map_dc_erosion.tscn as an ext_resource, not
# load()ed here. export_filter is "selected_scenes", so only scene DEPENDENCIES
# are exported and a runtime load() is not one — the exported build logged
# "Cannot load shader: res://scenes/maps/dc_erosion_color.gdshader" because the file
# had never entered the PCK.
# ROOT SPAN, not half of it. OctreeClipmap::initialize centres the root and
# offsets by root_span/2, so voxels_per_cell * 2^(depth-1) IS the full extent.
# proc_erosion_sunshine_test.gd calls the same expression `half_ext` and prints
# double it — that print is wrong; do not copy it.
var span: float = float(1 << (OCTREE_DEPTH - 1)) * 28.0 * VOXEL_SIZE
print("[MapDCErosion] voxel %.2f m depth %d -> span %.1f km (+/- %.1f km)" %
[VOXEL_SIZE, OCTREE_DEPTH, span / 1000.0, span / 2000.0])
print("[MapDCErosion] peak spacing %.1f km, octaves %d (%.1f/%.1f/%.1f/%.1f km), " %
[TERRAIN_HEIGHT / MOUNTAIN_SCALE / 1000.0, MOUNTAIN_OCTAVES,
TERRAIN_HEIGHT / MOUNTAIN_SCALE / 1000.0,
TERRAIN_HEIGHT / MOUNTAIN_SCALE / 2000.0,
TERRAIN_HEIGHT / MOUNTAIN_SCALE / 4000.0,
TERRAIN_HEIGHT / MOUNTAIN_SCALE / 8000.0] +
"relief lobes %.1f km, relief %.0f m .. %.0f m" %
[TERRAIN_HEIGHT / RELIEF_SCALE / 1000.0,
(CEILING_Y - FLOOR_Y) * MOUNTAIN_HEIGHT * RELIEF_FLOOR,
(CEILING_Y - FLOOR_Y) * MOUNTAIN_HEIGHT])
const _NO_SURFACE := -999999.0
func _surface_y(terrain, x: float, z: float) -> float:
var lo := FLOOR_Y + 1.0
var hi := CEILING_Y - 1.0
var step := 50.0
# Scan DOWNWARD from the ceiling so we find the TOP surface. Scanning up from
# the floor finds the bottom of the first overhang instead, and while this
# generator is a heightfield today it will not be once caves land.
var prev_y := hi
var prev: float = terrain.sample_density(Vector3(x, hi, z))
var y := hi - step
while y >= lo:
var d: float = terrain.sample_density(Vector3(x, y, z))
if (prev >= 0.0) != (d >= 0.0):
var a := y # inside the sign change
var b := prev_y # outside
for _i in range(28):
var m: float = (a + b) * 0.5
if (terrain.sample_density(Vector3(x, m, z)) >= 0.0) == (d >= 0.0):
a = m
else:
b = m
return (a + b) * 0.5
prev = d
prev_y = y
y -= step
return _NO_SURFACE
func _place_player() -> void:
var terrain := get_node_or_null("DCTerrain")
if terrain == null or not is_instance_valid(terrain):
push_error("[MapDCErosion] no DCTerrain — cannot place spawn on the surface")
return
var surface := _surface_y(terrain, SPAWN_XZ.x, SPAWN_XZ.y)
if surface == _NO_SURFACE:
# LOUD, not a silent fallback to some default height. A spawn column with
# no surface means the vertical band and the field have come apart, which
# is the "empty world, healthy log" failure this map exists to catch.
push_error("[MapDCErosion] no surface in column (%.0f, %.0f) between %.0f and %.0f — "
% [SPAWN_XZ.x, SPAWN_XZ.y, FLOOR_Y, CEILING_Y] +
"the terrain and the vertical band disagree. Leaving the scene's " +
"authored positions alone.")
return
var spawn_pos := Vector3(SPAWN_XZ.x, surface + SPAWN_CLEARANCE, SPAWN_XZ.y)
print("[MapDCErosion] surface at spawn column = %.1f m -> spawn %.1f m" %
[surface, spawn_pos.y])
var player := get_node_or_null("Player")
if player != null and player is Node3D:
(player as Node3D).position = spawn_pos
# Keep the authored SpawnPoint marker in step, or respawn puts you back at
# whatever height the .tscn happens to still carry.
# The player's fall-through-the-world floor is an ABSOLUTE Y, and the band just
# moved past its old default of -200: with the band centred a valley floor
# reaches -5300, and every player standing in one would be auto-respawned where
# they stood. player_procedural.gd resolves it from the terrain's own floor_y
# instead, so there is nothing to set here — this note exists because the
# coupling is invisible from either file alone.
var marker := get_node_or_null("SpawnPoint")
if marker != null and marker is Node3D:
(marker as Node3D).position = spawn_pos
# The teleporter gets its OWN column query — 6 m away is a different height.
var tp := get_node_or_null("TeleporterToHome")
if tp != null and tp is Node3D:
var tx: float = SPAWN_XZ.x + TELEPORTER_OFFSET_X
var ts := _surface_y(terrain, tx, SPAWN_XZ.y)
if ts == _NO_SURFACE:
ts = surface
(tp as Node3D).position = Vector3(tx, ts + TELEPORTER_CLEARANCE, SPAWN_XZ.y)
print("[MapDCErosion] teleporter placed at %.1f m" % (ts + TELEPORTER_CLEARANCE))
# ---- caves: what it would take, and why they are not here ------------------
#
# This generator is a HEIGHTFIELD by construction. The assembled GLSL in
# dc_terrain_generator_procnodes.cpp ends in
#
# float d = (has.x + erosion_delta.x) - y_mapped;
#
# so Y enters once, linearly, and every (x,z) column has exactly ONE surface. No
# parameter produces a cave, an overhang, or anything above your head — it is the
# functional form, not a setting.
#
# THE PIECES FOR CAVES ALREADY EXIST, in the OTHER generator. The built-in one does
# it at shaders/generators/noise_sdf.glsl:307-328 — two 3D noise fields, carve where
# their combined distance falls inside a radius:
#
# tube_carve = max(1 - (ta*ta + tb*tb) / radius^2, 0)
# d -= tube_carve * tunnel_strength
#
# which gives TUBE-shaped voids rather than the swiss cheese a single threshold
# produces. Porting that into this wrapper is a contained change to one string plus
# three properties, exactly like relief_scale/relief_floor were.
#
# THE ONE REAL OBSTACLE: noise3/fbm3 live in DENSITY_GLSL_BODY, and a custom
# generator REPLACES that body — so this shader has no 3D noise to call. It does
# have perm_tables[] (declared in DENSITY_GLSL_HEADER, shared by every body), so the
# fix is to give the wrapper its own noise3, not to plumb a new buffer.
#
# And it is not a free addition: tunnels read `d` to decide where to carve
# (noise_sdf.glsl:311 gates on a crust band), so cave placement and the erosion
# surface are coupled through that test.