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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user