tower / commit
feat(terrain): a fourth noise octave, and colour that mottles
content/geography.json | 7 ++-- decisions.md | 31 +++++++++++++++++ docs/screenshots/desktop-exterior-true.png | Bin 482771 -> 506317 bytes docs/screenshots/desktop-exterior.png | Bin 277105 -> 332578 bytes docs/screenshots/desktop-interior-night.png | Bin 239727 -> 278010 bytes docs/screenshots/desktop-interior-overhead.png | Bin 438336 -> 545753 bytes docs/screenshots/desktop-interior-suncap.png | Bin 336884 -> 385286 bytes docs/screenshots/desktop-interior.png | Bin 461697 -> 546693 bytes docs/screenshots/desktop-map-sightline.png | Bin 618294 -> 791611 bytes docs/screenshots/desktop-map.png | Bin 509255 -> 711911 bytes docs/screenshots/desktop-reader.png | Bin 499059 -> 699893 bytes docs/screenshots/mobile-exterior.png | Bin 70620 -> 81770 bytes docs/screenshots/mobile-interior.png | Bin 149395 -> 172347 bytes docs/screenshots/mobile-map.png | Bin 131771 -> 190936 bytes docs/screenshots/mobile-panel.png | Bin 72184 -> 74912 bytes docs/screenshots/overview.png | Bin 954082 -> 1080288 bytes src/views/raster.ts | 2 +- static/terrain-color.png | Bin 1310628 -> 1933009 bytes static/terrain-corridor.png | Bin 1149358 -> 1529037 bytes static/terrain-height.png | Bin 2909819 -> 3241370 bytes test/raster.test.ts | 9 +++-- tools/terrain.mjs | 46 +++++++++++++++++++++---- 22 files changed, 84 insertions(+), 11 deletions(-) diff --git a/content/geography.json b/content/geography.json index 59926c0..d176496 100644 --- a/content/geography.json +++ b/content/geography.json @@ -30,7 +30,7 @@ "gridWidth": 2048, "gridHeight": 1192, "metresPerPixel": 263.7, - "minHeightM": -520, + "minHeightM": -560, "maxHeightM": 4400, "$heightRangeNote": "The working range of terrain-height.png, not a limit on the world. Browsers decode a 16-bit PNG to 8 bits, so the app reads 256 levels across this span — keeping it tight to the terrain that exists is what makes the interior view's ground smooth. Below zero is real: lake and sea floors are cut beneath the datum.", "mapThetaOrigin": 3.14159265, @@ -87,7 +87,10 @@ "noiseScaleM": 78000, "octaves": 5, "detailAmpM": 45, - "detailScaleM": 12000 + "detailScaleM": 12000, + "microAmpM": 26, + "microScaleM": 1500, + "$microNote": "The octave the raster can just about hold: 1.5 km features at 264 m per pixel. Below this the PNG cannot carry it and the interior view has to invent it." }, "notes": "Everything outside the developed corridor. Deliberately gentle — this is shaped ground that nobody has bothered to make dramatic." }, diff --git a/decisions.md b/decisions.md index 026e61a..eaf2dad 100644 --- a/decisions.md +++ b/decisions.md @@ -1547,3 +1547,34 @@ part of it. Peaks reach about 3,900 m, so they cross the 3,000 m snowline and the ring reads as snow the whole way round. The rivers still cut it, because carve runs after relief. + +## D-052 — A fourth noise octave, and colour that mottles instead of dimming + +**Height.** The base had three noise layers — 78 km, 12 km, 3.4 km — and a pixel is +264 m. Between the finest layer and the raster's ~530 m Nyquist limit there was an +octave and a half of nothing, so the ground read as smooth swells with no grain on +them at exactly the scale you look at it from inside. A fourth layer at 1.5 km and +±26 m fills that band. It goes in the *base*, deliberately: carve and flood run +afterwards, so rivers still cut through it and lakes keep flat surfaces rather than +inheriting a rippled floor. + +**Colour.** There was already per-pixel variation, and it was the wrong kind — one +noise field scaling all three channels together, which dims and brightens without +ever changing the colour. A second, finer field now **mottles**: it pushes the +channels apart, green moving most and blue least, so ground reads as patchy +vegetation against bare soil rather than as one paint turned up and down. Keeping +blue nearly fixed is what stops the desert drifting toward the same green as the +forest. + +**It exposed a live bug, and the fix matters more than the noise does.** The extra +relief pushed the terrain to **-521 m** against a declared floor of **-520 m**, so +every pixel down there was being silently flattened. The generator printed a warning +and carried on; nothing else noticed. That is D-024's failure mode exactly — a +clipped raster displaces terrain rather than breaking it, so nothing downstream +complains. + +Two changes: the floor moves to **-560 m**, and `npm run terrain` now **exits +non-zero** when any pixel is clamped instead of printing a line into a log nobody +reads. The unit test could never have caught this — it compares the declaration +against extremes written down when they were last true, not against the terrain that +just came out — and it now says so where it sits. diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png index 5a05e92..684a263 100644 Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png index cdfb9a3..a01d9ec 100644 Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png index 2b9d512..5230a7f 100644 Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png index 61ecd98..b6e2c73 100644 Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png index abfcbec..78d87a5 100644 Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png index 81123d7..3d81c76 100644 Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png index 3980290..36adbc9 100644 Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png index 8c45102..9359a35 100644 Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png index 28ef19f..239d0b8 100644 Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png index d41319b..c573100 100644 Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png index 48eb82a..61ae7c8 100644 Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png index ecad338..63491ee 100644 Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png index 06669cf..16fb540 100644 Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png index c6d8ec1..f7ec772 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/src/views/raster.ts b/src/views/raster.ts index 928d80a..609396c 100644 --- a/src/views/raster.ts +++ b/src/views/raster.ts @@ -16,7 +16,7 @@ export const RASTER_HEIGHT = 1192 * finer every step in it. Below zero is real: lake and sea floors are cut beneath * the datum, and the old fixed 0..5,000 m encoding clipped them flat. */ -export const RASTER_MIN_HEIGHT_M = -520 +export const RASTER_MIN_HEIGHT_M = -560 export const RASTER_MAX_HEIGHT_M = 4400 export const RASTER_HEIGHT_SPAN_M = RASTER_MAX_HEIGHT_M - RASTER_MIN_HEIGHT_M diff --git a/static/terrain-color.png b/static/terrain-color.png index 932924a..b4d0e67 100644 Binary files a/static/terrain-color.png and b/static/terrain-color.png differ diff --git a/static/terrain-corridor.png b/static/terrain-corridor.png index 75947f1..394bdbd 100644 Binary files a/static/terrain-corridor.png and b/static/terrain-corridor.png differ diff --git a/static/terrain-height.png b/static/terrain-height.png index 0c13777..c3ea60e 100644 Binary files a/static/terrain-height.png and b/static/terrain-height.png differ diff --git a/test/raster.test.ts b/test/raster.test.ts index 3d6125d..f12bafd 100644 --- a/test/raster.test.ts +++ b/test/raster.test.ts @@ -35,8 +35,13 @@ describe('raster metadata matches the generator', () => { * check it still does — the generator warns, but nobody watches a build log. */ it('still covers the terrain it is meant to cover', () => { - expect(RASTER_MIN_HEIGHT_M).toBeLessThanOrEqual(-508) - expect(RASTER_MAX_HEIGHT_M).toBeGreaterThanOrEqual(4392) + // The extremes the generator last measured. These are a second line of defence + // and a weak one — they are numbers written down when they were true, not the + // terrain that came out of the most recent run, so they cannot notice the + // geography growing past them. `npm run terrain` now exits non-zero when a + // pixel is clamped, which is the check that actually holds. + expect(RASTER_MIN_HEIGHT_M).toBeLessThanOrEqual(-521) + expect(RASTER_MAX_HEIGHT_M).toBeGreaterThanOrEqual(4381) }) it('agrees on where the map is cut', () => { diff --git a/tools/terrain.mjs b/tools/terrain.mjs index f135fac..5bf5548 100644 --- a/tools/terrain.mjs +++ b/tools/terrain.mjs @@ -99,9 +99,21 @@ function opBase(f) { const broad = fbm(sb.x, sb.y, { octaves: r.octaves ?? 5, seed: 7, wrapY: sb.wrapY }) const fine = fbm(sf.x, sf.y, { octaves: 4, seed: 313, wrapY: sf.wrapY }) const grain = ridged(sg.x, sg.y, { octaves: 3, seed: 517, wrapY: sg.wrapY }) + /** + * A fourth layer, down near what the raster can actually hold. + * + * The three above bottom out at 3.4 km, and a pixel is 264 m — so between + * 3.4 km and the ~530 m Nyquist limit there was an octave and a half of + * nothing, and the ground read as smooth swells with no grain on them. This + * fills that band. It is deliberately the last thing the base does: carve and + * flood run afterwards, so rivers still cut through it and lake surfaces stay + * flat rather than inheriting a rippled floor. + */ + const sm = nc(z, wy, r.microScaleM ?? 1500) + const micro = fbm(sm.x, sm.y, { octaves: 4, seed: 1097, wrapY: sm.wrapY }) height[y * W + x] = r.baseM + (broad - 0.45) * 2 * r.noiseAmpM + (fine - 0.5) * 2 * r.detailAmpM - + (grain - 0.5) * 26 + + (grain - 0.5) * 26 + (micro - 0.5) * 2 * (r.microAmpM ?? 26) } } } @@ -578,11 +590,24 @@ function colorise(g) { c = [58 - 20 * d, 96 - 26 * d, 132 - 24 * d] shade = 0.93 + 0.07 * shade } else { - // A little per-pixel variation so large regions do not read as flat paint. + /** + * Two layers of per-pixel variation, because one was not enough to stop a + * region reading as flat paint. + * + * The broad one dims and brightens. The fine one **mottles**: it pushes the + * channels apart rather than scaling them together, so ground reads as + * patchy — vegetation against bare soil — instead of as a single colour + * turned up and down. Green moves most and blue least, which is roughly how + * real ground cover varies, and it keeps deserts sandy rather than tinting + * them toward the same green as the forest. + */ const sj = nc(px2z(x), px2y(y), 5200) const j = fbm(sj.x, sj.y, { octaves: 3, seed: 733, wrapY: sj.wrapY }) + const sm = nc(px2z(x), px2y(y), 1150) + const m = fbm(sm.x, sm.y, { octaves: 4, seed: 941, wrapY: sm.wrapY }) const k = 0.9 + 0.2 * j - c = c.map((v) => v * k) + const mo = (m - 0.5) * 0.13 + c = [c[0] * (k - mo * 0.55), c[1] * (k + mo), c[2] * (k - mo * 0.25)] if (field[i] > SNOW) { // Snow does not lie on cliffs. Blending it out with slope is what turns a // white blob into a mountain with rock showing through. @@ -661,9 +686,18 @@ line(1, 'height range', `terrain ${lo.toFixed(0)}..${hi.toFixed(0)} m in ${HMIN}..${HMAX} m, ` + `${(span / 255).toFixed(1)} m per 8-bit step`) if (clipped) { - console.warn(`\n ! ${clipped} pixel(s) fall outside minHeightM..maxHeightM and were clamped.`) - console.warn(` Terrain spans ${lo.toFixed(0)}..${hi.toFixed(0)} m; widen the range in`) - console.warn(' content/geography.json to keep them.\n') + /** + * Fail, rather than warn. This warned for one run and nobody saw it: the terrain + * had grown 1 m past the floor and every pixel down there was quietly flattened, + * which is D-024's failure mode exactly — the raster displaces terrain rather + * than breaking it, so nothing downstream complains. The unit test cannot catch + * it either, because it compares the declaration against numbers written down + * when it was last true rather than against the terrain that just came out. + */ + console.error(`\n ! ${clipped} pixel(s) fall outside minHeightM..maxHeightM and were clamped.`) + console.error(` Terrain spans ${lo.toFixed(0)}..${hi.toFixed(0)} m; widen the range in`) + console.error(' content/geography.json to keep them.\n') + process.exitCode = 1 } write('terrain-height.png', () => encodeGray16(world.width, world.height, norm)) write('terrain-color.png', () => encodeRGB(world.width, world.height, worldRGB))