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