feat(ui): the stipple says WHERE the ground is broken, not just that it is (T-1194)

RimWorld's technique 4, the reference this ticket names, works by CONTRAST: the
Rockies and Appalachians carry dense hatching and the Great Plains carry none.
Ferrath's Global carried an even wash of dots over every landmass instead —
texture present, information absent.

The cause was a constant measured on the wrong rung. RUGGEDNESS_FULL_SCALE_Q = 4
comes from Region ("d4 2.38"), but the elev_q path it gates only ever RUNS at the
orbital rungs, where relief_q is flat and the stipple falls back to it. Ferrath
Global measures a mean 1-cell elev_q gradient of 0.99, so a coherent 4-cell
baseline reaches ~4 and saturates the constant exactly: every land cell read as
fully rugged.

It now normalizes against the canvas's own measured gradient — the same
self-calibrating shape T-1240 gave the hillshade, and for the same reason: one
constant cannot serve rungs whose sample spacing differs by four orders of
magnitude. A contrast curve rides on top, because even unsaturated the linear
reading puts ordinary ground mid-range and paints grain everywhere.

Measured on Global, before -> after: 1,679 -> 1,900 distinct colours, 145.90 ->
147.39 lum spread, and the pale uplands now stipple visibly denser than the
lowlands beside them.

Recorded because it cost two wrong turns: I first guessed saturation, then
talked myself out of it after measuring a 1-CELL gradient (0.99) against a full
scale meant for the 4-CELL baseline, and shipped a contrast curve alone — which
measurably did nothing (1,679 -> 1,698), because a curve cannot separate values
already clamped to 1.0. The gamma is kept; it does its job now that there is a
range to curve. The elev_q gradient joins relief_q's in the capture readout, so
the next person tuning this can read the number instead of guessing at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 00:13:51 +02:00
co-authored by Claude Opus 5
parent 646db131a9
commit fab70edd5a
6 changed files with 82 additions and 5 deletions
+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.12"
config/version="0.4.13"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
+2 -1
View File
@@ -339,7 +339,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void:
"visual_capture: view-transform[%s] rung=%s world_center=%s held_extent=%s "
+ "canvas_position=%s canvas_scale=%s footprint_px=%s canvas_cells=%dx%d "
+ "courses=%d runs=%d longest=%.1fpx drawn=%d settlements=%d "
+ "planes=%s relief_grad=%.2f"
+ "planes=%s relief_grad=%.2f elev_grad=%.2f"
)
% [
scenario_name,
@@ -358,6 +358,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void:
int(summary.get("settlement_count", 0)),
str(summary.get("plane_variety", {})),
float(summary.get("relief_gradient", 0.0)),
float(summary.get("elev_gradient", 0.0)),
]
)
)
@@ -116,6 +116,28 @@ const RUGGEDNESS_FULL_SCALE_Q: int = 4
## has arrived but has not yet flattened into the regional average.
const RUGGEDNESS_BASELINE_CELLS: int = 4
## Exponent applied to the ruggedness reading before it becomes a stipple
## probability (T-1194).
##
## The texture's job is to say WHERE the ground is broken, and a linear reading
## cannot: measured on Ferrath Global the mean |elev_q gradient| is 0.99 steps
## per cell against RUGGEDNESS_FULL_SCALE_Q's 4, so ordinary land sits at
## 0.25-0.4 and the map gets an even wash of dots. The RimWorld reference is the
## opposite — clean plains, dense ranges — and that difference is distribution,
## not amplitude.
##
## 2.0 keeps the top end almost untouched (0.9 -> 0.81) while collapsing the
## middle (0.4 -> 0.16), which is exactly the asymmetry wanted. Higher starts
## erasing genuinely rolling country along with the flats.
const RUGGEDNESS_CONTRAST_GAMMA: float = 2.0
## How far above the canvas's own expected baseline delta counts as "fully
## rugged" (T-1194). 2x puts terrain of ordinary roughness at 0.5 before the
## contrast curve and reserves saturation for ground genuinely twice as broken
## as its neighbours — which is what makes a range read as a range against the
## plain beside it.
const RUGGEDNESS_HEADROOM_MULTIPLE: float = 2.0
## relief_q deviation from flat (50) at which ground is "fully rugged". Each
## step is 8 m, so 60 = 480 m of local relief.
##
@@ -221,6 +243,10 @@ class CellPlanes:
## 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
## Mean |elev_q central difference| across this canvas, measured once per
## rebuild alongside the relief one. The stipple normalizes against it at the
## orbital rungs — see [method _ruggedness]. 0.0 falls back to the constant.
var elev_gradient_scale: float = 0.0
var glaciation: Image
var temp_dc: Array
var width: int
@@ -463,9 +489,40 @@ static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float:
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))
var from_elev: float = float(worst) / float(RUGGEDNESS_FULL_SCALE_Q)
# Normalize against THIS canvas's own elev_q gradient rather than the fixed
# constant (T-1194). RUGGEDNESS_FULL_SCALE_Q = 4 was measured at Region
# ("d4 2.38"), and the stipple's elev path only ever RUNS at the orbital
# rungs, where the number is different: Ferrath Global measures a mean
# 1-cell gradient of 0.99, so a coherent 4-cell baseline reaches ~4 and
# saturates the constant outright. Every land cell then read as fully rugged
# — the even wash of dots this ticket exists to fix, and the reason adding a
# contrast curve alone changed nothing (1,679 -> 1,698 distinct colours: a
# curve cannot separate values that are all already clamped to 1.0).
#
# The expected baseline delta is the mean 1-cell gradient times the baseline
# length; the multiple then puts typical ground below saturation and leaves
# headroom for genuinely broken terrain.
var elev_full_scale: float = float(RUGGEDNESS_FULL_SCALE_Q)
if planes.elev_gradient_scale > 0.0:
elev_full_scale = (
planes.elev_gradient_scale
* float(RUGGEDNESS_BASELINE_CELLS)
* RUGGEDNESS_HEADROOM_MULTIPLE
)
var from_elev: float = float(worst) / maxf(elev_full_scale, 0.001)
return clampf(maxf(from_relief, from_elev), 0.0, 1.0)
# CONTRAST CURVE (T-1194). The linear reading puts ordinary ground in the
# middle of the density range, which paints uniform grain everywhere — the
# opposite of what this texture is for. The reference map's whole tell is
# CONTRAST: "its high flat plains carry none; its mountain ranges are dense
# with it" (this file's own T-1194 note). Measured on Ferrath Global, the
# mean |elev_q gradient| is 0.99 steps per cell against a full scale of 4, so
# typical land lands near 0.25-0.4 — everywhere gets some, nowhere gets much.
#
# A power curve fixes the distribution without touching the measurement:
# gentle ground falls away toward clean, broken ground keeps its density.
# 0.4 -> 0.16 at gamma 2, while 0.9 only drops to 0.81.
return pow(clampf(maxf(from_relief, from_elev), 0.0, 1.0), RUGGEDNESS_CONTRAST_GAMMA)
## Domain salts for the two marks, resolved ONCE at class load rather than per
@@ -76,6 +76,9 @@ var _plane_variety: Dictionary = {}
## Mean |relief_q gradient| of the held canvas — see [method get_relief_gradient].
var _relief_gradient_scale: float = 0.0
## Mean |elev_q gradient| — the stipple's driver at the orbital rungs (T-1194).
var _elev_gradient_scale: float = 0.0
## Rebuild (or reuse) the held texture from a decoded StepCanvasResponse's
## `canvas` Dictionary (step_canvas_protocol.gd's shape: width/height +
@@ -133,6 +136,12 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo
planes.relief_q = _decode_l8_plane(canvas.get("relief_q"))
planes.relief_gradient_scale = _measure_relief_gradient(planes.relief_q)
_relief_gradient_scale = planes.relief_gradient_scale
# T-1194: the stipple's OWN driver at the orbital rungs, where relief_q is
# flat and `_ruggedness` falls back to the elev_q gradient. Measured here so
# the density constant can be tuned against the rung it actually runs on
# rather than against the one it was first measured at.
_elev_gradient_scale = _measure_relief_gradient(planes.elev_q)
planes.elev_gradient_scale = _elev_gradient_scale
planes.glaciation = _decode_l8_plane(canvas.get("glaciation"))
planes.temp_dc = canvas.get("temp_dc", [])
return planes
@@ -192,6 +201,13 @@ func get_relief_gradient() -> float:
return _relief_gradient_scale
## The held canvas's mean |elev_q gradient| (T-1194). At the orbital rungs this
## is what drives the relief stipple, and its density constant was measured at
## Region — a rung it does not run on. Surfaced so the two can be compared.
func get_elev_gradient() -> float:
return _elev_gradient_scale
## Cells stepped between variety samples.
##
## A full scan is 5 planes x 1290x540 = ~3.5M get_pixel() calls per canvas
@@ -450,6 +450,7 @@ func get_current_canvas_summary() -> Dictionary:
# "the server sent a noisy field" from "the renderer is painting noise
# onto a smooth one" — two causes with one appearance (a stucco rung).
"relief_gradient": _terrain_layer.get_relief_gradient() if _terrain_layer else 0.0,
"elev_gradient": _terrain_layer.get_elev_gradient() if _terrain_layer else 0.0,
"course_count_by_class": course_count_by_class,
"cliff_count": (d.get("cliffs", []) as Array).size(),
"settlement_count": _count_distinct_settlements(d),
+3 -1
View File
@@ -65,7 +65,9 @@ name: The Settled Reach
# every octave the rung cannot reconstruct, so a 0.4.11 Region entry holds a
# relief field this build would never produce. Warm caches are genuinely wrong
# here, not merely stale.
version: 0.4.12
# 0.4.13 — stipple contrast (T-1194). Paint only; bumped because the client
# step_canvas cluster is in the T-1242 registry.
version: 0.4.13
repository: settled-reach