fuzzyworld

tower / commit

feat(terrain): a fourth noise octave, and colour that mottles

9a621732d4da5ae2d916908b54806ad0b4e1e50a · Pavel Safronov · 2026-09-08 00:11:05 -0700 · parents 18958d3d9c16

feat(terrain): a fourth noise octave, and colour that mottles

Height: the base had layers at 78 km, 12 km and 3.4 km, and a pixel is
264 m. Between the finest of them 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 at exactly the scale the interior view looks at it
from. A fourth layer at 1.5 km, +/-26 m, fills that band. It goes in the
base rather than after everything, so carve and flood still run on top:
rivers cut through it and lakes keep flat surfaces instead of 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, pushing the channels apart with green moving most and blue least,
so ground reads as patchy vegetation against bare soil. Holding blue nearly
fixed is what keeps the desert sandy instead of drifting toward the forest's
green.

The extra relief also found a live bug. It pushed the terrain to -521 m
against a declared floor of -520 m, so every pixel down there was being
silently flattened — D-024's failure mode, where a clipped raster displaces
terrain rather than breaking it and nothing downstream complains. The
generator warned and carried on.

So the floor moves to -560 m, and `npm run terrain` now exits non-zero when
a pixel is clamped rather than printing into a log nobody reads. Verified by
narrowing the range on purpose and checking it fails. The unit test could
not have caught this and now says so: it compares the declaration against
numbers written down when they were last true, not against the terrain that
just came out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 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))