tower / commit
feat(interior): a flight recorder, for faults that will not be reproduced
README.md | 32 +++++- decisions.md | 40 ++++++++ src/views/interior.ts | 270 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 338 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3bb4f26..9b8d9dd 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ regressions, not things that were already broken. ``` npm install npm run dev # http://localhost:5173, plus a LAN URL and a QR code for it -npm test # 94 tests; canon figures are pinned to design.md +npm test # 100 tests; canon figures are pinned to design.md npm run build npm run terrain # regenerate the maps from content/geography.json npm run screenshots # drive the app and rephotograph docs/screenshots/ @@ -133,6 +133,36 @@ it from `https://pavelsafronov.gitlab.io/tower/` or from a generated unique doma at a root. Set a `BASE` CI variable to force an absolute base if that is ever needed. +### When the interior view looks wrong + +The interior keeps a flight recorder — the last quarter-minute of camera, draw calls +and frame colour — because the rendering faults worth catching there have all been +intermittent (decisions.md D-057, D-058). While the fault is **still on screen**, open +the console and call: + +```js +towerDump() // logs the whole state and copies it to the clipboard +``` + +The first line is the answer most of the time. It draws each surface alone and +measures how colourful it is: + +``` +[tower] alone in frame — ground 31.32, sunCap 19, farCap 31.3, bore 19, beam 19 (19 = nothing drawn) +``` + +`19` is the clear colour, so it means that surface put nothing in the middle of the +view — expected for anything currently off screen. The ground reads about `30` when it +is carrying land. **A ground near `19` while you are looking at the ground is the +diagnosis**: the terrain shader is not getting its raster. The rest of the dump carries +the camera, the sampler settings for both rasters, whether their GL textures are still +live, and the per-frame trace, so the heading the view changed at can be read off +afterwards. `window.tower` is the view itself, for poking at directly. + +This is on in the built site as well as in dev, deliberately: the fault has only ever +turned up in ordinary use, and it costs two properties on `window` that do nothing +until they are called. + ## Geography [`content/geography.json`](content/geography.json) is the authored source of truth diff --git a/decisions.md b/decisions.md index 98c5d34..4698123 100644 --- a/decisions.md +++ b/decisions.md @@ -1759,3 +1759,43 @@ what it should show and what was photographed is specifically the two rasters, n 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. + +## D-058 — The interior keeps a flight recorder + +D-057 was diagnosed from a photograph. That worked, and it should not have had to: +the fault was intermittent, it was reproduced by the author and never once by the +tooling, and every question about it — was the geometry there, was the camera sane, +were the textures still live — had to be answered by inference from a screenshot +rather than by reading the number. So the interior now records what it is doing. + +**A ring of the last nine hundred frames**, about a quarter of a minute: camera, +heading, field, where the observer stands, draw calls, triangle count, and every +fifteenth frame a readback of a 64-pixel block from the middle of the frame. The +readback has to happen immediately after `render`, while the drawing buffer is still +the current target; a first attempt read it from a 2D canvas a moment later and got +nothing but zeroes, which looked for a while like a finding of its own. + +**`towerDump()` on the console prints the lot and copies it to the clipboard.** It is +present in the built site, not only in dev, because this fault has never once appeared +while anybody was looking for it. It is two properties on `window` that do nothing +until they are called. + +**The line that actually answers the question draws each surface alone.** A flat, +colourless frame can be the ground shader losing its raster, or the ground missing +altogether with a cap or the bare clear colour behind it — indistinguishable on +screen, entirely different faults. Rendering the ground, the two caps, the bore and +the beam one at a time and measuring each separates them in a single dump. The clear +colour reads 19 in chroma, so 19 means *that surface drew nothing here*; the ground +reads about 30 when it is carrying land. Verified by taking the colour raster away +from the ground shader mid-flight: ground fell from 31.3 to 19.5 while the far cap +stayed at 31.3, which is exactly the signature to look for. + +**There is deliberately no automatic alarm, and that is a result rather than a gap.** +The first version warned when the middle of the frame lost its colour, on the theory +that the fault paints a neutral wash. It does not. It paints the *haze* tint, and the +haze tint is itself a blue-grey measuring 19 — while a legitimate view of the far side +through 200 km of air measures about 20, against about 30 for land underfoot. Any +threshold that caught the fault would have fired every time anyone looked at the +horizon. The recorder therefore records and the human decides; a quarter-minute of +history is long enough that noticing the fault, reaching for the console and typing +still catches the frames on both sides of the change. diff --git a/src/views/interior.ts b/src/views/interior.ts index b932e7d..ee834fe 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -560,6 +560,43 @@ void main() { } ` +// ------------------------------------------------------------ flight recorder + +/** + * One sample of what the view was doing. Small on purpose: this is kept for every + * frame of the last several seconds, and anything expensive here would change the + * thing it is measuring. + */ +interface Probe { + chroma: number + luma: number + r: number + g: number + b: number +} + +interface TraceFrame { + /** Milliseconds since the page loaded, so samples line up with the console. */ + t: number + yaw: number + pitch: number + fov: number + eyeM: number + z: number + theta: number + calls: number + tris: number + /** + * A block of the rendered frame, averaged, 0..255 — or null on the frames + * between probes. Colour is what tells a good frame from a bad one here: the + * fault paints the world in the haze tint instead of in land, and the haze tint + * is a known constant, so the trace carries the channels rather than a verdict. + */ + probe: Probe | null +} + +const TRACE_MAX = 900 + // ------------------------------------------------------------------------- view export class InteriorView { @@ -574,6 +611,16 @@ export class InteriorView { private dragging = false private lastPointer = { x: 0, y: 0 } private saveTimer: ReturnType<typeof setTimeout> | null = null + /** + * The flight recorder (D-058). A ring of what the last few seconds looked like, + * kept always, because the fault this exists for is intermittent and cannot be + * asked to happen again once it has. `probe` is the frame count between chroma + * readbacks: a readPixels stalls the pipeline, so it runs at a few hertz rather + * than every frame, which is far finer than a fault anyone can see by eye. + */ + private trace: TraceFrame[] = [] + private frames = 0 + private probeEvery = 15 /** The height raster, read back so the observer can stand on the ground. */ private heights: { data: Uint8ClampedArray; w: number; h: number } | null = null @@ -638,6 +685,36 @@ export class InteriorView { // The canvas is hidden until this view is opened, so its box is zero-sized at // construction time when the view is restored on load. Same trap as D-029. new ResizeObserver(() => this.resize()).observe(canvas) + + /** + * The handle the flight recorder is read through (D-058). + * + * Left on in the built site as well as in dev, because the fault this is for + * has only ever been seen by somebody using the thing rather than testing it, + * and a diagnostic that is not there when it happens is not a diagnostic. It + * is two properties on `window` and it does nothing until it is called. + */ + const w = window as unknown as Record<string, unknown> + w['tower'] = this + w['towerDump'] = (): Record<string, unknown> => { + const d = this.diagnose() + const by = (d['surfaces'] as { name: string; chroma: number }[]) + .map((x) => `${x.name} ${x.chroma}`).join(', ') + /** + * The headline is the per-surface probe, because it is the one line that + * answers which thing is wrong. A surface drawing nothing in the current + * view reads the clear colour, 19; the ground reads about 30 when it is + * carrying land. A ground at 19 in a view pointed at the ground is the + * whole diagnosis. + */ + console.log(`[tower] alone in frame — ${by} (19 = nothing drawn)`) + console.log(JSON.stringify(d, null, 1)) + navigator.clipboard?.writeText(JSON.stringify(d)).then( + () => console.log('[tower] dump copied to the clipboard'), + () => console.log('[tower] dump logged above; the clipboard refused it'), + ) + return d + } } // ------------------------------------------------------------------ geometry @@ -696,7 +773,11 @@ export class InteriorView { uv.setXY(i, uv.getY(i), uv.getX(i) - shift) } uv.needsUpdate = true - return new Mesh(g, this.groundMat) + const mesh = new Mesh(g, this.groundMat) + // Named for the per-surface probe in diagnose(), which asks each one in turn + // whether it is the one that lost its colour. + mesh.name = 'ground' + return mesh } /** @@ -868,9 +949,11 @@ export class InteriorView { private buildCaps(): void { // The tint is a multiplier over the pattern, not the colour of the cap. const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.sunCapMaterial()) + sun.name = 'sunCap' this.scene.add(sun) const far = new Mesh(new CircleGeometry(R, 128), this.glazedCapMaterial()) + far.name = 'farCap' far.position.z = L + HULL_D this.scene.add(far) @@ -879,7 +962,9 @@ export class InteriorView { const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true) bore.rotateX(Math.PI / 2) bore.translate(0, 0, L + HULL_D / 2) - this.scene.add(new Mesh(bore, this.revealMaterial(0x9aa0aa))) + const reveal = new Mesh(bore, this.revealMaterial(0x9aa0aa)) + reveal.name = 'bore' + this.scene.add(reveal) } private buildBeam(): void { @@ -892,7 +977,9 @@ export class InteriorView { transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide, }) this.shared.push(m) - this.scene.add(new Mesh(g, m)) + const beam = new Mesh(g, m) + beam.name = 'beam' + this.scene.add(beam) } // -------------------------------------------------------------------- camera @@ -1278,6 +1365,153 @@ export class InteriorView { } } + // --------------------------------------------------------------- diagnostics + + /** + * Read a block of the frame just drawn and measure how colourful it is. + * + * Called immediately after `render`, while the drawing buffer is still the + * current target — a moment later the compositor has taken it and a readback + * comes up empty, which is exactly the trap a first attempt at this fell into. + * + * The block is a square in the middle of the canvas rather than the whole + * frame: it costs one readPixels instead of one per sample point, and the + * middle of the view is where the ground is when anyone is looking at it. + */ + private probeFrame(): Probe | null { + const gl = this.renderer.getContext() + const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight + const n = 64 + if (w < n || h < n) return null + const px = new Uint8Array(n * n * 4) + gl.readPixels((w - n) >> 1, (h - n) >> 1, n, n, gl.RGBA, gl.UNSIGNED_BYTE, px) + let chroma = 0, r = 0, g = 0, b = 0 + for (let i = 0; i < px.length; i += 4) { + const pr = px[i]!, pg = px[i + 1]!, pb = px[i + 2]! + chroma += Math.max(pr, pg, pb) - Math.min(pr, pg, pb) + r += pr; g += pg; b += pb + } + const c = px.length / 4 + return { + chroma: chroma / c, luma: (r + g + b) / (3 * c), + r: r / c, g: g / c, b: b / c, + } + } + + /** + * Draw the scene once per surface, with only that surface in it, and measure + * each. Restores what it found; this runs on demand, never in the loop. + * + * The point is to name the culprit rather than infer it. A flat, colourless + * frame could be the ground shader losing its raster, or the ground missing + * altogether with a cap or the haze behind it filling the view — those look + * alike on screen and are entirely different faults. Asking each surface on its + * own separates them in one dump: the ground alone should measure strongly + * coloured, and if it does not, the fault is in the ground. + */ + private probeSurfaces(): Record<string, unknown>[] { + const meshes = this.scene.children.filter((c) => c instanceof Mesh) as Mesh[] + const was = meshes.map((m) => m.visible) + const out: Record<string, unknown>[] = [] + for (const m of meshes) { + for (const o of meshes) o.visible = o === m + this.renderer.render(this.scene, this.camera) + const p = this.probeFrame() + out.push({ + name: m.name || '(unnamed)', + visible: was[meshes.indexOf(m)], + chroma: p ? +p.chroma.toFixed(2) : null, + luma: p ? +p.luma.toFixed(2) : null, + tris: this.renderer.info.render.triangles, + }) + } + meshes.forEach((m, i) => { m.visible = was[i]! }) + this.renderer.render(this.scene, this.camera) + return out + } + + /** + * Everything that could plausibly differ between a good frame and a bad one. + * + * Deliberately broad rather than aimed: the point of a dump is that it is taken + * while the fault is on screen, and a second chance to ask a question that was + * left out may not come. The texture entries are the ones that matter most — + * `uploaded` says whether Three still has a live GL texture for the raster, and + * `mips`/`minFilter`/`aniso` say whether the sampler is the one D-057 asked for. + */ + diagnose(): Record<string, unknown> { + const gl = this.renderer.getContext() + const tex = (name: string): Record<string, unknown> | null => { + const t = this.groundMat.uniforms[name]?.value as Texture | null + if (!t) return null + const gpu = (this.renderer.properties.get(t) as { __webglTexture?: WebGLTexture }) + .__webglTexture + const img = t.image as { width?: number; height?: number } | undefined + return { + size: [img?.width ?? null, img?.height ?? null], + mips: t.generateMipmaps, + minFilter: t.minFilter, + magFilter: t.magFilter, + aniso: t.anisotropy, + wrap: [t.wrapS, t.wrapT], + flipY: t.flipY, + version: t.version, + uploaded: !!gpu, + live: gpu ? gl.isTexture(gpu) : false, + } + } + const probe = this.probeFrame() + return { + when: new Date().toISOString(), + url: location.href, + agent: navigator.userAgent, + gl: { + version: gl.getParameter(gl.VERSION), + renderer: gl.getParameter(gl.RENDERER), + lost: gl.isContextLost(), + // Drained, not peeked at: an error sticks until it is read, so a stale + // one from page load would otherwise be reported at every dump. + error: gl.getError(), + maxAniso: this.renderer.capabilities.getMaxAnisotropy(), + vertexTextureUnits: gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS), + }, + camera: { + position: this.camera.position.toArray(), + quaternion: this.camera.quaternion.toArray(), + up: this.camera.up.toArray(), + near: this.camera.near, far: this.camera.far, + fov: this.camera.fov, aspect: this.camera.aspect, + matrixWorld: this.camera.matrixWorld.elements.slice(), + }, + observer: { + theta: this.at.theta, z: this.at.z, eyeM: this.eyeM, + yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg, + groundKm: this.groundKm(this.at), heights: !!this.heights, + lighting: this.lighting, dayPhase: this.dayPhase, clockDriven: this.clockDriven, + }, + uniforms: { + uCam: (this.groundMat.uniforms['uCam']!.value as Vector3).toArray(), + uExposure: this.groundMat.uniforms['uExposure']!.value, + uHaze: (this.groundMat.uniforms['uHaze']!.value as Color).getHex(), + uFrontZ: this.groundMat.uniforms['uFrontZ']!.value, + uBelowFront: this.groundMat.uniforms['uBelowFront']!.value, + uAboveFront: this.groundMat.uniforms['uAboveFront']!.value, + uSpin: this.groundMat.uniforms['uSpin']!.value, + uStarGain: this.groundMat.uniforms['uStarGain']!.value, + }, + textures: { uColor: tex('uColor'), uHeight: tex('uHeight') }, + programs: this.renderer.info.programs?.map((p) => ({ + name: p.name, usedTimes: p.usedTimes, + })), + render: { ...this.renderer.info.render }, + memory: { ...this.renderer.info.memory }, + probe, + surfaces: this.probeSurfaces(), + // Oldest first, so the transition reads left to right. + trace: this.trace, + } + } + resize(): void { const { width, height } = this.canvas.getBoundingClientRect() if (!width || !height) return @@ -1309,12 +1543,42 @@ export class InteriorView { this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI) for (const m of this.shared) m.uniforms['uSpin']!.value = this.spin this.renderer.render(this.scene, this.camera) + this.record(now) this.updateLabels() requestAnimationFrame(tick) } requestAnimationFrame(tick) } + /** + * Append this frame to the flight recorder. + * + * There is deliberately no alarm here, and that is a finding rather than an + * omission. The first version of this warned when the middle of the frame lost + * its colour, on the theory that the fault paints a neutral wash. Tested against + * a forced reproduction — the colour raster taken away from the ground shader + * mid-flight — the theory did not hold: the wash is not neutral, it is the haze + * tint, and the haze tint is itself a blue-grey measuring 19 out of 255 in + * chroma. A healthy view of land measures about 30 and a legitimate view of the + * far side through 200 km of air measures about 20, so a threshold that caught + * the fault would also fire every time anybody looked at the horizon. + * + * So the recorder records and the human decides. `towerDump()` carries the last + * quarter-minute with it, which is long enough that noticing the fault, reaching + * for the console and typing still catches the frames either side of the change. + */ + private record(now: number): void { + this.frames++ + const probe = this.frames % this.probeEvery === 0 ? this.probeFrame() : null + const r = this.renderer.info.render + this.trace.push({ + t: Math.round(now), yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg, + eyeM: this.eyeM, z: this.at.z, theta: this.at.theta, + calls: r.calls, tris: r.triangles, probe, + }) + if (this.trace.length > TRACE_MAX) this.trace.splice(0, this.trace.length - TRACE_MAX) + } + stop(): void { this.running = false }