feat(ui): the copse and the rocky outcrop, drawn per cover class (T-1213)

D-258 invariant 2 asks the map to show the minority the orbital summary
suppressed — clearings, marsh, rock, scrub inside a cell that reads "forest" from
space. composition.rs goes to real trouble to invent it, and the conservation
harness measures it: over a District patch on Ferrath the tally is {Barren: 175,
Forest: 16209}, so 1.07% of that ground is exposed rock.

The renderer was averaging it back out. One test, `veg >= Scrub`, at one density
and one strength: Forest, Scrub and both Riparian classes drew the IDENTICAL
mark, and Barren drew none at all. A wood looked like scrub, and bare rock was
invisible by construction — on the rungs whose whole purpose is to show what the
summary hid.

Each class now has its own grammar, differing on the three axes a mark has:
density (how much of the class's ground carries it), strength (how far it moves
the base colour), and lattice (the block size marks are decided on — bigger reads
as a clump, smaller as grain). Rock LIGHTENS where everything else darkens, which
is the point rather than a flourish: bare stone catching the light is the one
cover type brighter than the ground around it, so it separates from vegetation by
sign alone and can never read as "denser plants". It gets its own ScatterField
salt so an outcrop does not preferentially land where a copse already did.

Tuned against captures, not guessed. A first pass gave Forest a 4x4 lattice at
52% density, which produced visibly axis-aligned dark SQUARES — a 4-cell block is
8 screen px at District — and read as an artefact laid over the hillshade. It
also mistook the background for a feature: Forest is 98.9% of this frame, so
marking half of it dark is not "the occasional copse", it is a second colour
layer. Pulled back to grain at a 2x2 lattice, and the occasional thing is now the
thing that catches the eye: the outcrops read as scattered pale clusters of
exposed ground, exactly the "occasional copse/tree/rocky outcropping" that was
missing.

District holds its form through the change (lum p1-p99 74.15, against 74.43
before the cover marks and 13.72 when the rung was flat).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:04:32 +02:00
co-authored by Claude Opus 5
parent 8787ee1844
commit 6d4c9e92c2
3 changed files with 99 additions and 14 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.10"
config/version="0.4.11"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
@@ -57,12 +57,47 @@ const MORPHOLOGY_OPEN_OCEAN: int = 0
## and Barren(1) get none, which is the point: bare ground should read bare.
const VEGETATION_SCRUB: int = 2
## The rest of VegetationClass (district_profile.rs): Barren is bare rock above
## treeline or hyper-arid, Forest is closed canopy, and the two Riparian classes
## are the 1-3 tile band along perennial water that takes precedence over the
## base class. Each earns its own mark — see [method _cover_mark].
const VEGETATION_BARREN: int = 1
const VEGETATION_FOREST: int = 3
const VEGETATION_RIPARIAN_SCRUB: int = 4
const VEGETATION_RIPARIAN_THICKET: int = 5
## Per-class cover marks (T-1213). Densities are the share of a class's ground
## that carries a mark; strengths are how far it moves the base colour.
##
## Tuned so the classes separate at a glance without any of them becoming a
## second colour layer: rock is the sparsest and strongest (a few bright chips),
## forest the densest and coarsest (merging blobs), scrub the quietest (grain).
## Riparian sits above its dry counterpart on both axes, because a waterside
## thicket genuinely is the thickest cover on the map.
const ROCK_OUTCROP_DENSITY: float = 0.18
const ROCK_OUTCROP_STRENGTH: float = 0.20
const SCRUB_PATCH_DENSITY: float = 0.26
const SCRUB_PATCH_STRENGTH: float = 0.08
## Forest is the BACKGROUND on a wooded body, not a feature — measured on
## Ferrath's District patch it is 98.9% of the ground. A first pass marked 52% of
## it at a 4x4 lattice, which produced visibly axis-aligned dark SQUARES (a 4-cell
## block is 8 screen px at this rung) and read as a rendering artefact laid over
## the hillshade rather than as woodland. Pulled back to grain at a 2x2 lattice:
## the forest should texture the ground it covers, and the OCCASIONAL thing —
## the outcrop, the clearing — should be what catches the eye.
const FOREST_PATCH_DENSITY: float = 0.30
const FOREST_PATCH_STRENGTH: float = 0.12
const RIPARIAN_SCRUB_DENSITY: float = 0.38
const RIPARIAN_THICKET_DENSITY: float = 0.62
const RIPARIAN_THICKET_STRENGTH: float = 0.20
## 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 single VEGETATION_PATCH_* pair that used to live here is gone: one density
# and one strength for every vegetated class is what made a wood look like scrub.
# Replaced by the per-class marks below (T-1213).
## 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
@@ -266,16 +301,59 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
if rug > 0.0 and ScatterField.chance(_relief_salt, col, row, 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 ScatterField.chance(_veg_salt, col >> 1, row >> 1, VEGETATION_PATCH_DENSITY)
):
out = out.darkened(VEGETATION_PATCH_STRENGTH)
return out
return _cover_mark(out, _l8_value(planes.vegetation, col, row), col, row)
## Ground cover, marked PER CLASS — the copse, the thicket, the rocky outcrop
## (T-1213, D-258 invariant 2).
##
## This used to be one test, `veg >= Scrub`, at one density and one strength. So
## Forest, Scrub and both Riparian classes drew the identical mark and Barren
## drew NONE — meaning a wood looked like scrub, and exposed rock was invisible
## by construction, on a map whose whole purpose below Global is to show the
## minority the orbital summary suppressed. The composition tier goes to real
## trouble to put clearings and outcrops inside a forest cell (composition.rs's
## rare inclusions; the conservation harness measures ~1.07% minority over a
## District patch) and the renderer was averaging them back out.
##
## Each class now gets its own grammar, differing on all three axes a mark has:
## DENSITY how much of the class's ground carries it
## STRENGTH how far it moves the base colour
## LATTICE the block size marks are decided on — a bigger block reads as a
## CLUMP (a copse), a smaller one as grain (scrub)
##
## Rock LIGHTENS where everything else darkens, and that is the point rather than
## a flourish: bare stone catching the light is the one cover type that is
## brighter than the ground around it, so it separates from vegetation by sign
## alone and never reads as "denser plants".
##
## Water carries no mark at all — the caller returns before this on the two open
## water zones.
static func _cover_mark(base: Color, veg: int, col: int, row: int) -> Color:
match veg:
VEGETATION_BARREN:
# Outcrops: sparse, high-contrast, fine — a scatter of stone, not a
# field of it. Own salt so rock does not shadow the copse lattice.
if ScatterField.chance(_rock_salt, col >> 1, row >> 1, ROCK_OUTCROP_DENSITY):
return base.lightened(ROCK_OUTCROP_STRENGTH)
VEGETATION_SCRUB:
if ScatterField.chance(_veg_salt, col >> 1, row >> 1, SCRUB_PATCH_DENSITY):
return base.darkened(SCRUB_PATCH_STRENGTH)
VEGETATION_FOREST:
# 2x2, not 4x4: a coarser lattice quantises into axis-aligned squares
# at these rungs, which reads as a bug rather than as canopy.
if ScatterField.chance(_veg_salt, col >> 1, row >> 1, FOREST_PATCH_DENSITY):
return base.darkened(FOREST_PATCH_STRENGTH)
VEGETATION_RIPARIAN_SCRUB:
if ScatterField.chance(_veg_salt, col >> 1, row >> 1, RIPARIAN_SCRUB_DENSITY):
return base.darkened(SCRUB_PATCH_STRENGTH)
VEGETATION_RIPARIAN_THICKET:
# The densest cover on the map: a thicket along water is the one
# place vegetation genuinely closes over. Same 2x2 lattice as forest
# — it stays distinct on density and strength, not on block size.
if ScatterField.chance(_veg_salt, col >> 1, row >> 1, RIPARIAN_THICKET_DENSITY):
return base.darkened(RIPARIAN_THICKET_STRENGTH)
return base
## Directional HILLSHADE from relief_q as a -1..1 fraction: +1 fully lit, -1
@@ -397,6 +475,10 @@ static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float:
## double contrast.
static var _relief_salt: int = ScatterField.domain(&"atlas/relief_stipple")
static var _veg_salt: int = ScatterField.domain(&"atlas/vegetation_patch")
## Own stream, so an outcrop does not preferentially land where a copse already
## did — the same reason composition.rs salts its inclusion test separately from
## its smooth field.
static var _rock_salt: int = ScatterField.domain(&"atlas/rock_outcrop")
static func _base_color(planes: CellPlanes, col: int, row: int) -> Color:
+4 -1
View File
@@ -58,7 +58,10 @@ name: The Settled Reach
# 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
# 0.4.11 — per-class cover marks (copse, thicket, rocky outcrop). Paint again,
# not derivation; bumped because the client step_canvas cluster is in the T-1242
# registry.
version: 0.4.11
repository: settled-reach