feat(ui): hillshade the deep rungs — form from light across slope (T-1213)
relief_q now reaches the renderer, and the first pass spent it on brightness:
lighten where the ground is high, darken where it is low. That moved the numbers
(District 13.72 -> 77.01 lum spread) and still looked like moss, because the eye
does not read landform from absolute brightness. It reads it from light falling
ACROSS a gradient — height-shading gives a rise and a fall the same tone, so no
ridge ever reads as a ridge.
So relief drives a proper hillshade: the local gradient of the field dotted with
a light from the upper-left. The light direction is not a free choice; lit from
the lower-right the brain inverts the read and valleys pop out as ridges.
THE SCALE IS MEASURED PER CANVAS, not fixed, and the first attempt at this failed
exactly the way this file already warned a fixed gradient constant would (see
RUGGEDNESS_BASELINE_CELLS: "the same 4-cell delta reads 21.86 at Region and 0.08
at District"). With a constant full-scale of 8:
Region 81.72 but District 77.01 -> 20.01, Quarter 42.56 -> 16.44
because at District's 3.8 m per cell neighbouring cells barely differ. The
terrain layer now measures each canvas's own mean |gradient| once per rebuild and
the hillshade normalizes against it, so one constant works at every rung.
Ladder (tooling/atlas-flatness, lum p1-p99), flat -> shipped:
Global 145.69 -> 145.69 unchanged; relief_q is flat 50 at orbital
Region 33.59 -> 54.30
District 13.72 -> 74.43
Quarter 11.01 -> 73.72
District and Quarter now read as terrain — ridgelines, valleys, and the stipple
organised into contour-like bands. Judged by eye on the captures, not by the
metric alone.
Stipple full-scale 25 -> 60. The old value was calibrated against a relief_q that
never arrived, so it was tuned to the elev_q fallback; with the real plane nearly
every land cell earned a mark and Region read as static (17,599 distinct colours,
more than twice Global's, for a quarter of the legibility). Form comes from the
hillshade now; the stipple is grain on top of it.
REGION IS NOT FIXED, and the cause is T-1240 rather than this change. It renders
as fine uniform stucco: a Region cell is 379 m of ground while the relief field's
content sits in the 128-1024 m band, so the field is at or below Nyquist and the
gradient the hillshade reads is aliasing, not slope. min_wl_m defaults to 0 on
the served path, so nothing truncates the octaves Region cannot resolve — which
is precisely what T-1240 proposes to fix. That ticket said the stale cutoff was
"currently inert"; it is now the thing capping Region, and T-1240 is updated
with the measurement.
Three tests, on direction rather than magnitude so tuning does not rewrite them:
a hill's west flank lit and east flank shadowed, a uniform field shading nothing,
and the canvas edge not drawing a rim. That last one is a bug this nearly
shipped: `_l8_value` returns 0 out of bounds and 0 on relief_q means MAXIMUM
HOLLOW, so sampling off-canvas posts a full-scale false gradient all the way
round the frame. The sample position is clamped instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,6 @@ const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0)
|
||||
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
|
||||
|
||||
## MorphologyZone::Lake discriminant (D-239 §6 order, T-1046) — matches
|
||||
## AtlasOverlayColors.MORPHOLOGY_LAKE (duplicated here per this file's own
|
||||
## "no cross-file constant sharing beyond the preloaded module" precedent,
|
||||
@@ -83,10 +82,22 @@ const RUGGEDNESS_FULL_SCALE_Q: int = 4
|
||||
const RUGGEDNESS_BASELINE_CELLS: int = 4
|
||||
|
||||
## relief_q deviation from flat (50) at which ground is "fully rugged". Each
|
||||
## step is 8 m, so 25 = 200 m of local relief — a substantial ridge at the
|
||||
## sub-district scale, and comfortably inside the range the field actually
|
||||
## uses (Region spans all 101 values; District 35 of them).
|
||||
const RELIEF_TEXTURE_FULL_SCALE_Q: int = 25
|
||||
## step is 8 m, so 60 = 480 m of local relief.
|
||||
##
|
||||
## RETUNED 2026-08-16 (T-1213), from 25 to 60. The old value was calibrated
|
||||
## against a relief_q that never arrived — the plane was missing from the
|
||||
## protocol decode, so `_ruggedness` always took its elev_q-gradient fallback and
|
||||
## the "Region spans all 101 values" claim above described the field's potential,
|
||||
## not what the renderer ever saw. With the plane actually decoding, |relief-50|
|
||||
## reaches 25 across most of a Region canvas, so nearly every land cell earned a
|
||||
## mark and the map read as static: 17,599 distinct colours at Region, more than
|
||||
## twice Global's, for a quarter of the legibility.
|
||||
##
|
||||
## 60 puts typical ground low in the density range and keeps the marks for
|
||||
## genuinely broken terrain, which is what the RimWorld reference does — its
|
||||
## plains are clean and its ranges are dense. Form now comes from the hillshade;
|
||||
## the stipple is grain on top of it, not a substitute for it.
|
||||
const RELIEF_TEXTURE_FULL_SCALE_Q: int = 60
|
||||
|
||||
## How far SIGNED relief may push the base lightness, as a fraction (T-1213).
|
||||
##
|
||||
@@ -104,18 +115,59 @@ const RELIEF_TEXTURE_FULL_SCALE_Q: int = 25
|
||||
## different ground... the reverse is not recoverable". A valley and a ridge
|
||||
## rendered identically.
|
||||
##
|
||||
## So relief now also shades CONTINUOUSLY and SIGNED: rises lighten, hollows
|
||||
## darken, every cell every time. That is the oldest trick in relief cartography
|
||||
## and it is what makes ground read as ground. The stipple stays, because
|
||||
## ruggedness (unsigned) is a genuinely different quantity from elevation
|
||||
## (signed) — grain over form, not grain instead of it.
|
||||
## So relief now drives a continuous HILLSHADE instead (see [method _hillshade]):
|
||||
## slopes facing the light brighten and slopes facing away darken, every cell,
|
||||
## every time. The stipple stays, because ruggedness (unsigned) is a genuinely
|
||||
## different quantity from slope (signed) — grain over form, not grain instead
|
||||
## of it.
|
||||
##
|
||||
## An intermediate version shaded by the cell's own signed HEIGHT rather than by
|
||||
## slope. It moved the numbers (District 13.72 -> 77.01 spread) and still looked
|
||||
## like moss, because the eye reads landform from light falling ACROSS a
|
||||
## gradient, not from absolute brightness: height-shading gives a rise and a fall
|
||||
## the same tone, so no ridge ever reads as a ridge.
|
||||
##
|
||||
## Kept gentle for the same reason the stipple is: this rides UNDER the hue and
|
||||
## must not read as a second colour layer. 0.30 lets a full ±200 m swing move
|
||||
## lightness by roughly a third, which is visible at a glance without swamping
|
||||
## the morphology hue the legend is keyed to.
|
||||
## must not read as a second colour layer. 0.30 moves lightness by roughly a
|
||||
## third at full swing — visible at a glance without swamping the morphology hue
|
||||
## the legend is keyed to.
|
||||
const RELIEF_SHADE_STRENGTH: float = 0.30
|
||||
|
||||
## Light direction for the hillshade, normalized, pointing from the upper-left.
|
||||
##
|
||||
## The printed-map convention, and not arbitrary: lit from the lower-right the
|
||||
## brain inverts the read and valleys read as ridges. Kept as two constants
|
||||
## rather than a Vector2 so the hot path does no allocation — this runs per cell,
|
||||
## ~700,000 times per rebuild at 1290x540.
|
||||
const LIGHT_DIR_X: float = -0.7071
|
||||
const LIGHT_DIR_Y: float = -0.7071
|
||||
|
||||
## Multiple of the canvas's OWN mean relief gradient at which a slope reads as
|
||||
## fully lit or fully shadowed.
|
||||
##
|
||||
## NOT A PER-CELL CONSTANT, and the first version of this was — which broke the
|
||||
## deep rungs exactly the way this file already warned a fixed gradient scale
|
||||
## would (see RUGGEDNESS_BASELINE_CELLS: "the same 4-cell delta reads 21.86 at
|
||||
## Region and 0.08 at District, so any single full-scale constant either
|
||||
## saturates the one or vanishes on the other"). Measured with a fixed scale of
|
||||
## 8: Region 81.72 spread but District collapsed 77.01 -> 20.01 and Quarter
|
||||
## 42.56 -> 16.44, because at District's 3.8 m per cell the relief field barely
|
||||
## changes between neighbours.
|
||||
##
|
||||
## So the scale is derived per canvas from that canvas's own mean |gradient|
|
||||
## (StepCanvasTerrainLayer measures it once per rebuild). A rung whose gradients
|
||||
## are tiny gets a correspondingly tiny full-scale and still shades; a rung whose
|
||||
## gradients are large is not blown out. 2.5x the mean puts typical ground
|
||||
## comfortably inside the range and saves saturation for the steep tail.
|
||||
##
|
||||
## The trade, stated plainly: shading becomes RELATIVE to the frame rather than
|
||||
## absolute, so the same hillside is not guaranteed the same tone at two rungs.
|
||||
## That is acceptable here and would not be for hue or elevation lightness —
|
||||
## those are legend-keyed quantities the user reads values off, while hillshade
|
||||
## is a legibility device whose only job is to make form visible.
|
||||
const HILLSHADE_GRADIENT_MULTIPLE: float = 2.5
|
||||
|
||||
|
||||
## One decoded plane set, pre-extracted from the five L8 Images + the two
|
||||
## raw-array fields a caller needs per cell — built once per arrived canvas
|
||||
## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel.
|
||||
@@ -129,6 +181,11 @@ class CellPlanes:
|
||||
var vegetation: Image
|
||||
var lake_margin_q: Image
|
||||
var relief_q: Image
|
||||
## Mean |relief_q central difference| across this canvas, measured once per
|
||||
## rebuild by StepCanvasTerrainLayer. The hillshade normalizes against it so
|
||||
## one constant works at every rung — see HILLSHADE_GRADIENT_MULTIPLE. 0.0
|
||||
## means flat (or unmeasured), which shades nothing.
|
||||
var relief_gradient_scale: float = 0.0
|
||||
var glaciation: Image
|
||||
var temp_dc: Array
|
||||
var width: int
|
||||
@@ -195,11 +252,11 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
|
||||
return base
|
||||
var out: Color = base
|
||||
|
||||
# FORM FIRST, then grain (T-1213). Signed relief shading runs before the
|
||||
# stipple so the marks land on already-modelled ground rather than carrying
|
||||
# the shape by themselves — see RELIEF_SHADE_STRENGTH for why the density-only
|
||||
# read was the thing making the deep rungs flat.
|
||||
var shade: float = _signed_relief(planes, col, row)
|
||||
# FORM FIRST, then grain (T-1213). Hillshade runs before the stipple so the
|
||||
# marks land on already-modelled ground rather than carrying the shape by
|
||||
# themselves — see RELIEF_SHADE_STRENGTH for why the density-only read was
|
||||
# what made the deep rungs flat.
|
||||
var shade: float = _hillshade(planes, col, row)
|
||||
if shade > 0.0:
|
||||
out = out.lightened(RELIEF_SHADE_STRENGTH * shade)
|
||||
elif shade < 0.0:
|
||||
@@ -221,29 +278,73 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
|
||||
return out
|
||||
|
||||
|
||||
## SIGNED local relief as a -1..1 fraction: +1 is a full-scale rise, -1 a full
|
||||
## -scale hollow, 0.0 flat or unavailable (T-1213).
|
||||
## Directional HILLSHADE from relief_q as a -1..1 fraction: +1 fully lit, -1
|
||||
## fully shadowed, 0.0 flat or unavailable (T-1213).
|
||||
##
|
||||
## The signed counterpart to [method _ruggedness], which magnitudes this same
|
||||
## field for the stipple. Both are wanted and they are not the same question:
|
||||
## "how broken is this ground" (unsigned, grain) versus "is this up or down"
|
||||
## (signed, form).
|
||||
## WHY NOT SHADE BY HEIGHT. The first version of this lightened by the cell's own
|
||||
## signed relief — high pale, low dark. That reads as blotches, because a human
|
||||
## reads landform from LIGHT ACROSS A SLOPE, not from absolute brightness: the
|
||||
## eye recovers shape from the way illumination falls on a gradient. Measured on
|
||||
## the ladder it moved the numbers (District 13.72 -> 77.01 spread) while still
|
||||
## looking like moss rather than ground.
|
||||
##
|
||||
## So this is the standard cartographic hillshade: take the local gradient of the
|
||||
## relief field and dot it with a fixed light direction. A slope facing the light
|
||||
## brightens, the slope behind it darkens, and the PAIR is what makes a ridge
|
||||
## legible as a ridge. Height alone cannot produce that pairing.
|
||||
##
|
||||
## The light comes from the upper-left, the convention in every printed relief
|
||||
## map — and not a free choice: lit from the lower-right the brain inverts the
|
||||
## read and valleys pop out as ridges (the classic relief-inversion illusion).
|
||||
##
|
||||
## Absent plane reads FLAT (0.0), never a full swing — the same fail-safe
|
||||
## _ruggedness documents, and for the same reason: `_l8_value` zero-fills a
|
||||
## missing image, and 0 on this field means maximum hollow, so a payload without
|
||||
## the plane would render every cell fully shaded rather than unshaded.
|
||||
## _ruggedness documents: `_l8_value` zero-fills a missing image, and 0 on this
|
||||
## field means maximum hollow, so a payload without the plane would otherwise
|
||||
## render every cell fully shaded.
|
||||
##
|
||||
## Orbital rungs get no shading from here by construction: they skip the
|
||||
## sub-district derive, so relief_q is a flat 50 and this returns 0.0. Global
|
||||
## keeps reading its form from elev_q lightness, which is richly varied at that
|
||||
## scale — the fallback _ruggedness applies for the stipple.
|
||||
static func _signed_relief(planes: CellPlanes, col: int, row: int) -> float:
|
||||
## Orbital rungs shade to nothing by construction: they skip the sub-district
|
||||
## derive, so relief_q is a flat 50, every gradient is 0 and this returns 0.0.
|
||||
## Global keeps reading its form from elev_q lightness, which is richly varied at
|
||||
## that scale.
|
||||
static func _hillshade(planes: CellPlanes, col: int, row: int) -> float:
|
||||
if planes.relief_q == null:
|
||||
return 0.0
|
||||
var rel: int = _l8_value(planes.relief_q, col, row)
|
||||
var signed: float = (float(rel) - 50.0) / float(RELIEF_TEXTURE_FULL_SCALE_Q)
|
||||
return clampf(signed, -1.0, 1.0)
|
||||
# Central differences with the SAMPLE clamped into the canvas, never
|
||||
# _l8_value's raw out-of-bounds read: that returns 0, and 0 on relief_q means
|
||||
# maximum hollow rather than "no data" (the fail-safe _ruggedness documents).
|
||||
# Reading it off the edge would post a full-scale false gradient all the way
|
||||
# round the canvas and draw a lit/shadowed rim on every frame.
|
||||
var west: float = _relief_clamped(planes, col - 1, row)
|
||||
var east: float = _relief_clamped(planes, col + 1, row)
|
||||
var north: float = _relief_clamped(planes, col, row - 1)
|
||||
var south: float = _relief_clamped(planes, col, row + 1)
|
||||
var dzdx: float = (east - west) * 0.5
|
||||
var dzdy: float = (south - north) * 0.5
|
||||
|
||||
# Dot with the upper-left light: brighter where the ground rises toward it.
|
||||
var lit: float = -(dzdx * LIGHT_DIR_X + dzdy * LIGHT_DIR_Y)
|
||||
|
||||
# Normalize against THIS canvas's own gradients (see
|
||||
# HILLSHADE_GRADIENT_MULTIPLE). A flat field measures ~0 and must shade
|
||||
# nothing rather than divide by it — that is the Global case, where relief_q
|
||||
# is a uniform 50 by construction.
|
||||
var full_scale: float = planes.relief_gradient_scale * HILLSHADE_GRADIENT_MULTIPLE
|
||||
if full_scale < 0.001:
|
||||
return 0.0
|
||||
return clampf(lit / full_scale, -1.0, 1.0)
|
||||
|
||||
|
||||
## relief_q at (col, row) with the SAMPLE POSITION clamped into the canvas.
|
||||
##
|
||||
## Distinct from `_l8_value`, which returns 0 out of bounds. That is the right
|
||||
## softness for a classification plane, where 0 is a benign "zone 0" fallback,
|
||||
## and the wrong one here: on relief_q, 0 means maximum hollow. Clamping the
|
||||
## position instead makes an edge cell compare against itself, so the border
|
||||
## gradient is 0 — flat, which is the honest answer for ground we cannot see.
|
||||
static func _relief_clamped(planes: CellPlanes, col: int, row: int) -> float:
|
||||
var c: int = clampi(col, 0, planes.relief_q.get_width() - 1)
|
||||
var r: int = clampi(row, 0, planes.relief_q.get_height() - 1)
|
||||
return float(_l8_value(planes.relief_q, c, r))
|
||||
|
||||
|
||||
## Local relief as a 0..1 fraction — the largest elev_q step to a 4-neighbour,
|
||||
|
||||
@@ -49,8 +49,12 @@ extends Node2D
|
||||
## second internal scale field so this node's own footprint math never has
|
||||
## to know it exists.
|
||||
|
||||
const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd")
|
||||
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
|
||||
const StepCanvasColorize := preload(
|
||||
"res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd"
|
||||
)
|
||||
const StepCanvasTransport := preload(
|
||||
"res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd"
|
||||
)
|
||||
|
||||
var _texture: ImageTexture = null
|
||||
var _texture_size: Vector2i = Vector2i.ZERO
|
||||
@@ -124,6 +128,7 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo
|
||||
planes.vegetation = _decode_l8_plane(canvas.get("vegetation"))
|
||||
planes.lake_margin_q = _decode_l8_plane(canvas.get("lake_margin_q"))
|
||||
planes.relief_q = _decode_l8_plane(canvas.get("relief_q"))
|
||||
planes.relief_gradient_scale = _measure_relief_gradient(planes.relief_q)
|
||||
planes.glaciation = _decode_l8_plane(canvas.get("glaciation"))
|
||||
planes.temp_dc = canvas.get("temp_dc", [])
|
||||
return planes
|
||||
@@ -187,6 +192,41 @@ func get_plane_variety() -> Dictionary:
|
||||
const VARIETY_SAMPLE_STRIDE: int = 4
|
||||
|
||||
|
||||
## Mean absolute central-difference of relief_q across the canvas, in relief_q
|
||||
## steps per cell — the scale the hillshade normalizes against (T-1213).
|
||||
##
|
||||
## Measured per canvas rather than fixed, because a per-cell gradient constant
|
||||
## cannot work at more than one rung: at Region a neighbour is 379 m away and at
|
||||
## District 3.8 m, so the same terrain produces gradients two orders of magnitude
|
||||
## apart. A fixed scale of 8 was tried and collapsed District from 77.01 to 20.01
|
||||
## spread while Region was fine.
|
||||
##
|
||||
## Sampled on the same stride as the variety scan, and for the same reason: this
|
||||
## is a canvas-wide average, and every 4th cell estimates it as well as every
|
||||
## cell for a fraction of the cost. Returns 0.0 for an absent or flat plane,
|
||||
## which the hillshade reads as "shade nothing".
|
||||
func _measure_relief_gradient(relief: Image) -> float:
|
||||
if relief == null:
|
||||
return 0.0
|
||||
var w: int = relief.get_width()
|
||||
var h: int = relief.get_height()
|
||||
if w < 3 or h < 3:
|
||||
return 0.0
|
||||
var total: float = 0.0
|
||||
var count: int = 0
|
||||
for row in range(1, h - 1, VARIETY_SAMPLE_STRIDE):
|
||||
for col in range(1, w - 1, VARIETY_SAMPLE_STRIDE):
|
||||
var dx: float = absf(
|
||||
(relief.get_pixel(col + 1, row).r8 - relief.get_pixel(col - 1, row).r8) * 0.5
|
||||
)
|
||||
var dy: float = absf(
|
||||
(relief.get_pixel(col, row + 1).r8 - relief.get_pixel(col, row - 1).r8) * 0.5
|
||||
)
|
||||
total += maxf(dx, dy)
|
||||
count += 1
|
||||
return 0.0 if count == 0 else total / float(count)
|
||||
|
||||
|
||||
## Count distinct values per L8 plane. Runs once per canvas adoption, never per
|
||||
## frame, and only over the planes whose collapse would flatten the map.
|
||||
func _measure_plane_variety(planes: StepCanvasColorize.CellPlanes) -> Dictionary:
|
||||
|
||||
Reference in New Issue
Block a user