extends "res://scenes/maps/map_core.gd"
## MAP DC DIFFUSION — the erosion map with the TERRAIN SWAPPED.
##
## A copy of map_dc_erosion.gd. Every constant below is the same, deliberately:
## the vertical band, the colour ramp, the detail layer, the erosion parameters.
## The one change is where the large forms come from. Instead of the fBm
## multifractal mountain layer, they are read from a BAKED terrain-diffusion
## block -- xandergos/terrain-diffusion-30m, seed 1234, fp32 -- and the same
## erosion filter carves that instead. hyper-games #50 section 5.3.
##
## The generator does the swap by replacing pn_terrain_offset, so the filter
## receives the model's height AND its gradient and cuts gullies down the
## ranges the model actually made, rather than decorating them.
##
## WHY GN_DETAIL IS 0 HERE AND 3 IN THE EROSION MAP. The Gradient Noise node is
## not fine detail: at gain 0.1 its amplitude reaches gn.x * HEIGHT_AMP * 0.5 =
## 0.125 of the band, which is about 2900 m -- a co-equal layer with the model's
## own 6900 m of relief. Leaving it on would bury the landscape this map exists
## to show under fBm. fractal_noise_2d_lod loops `for i < octaves`, so 0 returns
## exactly vec3(0.0); this is an off switch, not a small value.
##
## THE BLOCK IS FINITE -- 30.72 km, world x 38370..69150, z 23010..53790. The
## sampler CLAMPS outside it, so the terrain runs flat to the horizon past those
## edges. That is a property of a baked block, not a bug, and it is why the
## spawn sits at the block's centre.
## 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.
const FLOOR_Y := -10900.0
const CEILING_Y := 10100.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.
const WATER_Y := -5900.0
## LOOKDEV COLOUR MODE — 0 game material, 1 the lookdev's 3D material,
## 2 the lookdev's 2D hypsometric map tint.
##
## Set HERE rather than only from the terrain panel's checkbox. The checkbox
## exists too, but a control that has to be found and clicked cannot tell you
## whether it WORKED — the first run with it produced "just the normal shading"
## and there was no way, from the log, to tell a mode that never got set from a
## mode that got set and did nothing. This prints what it wrote.
const LOOKDEV_COLOUR_MODE := 1
## Peak spacing = TERRAIN_HEIGHT / MOUNTAIN_SCALE = 20 km between major peaks.
const MOUNTAIN_SCALE := 1.0
## 20 / 10 / 5 / 2.5 km. All four above Nyquist at the LODs you view a range from.
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.
## ZERO. The erosion node adds pn_terrain_offset to whatever Height/Slope it is
## given, and that offset IS the mountain layer. With the heightfield supplying
## the terrain, the mountain has to be off or it is added on top of it. A map
## setting, not a code path.
const MOUNTAIN_HEIGHT := 0.0
## 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.
## ZERO, and that is the point. detail_offset() early-returns 0.0 when the scale
## is <= 0, so the wrapper contributes no fBm at all. The lookdev is the
## heightmap and the erosion filter and nothing else; a 600 here puts a 4-octave
## +/-30 m noise layer into the output the lookdev never had, which is what made
## "identical to lookdev" untrue the first time.
const DETAIL_SCALE := 0.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.
## The baked block. Shipped as a project asset rather than load()ed from an
## absolute path: export_filter is "selected_scenes", so a file that is not a
## scene dependency never enters the PCK -- the same trap that shipped
## map_dc_erosion without its colour shader. assets/terrain/*.hblk is in
## export_presets.cfg include_filter for exactly that reason.
## THE BAKED FIELD. 16x16 lookdev tiles, 4098 px + apron, 122.88 km across,
## world x -30750..92130 z -69150..53730, elevation -4230..2782 m.
##
## WAS ONE 4x4 BLOCK, 30.72 km, and the sampler CLAMPS outside its footprint —
## so past the edge the terrain went flat. stitch_block.py --list reports 50
## SOLID 16x16 regions in the 841-tile cache, and this is the one whose centre
## has the most BIOME DIVERSITY while still containing the spawn column: 7
## classes at >=1% of area and 46.9% land, against 6 and 33.6% for the block
## centred on the spawn. Ranked across all 50 solid regions from one coarse
## survey; the extent grew 4x linear / 16x area with no
## change to the pipeline at all: same .hblk format, same loader, same mip
## builder. A streaming window is only needed beyond this, for the full
## 829 x 668 km the cache spans sparsely.
##
## Both blocks align to the same underlying tile grid (origin = tile_start - 1px
## of apron), so identical world coordinates sample identical values and
## SPAWN_SURFACE_Y did not move.
const HEIGHTFIELD := "res://assets/terrain/diffusion_123km_b.hblk"
## THE WHOLE GENERATED TILE SET, instead of one stitched block.
##
## The block above is a SNAPSHOT: 4098^2 of solid tiles, 122.88 km, fixed at
## bake time. The generated set is 841 tiles scattered over tx -30..77,
## ty -30..56 — 5.9% of the 119x119 world and only 8.9% of its own bounding
## box. It is a flight path, not a rectangle, and there is no solid region large
## enough to stitch that would show it.
##
## So the node takes a LIST and the gaps read as STUB. Absent is not an error
## and it is not a clamp: clamping smears the nearest loaded row across the
## hole, which reads as terrain, and that is the failure this exists to avoid.
const TILE_DIR := "user://tile_cache/1234_hblk"
const USE_TILE_SET := true
## ⚠ Gaps read as the hard FLOOR, deliberately. A stub near the terrain band
## would look like flat ground and hide how incomplete the world is; at the
## floor it is unmistakably a hole. 5.9% coverage should LOOK like 5.9%.
const TILE_STUB_Y := FLOOR_Y
## EROSION, over the baked field.
##
## HEIGHT_SCALE is not a taste knob: the wrapper's y_mapped is
## (y - floor) * (HEIGHT_SCALE / y_range), and the Heightfield node's YScale is
## the same quotient. Change one without the other and the world moves.
## EROSION ON OR OFF — the filter is an ENHANCEMENT, not the terrain.
##
## The heightmap IS the terrain here: the Heightfield node samples the baked
## diffusion tile and displaces the surface to it, and that is a complete map on
## its own. The erosion filter runs on a FINER GRID than the model's 30/90 m and
## synthesises the detail below the model's resolution -- hyper-games #50: "the
## erosion filter belongs on a finer grid than the model's, not beside it."
##
## With this false the graph is the Heightfield node ALONE: no erosion node, no
## filter, no noise library in the emitted shader. That is the isolation step --
## look at the tile first, then put the filter back on top and see what it adds.
## Turning it off must not change where the ground is, only how detailed it is.
const EROSION_ENABLED := true
const EROSION_HEIGHT_SCALE := 5.0
## THE GULLY BAND, IN METRES — and the reason it is in metres.
##
## erode_heightmap.cpp asks for the coarsest gully as a LENGTH and derives the
## filter's scale from it, "because a raw scale number is unreadable". Same here:
##
## erosion_scale = GULLY_METRES / (TERRAIN_HEIGHT * CELL_SCALE)
##
## WHY 60 m. The heightmap is 30 m/px and the diffusion model resolves features
## of about two pixels, so 60 m is the finest thing the tile can carry. Anything
## coarser than that and the filter is inventing structure the model already
## has; anything at or below it is filling in what the model cannot reach, which
## is the whole point (#50: "the erosion filter belongs on a finer grid than the
## model's, not beside it").
##
## For contrast, the band this map shipped with was 60000 * 0.15 * 0.7 = 6300 m,
## halving over 7 octaves to 98 m — every octave ABOVE the model's limit, so the
## filter never once reached under it.
##
## At a 1 m voxel nyquist_octaves(60, 1, 2) = 4.9, so about five of the seven
## octaves survive at LOD 0 — which is runevision's own reference count.
const EROSION_GULLY_METRES := 60.0
## GULLY DEPTH, IN METRES — how deep the filter cuts.
##
## This was a depth:WIDTH ratio, and that was the wrong invariant. The ratio
## exists in erode_heightmap.cpp to make erosion SCALE-INVARIANT, which is
## exactly what you do not want when the band moves 105x down. Depth in metres
## is the thing being asked for, so it is the thing written down.
##
## ⚠ THE OLD DERIVATION WAS WRONG AND SILENTLY DELIVERED 1/132 OF WHAT IT SAID.
##
## depth = 2 * strength * H => strength = DEPTH_METRES / (2 * H)
##
## has no erosion_scale term. terrain-lookdev/amplitude_law.py measured the
## filter's actual amplitude against BOTH parameters, one at a time over 128x,
## and got log-log slopes of +1.000 (strength) and +1.003 (erosion_scale): the
## incision is proportional to the PRODUCT. So when #55 moved the band from
## 6300 m to 60 m, the depth fell by that same 105x and the formula did not
## notice. EROSION_DEPTH_METRES = 30 was delivering 0.227 m rms / 0.392 m peak.
##
## Three independent measurements agreed on it before this was changed: the
## standalone renderer at the map's own settings, the amplitude law above, and
## gate_dc_vs_lookdev's CONTROL — which could not tell erosion-on from
## erosion-off, because at 0.23 m they are the same field.
##
## THE CONSTANT IS CALIBRATED, NOT DERIVED. Deriving it from the filter's
## algebra is what produced the wrong formula twice; this number comes from a
## render. amplitude_law.py measured
##
## 0.227 m rms at strength 0.00571429, erosion_scale 0.00142857
##
## so amplitude = K * strength * erosion_scale with K = 27807. Inverting:
##
## strength = DEPTH_METRES * TERRAIN_HEIGHT * CELL_SCALE / (K * GULLY_METRES)
##
## Verified by prediction, not by re-fitting: depth_strip.py asked for 3 / 10 /
## 30 m through this expression and measured 3.01 / 10.04 / 30.12 m.
##
## ⚠ DEPTH_METRES IS RMS OVER THE FIELD, and K was calibrated on the window at
## (50000, 34000) of the shipped 30 km block. A flatter stretch of terrain
## erodes less, so this is a good dial and not a guarantee. Peak incision runs
## about 1.72x the rms (measured 0.392/0.227, 5.17/3.01, 17.23/10.04 — stable).
##
## WHY 10 AND NOT 30. Rendered side by side at the 60 m band, 30 m is a
## depth:width of 0.86 — a slot canyon, not a gully. 10 m is 0.29, just above
## the 0.05-0.25 erode_heightmap.cpp quotes for real valleys, which is right for
## gullies at a resolution the heightmap cannot carry.
const EROSION_DEPTH_METRES := 10.0
## Measured, not derived. See the note above. terrain-lookdev/amplitude_law.py
## reproduces it; if the filter's internals change, re-run that and update this.
const EROSION_AMPLITUDE_K := 27807.0
## THE FIELD'S HALF-RELIEF, IN METRES — the SLOPE's vertical unit.
##
## Measured over all 841 cached diffusion tiles (829 x 668 km of seed 1234):
## the field spans -4829 .. 5110 m, so half-range 4970 m. The PLAYABLE band
## (CEILING_Y - FLOOR_Y = 21000 m) is 2.1x larger, and normalising the slope by
## it under-reads the terrain's steepness by that factor.
##
## This is GLOBAL on purpose. erode_heightmap.cpp normalises per tile, which is
## why it reads the terrain well AND why erosion_wrapper_glsl.h refused to copy
## it: two adjacent tiles of different relief normalise differently and the join
## steps. One constant for the whole field has the reference's magnitude and no
## seam — and it keeps working when the single block becomes many, which is why
## this and the tiling work belong together.
##
## ⚠ If the seed changes, this is wrong. Re-measure over the tile cache.
const EROSION_FIELD_HALF_RANGE := 4970.0
const EROSION_H := (CEILING_Y - FLOOR_Y) * 0.25 * 0.5
const EROSION_CELL_SCALE := 0.7
const EROSION_OCTAVES := 7
## gain = 1/lacunarity is what makes the filter self-similar across octaves;
## #45 measured slope growing 6.3x across octaves at other values.
const EROSION_GAIN := 0.5
## The block's centre. Spawning outside its footprint would put the player on
## the clamped edge, which is flat and reads as a broken generator.
const SPAWN_XZ := Vector2(53760.0, 38400.0)
## The ERODED surface at the spawn column, in metres, MEASURED rather than
## guessed -- _surface_y below cannot find it. sample_density() is the CPU
## generator path and the heightfield binding is GPU-only, so the CPU still
## evaluates the fBm mountain and would return a surface from a different world.
##
## The ERODED surface at the spawn column, measured with the harness at this
## map's own parameters (the raw field there is 165.0 m; erosion takes it down):
## cd dc-terrain && /tmp/erosion_render4 \
## --heightmap ../hyper-tps/assets/terrain/diffusion_gully_30km.hblk \
## --ox 53760 --oz 38400 --n 1 --mpp 30
## -> -615.3 m
##
## gate_dc_vs_lookdev.gd checks the whole surface against the same harness, so a
## stale number here shows up as a gate failure rather than as a long fall.
const SPAWN_SURFACE_Y := 165.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
var erosion_node: Object = null
func _ready() -> void:
super._ready()
_setup_terrain()
_place_player()
# NOT here. DCCompute initialises its GPU thread ~0.5 s after the map's
# _ready, so an oracle called from _ready measures a device that does not
# exist yet and reports "the GPU returned NOTHING" — which is a statement
# about the clock, not about the terrain. It cost a run to learn that.
set_process(true)
## GPU vs CPU, on the same points, in the running engine.
##
## The CPU side is already tied to the lookdev (gate_dc_vs_lookdev: 0.028 m over
## 256 columns). So if the GPU agrees with the CPU here, the chain is closed:
## lookdev == CPU == GPU. If it does not, this says so in one line instead of
## leaving "it looks like noise" to be argued about.
##
## An EMPTY return means the GPU declined the work. Early in the map's life that
## is just the GPU thread still starting, so this RETRIES rather than concluding;
## only after the deadline is an empty answer a finding.
## ⛔ 20 s IS LOAD-BEARING. DO NOT RAISE IT.
##
## _gpu_oracle() does not observe -- it SUBMITS. debug_gpu_density() calls
## gpu_thread_->submit(), a real density fill, once per frame until it succeeds
## or this deadline passes.
##
## GPU bring-up takes ~25-29 s after map load (measured 2026-09-03: map at
## ~13.6 s, "GPU compute ready" at ~42.8 s), so at 20 s the oracle gives up
## BEFORE the GPU thread is live and never contends with it. That is why it
## reports "the GPU never accepted a density fill" -- the message is accurate,
## and the oracle is simply not able to answer on this machine.
##
## Raised to 120 s on 2026-09-03 to "fix" that, and it broke the terrain: the
## oracle's submits then interleaved with the mesh pipeline's compute lists, so
## the DENSITY uniform set (bindings 0-13, binding 4 a UniformBuffer of 176 B)
## got bound while a MESH pipeline (bindings 0-11, all StorageBuffer) was
## current. Every dispatch failed with "uniform_set is null", leaves climbed to
## 3382, and the terrain vanished.
##
## If this oracle is ever to answer, it must WAIT for the GPU thread rather than
## race it -- not simply be given a longer runway to race in.
const ORACLE_TIMEOUT_MS := 20000.0
var _oracle_done := false
var _oracle_deadline := 0.0
var _oracle_start := 0.0
func _process(_delta: float) -> void:
if _oracle_done:
set_process(false)
return
if _oracle_deadline == 0.0:
_oracle_start = Time.get_ticks_msec()
_oracle_deadline = _oracle_start + ORACLE_TIMEOUT_MS
if _gpu_oracle():
_oracle_done = true
set_process(false)
elif Time.get_ticks_msec() > _oracle_deadline:
push_warning("[MapDCDiffusion] GPU ORACLE: still nothing after %.0f s. " % [
(Time.get_ticks_msec() - _oracle_start) / 1000.0] +
"The GPU never accepted a density fill, so everything on screen " +
"came from the CPU fallback.")
_oracle_done = true
set_process(false)
## Returns false while the GPU is not ready yet, true once it has answered.
func _gpu_oracle() -> bool:
var terrain := get_node_or_null("DCTerrain")
if terrain == null or not terrain.has_method("debug_gpu_density"):
push_warning("[MapDCDiffusion] no debug_gpu_density — extension is older than this map")
return true
var cells := 8
var vs := 30.0
var org := Vector3(SPAWN_XZ.x - 120.0, SPAWN_SURFACE_Y - 120.0, SPAWN_XZ.y - 120.0)
var g: PackedFloat32Array = terrain.debug_gpu_density(org, vs, cells)
if g.is_empty():
return false # GPU thread not up yet — _process will try again
var stride := cells + 1
var worst := 0.0
var sum_sq := 0.0
var count := 0
var gpu_lo := 1e30
var gpu_hi := -1e30
for iz in range(stride):
for iy in range(stride):
for ix in range(stride):
var gv: float = g[iz * stride * stride + iy * stride + ix]
gpu_lo = minf(gpu_lo, gv)
gpu_hi = maxf(gpu_hi, gv)
var p := Vector3(org.x + ix * vs, org.y + iy * vs, org.z + iz * vs)
var cv: float = terrain.sample_density(p)
var d: float = absf(gv - cv)
worst = maxf(worst, d)
sum_sq += d * d
count += 1
# DOES THE SHADER READ THE FIELD AT ALL?
#
# The check above says gpu != cpu but not why. This one is single-purpose:
# sample at a FIXED y across 2.4 km of x/z, where the block's own relief is
# hundreds of metres. If the shader reads hblk_data the density must vary by
# that much. If it reads zeros, heightmap_gn returns a constant and the only
# variation left is the 30 m detail layer -- a flat plane with low noise,
# which is exactly what is on screen.
var wide_vs := 300.0
var wide_org := Vector3(SPAWN_XZ.x - 1200.0, SPAWN_SURFACE_Y, SPAWN_XZ.y - 1200.0)
var w: PackedFloat32Array = terrain.debug_gpu_density(wide_org, wide_vs, 8)
if not w.is_empty():
var ws := 9
var w_lo := 1e30
var w_hi := -1e30
var c_lo := 1e30
var c_hi := -1e30
for iz in range(ws):
for ix in range(ws):
# y index 0 == the fixed plane at wide_org.y
var wv: float = w[iz * ws * ws + 0 * ws + ix]
w_lo = minf(w_lo, wv); w_hi = maxf(w_hi, wv)
var cv2: float = terrain.sample_density(
Vector3(wide_org.x + ix * wide_vs, wide_org.y, wide_org.z + iz * wide_vs))
c_lo = minf(c_lo, cv2); c_hi = maxf(c_hi, cv2)
print("[MapDCDiffusion] FIELD-READ PROBE over 2.4 km at fixed y:")
print(" GPU density spread %.1f m (%.1f .. %.1f)" % [w_hi - w_lo, w_lo, w_hi])
print(" CPU density spread %.1f m (%.1f .. %.1f)" % [c_hi - c_lo, c_lo, c_hi])
if w_hi - w_lo < 0.25 * (c_hi - c_lo):
push_warning("[MapDCDiffusion] the GPU field barely varies across 2.4 km " +
"while the CPU field varies by %.0f m. The shader is NOT reading " % (c_hi - c_lo) +
"hblk_data — it is bound to something empty.")
else:
print(" both vary — the shader IS reading the heightfield")
# WRITE THE TERRAIN AS AN IMAGE, from the GPU.
#
# One debug_gpu_density dispatch over a 64^3 block spanning the vertical
# range, then the first sign change down each column. That is a top-down
# heightmap of what the ENGINE actually generates, which can be laid beside
# the lookdev's picture of the same ground. Every other check tonight has
# been a number; a number has not once told anybody what they were looking at.
var img_n := 63
var img_span := 7680.0
var img_vs := img_span / float(img_n)
var img_org := Vector3(SPAWN_XZ.x - img_span * 0.5,
SPAWN_SURFACE_Y - img_span * 0.5, SPAWN_XZ.y - img_span * 0.5)
var blk: PackedFloat32Array = terrain.debug_gpu_density(img_org, img_vs, img_n)
if not blk.is_empty():
var st := img_n + 1
var out := FileAccess.open("/tmp/engine_gpu_surface.f32", FileAccess.WRITE)
var hits := 0
for iz in range(st):
for ix in range(st):
var surf := -99999.0
# scan DOWN: density is positive below the surface
for iy in range(st - 1, 0, -1):
var a: float = blk[iz * st * st + iy * st + ix]
var b: float = blk[iz * st * st + (iy - 1) * st + ix]
if (a >= 0.0) != (b >= 0.0):
var t: float = a / (a - b)
surf = img_org.y + (float(iy) - t) * img_vs
hits += 1
break
out.store_float(surf)
out.close()
print("[MapDCDiffusion] wrote /tmp/engine_gpu_surface.f32 %dx%d over %.2f km "
% [st, st, img_span / 1000.0] +
"from (%.0f, %.0f), %d/%d columns hit" % [img_org.x, img_org.z, hits, st * st])
# ISOLATE THE BUFFER READ.
#
# heightfield_only makes the shader "hblk_height_m(x,z) - y" and nothing
# else -- no erosion, no graph, no detail. Flipping it at runtime recompiles
# the GLSL, so one probe in that mode answers the only question left: can
# the shader see hblk_data? If the density there is constant across 2.4 km,
# the buffer is empty or unbound, and no amount of correctness in the
# erosion path can matter.
if _gen != null:
_gen.set_heightfield_only(true)
var iso: PackedFloat32Array = terrain.debug_gpu_density(wide_org, 300.0, 8)
if not iso.is_empty():
var ws2 := 9
var lo2 := 1e30
var hi2 := -1e30
for iz in range(ws2):
for ix in range(ws2):
var v2: float = iso[iz * ws2 * ws2 + 0 * ws2 + ix]
lo2 = minf(lo2, v2); hi2 = maxf(hi2, v2)
print("[MapDCDiffusion] BUFFER-READ ISOLATION (heightfield_only, 2.4 km):")
print(" GPU spread %.1f m (%.1f .. %.1f)" % [hi2 - lo2, lo2, hi2])
if hi2 - lo2 < 10.0:
push_warning("[MapDCDiffusion] the shader reads a CONSTANT from " +
"hblk_data — the buffer is empty or not bound at binding 13. " +
"That is the whole bug; everything else is downstream of it.")
else:
print(" the shader CAN read hblk_data — the bug is in the erosion path")
_gen.set_heightfield_only(false)
var rms: float = sqrt(sum_sq / float(count))
print("[MapDCDiffusion] GPU ORACLE over %d points: max |gpu-cpu| = %.3f, rms = %.3f"
% [count, worst, rms])
print("[MapDCDiffusion] GPU density range %.1f .. %.1f" % [gpu_lo, gpu_hi])
if gpu_hi - gpu_lo < 1.0:
push_warning("[MapDCDiffusion] the GPU field is CONSTANT across the probe — " +
"that is what an unbound or zero-filled heightfield buffer looks like.")
elif worst > 1.0:
push_warning("[MapDCDiffusion] GPU and CPU DISAGREE by %.1f — the shader is " % worst +
"not computing what sample_density computes.")
else:
print("[MapDCDiffusion] GPU agrees with CPU, and CPU is tied to the lookdev. " +
"Chain closed: lookdev == CPU == GPU.")
return true
## Load every generated tile into the node's grid.
##
## The index table is sized to the WORLD, not to these tiles, so a tile fetched
## later lands without changing the emitted GLSL — see
## spec/tile-residency-and-generation.md and HeightfieldNode::_rebuild_grid.
## The span formula is the octree's own, kept here rather than defaulted so the
## two cannot drift.
func _load_tile_set(hf: Object) -> int:
hf.set_stub_height(TILE_STUB_Y)
hf.set_world_span(28.0 * float(1 << (OCTREE_DEPTH - 1)) * VOXEL_SIZE)
var d := DirAccess.open(TILE_DIR)
if d == null:
push_warning("[MapDCDiffusion] no tile cache at %s — falling back to the block" % TILE_DIR)
hf.set("tile_path", HEIGHTFIELD)
return 0
var t0 := Time.get_ticks_msec()
var n := 0
for f in d.get_files():
if not f.ends_with(".hblk"):
continue
if hf.add_tile(TILE_DIR.path_join(f)):
n += 1
var info: Dictionary = hf.get_grid_info()
var mb := float(int(info["table"]) + n * int(info["n"]) * int(info["n"])) * 4.0 / 1048576.0
print("[MapDCDiffusion] tile set: %d tiles in %d ms, %dx%d world table, %.1f MiB"
% [n, Time.get_ticks_msec() - t0, int(info["nx"]), int(info["ny"]), mb])
if n == 0:
push_warning("[MapDCDiffusion] tile cache is EMPTY — falling back to the block")
hf.set("tile_path", HEIGHTFIELD)
return n
func _setup_terrain() -> void:
var terrain := get_node_or_null("DCTerrain")
if terrain == null or not is_instance_valid(terrain):
push_warning("[MapDCDiffusion] 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()
# THE TERRAIN IS A NODE. Heightfield replaces GradientNoise in the same two
# sockets; everything downstream is untouched. Swap this node and the level
# gets a different terrain -- which is what a composed density graph is for,
# and why this is not a flag on the generator.
# ONE NODE. The heightmap and nothing else -- no erosion, no base noise, no
# second stage. If this does not show the tile then nothing more elaborate
# can, and every layer added before proving it just hides where the fault is.
# The erosion node goes back on top once this is confirmed.
var noise := g.add_node(HeightfieldNode.new())
if USE_TILE_SET:
_load_tile_set(g.get_node(noise))
else:
g.get_node(noise).set("tile_path", HEIGHTFIELD)
g.get_node(noise).set_input_default(1, TERRAIN_HEIGHT) # normalised pos -> world
g.get_node(noise).set_input_default(2, (CEILING_Y - FLOOR_Y) * 0.25 * 0.5) # metres per gn unit
g.get_node(noise).set_input_default(3, (FLOOR_Y + CEILING_Y) * 0.5) # band centre
g.get_node(noise).set_input_default(4, FLOOR_Y) # floor, for the y-map
g.get_node(noise).set_input_default(5, 5.0 / (CEILING_Y - FLOOR_Y)) # height_scale / y_range
# THE EROSION NODE, PUT BACK. The comment above described the isolation step
# -- one node, the heightmap alone, to prove the tiles reach the engine --
# and said "the erosion node goes back on top once this is confirmed". It was
# confirmed and this never went back, so the map has been shipping the bare
# 30 m diffusion field: no gullies, no ridges, just Catmull-Rom between
# texels. The compiled shader showed it plainly -- the Heightfield node
# computed gn_height and gn_slope and NOTHING consumed them.
#
# Wired exactly as scenes/test/proc_erosion_sunshine_test.gd wires it, which
# is the reference the parameter comments already point at: outputs 0 and 1
# (Height, Slope) into the erosion node's EXTERNAL inputs 7 and 8. That is
# the use_external path in noise_nodes.cpp, so the filter reads the model's
# height AND its gradient rather than re-deriving a gradient by finite
# difference off the sampled field.
# THE FILTER, ON TOP OF THE HEIGHTMAP. Always in the graph; whether it is
# EMITTED is the generator's erosion_enabled property, so it can be toggled
# in game from the terrain panel rather than by editing this file.
#
# Not in the graph means not in the shader: no erosion node, so
# compile_to_function emits no noise library either, and evaluate_density is
# the heightmap displacement, the y-mapping and the clip. The Heightfield
# node already writes pos.y from the tile, so it is a complete terrain by
# itself and proc_density returns (tile height - query height) directly.
var erosion := g.add_node(ErosionNoiseNode.new())
# HeightScale MUST be 5.0: the wrapper's y_mapped divides by exactly this
# (EN_HS / y_range, and YScale above is 5.0/(CEILING-FLOOR)). They are two
# halves of one mapping and a mismatch shifts the whole world vertically --
# which is precisely the 2-6.5 m offset the six-digit y-scale literal caused.
g.get_node(erosion).set_input_default(1, EROSION_HEIGHT_SCALE)
# DERIVED, not typed. See EROSION_GULLY_METRES / EROSION_DEPTH_METRES.
g.get_node(erosion).set_input_default(2,
EROSION_DEPTH_METRES * TERRAIN_HEIGHT * EROSION_CELL_SCALE
/ (EROSION_AMPLITUDE_K * EROSION_GULLY_METRES))
g.get_node(erosion).set_input_default(3, 0.6) # SlopePower: dead, kept so old graphs load
g.get_node(erosion).set_input_default(4, EROSION_CELL_SCALE)
g.get_node(erosion).set_input_default(5, EROSION_OCTAVES)
g.get_node(erosion).set_input_default(6, EROSION_GAIN)
g.connect_sockets(noise, 0, erosion, 7) # Height -> external height
g.connect_sockets(noise, 1, erosion, 8) # Slope -> external slope
_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)
# 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.
# BEFORE set_graph: set_heightfield_path recompiles the GLSL, and so does
# set_graph. Setting the field first means one compile with the field in it
# rather than one without followed by another with.
# HEIGHTFIELD ONLY. No erosion, no graph, no noise -- density is the field
# and nothing else. This is the isolation step: bring the tiles in, look at
# them, and only then put the filter back on top. Everything that made the
# first attempt hard lives on the other side of this switch.
# The generator no longer owns the tile — the node does, and the generator
# just collects its floats as opaque custom data. Left set to "" rather than
# removed so the older path stays available.
_gen.set_heightfield_path("")
# EROSION BACK ON. heightfield_only was the isolation step -- it proved the
# tiles reach the engine. The erosion parameters below are the map's own,
# which are also what erosion_wrapper_params_from_map() hands the lookdev
# harness, so "identical to lookdev" is a property of both reading the same
# constants rather than of two lists agreeing today.
#
# The one that must match and is easy to miss: Gradient Noise Detail is 0,
# so gn is exactly vec3(0) and the filter sees the heightfield ALONE -- which
# is what the harness does in heightmap mode. Any other value adds an fBm
# layer the lookdev never had.
_gen.set_heightfield_only(false)
# THE LOOKDEV'S OWN WRAPPER, not a transcription of it. The generator stops
# assembling its 47 KB engine wrapper and emits erosion_wrapper_glsl instead,
# so what runs is gpu_wrapper() -- the same function erosion_render compiles
# and test_erosion_parity guards. With DETAIL_SCALE 0 that is exactly
# heightmap + erosion, plus the y-mapping and the floor/ceiling clip.
_gen.set_lookdev_wrapper(false)
# EROSION ON OR OFF. The generator skips the ErosionNoise node when this is
# false, so the filter and the whole noise library leave the emitted shader.
# Set from the map's constant here, and toggleable at runtime from the
# terrain panel's erosion_enabled checkbox.
# THE GULLY BAND, derived from the length we actually want.
_gen.set_erosion_scale(EROSION_GULLY_METRES / (TERRAIN_HEIGHT * EROSION_CELL_SCALE))
_gen.set_field_half_range(EROSION_FIELD_HALF_RANGE)
_gen.set_erosion_enabled(EROSION_ENABLED)
# THE EROSION-MINIMAL WRAPPER. This map's terrain is the baked tile and the
# filter on top of it; the general wrapper's mountain, relief and detail
# layers contribute nothing here but branches. Minimal emits the heightfield,
# the filter, the y-mapping and the clip, and nothing else.
_gen.set_erosion_minimal(true)
_gen.set_graph(g)
# OUR GEISS-STYLE UNDERSEA TERM (§4 P3), 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; cut out of blender/undersea_geiss.tscn by
# UnderseaGeissGraph.install_detail. The minimal wrapper had no submarine term, so
# the water level it masks against is set here.
_gen.set_submarine_water_y(WATER_Y)
var undersea_why := UnderseaGeissGraph.install_detail(_gen)
if undersea_why != "":
push_warning("[MapDCDiffusion] 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.
# The parent map asserts the compiled GLSL calls erosion_node_eval. This graph
# deliberately has no erosion node, so that check is inherited noise -- it
# fired every run and said the generator had failed when it had not. What
# matters here is that the heightfield node emitted its sampler.
var glsl: String = g.compile_to_glsl_function("terrain_density", true)
# ⛔ CHECK THE CALL SITE, NOT THE DEFINITION.
#
# `glsl.contains("hblk_sample(")` CANNOT FAIL: the Heightfield node's prelude
# DEFINES `vec3 hblk_sample(float x, float z)` whether or not one line calls
# it. On 2026-09-03 the tile grid loaded, 839 tiles went resident, 213 MiB
# uploaded — and HeightfieldNode::compile_glsl returned its flat-field
# fallback (`gn_height = 0.0`) because it gated on the SINGLE tile's n_.
# The world was flat and this check passed throughout.
#
# The node emits exactly one call site, `vec3 hf_s = hblk_sample(...)`, so
# that string is the presence of the heightfield IN the density function.
if not glsl.contains("hf_s = hblk_sample("):
push_warning("[MapDCDiffusion] the density GLSL never CALLS the heightfield " +
"sampler — the tiles are loaded and uploaded but the graph emitted " +
"a flat field. You are not looking at the terrain.")
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))
print("[MapDCDiffusion] colour ramp: floor %.0f ceiling %.0f water %.0f m (h=%.3f)"
% [FLOOR_Y, CEILING_Y, WATER_Y, water_frac])
# THE MODE, and proof it was written. Reading it back means a silent
# failure (wrong name, wrong type, material not the one being drawn)
# shows up here rather than as "the colours did not change".
(mat as ShaderMaterial).set_shader_parameter("color_mode", LOOKDEV_COLOUR_MODE)
var read_back: Variant = (mat as ShaderMaterial).get_shader_parameter("color_mode")
print("[MapDCDiffusion] color_mode set to %d, reads back as %s (%s)"
% [LOOKDEV_COLOUR_MODE, str(read_back),
"MATCH" if int(read_back) == LOOKDEV_COLOUR_MODE else "MISMATCH"])
var shader_res: Variant = (mat as ShaderMaterial).shader
print("[MapDCDiffusion] shader is %s" % [
shader_res.resource_path if shader_res != null else "<null>"])
else:
push_warning("[MapDCDiffusion] 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("[MapDCDiffusion] voxel %.2f m depth %d -> span %.1f km (+/- %.1f km)" %
[VOXEL_SIZE, OCTREE_DEPTH, span / 1000.0, span / 2000.0])
print("[MapDCDiffusion] 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])
## Surface height in a column, or null_h if the column never crosses zero.
##
## Coarse scan to bracket, then bisect. The coarse step must be smaller than the
## thinnest feature worth landing on; 50 m over a 21 km band is 420 samples,
## which is instant and cannot step over a mountain.
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("[MapDCDiffusion] no DCTerrain — cannot place spawn on the surface")
return
# THE MEASURED SURFACE, not a queried one. _surface_y walks sample_density(),
# which is the CPU generator; the heightfield is bound on the GPU only, so
# that walk would return the surface of the fBm world this map replaced.
# Using it here would place the spawn confidently and wrongly, which is worse
# than not placing it. SPAWN_SURFACE_Y is measured — see its comment.
var surface := SPAWN_SURFACE_Y
# Kept as a DIAGNOSTIC: how far the CPU field is from the GPU one is exactly
# the size of the gap left open, and printing it means nobody has to rediscover
# that the two paths disagree.
var cpu_surface := _surface_y(terrain, SPAWN_XZ.x, SPAWN_XZ.y)
if cpu_surface != _NO_SURFACE:
var gap: float = absf(cpu_surface - surface)
if gap < 1.0:
print("[MapDCDiffusion] CPU surface agrees with the measured one: %.1f m." % surface)
else:
push_warning("[MapDCDiffusion] CPU says %.1f m, GPU expects %.1f m (%.0f m apart). "
% [cpu_surface, surface, gap] +
"Under heightfield_only these must match; they do not.")
# The parent map's "no surface in this column" error is gone with the query
# that raised it. The equivalent failure here is a STALE SPAWN_SURFACE_Y, and
# it is caught differently: the constant carries the command that produced it,
# and the band check below is what notices if it drifts out of the world.
if surface <= FLOOR_Y or surface >= CEILING_Y:
push_error("[MapDCDiffusion] SPAWN_SURFACE_Y %.1f is outside the band " % surface +
"(%.0f .. %.0f) — re-measure it; see its comment." % [FLOOR_Y, CEILING_Y])
return
var spawn_pos := Vector3(SPAWN_XZ.x, surface + SPAWN_CLEARANCE, SPAWN_XZ.y)
print("[MapDCDiffusion] 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
# Same reason as the spawn: no CPU query here either. 6 m along the
# surface is well inside one 30 m heightfield texel, so the spawn's own
# measured height is the right value rather than an approximation.
var ts := surface
(tp as Node3D).position = Vector3(tx, ts + TELEPORTER_CLEARANCE, SPAWN_XZ.y)
print("[MapDCDiffusion] 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.