fuzzyworld

tower / commit

fix(interior): the height raster carries no mipmaps, and a heading wraps

ada068bce8f75d8a57082da198b4d01b3393fb8f · Pavel Safronov · 2026-09-08 22:46:46 -0700 · parents c48030fbeda1

fix(interior): the height raster carries no mipmaps, and a heading wraps

Two unrelated faults in the same file.

The interior rendered wrongly in Firefox on Apple Silicon, intermittently:
the terrain lost its colour to a flat blue-grey wash and its relief to a
coarse grid of creases, and came back on a hard reload. elevation() reads
the height raster in the *vertex* shader, which has no derivatives and so
no way to compute a level of detail — the level is the driver's to choose,
and the raster was loaded with generated mipmaps and maximum anisotropy,
which is eleven levels and an ambiguous rule for picking one. Naming the
level in the shader is the obvious guard and is not available: the
vertex-only texture2DLod does not survive Three's WebGL2 prefix and fails
the program link outright. So the ambiguity goes at the other end — that
raster is built with one level and nothing to choose between. The colour
raster keeps its mipmaps and anisotropy, which it earns: it is read in the
fragment shader at every grazing angle the inside of a cylinder can offer.

Separately, the saved heading was range-checked like a distance, and the
panning loop that feeds it never wrapped. About sixteen turns in one
direction put it outside the guard, and a reload then silently reset the
observer to facing deepward. A heading is an angle: it now wraps at every
write, and any finite saved value is accepted and wrapped on restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 decisions.md          | 52 ++++++++++++++++++++++++++++++++++++++++++
 src/views/interior.ts | 63 +++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 106 insertions(+), 9 deletions(-)

diff --git a/decisions.md b/decisions.md
index 990fe03..98c5d34 100644
--- a/decisions.md
+++ b/decisions.md
@@ -916,6 +916,15 @@ nothing with no obvious way back, and silently-bad persisted state has already c
 this project once (D-025). Saves are debounced: OrbitControls fires `change` every
 frame of a drag.
 
+**Amended: a heading is an angle, so it validates by wrapping, not by range.** The
+interior view persists the same way, and its saved heading was range-checked like a
+distance — rejected outside ±100 radians, which the panning loop reached after about
+sixteen turns in one direction, at which point a reload silently put the observer back
+facing deepward. Nothing was corrupt; the check was asking the wrong question. The
+heading is now wrapped into [0, 2pi) at every write, and on restore any finite value is
+accepted and wrapped, because an unwrapped heading from an older save is still a
+perfectly good direction to face.
+
 **Caps.** The sun cap and far cap are now features in `content/geography.json`, so
 they appear in the place list and can be focused like anywhere else. Two things had
 to be special-cased, both for the same underlying reason — *a cap is not a patch of
@@ -1707,3 +1716,46 @@ denser, but because a star no longer has to land near a pixel centre to be seen
 true brightness. The variation that remains is mostly stars genuinely entering and
 leaving the frame as the view turns, which is not flicker; per star, the worst case
 went from a factor of three between neighbouring samples to about 16%.
+
+## D-057 — The height raster carries no mipmaps, because the vertex stage cannot choose one
+
+Firefox on Apple Silicon rendered the interior wrongly, intermittently: the terrain
+lost its colour to a flat blue-grey wash and its relief to a coarse grid of creases,
+and it came back on a hard reload. It followed where the view was pointed rather than
+where the observer stood — changing eye height did not clear it — which is what made
+it read as a bug about *rotation* rather than about a texture.
+
+**The cause is a texture fetch in a vertex shader that never named its mip level.**
+`elevation()` in the ground shader reads the height raster once per cell corner, in
+the vertex stage. A vertex shader has no derivatives, so there is nothing for an
+implicit `texture2D` to compute a level of detail *from* — the level is whatever the
+driver decides, and the driver is entitled to decide anything. The raster was loaded
+with Three's defaults, which means generated mipmaps, and with the card's maximum
+anisotropy on top: a sampler with eleven levels and an ambiguous rule for picking one.
+On a driver that picks badly the shell is displaced by a downsampled height field,
+which flattens the terrain into blocks and shows the block seams as creases, and the
+colour raster read at the same wrong scale averages into the flat wash. Every symptom
+follows, including the reload: a fresh context re-uploads and re-mips the textures.
+
+**The ambiguity is removed at the sampler rather than at the fetch.** The obvious
+guard is to name the level in the shader, and it is not available here — the
+vertex-only `texture2DLod` does not survive Three's WebGL2 shader prefix, and asking
+for it fails the program link outright, which trades an intermittent bug for a
+permanent black world. So the height raster is built with `generateMipmaps` off,
+`LinearFilter` at both ends and anisotropy 1: one level, nothing to choose between.
+
+This costs nothing. The height raster is read at a fixed rate — one texel per 0.7 km
+cell corner — that has no relationship to how the cell lands on screen, so mipmaps and
+anisotropy were never doing anything for it but occupying a third more texture memory.
+The **colour** raster keeps both, and should: it is read in the fragment shader, at
+every grazing angle the inside of a cylinder can offer, which is exactly what
+anisotropic filtering is for.
+
+**Honest about the evidence.** The failure was not reproduced on demand — four hundred
+scripted pan steps in Firefox on the same machine rendered correctly throughout, with
+no WebGL warnings and no context loss. The diagnosis is fitted to the artefact: the
+reported view was reproduced exactly from its saved state, and the difference between
+what it should show and what was photographed is specifically the two rasters, not the
+camera, the depth buffer or the geometry. If it recurs, the next thing to drop is the
+anisotropy on the colour raster, which would move the suspicion from the vertex stage
+to the sampler state as a whole.
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 33703a0..b932e7d 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -20,7 +20,7 @@
 import type { Texture } from 'three'
 import {
   AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide,
-  Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry,
+  LinearFilter, Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry,
   Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer,
 } from 'three'
 import {
@@ -163,7 +163,21 @@ varying vec2 vUv;
 varying vec3 vWorld;
 varying vec3 vNormal2;
 
-/** The raster is stretched across a declared working range, not 0..anything. */
+/**
+ * The raster is stretched across a declared working range, not 0..anything.
+ *
+ * This fetch is in a *vertex* shader, and that is why uHeight is built with no
+ * mipmaps at all (loadRasters). A vertex shader has no derivatives, so there is
+ * nothing for texture2D to pick a mip level from — the level is left to the
+ * driver, and a driver is entitled to pick any of them. On one that picks badly
+ * the shell gets displaced by a downsampled height field: the terrain flattens
+ * into blocks with the cell seams showing as creases, and it comes back on a
+ * reload, which is what made it look intermittent rather than wrong. Naming the
+ * level here instead would be the obvious guard and is not available — the
+ * vertex-only texture2DLod does not survive Three's WebGL2 shader prefix — so
+ * the ambiguity is removed at the other end, by giving the sampler exactly one
+ * level to choose between.
+ */
 float elevation(vec2 at) {
   return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)}
        + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)};
@@ -567,7 +581,14 @@ export class InteriorView {
   at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 }
   /** Eye height above the floor, in metres. */
   eyeM = EYE_MIN_M
-  /** Heading, radians clockwise from deepward (+z). */
+  /**
+   * Heading, radians clockwise from deepward (+z), kept in [0, 2pi).
+   *
+   * Wrapped at every write rather than only where it is read. Panning adds to it
+   * without bound, and an unbounded heading is a heading that eventually fails
+   * `restoreState`'s range check and silently resets — sixteen turns of dragging
+   * one way and reloading put you back facing deepward.
+   */
   yaw = 0
   /** Elevation angle, radians. Clamped just short of straight up and down. */
   pitch = 0
@@ -678,20 +699,40 @@ export class InteriorView {
     return new Mesh(g, this.groundMat)
   }
 
+  /**
+   * Load a raster.
+   *
+   * `mipped` is the whole reason this takes an argument. The colour raster is read
+   * in the fragment shader, at every grazing angle the inside of a cylinder can
+   * offer, so it wants mipmaps and all the anisotropy the card will give. The
+   * height raster is read in the *vertex* shader, once per cell corner, at a fixed
+   * rate that has nothing to do with how the cell lands on screen — mips and
+   * anisotropy there are not merely wasted, they are a level the vertex stage has
+   * no way to choose between (see elevation()). So it is built with exactly one.
+   */
   private async loadRasters(m: ShaderMaterial): Promise<void> {
-    const load = (name: string): Promise<Texture> =>
+    const load = (name: string, mipped: boolean): Promise<Texture> =>
       new Promise((resolve, reject) => {
         new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => {
           tex.wrapS = RepeatWrapping
           tex.wrapT = RepeatWrapping
           // uv.y is computed rather than read from the file's row order, so no flip.
           tex.flipY = false
-          tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
+          if (mipped) {
+            tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
+          } else {
+            tex.generateMipmaps = false
+            tex.minFilter = LinearFilter
+            tex.magFilter = LinearFilter
+            tex.anisotropy = 1
+          }
           resolve(tex)
         }, undefined, reject)
       })
     try {
-      const [color, height] = await Promise.all([load(COLOR_RASTER), load(HEIGHT_RASTER)])
+      const [color, height] = await Promise.all([
+        load(COLOR_RASTER, true), load(HEIGHT_RASTER, false),
+      ])
       m.uniforms['uColor']!.value = color
       m.uniforms['uHeight']!.value = height
       m.needsUpdate = true
@@ -896,7 +937,7 @@ export class InteriorView {
    */
   visit(p: SurfacePoint, facing: SurfacePoint | null = null): void {
     this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
-    if (facing) this.yaw = this.headingTo(facing)
+    if (facing) this.yaw = normalizeTheta(this.headingTo(facing))
     // Arriving somewhere looking at your feet is never what was wanted.
     this.pitch = 0
     this.place()
@@ -1132,7 +1173,7 @@ export class InteriorView {
       this.lastPointer = { x: e.clientX, y: e.clientY }
       // Scale with the field of view, so zoomed in does not mean flung around.
       const rate = (this.fovDeg / 60) * 0.0032
-      this.yaw += dx * rate
+      this.yaw = normalizeTheta(this.yaw + dx * rate)
       this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
       this.place()
       this.cb.onLook()
@@ -1192,7 +1233,11 @@ export class InteriorView {
       const z = num(s['z'], 0, LENGTH_M)
       if (theta !== null && z !== null) this.at = { theta, z }
       this.eyeM = num(s['eye'], EYE_MIN_M, EYE_MAX_M) ?? this.eyeM
-      this.yaw = num(s['yaw'], -100, 100) ?? this.yaw
+      // Any finite heading is a heading: it is an angle, so it wraps rather than
+      // going out of range. Older saves hold the unwrapped value this used to
+      // write, and those are still perfectly good directions to face.
+      const yaw = num(s['yaw'], -Infinity, Infinity)
+      if (yaw !== null) this.yaw = normalizeTheta(yaw)
       this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
       this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
       if (isLighting(s['lighting'])) {