tower / commit
fix(interior): the height raster carries no mipmaps, and a heading wraps
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'])) {