feat(ui): relief and vegetation texture over the terrain hue (T-1194)

RimWorld technique 4 from the reference map: texture as data, not decoration.

WHY IT WAS FLAT. The base layer reads hue from morphology and lightness from
elev_q. Below Global, morphology resolves to exactly ONE zone per canvas, so
the frame became a single colour whose only variation was a lightness ramp too
subtle to see. Measured on Ferrath at Region: 1 morphology zone, but 49
distinct elev_q values. The information was already on the wire and arriving —
the renderer was discarding it by expressing it in lightness alone.

TWO MARKS, NOT ONE SLIDER — the ticket's design question (b), settled by
looking at the reference rather than reasoning about it:
  - RELIEF stipple: fine, dense, darker, keyed to RUGGEDNESS not height. The
    reference's high flat plains carry none while its ranges are dense with it,
    so the driver is the local elev_q gradient; a high plateau stays clean.
  - VEGETATION blotch: coarser, softer, marked on a half-frequency lattice so
    it reads as patches rather than a second speckle at the same pitch.

Inline in the existing per-cell loop (question (a)) and always-on, base layer
only (question (c)). The TMP/MST/VEG toggles are ANALYTIC reads — stippling a
temperature ramp would corrupt the quantity being read.

THE BASELINE IS MEASURED, NOT GUESSED, and the first attempt got it wrong: a
1-cell ruggedness delta samples mostly quantization noise, reads
near-identically everywhere, and rendered as uniform static over flat green —
grain, not structure. The gradient saturates by about 4 cells (Region: d1 1.40,
d4 2.38, d8 2.41, d16 2.51), so the baseline is 4 and the full scale 4.
Verified by capture at native resolution: on Global the stipple now
concentrates on rugged ground and leaves plains clean.

WATER TAKES NEITHER MARK, and gets a flat tone. An earlier version excluded
Lake alone and stippled the entire ocean — the one surface with no relief to
express. Both open-water zones are excluded now.

The ocean also stops shading by elev_q, which is the same argument T-1188
already made for lakes and never applied here: elev_q on a water cell is the
bedrock UNDER the water, not the surface, so shading the sea by it paints
seabed relief nobody can see. Near a coast that bedrock rises steeply and
quantizes hard, which is exactly where it showed — a pale, pixellated, broken
fringe hugging every shore (Jeroen, on the capture). One tone for the sea reads
as water and lets the coastline be the edge.

D-255(e)-legal throughout: texture-space dithering of already-derived per-cell
values, decided per server cell by a hash of its own coordinates and values —
no sample invented between cells, identical on cache hit and miss.

Two colorize tests updated: both asserted the old ocean shading incidentally
while testing zero-fill/no-crash. Property under test unchanged.

1838 client tests, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 15:20:04 +02:00
co-authored by Claude Opus 5
parent 8eb5cb9ff7
commit 566b566519
5 changed files with 170 additions and 12 deletions
@@ -100,3 +100,4 @@ ticket''s scope, but it will be visible in the same captures, so do not mistake
for a failure of the un-summarisation work.', NULL, '2026-08-06 15:59:39', '2026-08-06 15:59:39.623', '2026-08-06 15:59:39.623', NULL, 'fff0549b0da56272e5c13e9de4374f76', 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 ('06FT0TZC9MJV6KZTSRVRYQ327M', 'status', 'backlog', 'in_progress', NULL, '2026-08-07 11:36:05', '2026-08-07 11:36:05.551', '2026-08-07 11:36:05.551', NULL, '56946f83904827105a9ae52d64a91a1d', 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 ('06FT0TZC9MJV6KZTSRVRYQ327M', 'status', 'in_progress', 'in_progress', NULL, '2026-08-07 11:36:20', '2026-08-07 11:36:20.465', '2026-08-07 11:36:20.465', NULL, 'd57ff6176db9e02ac1e97fb8cb8e609b', 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 ('06FSJWSX11WV3C1XXZEV88Q3P0', 'status', 'backlog', 'in_progress', NULL, '2026-08-07 12:44:21', '2026-08-07 12:44:21.620', '2026-08-07 12:44:21.620', NULL, '013321fe4ccaa686875a688ae6174292', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -165,3 +165,4 @@ Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-07 11:36:20.464', NULL, '94ccf78431914ea836ba5d59170a3395', 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 OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
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 ('06FSJWSX11WV3C1XXZEV88Q3P0', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Biome/relief stipple-density layer on the terrain build (RimWorld technique 4)', 'Follow-up from T-1175''s assess-only item (2026-07-25, stig''s write-up): a stipple/texture-density layer carrying hills/mountains/forest on top of hue, driven by the already-wire-carried vegetation and elev_q L8 planes — presentation-only, D-255(e)-legal (texture-space dithering of already-derived per-cell values, deterministically seeded per cell coordinate + value so it is stable across cache hit/miss; never invents samples between server cells). Would live as a post-process in step_canvas_terrain_layer.gd::rebuild_from_canvas()''s Image.set_pixel build. Design questions to settle at pickup: (a) stipple dots inline in the existing per-cell loop (cheap, same O(wxh) pass) vs a second overlay pass (simpler code, doubles pixel-touch cost); (b) density from vegetation class directly vs a combination with elev_q — relief hachures and forest texture are two different visual grammars in the RimWorld reference, not one slider; (c) own legend toggle (TMP/MST/VEG overlay-bar pattern) vs always-on like the elevation lightness modifier. Reference: docs/design/references/rimworld-world-map-fluency.jpg. Related: T-1175, T-1162 (vegetation patchiness fields), D-255(e).', 'in_progress', 'low', NULL, 'client', NULL, '2026-07-25 13:24:54.152', '2026-08-07 12:44:21.620', NULL, 'c1560b624bd9a9d79fe919f282620f22', 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 OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+10 -10
View File
@@ -98,22 +98,22 @@ func test_null_plane_falls_back_to_zero_rather_than_crashing() -> void:
p.glaciation = null
p.temp_dc = []
# Must not crash — morphology/elev_q both read as 0 (OpenOcean, elev 0).
# OpenOcean is a flat tone since T-1194 (elev_q under water is seabed
# bedrock, not the surface), so the expectation is the bare morphology
# colour; the no-crash property under test is unchanged.
var got: Color = StepCanvasColorize.cell_color(p, 0, 0, "")
assert_that(got).is_equal(
AtlasOverlayColors.district_window_elevation_lightness(
AtlasOverlayColors.district_window_morphology_color(0), 0
)
)
assert_that(got).is_equal(AtlasOverlayColors.district_window_morphology_color(0))
## Out-of-bounds reads zero-fill rather than crashing. Zone 0 is OpenOcean, and
## since T-1194 the open ocean is a FLAT tone — no elev_q lightness, because
## elev_q under water is seabed bedrock, not the surface. So the expected value
## is the bare morphology colour; the property under test (no crash, zero-fill)
## is unchanged.
func test_out_of_bounds_cell_index_returns_zero_not_a_crash() -> void:
var planes := _planes([8], [50], [0], [0], [0], [], 1, 1)
var got: Color = StepCanvasColorize.cell_color(planes, 99, 99, "")
assert_that(got).is_equal(
AtlasOverlayColors.district_window_elevation_lightness(
AtlasOverlayColors.district_window_morphology_color(0), 0
)
)
assert_that(got).is_equal(AtlasOverlayColors.district_window_morphology_color(0))
## gdUnit4's generic assert_that() has no Color-typed is_equal_approx() —
@@ -41,6 +41,41 @@ const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
## same as step_canvas_protocol.gd's status-decode duplication rationale).
const MORPHOLOGY_LAKE: int = 1
## MorphologyZone::OpenOcean — the other open-water discriminant (D-239 §6
## ordering; AtlasOverlayColors documents 0 and 1 as the only two zones that
## are actually water, everything from TidalFlat onward being land or a
## transition).
const MORPHOLOGY_OPEN_OCEAN: int = 0
## VegetationClass::Scrub (district_profile.rs) — the threshold at or above
## which a cell carries enough plant cover to earn the patch mark. Absent(0)
## and Barren(1) get none, which is the point: bare ground should read bare.
const VEGETATION_SCRUB: int = 2
## T-1194 texture tuning. Deliberately gentle — the reference's texture reads
## as grain over a still-legible hue, never as a second colour layer competing
## with it. Raising RELIEF_STIPPLE_STRENGTH past ~0.35 starts reading as noise.
const RELIEF_STIPPLE_STRENGTH: float = 0.22
const VEGETATION_PATCH_STRENGTH: float = 0.10
const VEGETATION_PATCH_DENSITY: float = 0.34
## The elev_q delta at which relief is "fully rugged" and every cell earns a
## mark. Measured, not guessed: on Ferrath at Region the mean |delta| over a
## 4-cell baseline is 2.38 with 61% of pairs at >=2, so a full scale of 4 puts
## the typical reading near the middle of the density range and leaves genuine
## headroom at both ends. A larger value flattens the texture toward "always
## sparse"; a smaller one saturates it back to the uniform grain this replaced.
const RUGGEDNESS_FULL_SCALE_Q: int = 4
## Cells between the two samples the relief delta is measured across.
##
## NOT 1. The elevation field's gradient saturates by about 4 cells (Region
## measures d1 1.40, d4 2.38, d8 2.41, d16 2.51), so a 1-cell baseline samples
## mostly quantization noise: it reads near-identically everywhere and renders
## as uniform static rather than as ruggedness. Four cells is where the signal
## has arrived but has not yet flattened into the regional average.
const RUGGEDNESS_BASELINE_CELLS: int = 4
## 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.
@@ -74,10 +109,89 @@ static func cell_color(planes: CellPlanes, col: int, row: int, active_toggle: St
TOGGLE_VEGETATION:
base = _vegetation_color(planes, col, row)
_:
base = _base_color(planes, col, row)
base = _texture(planes, col, row, _base_color(planes, col, row))
return _apply_glaciation(planes, col, row, base)
## Relief and vegetation TEXTURE over the base hue (T-1194, RimWorld technique
## 4 — "texture as data, not decoration").
##
## WHY. The base layer reads hue from morphology and lightness from elev_q, and
## on a real body below Global that collapses: morphology resolves to ONE zone
## per canvas, so the frame becomes a single flat colour whose only variation is
## a lightness ramp too subtle to see. Measured on Ferrath at Region — 1
## morphology zone, but 49 distinct elev_q values. The information was already
## on the wire and arriving; the renderer was throwing it away by expressing it
## in lightness alone.
##
## The reference map (docs/design/references/rimworld-world-map-fluency.jpg)
## uses TWO marks, not one slider — the ticket's design question (b), settled by
## looking at it:
## - RELIEF stipple: fine, dense, darker, keyed to RUGGEDNESS not height. Its
## high flat plains carry none; its mountain ranges are dense with it. So
## the driver here is the local elev_q *gradient*, not elev_q itself: a high
## plateau is smooth and stays clean.
## - VEGETATION blotch: coarser, softer, a different grammar entirely. Keyed
## to the vegetation class and marked at half frequency so it reads as
## patches rather than grain.
##
## ALWAYS-ON, and base-layer ONLY (design question (c)). It is terrain
## information in the same sense the elev_q lightness modifier is, not an
## overlay — but the TMP/MST/VEG toggles are ANALYTIC reads, and stippling a
## temperature ramp would corrupt the quantity the user is trying to read.
##
## D-255(e)-legal: pure texture-space dithering of already-derived per-cell
## values. The mark is decided per SERVER CELL by a hash of its own coordinates
## and values — never a sample invented between cells, and identical on a cache
## hit and a cache miss because nothing but (col, row, plane values) enters it.
static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Color:
# Water carries neither mark: relief stipple under a sea reads as noise,
# and vegetation does not grow there. BOTH open water zones, not just Lake
# — an earlier version excluded Lake alone and stippled the entire ocean,
# which is the one surface with no relief to express.
var zone: int = _l8_value(planes.morphology, col, row)
if zone == MORPHOLOGY_LAKE or zone == MORPHOLOGY_OPEN_OCEAN:
return base
var out: Color = base
var rug: float = _ruggedness(planes, col, row)
if rug > 0.0 and _dither(col, row, 0x51F) < rug:
out = out.darkened(RELIEF_STIPPLE_STRENGTH * rug)
# Half-frequency lattice: one mark per 2x2 cells, so the vegetation grain is
# visibly coarser than the relief grain rather than a second speckle at the
# same pitch.
var veg: int = _l8_value(planes.vegetation, col, row)
if veg >= VEGETATION_SCRUB and _dither(col >> 1, row >> 1, 0xB33F) < VEGETATION_PATCH_DENSITY:
out = out.darkened(VEGETATION_PATCH_STRENGTH)
return out
## Local relief as a 0..1 fraction — the largest elev_q step to a 4-neighbour,
## normalized by the span at which terrain already reads as mountainous.
## Clamped at the edges by sampling the centre, so a canvas border neither
## invents relief nor loses it.
static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float:
var here: int = _l8_value(planes.elev_q, col, row)
var worst: int = 0
var b: int = RUGGEDNESS_BASELINE_CELLS
for d: Vector2i in [Vector2i(b, 0), Vector2i(-b, 0), Vector2i(0, b), Vector2i(0, -b)]:
var c: int = clampi(col + d.x, 0, planes.width - 1)
var r: int = clampi(row + d.y, 0, planes.height - 1)
worst = maxi(worst, absi(_l8_value(planes.elev_q, c, r) - here))
return clampf(float(worst) / float(RUGGEDNESS_FULL_SCALE_Q), 0.0, 1.0)
## Deterministic [0,1) dither for a cell, salted per mark type so the relief and
## vegetation lattices never correlate (sharing a hash would stack both marks on
## the same cells and read as one texture at double contrast).
static func _dither(col: int, row: int, salt: int) -> float:
var h: int = (col * 0x1F1F1F1F) ^ (row * 0x9E3779B1) ^ salt
h = (h ^ (h >> 15)) * 0x2545F491
h = (h ^ (h >> 13)) * 0x27220A95
return float(absi(h) % 4096) / 4096.0
static func _base_color(planes: CellPlanes, col: int, row: int) -> Color:
var zone: int = _l8_value(planes.morphology, col, row)
var base: Color = AtlasOverlayColors.district_window_morphology_color(zone)
@@ -89,6 +203,16 @@ static func _base_color(planes: CellPlanes, col: int, row: int) -> Color:
if zone == MORPHOLOGY_LAKE:
var lmq: int = _l8_value(planes.lake_margin_q, col, row)
return AtlasOverlayColors.lake_margin_lightness(base, lmq)
# The open ocean is a FLAT tone — the same argument the Lake branch above
# makes, finally applied to the other water zone. elev_q on a water cell is
# the bedrock elevation UNDER the water, not the surface, so shading the sea
# by it paints seabed relief the viewer cannot see and has no use for. Near
# a coastline that bedrock rises steeply and quantizes hard, which is
# exactly where it showed: a pale, pixellated, broken fringe hugging every
# shore. One tone for the sea reads as water and lets the coastline itself
# be the edge.
if zone == MORPHOLOGY_OPEN_OCEAN:
return base
var eq: int = _l8_value(planes.elev_q, col, row)
return AtlasOverlayColors.district_window_elevation_lightness(base, eq)
+33 -1
View File
@@ -2582,9 +2582,41 @@ mod tests {
_ => 0,
};
// Mean |elev_q delta| at several baselines. T-1194's relief stipple
// needs a driver that VARIES across the frame; a baseline shorter
// than the finest terrain octave measures quantization noise
// instead and comes out near-constant, which renders as uniform
// grain rather than as ruggedness.
let w = raw.width as usize;
let h = raw.height as usize;
let mut grad = String::new();
for base in [1usize, 4, 8, 16] {
let mut sum = 0.0f64;
let mut n = 0.0f64;
let mut hits = 0.0f64;
for r in 0..h {
for c in 0..w.saturating_sub(base) {
let a = raw.elev_q[r * w + c] as i32;
let b = raw.elev_q[r * w + c + base] as i32;
let d = (a - b).abs() as f64;
sum += d;
n += 1.0;
if d >= 2.0 {
hits += 1.0;
}
}
}
grad += &format!(
" d{}[mean {:.2} p>=2 {:.0}%]",
base,
sum / n.max(1.0),
100.0 * hits / n.max(1.0)
);
}
println!(
"{:8?} {:>9.1} m/gu {}x{} elev[uniq {:3} span {:3}] moist[uniq {:3} span {:3}] \
morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}",
morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}{grad}",
rung,
spacing,
raw.width,