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:
2026-08-16 18:30:21 +02:00
co-authored by Claude Opus 5
parent b9cd26429e
commit 8787ee1844
7 changed files with 320 additions and 43 deletions
+27
View File
@@ -634,3 +634,30 @@ was missing, because each asserted a field it already knew about and none
asserted the SET. There is now a test that walks all eight dense planes of
EncodedStepCanvas and fails if any is absent from the decode — verified by
disabling the fix and watching it fail by name.', NULL, '2026-08-16 13:05:35', '2026-08-16 13:05:35.162', '2026-08-16 13:05:35.162', NULL, 'fc36ad2f1acb58e73b53d53ac6402a95', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'description', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.', NULL, '2026-08-16 16:23:17', '2026-08-16 16:23:17.190', '2026-08-16 16:23:17.190', NULL, '42875477f1917d72cd064d584df2b11f', 2) ON CONFLICT(hash) DO NOTHING;
+27
View File
@@ -611,3 +611,30 @@ asserted the SET. There is now a test that walks all eight dense planes of
EncodedStepCanvas and fails if any is absent from the decode — verified by
disabling the fix and watching it fail by name.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-16 13:05:35.162', NULL, 'e611e465ff1fd8de7b71b3de10702fec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0P0F9C3RKKK23ZHHTANCPZW', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_visibility_texture_update_performance flakes under gate load — min-of-7 was not enough', 'Wall-clock perf assertion in client/tests/test_fog_shader.gd:213 (best-of-7 < 0.5 ms, D-059 budget) failed the pre-push gate on 2026-08-16 at 0.606 ms, then passed 23/23 in isolation minutes later on the same build. The failing run was a full-suite pass inside the hook, which runs AFTER the tooling suite and cargo checks, so every one of the seven samples was inflated together by a loaded machine. My manual full-suite run of the identical tree passed 1830/0. Nothing in the pushed change touches the fog path (Atlas protocol decode, colorize, terrain layer, tooling). This is the SECOND recorded hardening cycle for the same failure mode: the estimator is already min-of-7 (T-1210) after median-of-5 flaked three times on 2026-07-26 when a cargo build saturated the cores (0.549/0.503/0.638). Taking the minimum defends against ONE slow sample, not against sustained saturation, which is precisely the condition the gate creates by design. Direction: measure CPU time rather than wall clock (Time.get_ticks_usec measures elapsed, so it prices the scheduler in), or make the budget assertion load-aware, or move the perf assertions out of the correctness gate into a dedicated bench run where the machine is quiet. Do NOT simply raise the 0.5 threshold -- that discards the budget D-059 set without deciding to.', 'backlog', 'medium', NULL, 'client', NULL, '2026-08-16 14:37:49.792', '2026-08-16 14:37:49.792', NULL, '513d27f28c0ff1a50bd36692fb96fa46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.', 'backlog', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-16 16:23:17.190', NULL, '1ed56273a1d81dbb6d0e1614598f744b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+1 -1
View File
@@ -17,7 +17,7 @@ config/name="The Settled Reach"
; in the editor and in a shipped build, where res://../project.yaml does not
; exist at all (T-1241). Kept honest by `make check-client-version`, which the
; pre-push hook runs — do not edit this by hand without moving project.yaml too.
config/version="0.4.9"
config/version="0.4.10"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
+82 -4
View File
@@ -7,7 +7,9 @@
class_name TestStepCanvasColorize
extends GdUnitTestSuite
const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd")
const StepCanvasColorize := preload(
"res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd"
)
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
@@ -22,8 +24,14 @@ static func _l8_image(values: Array, width: int, height: int) -> Image:
static func _planes(
morphology: Array, elev_q: Array, moisture_q: Array, vegetation: Array, glaciation: Array,
temp_dc: Array, width: int, height: int
morphology: Array,
elev_q: Array,
moisture_q: Array,
vegetation: Array,
glaciation: Array,
temp_dc: Array,
width: int,
height: int
) -> StepCanvasColorize.CellPlanes:
var p := StepCanvasColorize.CellPlanes.new()
p.width = width
@@ -66,7 +74,9 @@ func test_moisture_toggle_ramps_dry_to_wet() -> void:
func test_vegetation_toggle_marine_is_transparent() -> void:
var planes := _planes([8], [50], [0], [6], [0], [], 1, 1) # VEGETATION_MARINE = 6
var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, StepCanvasColorize.TOGGLE_VEGETATION)
var got: Color = StepCanvasColorize.cell_color(
planes, 0, 0, StepCanvasColorize.TOGGLE_VEGETATION
)
assert_that(got).is_equal(Color.TRANSPARENT)
@@ -116,6 +126,74 @@ func test_out_of_bounds_cell_index_returns_zero_not_a_crash() -> void:
assert_that(got).is_equal(AtlasOverlayColors.district_window_morphology_color(0))
## T-1213 hillshade. Asserted through cell_color() rather than the private
## helper, so it pins the OBSERVABLE property, and on DIRECTION rather than
## magnitude, so tuning the light or the full-scale constant does not rewrite the
## test.
##
## A HILL, not a ramp: relief 30-70-30, so the west flank climbs toward the
## upper-left light and the east flank falls away from it. The west flank must
## come out brighter than the east.
##
## A monotonic ramp cannot test this and the first version of this test used one:
## with the edge SAMPLE clamped, both end cells compare their interior neighbour
## against themselves and produce the same half-gradient, so they render
## identically and the test fails against correct code. The pairing of a lit face
## and a shadowed face is the whole reason hillshade replaced height-shading —
## shading by height alone gives a rise and a fall the same tone, which is why it
## read as blotches.
func test_hillshade_lights_slopes_facing_the_light() -> void:
var planes := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
planes.relief_q = _l8_image([30, 70, 30], 3, 1)
# The scale the terrain layer measures per canvas; each flank's central
# difference here is (70-30)/2 = 20.
planes.relief_gradient_scale = 20.0
var west: Color = StepCanvasColorize.cell_color(planes, 0, 0, "")
var east: Color = StepCanvasColorize.cell_color(planes, 2, 0, "")
(
assert_bool(west.v > east.v)
. override_failure_message(
(
(
"west flank %s should be lit and east flank %s shadowed on a hill "
+ "under an upper-left light — if these are equal the hillshade is "
+ "reading height instead of slope"
)
% [west, east]
)
)
. is_true()
)
func test_hillshade_is_flat_where_relief_is_uniform() -> void:
# A uniform relief field has no gradient, so shading must be a no-op — the
# guard against a hillshade that darkens (or lightens) the whole map.
var planes := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
planes.relief_q = _l8_image([50, 50, 50], 3, 1)
var flat_shaded: Color = StepCanvasColorize.cell_color(planes, 1, 0, "")
var no_relief := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
no_relief.relief_q = null
var unshaded: Color = StepCanvasColorize.cell_color(no_relief, 1, 0, "")
_assert_color_approx(flat_shaded, unshaded)
## The canvas edge must not invent a cliff. `_l8_value` returns 0 out of bounds
## and 0 on relief_q means MAXIMUM HOLLOW, so sampling off-canvas would post a
## full-scale false gradient and draw a lit/shadowed rim around every frame. The
## sample POSITION is clamped instead, making a border cell compare against
## itself — so on a uniform field the edge must match the interior exactly.
func test_hillshade_does_not_draw_a_rim_at_the_canvas_edge() -> void:
var planes := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
planes.relief_q = _l8_image([50, 50, 50], 3, 1)
var edge: Color = StepCanvasColorize.cell_color(planes, 0, 0, "")
var interior: Color = StepCanvasColorize.cell_color(planes, 1, 0, "")
_assert_color_approx(edge, interior)
## gdUnit4's generic assert_that() has no Color-typed is_equal_approx() —
## per-channel float comparison, matching test_atlas_window_colors.gd's own
## _assert_color_approx() precedent (this is a deliberate duplicate, same
@@ -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:
+5 -1
View File
@@ -54,7 +54,11 @@ name: The Settled Reach
# rendering the deep rungs as a flat wash even against a build that reads it.
# This is the first bump in the series where a warm cache is wrong about CONTENT
# rather than merely stale.
version: 0.4.9
# 0.4.10 changes how a canvas is PAINTED, not how it is derived, so cached
# entries are still valid content — but the client step_canvas cluster is in the
# T-1242 registry and the rule is bump-when-touched, so it moves. Cheap: one
# round of misses against a map that now reads as terrain.
version: 0.4.10
repository: settled-reach