# Finding and fixing museum performance problems

Written 2026-08-02 after an iPad slowdown whose cause was not obvious. The
point of this page is to stop the next investigation from starting with a
guess.

## The fixes that are confirmed working

Written down at the owner's request (2026-08-26) so the next reader does not
have to reconstruct them from the narrative below. Each of these was measured
before and after on a real device, and each is load in one form or another —
**the museum's frame time has never been its problem; getting the pictures
onto the GPU has.** They compose, and the order matters: the ones near the top
made the ones below them measurable.

| Fix | Where | What it bought | How it was confirmed |
|---|---|---|---|
| **Arrival resolution** — every picture arrives at 192 px/m instead of 512 | `artworkArrivalPixelsPerMetre`, `artworkTextureCut` | Journey of Art walk-in **446 MB → 97.7 MB**; queued pictures 308.9 → 50.9 MB | Queue totalled at the door, before and after |
| **A byte-metered upload budget** — the queue spends milliseconds, not entries | `uploadQueuedArtwork` | Worst frame **9.59 MB → 1.98 MB** | Per-frame bytes logged across a walk |
| **Nearest-first ordering** | `uploadQueuedArtwork` sort | What resolves first is what the visitor is looking at | Order dumped at the door |
| **Slicing big uploads into row strips** | `enqueueSlicedUpload`, 1 MB threshold / 256 KB strips | No single texture can own a frame | Strip count and per-strip bytes |
| **A budget that is larger under cover** | `artworkUploadMsUnderCover` (12 ms vs 5 ms) | The entry wave finishes on frames nobody is steering: ~8.4 s → ~3.5 s | Wave time at the measured 29 ms/MB |
| **The ride waits for the room** | `roomWorkPending()`, `uploadGraceSeconds` 12 | The door opens on a room that has arrived, not one still arriving | Grace consumed, per capture |
| **Limiting concurrent decodes** | `withDecodeSlot`, `ARTWORK_DECODES_AT_ONCE` 2 | Removed a hundred unbudgeted main-thread lumps; frames with a decode outstanding were p50 48 ms against 17 ms idle | Bench `decodesPending` beside frame time |
| **Sizing from the file header, not from a decode** | `imagePixelsFromHeader` | One `createImageBitmap` per picture instead of two — the full-size resample is gone. This room: **218 calls → 135** | `createImageBitmap` counted in the browser; parser agrees with `sips` on all 95 repository pictures |
| **Decoding on a worker thread** | `artworkDecodeWorker.js` | The last main-thread lump: decoding frames went from **p50 44 ms / 19.7 fps to p50 17 ms / 56.3 fps** — the same as an idle frame | Device capture before and after, plus `createImageBitmap` counted in the browser both ways round (worker path 0 main-thread decodes over 270 pictures; forced fallback 135, all 129 pictures still hung) |
| **Compiling a room's shaders in batches** | `precompileRoom`, 4 materials a frame | Two ~1.1 s frames per room load became a spread of short ones | Frame times around `compileAsync` |
| **Promotion without a distance gate** | `promoteNearestDetail`, `ARTWORK_PROMOTED_CEILING` | Density stopped being an input: the worst grid point had made **61 pictures** eligible in one frame | Floor swept on a 2 m grid, pictures counted per point |
| **`crossorigin="anonymous"` on every `<img>` + zone-level `Vary: Origin`** | five `<img>` sites, Cloudflare rule | Fixed pictures that vanished for good on a poisoned cache | Owner reproduced on iPad and in Chrome; healed by `cache: "reload"` |

Two things that are **not** fixes, kept here because both were proposed and
both measure badly: raising the frame budget (the budget was never the
bottleneck — the work outside it was), and gating any of this on distance
(density is not something a visitor controls).

### What the black hole costs, and the one thing a bench cannot tell you (2026-09-11)

The owner's iPad in The Manifold, two captures, both with the sphere's pass
already at its last step:

| where | fps | p95 | draws | tris | kerr |
|---|---|---|---|---|---|
| on the glass, room in view | 42 | 27.0 ms | 435 | 256 272 | half 195k rays x0.40 |
| a few metres from the hole | 22 | 48.0 ms | 128 | 175 264 | half 202k rays x0.40 |

Fewer draw calls, fewer triangles, the same number of rays, twice as slow —
so the cost is neither the rasteriser nor the artwork pipeline (`artwork
paint` was 0.00 ms in both). The obvious next suspect was the work per ray,
and inside the gas the integrator's step is chosen by the segment cap rather
than by the error estimate (the kernel says so at `uMaxSegment`). So a second
adaptation stage was built — `GAS_STEPS`, coarsening the sampling once the
pass had nothing left to give — and shipped to the owner's iPad.

**It is in, and the evidence for it is the owner's hands rather than a
number.** The device reported `x0.40 gas2.4 47.1ms` at the worst spot, which
is what it reported before; the owner, riding and walking the room, reported
that it had stopped feeling laggy. Those are not in conflict — the worst spot
can stay the worst spot while everything else comes up, and the read-out that
would separate them (`fps` / `p95`, the HUD's own second line) was not
captured for that version. It was briefly reverted on the strength of the
number alone; that was wrong, and it is recorded here so the next reader does
not repeat it. **A frame-time EWMA from one standing spot is not a report that
a room is slow, and a person saying it plays is not refuted by one.**

What the dials actually do, measured on an M-series Mac at 2560×1600 looking
down at the hole from the funnel's base, with a `readPixels` stall after the
last frame so the wall clock includes the GPU (`gl.finish()` does not — see
the ladder's own note):

| change | ms/frame |
|---|---|
| baseline (tolerance 1e-4, budget 700, segment 0.5) | 23.6 |
| segment ×2.4 | 23.4 |
| tolerance 1e-3 | 25.6 |
| budget 350 | 25.5 |

None of them — and at the pass step the iPad actually runs at (x0.40) there
is barely anything left for them to move on this machine: the whole tracer is
about 2.5 ms of the frame there, 8.04 ms against 7.65 with the segment at
2.4× beside the hole, and 7.11 against 6.70 at the room's door. The dials
were measured on the machine that had no room to show them. Now the ray
count, same spot, pass pinned with `?debug&kerr=`:

| pass | rays | ms/frame |
|---|---|---|
| x1.00 | 1024k | 26.3 |
| x0.55 | 310k | 12.2 |
| x0.40 | 164k | 9.5 |
| x0.30 | 92k | 8.2 |
| x0.20 | 41k | 8.2 |
| x0.10 | 10k | 7.0 |
| off (tracer not drawn) | — | 9.5 |

So on THIS machine the tracer costs about 17 ms at one ray per CSS pixel and
about 2.5 at 0.40, **the curve flattens below 0.40**, and under it sits a
~7 ms floor that is not the tracer at all. A device whose per-ray cost is ten
times this one's has a different curve, and that is exactly what cannot be
guessed from here. Which is why `?debug&kerr=<scale>|off` exists: on the slow
device, read the frame at 1, 0.4, 0.2 and `off` at the same spot. If it
flattens the way this one does, the tracer has already given everything it
has and the rest of the frame is the thing to measure next; if it keeps
falling, there are more rays to take off.

**Read the `fps` / `p95` line when you do.** The trailing milliseconds on the
`kerr` line are the tracer's own average of the frame interval, which is what
moves its ladder — it is not a bench, it is one number from wherever the
visitor happened to be standing.

## Measure first — the knobs

Everything below is a URL parameter on `index.html?world=museum` — and every
one of them **requires `?debug` in the same URL** (owner 2026-08-04: a
visitor's link carries only what the landing page wrote — world / room /
avatar / gravity — so a pasted `&paint=off` or a `&presence=<url>` cannot
quietly change someone's museum). So a diagnosis run reads
`?world=museum&debug&bench=30`, and the dev-only `?inspect=<room>` and the
`?sky=off` / `?presence=off` switches need `&debug` too:

| Parameter | Effect | What it tells you |
|---|---|---|
| `&bench=30` | Samples 30 s and shows a JSON report you can copy or download | The numbers. Run it on the slow device |
| `&paint=off` | Freezes the Live Painting repaint (textures keep their last frame) | If the frame rate recovers, the cost is artwork painting, not the 3D world |
| `&faces=1..6` | Per-frame ceiling on repaints | Stops a hitch from bunching several uploads into one frame |
| `&hz=1..60` | How often each face repaints, in Hz | The real cost dial: every repaint is also a GPU upload |
| `&arttex=128..2048` | Per-face texture edge in pixels | Isolates texture size from everything else |
| `&dpr=1` | Forces the device pixel ratio | Separates fill-rate cost from CPU cost |
| `&live=0..1` | Scales every Live Painting's refresh rate; 0 freezes them | Mirrors the Menu's Live-effects slider so a benchmark can pin it |
| `&manual=1` | Replaces the render loop with `window.__museum.step(ms, steps, holdSpace, forward)` and `.probe()` | Lets a headless browser measure geometry and stepping cost without rAF |
| `&kerr=1\|0.4\|0.2` | Pins the black hole's pass at that fraction of a ray per CSS pixel, instead of letting it adapt | Whether a slow frame is slow BECAUSE of the tracer or merely slow WITH it |
| `&kerr=off` | Leaves the room drawn and the hole unpainted | The control the pinned readings are read against |

The Menu (Esc, or the Menu button on iPad) cycles **Graphics: Low → Medium →
High → Ultra**, which sizes each Live Painting face at 256 / 512 / 1024 /
2048 px and moves pixel ratio, faces-per-frame and refresh rate with it.
Desktops open at Ultra and touch devices at High (owner 2026-08-04, after
A/B-ing `?arttex=2048` on both real devices; the 2026-08-02 defaults were one
tier lower); a saved choice always wins — including over a NEW default, so a
device that ever stored a tier keeps it until the Menu changes it. High and
Ultra upload at most ONE face per frame and refresh slower than Medium on
purpose: a 1024 face is 4 MB (~21 ms of upload at the measured 5.3 ms/MB)
and a 2048 face is 16 MB, so Ultra also repaints slower than High — its
point is pixels, not rate. Note the tier names are *texture* resolution —
texels per face, so a 16 m room face gets 16 / 32 / 64 / 128 px per metre —
while `maxPixelRatio` is the screen-DPI knob.

Beside it, a **Live effects** slider runs from off to 100 %. Both are
remembered. The slider scales the refresh rate of every Live Painting at once. Space now uses
a static 4096 × 2048 panorama and consumes no scheduler turns or per-frame
canvas uploads; only room faces are live. A portal will join the scheduler only
after receiving a fixture. Thus 0 % is the honest "stop all painting" answer
and the middle is a cheap way to buy frame time.

### Static sky budget

**All three corridor skies are preloaded at startup and uploaded to the GPU
before the museum opens** (owner 2026-08-02): the corridor must never hitch
mid-flight, so nothing it needs is fetched later. Only room contents stream,
where a visible pause on entry already exists. Preloading alone is not
enough — decoding is not uploading, so each texture also goes through
`renderer.initTexture`, without which the first easter-egg swap still pays
~32 MiB of upload inside one frame.

`content/inception/assets/space/background.png` is the 4096 × 2048 RGB startup
panorama. It stays PNG because its sparse stars and dark gradients already fit
in 748,092 bytes. The two corridor-end variants preload as quality-95 JPEGs:
`background1.jpg` is 3,070,680 bytes and `background2.jpg` is 3,374,041 bytes.
Their reference PNG masters remain unimported; JPEG reduces their combined
network payload by 66.7% without changing the 4K runtime dimensions.

The sky's marks are the v7 package (owner 2026-08-03): **770 instanced
billboards** — 48 distant stars, 20 bright-star marks and 702 of nebula cloud
— drawn additively with `depthTest` off, animated entirely in the vertex
shader from one uniform a frame. No canvas, no upload, no scheduler turn.
`?sky=off` puts the plain panorama back, which is still the way to tell a
slow frame caused by the sky from one caused by everything else.

**The portal-transit chamber costs nothing until a portal is touched**
(owner design 2026-08-04). Its geometry, compiled painting and canvas all
build at init with the other corridor assets, but the chamber is hidden and
`updateLiveExtras` gives it **no paint turns while hidden**. While visible it
is ONE canvas — the celestial-rain painting: a pure-black shell plus four
alive layers drawn as whole images per repaint, at the authored 2:1 aspect so
the canvas is half a square's cost — on the same `dueFaces` cadence as every
other Live Painting (texture edge = quality tier × 2, capped at the authored
2048). No second animation loop, no per-entry allocation, and re-entering
portals reuses the same texture. Its clocks are wrapped pure functions of t,
so however long a slow load leaves it running, nothing accumulates and
nothing freezes.

**A sealed room does not draw the sky at all** (owner 2026-08-03). Van Gogh
House is six opaque painted faces with a return membrane standing against one
of them: the sky sphere is behind all of it, so every frame spent in that room
used to pay a full-screen fill — the 4K panorama, the warp shader once a sky
package is applied, and every additive star instance — and then cover it with
a wall. Each room document now carries `spaceBackground`, and the editor's
**space bg on/off** button toggles it. Entering a room calls
`ThreeMuseum.setSkyVisible()`; the stars ride along because they are children
of the sphere. `The Sky` (a dome) keeps it, because there the sky *is* the
ceiling — see `showsSpaceBackground` in `src/inception/world/roomDoc.js`,
which is also what a document written before the field falls back to.

Note this saves rendering, not loading: the sky sphere is built once at
startup for the corridor, which every visit begins in, so there is nothing
per-room left to fetch or upload.

Each decoded 4K texture still occupies approximately 32 MiB before mipmaps,
regardless of PNG or JPEG, so this optimization improves startup transfer and
lets preloading finish sooner rather than reducing GPU memory. An 8192 × 4096
texture would quadruple that to about 128 MiB before mipmaps. Measure loading
and memory on iPad before moving beyond 4K; use a GPU-compressed format such as
KTX2 if texture memory later becomes the bottleneck.

## The 2026-08-02 capture, read line by line

`docs/benchmarks/data/macbook-safari-museum-1024px-30s.json` (MacBook
Safari, 1024², dpr 2). The summary looks bad — avg 39 fps, p95 72 ms — and
the raw arrays explain why in one glance:

- `rawFrameMs` is **16–17 ms for roughly 1200 frames**: a locked 60 fps.
- `rawPaintMs` is **0 across all of those frames** — no artwork is painted
  in the corridor, because only the occupied room paints.
- Then twice: a 1.6 s stall (the room streaming in) followed by a sustained
  run of **~70 ms frames**, with `rawPaintMs` now 7–9 ms.

So the corridor is not slow, the benchmark is not slow, and "bench felt
slow" was the benchmark walking into a room. Inside a room, ~70 ms per
frame of which **only 7–9 ms is CPU painting**, on 30 draw calls and 17k
triangles — the GPU has nothing to do. The missing ~60 ms is the part
nobody measures directly: **uploading the painted canvases to the GPU**. A
1024² RGBA face is 4 MB; three of them per frame is 12 MB a frame, ~720 MB
a second. ADR-0002 and ADR-0003 both named this risk in advance.

### The contradiction, and how it was settled

The owner first reported that 1024² *felt* faster in a room, which did not
fit "uploads dominate" — and a pane measurement of mine seemed to agree
(11.8 ms per face at 512² against 9.98 ms at 1024²). Both were wrong. The
pane runs a dev build without hardware rendering, so its absolute paint
timings mean nothing; I should not have used them to argue against a
device measurement.

Two captures with **every other variable pinned** (dpr 2, `faces=2`,
`hz=12`, whole run inside the room) settled it —
`docs/benchmarks/data/macbook-safari-museum-{512,1024}px-pinned-30s.json`,
reading `byZone.room`:

| | room p50 | room p95 | frames over 50 ms | room paint p50 |
|---|---|---|---|---|
| **512²** | **17 ms (≈59 fps)** | 27 ms | 4 | 3 ms |
| **1024²** | **52 ms (≈19 fps)** | 57 ms | 332 | 6 ms |

Total frame time differs by 35 ms; CPU painting differs by 3 ms. **The
other ~32 ms is the upload** — 1 MB versus 4 MB per face, exactly four
times the bytes. The original hypothesis was right.

So the default is now 512² (the owner sees no difference on an iPad), and
`?arttex=1024` remains for stills. The real fix stays what it always was:
`.lpr` plus a GPU renderer that never round-trips a canvas (Phases 2–3).

### Where one frame goes today, and who the endgame helps

The two captures also give a rough cost per megabyte uploaded: 6 MB more
per frame cost 32 ms more, so ≈5.3 ms/MB. Applied back to the 512² run
(2 MB a frame):

| Part of one in-room frame at 512² | Cost |
|---|---|
| Painting the marks on the CPU | ~3 ms |
| **Uploading those canvases to the GPU** | **~11 ms** |
| Actually rendering the 3D world | ~3 ms |
| Total | ~17 ms |

Drawing the world costs three milliseconds. Two thirds of the frame is
still the canvas round-trip — it merely fits inside a 60 fps budget now.

That is why `.lpr` plus a GPU renderer is **not an iPad fix**. It deletes
the first two rows on every device; the difference is what each device buys
with the savings. A weak device buys playability. A desktop, already at
60 fps, buys capacity: more artworks in view, higher resolution without a
trade-off, several rooms at once, and headroom for multiplayer avatars.

That renderer now exists for marks — see **Marks on the GPU** below, which is
where Mr Yancy's Class Room went.

And the format earns its place for reasons that have nothing to do with
frame time: no executable brush source in published content, deterministic
versioned packages that can be cached, served from R2, snapshotted publicly
and thumbnailed for search, and baked static marks that shorten the ~1.5 s
stall when a room streams in.

## What we already know

- **The corridor is nearly free.** Measured 0.84 ms per stepped frame with
  no room loaded — the glass shell, the starfield and the avatar are not the
  problem.
- **Texture size barely changes CPU painting, and dominates upload.**
  Painting is bound by how many marks are drawn (3 ms at 512², 6 ms at
  1024²); uploading is bound by pixels (four times the bytes). Judge a
  texture-size change by room frame time, never by paint time alone.
- **Six full-face repaints per frame is the real cost.** That is the worst
  case the plan always meant to replace (execution plan §5.4), and it is why
  the museum now repaints faces in rotation instead of all at once.
- Absolute millisecond numbers from a dev server in a headless pane are not
  comparable with production-build numbers from a real device. Compare only
  measurements taken the same way.

## The scheduler

`src/inception/world/paintScheduler.js` decides which faces repaint this
frame: a rotation of `facesPerFrame` faces, so per-frame paint cost divides
by six / `facesPerFrame` while every face still refreshes several times a
second. Only repainted canvases are re-uploaded to the GPU.

Defaults: High = 2 faces per frame, Low = 1. The `.lpr` compiler and the GPU
renderer (plan Phases 2–3) replace this with the real thing; until then this
is the honest mitigation.

A face on the GPU mark path is **not in that index space at all**: it takes
no turn, so the scheduler's whole budget goes to the faces that still need a
canvas.

## Marks on the GPU (2026-08-17)

Mr Yancy's Class Room was the museum's only room with mark-bearing faces, and
it had three of them — 78,538 authored marks across the Pixels, Bits and
Games walls. On the canvas path each repaint walked EVERY mark of a package
(the time gate that hides an off-scene mark is inside the behaviour, not
around the stroke), drew the survivors with per-mark radial gradients, and
re-uploaded a 1024² canvas. Measured in the browser at the Madonna moment:
**24.7 ms of painting for one face**, plus the ~21 ms upload the table above
prices — three faces, eight times a second.

`src/inception/lpr/yancyMarkShader.js` is the twenty-three reviewed behaviours
transcribed into one vertex shader, and `src/inception/artwork/lprMarkFaceSurface.ts`
hangs one instanced billboard mesh per stroke on the face. The marks are
uploaded ONCE. What arrives per frame is one float per stroke.

Same room, same moment, same stepped-frame harness (`?manual=1`, dev build,
this MacBook — a ratio, not an absolute):

| | ms per stepped frame |
|---|---|
| canvas path (`&gpumarks=off`) | **4.58** |
| marks on the GPU | **0.20** |

What to know before touching it:

- **`?gpumarks=off` (with `&debug`) is the A/B and the rollback.** The canvas
  renderer stays the definition of correct output (plan §5.3).
- **A package is all-or-nothing.** One stroke the shader does not know, an
  alive layer, a warp field, a constellation stroke, an unknown behaviour
  VERSION, or a face that is not a flat rectangle keeps the whole face on the
  canvas. `packageRunsOnGpu` is the gate.
- **Two differences are deliberate and documented in the shader.** `rand` is
  the same hash but evaluated at 32-bit precision, so where a mark WAITS
  before it flies to its authored place differs; the lesson content — the
  assembled photograph, the letters, the snake's path, `image-assembly`'s
  seed-decoded colours — is exact arithmetic and matches. And a mark is held
  to a minimum on-screen span, because a size-1 mark that used to own a whole
  texel of a 1024² sheet is a sub-pixel quad once it is drawn straight into
  the scene: the first run culled the Madonna's 22,882 marks into thin air.
- **The trap that cost the most time**: canvas y counts down and a wall's
  local y counts up, so mapping a mark's corners through that flip mirrors
  every quad and reverses its winding. Front-face culling then hides all of
  them while the draw calls, the instance counts and the resolved colours all
  look healthy. The material is double-sided for that reason.
- Verify a change with `tests/yancy-mark-shader.test.js` (every number the
  reference uses for a behaviour must appear in that behaviour's branch) and
  then in the browser: the Games wall's table-driven behaviours match the
  reference pixel-count for pixel-count, which is what a wrong role or a
  wrong lookup would break first.

## Importing an avatar without sinking an iPad

Learned on My Angel (2026-08-04), the first scanned avatar. The counter-
intuitive part: **triangles and joints are almost never the problem —
textures are.**

- 34,794 triangles and a 5-joint skin are trivial next to what one 1024²
  Live Painting face costs per refresh. Do not spend effort decimating a
  mesh below ~40k for an optional avatar.
- **Except when something RAYCASTS the body.** Drawing a skinned mesh is the
  GPU's problem, but picking one is the CPU's: three.js re-skins every vertex
  per cast. That is what made the paint studio unusable on this same avatar,
  and it is the one place the joints turn out to be the whole bill — see
  "Painting a skinned avatar" (2026-08-28) below.
- **Scan-grade textures are the trap.** Photogrammetry (Polycam) exports
  arrive with 4096² maps; three of them decode to ≈190 MB of GPU memory —
  a fifth of iPad Safari's whole-page budget — for a character that stands
  0.3 m tall on screen and rarely covers more than a few hundred pixels.
- The runtime therefore caps every avatar texture at 1024² on load
  (`capAvatarTextures`, `src/inception/avatar/textureBudget.js`), which is
  visually indistinguishable at museum scale and ~16× cheaper. It really is
  every texture only since 2026-08-28: the cap read `material.map` alone, so
  the angel's normal and occlusion maps kept 128 of the 192 MB it was written
  to reclaim, and the paint studio — its own loader — never called it at all.
  Prefer fixing it at the SOURCE anyway:
  set texture size to 1024² in Blender before export — the download shrinks
  with it (the 8.5 MB angel is mostly JPEG bytes).
- Optional avatars download lazily: only the chosen one is fetched, so a
  big catalog costs nothing until a card is picked. Budgets: the default
  avatar keeps the strict Pilot budget (20k triangles / 4 MB); optional
  avatars get 40k / 10 MB, guarded per avatar in tests.
- Check every export BEFORE committing it:

  ```bash
  node scripts/inspect-glb.mjs <file.glb>
  ```

  It prints the clips (a single clip means the Blender exporter's
  Animation mode was not "Actions"), triangles, joints, texture sizes and
  whether the feet sit at the origin.

## Hanging a room's paintings (2026-08-24)

The Journey of Art is the first room whose ARTWORK, not its geometry, is the
frame budget. It hangs 129 panels. Six of them — the resin sheets — are
awaited by `loadRoom`, so they hold the transit chamber shut; the other 123
are deliberately not (`attachRemoteArtwork`), because a slow art host must
never be a door that will not open. So the visitor arrives while the pictures
are still coming, and every one that lands is a synchronous `texImage2D` on
the render thread.

What that adds up to, from the committed file sizes and the live gallery
manifest, after each picture is cut to what its own panel can show:

| Hung by | Panels | Cut to RGBA | Biggest one |
|---|---|---|---|
| the outer gallery ring | 43 | 138.7 MB | 3.25 MB |
| P2: Faith, Doubt, Dread | 10 | 82.4 MB | 8.24 MB |
| the P1 / P3 wall matrices | 30 | 47.0 MB | 9.6 MB |
| the curved portrait wall | 40 | ≤ 40 MB | 1 MB |

≈ 300 MB, which at the 5.3 ms/MB measured above is ~1.6 s of render thread —
spent inside the room, not during the ride. Two things were wrong with how it
was paced, and both are fixed:

- **The budget counted pictures, not bytes.** Two a frame is 34 ms of ring
  painting or 87 ms of P2 sheet: the same rule, five hundred percent apart.
  It is now `artworkUploadBytesPerFrame` (2 MB), with one picture always
  allowed through however big — so no frame costs more than the single
  largest picture in it, and a 3.25 MB ring painting takes a frame to itself
  where two of them used to share one.
- **The queue paced mesh-building, not uploading.** Three uploads a texture
  the first time it is DRAWN, so a queued picture behind the visitor cost
  nothing until they turned round — and then a whole wall of them landed on
  one frame. The drain now calls `renderer.initTexture` itself, which is what
  moves the cost onto the frame the queue chose for it.

The queue is also sorted NEAREST FIRST each frame, so the first frames are
spent on the wall the visitor is looking at, and a room they have left flushes
free rather than making the room they are in wait behind it.

### The thumbnail a visitor is standing too far away to doubt

The ring was over half that total, and the academy's manifest already names a
`thumb` for every photograph — 360 × 480 against a 1050 × 1400 original, and
29 KB against 202 KB. So a ring painting now ARRIVES on its thumbnail and
fetches its own file only when the visitor walks up to it, one at a time,
through the same budgeted queue (`upgradeNearbyArtwork`). The swap is a map
swap on the plane that is already hanging: the plane is measured from the FILE's
own shape — the manifest's, where the list states one — and never from whichever
cut happens to be in it, so nothing resizes and the thumbnail is disposed on the
spot.

Measured in the browser, on the room as it stands (`?debug&inspect=the-art-of-progression&manual=1`,
then read `world.artworkUploads`), this step alone:

| Walking in | Before | With thumbnails |
|---|---|---|
| pictures queued | 123 | 123 |
| texture to upload | **308.9 MB** | **197.7 MB** |
| frames to drain | 92 | 70 |
| ring painting, each | 3.25 MB | 0.66 MB |

A visitor who then tours the whole ring ends up holding all 138.7 MB anyway —
which is exactly what they used to pay for in the first three seconds without
being asked.

### The same bargain for every picture: an arrival resolution

The thumbnail only helped where a host publishes one. The pictures that were
left were the museum's own committed works, and the biggest of them were NOT
oversized: P2's sheets are 1200 × 1800 on a 3 × 4.5 m panel, which is 267 px/m
against a 512 px/m cap, so `artworkTextureCut` had nothing to say about them.
They were simply big.

So there are now TWO resolutions, and the second one is the answer to that:

- `artworkPixelsPerMetre` (512) — what a panel may SHOW. Unchanged.
- `artworkArrivalPixelsPerMetre` (192) — what it shows while the visitor is
  still walking towards it.

Every streamed picture arrives at the second and is promoted to the first on
approach, from the host's thumbnail where there is one and from the file itself
where there is not. Fetching the same file twice costs ONE download: every
artwork this museum serves is content-addressed and answers
`cache-control: immutable, max-age=31536000`, so the second ask is the
browser's own cache. A host without that promise would make this a bad trade.

What decides "on approach" is not a distance anyone picked. It is the point
where the arrival texture stops being as sharp as the screen —
`detailReachMetres`: the drawing buffer through the museum's lens gives the
screen pixels one metre of wall covers at one metre; divide by the texels per
metre the picture actually arrived with, times `artworkDetailMargin` (2) so it
is sharp before anyone could notice it was not. **The panel's own size cancels
out of that**, because a picture twice as big is twice as many texels and twice
as many screen pixels at the same distance — so one rule serves a 0.92 m
portrait and a 4.5 m sheet, and a Retina iPad asks from further away than a
small window does. On a 1600-tall buffer that lands near 13 m; the rails
(6–40 m) hold it sane on anything strange, which includes a headless pane whose
drawing buffer is 2 × 2.

Measured the same way, on the same room:

| Walking in | Original | + byte budget & thumbnails | + arrival resolution |
|---|---|---|---|
| texture to upload | 308.9 MB | 197.7 MB | **50.9 MB** |
| frames to drain | 92 | 70 | **31** |
| worst single frame | 9.59 MB | 9.59 MB | **1.98 MB** |
| pictures over 8 MB | 12 | 12 | **0** |

No picture is above the frame budget any more, so nothing has to be let through
oversized. At the 5.3 ms/MB measured above, the whole walk-in is now ~270 ms of
upload spread across half a second, where it was ~1.6 s of it.

### Why a black-and-white drawing can be the most expensive thing on the wall

`p1-ex02-01` is a 102 KB JPEG. It was 9.6 MB of texture — ninety-six times its
own file — and that is not a mystery about the picture, it is the two numbers
never being about the same thing:

- **The FILE** is black and white with large flat areas, which is the easiest
  thing in the world for JPEG. 3.1 million pixels in 102 KB is 0.03 bytes a
  pixel.
- **The TEXTURE** is RGBA8888 no matter what is in it. Black, white, and a Van
  Gogh all cost four bytes a pixel. Nothing about the content survives the
  decode.

So the only thing that sets the cost is HOW MANY PIXELS, and that is set by the
PANEL: EX02 hangs on a 3.0 × 3.2 m board, so the 512 px/m rule allows 1638 px
and the 1825 px scan was barely cut at all. Its own EX01 neighbours are the same
kind of scan from the same PDF — bigger files, 2100 × 2100 — hanging on 0.92 m
boards, where the same rule allows 471 px and cuts them to 0.85 MB. Same
picture family, eleven times cheaper, purely because the board is smaller.

Read the corollary before optimising an image: **a small file is not a cheap
texture, and a greyscale one is not cheaper than a colour one.** (A single-
channel upload would genuinely be four times cheaper, but it needs a shader
that knows to read one channel into three; nothing here does that today.)

### The writing on the walls

The paintings were never all of it. This room also draws its own titles,
descriptions and one 2.75 × 4.5 m translation panel — 19 canvases, **70.3 MB**,
the translation alone 12.2 MB — during `loadRoom`, so they lengthened the ride
as well as the walk-in.

Writing is the most obviously distance-bound thing in a museum: nobody reads a
wall from thirty metres at ANY resolution. So the lettering takes the same
bargain, with its own pair of budgets — `wallTextArrivalPixelsPerMetre` (192)
and `wallTextPixelsPerMetre` (700) — and joins the same `DetailUpgrade` list
the pictures use. Three things are worth knowing about it:

- It is REDRAWN, not refetched. The words were always here; only the canvas
  they were rasterized onto was small.
- The full budget is a ceiling in px/m where the code used flat 1400/2048 pixel
  widths, which also ends an old inconsistency: a 3 m title used to be twice as
  sharp as a 6.5 m one. Nothing gets bigger than it was — those widths still
  cap it.
- The swap replaces the texture **in place** in the room's `textures` array.
  That array is the paint scheduler's index space, so a still that appended
  would shift a live face along it.

Measured: **70.3 MB → 7.8 MB**, and a 0.28 m title still arrives 54 px tall.

### What this room costs walking in, now

Measured over the room's own group plus its picture queue, which is everything
entering the room actually pays for:

| | Before any of this | Now |
|---|---|---|
| pictures | 308.9 MB | **50.9 MB** |
| wall lettering (19) | 70.3 MB | **7.8 MB** |
| prop labels (95, never drawn) | 23.8 MB | **0** |
| resin sheets (6) | 14.4 MB | 14.4 MB |
| shell, floor, curve, panel surfaces | 28.6 MB | 24.6 MB |
| **total** | **446 MB** | **97.7 MB** |

A correction to an earlier reading of this room, in case it is repeated: five
2048 × 1026 canvases worth 40 MB are NOT matrix headings. They are the transit
chamber's celestial-rain painting — its shell plus four alive layers — which
belongs to the museum rather than to any room, is built once at startup, and is
a Live Painting doing exactly what it is for. Traverse `room.group`, not
`scene`, before attributing a texture to a room.

The 95 sprites in that table were worth naming exactly, because they were not
what they looked like. They are NOT panel captions — this room has none. They
were the editor's PROP LABELS, one per placement, built during `loadRoom` and
parented to a group with `visible = false`.

They could go entirely, because of an invariant nobody had connected to them:
`applyLabelState` shows the tag of the prop the editor has **selected**, and
nothing else. One tag, ever. So a room was rasterizing 95 canvases inside the
load the transit chamber is covering, and holding 23.8 MB of texture, for
lettering at most one of which could be drawn. The renderer now keeps each
prop's WORDS (`labelSpecs`) and draws a tag the first time the editor selects
that prop (`ensurePropLabel`). Clicking between two props costs two canvases.

What is left is the room's own shell maps. That one is real memory for real
pixels, and the answer to it is the compressed-texture work that `.lpr`
phases 2–3 already point at.

## The iPad said what a megabyte costs (2026-08-26)

A 182 s owner bench in the Journey of Art (iPad, Chrome, `bench=180`,
`docs/benchmarks/data/` is where the JSON belongs) put numbers on the walking
stutter and retired a wrong guess:

- **~29 ms/MB of `texImage2D` on the iPad**, 5.5× the measured MacBook. The
  cleanest evidence is the two isolated 241/238 ms frames: a promoted 8.24 MB
  P2 sheet, alone in its frame by the one-always-goes-through rule.
- The 2 MB/frame byte budget — 11 ms on the MacBook — was therefore ~58 ms a
  frame on the iPad: the 50–90 ms clusters felt while walking into each area
  for the first time.

So the budget now counts what a frame actually runs out of: **milliseconds**
(`artworkUploadMsPerFrame`, 5 ms), against a **measured** rate — every upload
is timed and feeds an EWMA (`artworkUploadMsPerMB`), seeded at the iPad's 29
so an unknown device under-spends first and earns speed from evidence, clamped
so one GC pause cannot rewrite it. The MacBook climbs to ~8 pictures a frame
within a second; the iPad settles near one small picture a frame and drains in
a few seconds instead of stuttering.

Two corrections worth keeping:

- **The 1086 ms entry frame was NOT the room backdrop.** Both catalogued
  backdrops double as corridor ends, so the startup preload has already
  uploaded them before the museum opens, and entering the room only
  cross-fades. The entry cluster's remaining suspects, in order: the six resin
  sheets (14.4 MB, awaited-decoded but uploaded on their first DRAW — the door
  frame — outside every queue), first-view shader compiles, and the paced
  queue's earliest frames. Pacing the resin sheets and slicing big uploads
  (`texSubImage2D` rows across frames) are the next levers.
- The backdrop work still shipped, as insurance rather than cure: the first
  backdrop that is NOT also a corridor end would have paid 32 MiB on the door
  frame, so `preloadRoomBackdrop` now starts it under the transit ride, and a
  non-shared backdrop skips its mipmap chain (a third more memory plus
  generation time, for minification detail nothing in a room gets close
  enough to need).

## The door stops presenting the bill (2026-08-26)

The owner's question that unlocked this: *"why can't the chamber preload the
GPU work — wasn't that the design?"* It was, and the chamber DID preload the
downloads and the decodes. What nobody had told it about was the GPU's half:
three uploads a texture and compiles a shader the first time a thing is
DRAWN, and a room behind the transit chamber is drawn by nobody. So the door's
opening frame paid the whole GPU bill at once — 1086 ms on the iPad bench,
411 ms on the MacBook ultra bench, each followed by seconds of degraded frames
as first-draw uploads and program links kept landing.

Three changes, all inside `loadRoom` and the upload queue:

- **Slices** (`enqueueSlicedUpload`): a picture over 1 MB arrives as ~256 KB
  row-strips through the budgeted queue — `copyTextureToTexture` with a source
  region is a `texSubImage2D` with `UNPACK_SKIP_ROWS`, so each strip costs only
  its rows (~1.4 ms MacBook, ~7 ms iPad). Storage is allocated empty first (a
  null-data `DataTexture` is allocation without upload); the mesh receives the
  finished texture on the LAST strip, so a half-arrived picture is never on a
  wall. This retires the isolated ~240 ms P2 promotion frames — the one cost
  the ms budget could order but not shrink. Sliced stills carry no mipmap
  chain (Linear), the room-backdrop trade: promotion means the visitor walked
  NEAR, and the arrival texture — the one seen from afar — keeps its chain.
- **Residency** (`enqueueTextureResidency`): every texture the room builds —
  resin sheets, floor and wall maps, the lettering's arrival canvases — is
  pushed through the same queue at load time, which keeps draining during the
  transit. Guarded by the room's generation: a turn arriving after the room
  unloaded uploads nothing, because `initTexture` on a disposed texture would
  resurrect a GPU copy nothing owns.
- **Shader precompile**: `loadRoom` now ends with an awaited
  `renderer.compileAsync(group, ...)` — parallel where the driver offers
  KHR_parallel_shader_compile, early either way. Awaited on purpose: the ride
  releases when "the room reports ready" (owner design 2026-08-04), and a room
  whose programs are not linked is not ready.

**And the ride now waits for the queue** (owner, 2026-08-26). Data and shaders
were only half of "ready": whatever was still queued when the door opened got
paid in the room, on the visitor's first frames. The barrel holds until
`uploadsPending()` reaches zero — the queue drains during the ride like any
other frame — bounded by `TRANSIT_CHAMBER.uploadGraceSeconds` (4 s), because
pictures keep arriving from the network and pushing new work: a slow art host
must lengthen the ride, never stop it.

### And half of what was left was the decode

The capture that finally said it (owner, Mac Safari, ultra/2048, 2026-08-26 —
and note the instrumentation added for it returned `uploadQueue: null`, because
the sampler pushed the first frame to `frameSamples` alone and a one-frame
difference voids the whole pairing):

| | p50 | p95 | frames > 30 ms |
|---|---|---|---|
| before entry | 17 ms | 17 ms | 7 of 877 |
| the ride (~1 s) | 72 ms | 143 ms | 54 of 60 |
| room, first ~8 s | 21 ms | 54 ms | ~100 |
| room, after +15 s | **17 ms** | **18 ms** | **0 of 2935** |

Two things in that table. The ride absorbing a second of 72 ms frames is the
design working — nobody is steering in a barrel. And the room settling to a
flat 17 ms for the remaining fifty seconds says the steady state is not the
problem; the first eight seconds are.

Reading those eight seconds closely killed the obvious theory. They are not
spikes on a good baseline: **every** frame is 40–45 ms with regular 65–160 ms
neighbours, in runs with a two-frame period. The upload budget is 5 ms and
cannot produce 45. And the owner had already measured cold and warm caches as
the same, which rules the network out and leaves local work.

123 pictures over ~8 s is ~65 ms each, which is the size of those frames. The
cause was that a room fires every picture fetch at build time and **decoded
each one the moment its bytes landed**. Fetching in parallel is right and
nearly free; decoding in parallel is neither — `createImageBitmap` promises to
work off the main thread and on Safari does a great deal of it on the main
thread anyway, so a hundred of them racing is a hundred unbudgeted lumps of
work, none of which any budget here governed.

So decodes are now limited to `ARTWORK_DECODES_AT_ONCE` (2) through
`withDecodeSlot` — the fetches stay parallel — and, because a picture waiting
to decode has not finished arriving however empty the upload queue looks, the
ride waits on `roomWorkPending()` (uploads **plus** decodes) rather than on
uploads alone. Two at a time also bounds the transient memory: a hundred
decoded bitmaps at once is hundreds of megabytes nobody asked for.

The bench now records `decodesPending` beside `uploadsPending`, so the next
capture can say which of the two a heavy frame was paying for — a heavy frame
with decodes in flight and an empty upload queue means the decode, and the
levers are its concurrency, not its budget.

### Half the entry wave was never the pictures

Owner, after the trickle landed: *"进门几秒微卡，走不走都会微卡。之后很丝滑。
是不是进门有别的东西在加载？"* — and yes. Measured at the door of the Journey
of Art, the queue holds **86.8 MB in 155 entries**, and only about a third of
it is arrival pictures:

| entry size | count | MB |
|---|---|---|
| 2–8 MB | 9 | 25.8 |
| 1–2 MB | 13 | 23.3 |
| 256 KB–1 MB | 62 | 29.9 |
| under 256 KB | 71 | 7.8 |

The big ones are the ROOM ITSELF — its shell, floor and curve maps, the six
resin sheets, the lettering — which the residency sweep put in the queue in
the first place. Before that they uploaded on first draw, all at once; now
they are paced, and being paced they take time.

**Time the visitor was paying in the room.** At the iPad's measured 29 ms/MB,
86.8 MB is ~2.5 s of upload — about 8.4 s at 5 ms a frame, against a ride
patient for 4. So the wave was half-finished when the door opened, and the
rest landed on frames the visitor was steering.

The fix is not less work, it is spending it where it is free. A millisecond
inside the transit chamber costs almost nothing — one canvas, a cylinder,
nobody steering, and covering a load is what the chamber is FOR — while a
millisecond in the room lands on a frame the iPad already fills. So there are
two budgets: `artworkUploadMsPerFrame` (5 ms, in the room) and
`artworkUploadMsUnderCover` (12 ms, while the chamber is on screen, switched
by `setTransitVisible`). The same wave becomes **~3.5 s**, and the room opens quiet.

The ride's patience was raised to match (`uploadGraceSeconds`, 4 s → 12 s;
owner: *"过场没有要求最多4秒，长点都行"*). Four was a first draft nobody asked
for, and it was the thing cutting the wave in half. Read it as a **stall
guard, not a tuning knob**: a hung art host cannot reach it, because work that
never arrives never enters the queue — the queue empties and the ride ends on
its own. The only thing that can run that clock is work genuinely arriving and
draining, which is exactly the case worth waiting for.

### Density is no longer an input (2026-08-26)

The owner, still feeling a stutter mid-room: *"走到快到中间的地方卡一下，那个地方
画多"* — and the proposal that followed was the right one. Everything already
arrives coarse; just sharpen the room from the visitor outwards, gently, with
no distance trigger at all.

The trigger was the problem, and it measures badly. Sweeping this room's floor
on a 2 m grid and counting the pictures inside the old promotion reach
(14.6 m on the owner's iPad):

| pictures in reach | grid points |
|---|---|
| 0–4 | 358 |
| 5–9 | 451 |
| 40–61 | 150 |
| **worst spot (x=4, z=−12)** | **61 at once** |

That spot is between the P2 route and the west-central matrix — exactly where
the stutter was felt. Walking into it made 61 pictures eligible in one frame.
Distance-gating ties the amount of work to how many pictures happen to be
near, which is precisely backwards.

So `promoteNearestDetail` has no reach. It promotes **the nearest un-promoted
picture, one at a time, and only while the upload queue is empty**:

- a burst is not possible at any density, because density is not an input;
- it is self-pacing rather than tuned — a fast machine empties the queue and
  rips through, a slow one trickles;
- it yields to the room: the walk-in's own uploads are in that queue, so
  nothing is promoted until the room has finished arriving;
- and the picture a visitor is actually looking at is promoted SOONER than
  before, because it is always the nearest one rather than one of sixty-one.

An ungated trickle needs a ceiling instead of a reach, or a visit would pull a
whole room to full size on its own: `ARTWORK_PROMOTED_CEILING` (200 MB per
room), charged when a promotion starts. Nearest-first means what it gives up
when it bites is the far side of the room. This room's full-size footprint
sits under it, so it sharpens completely and the valve is there for the next,
larger room.

Measured in the browser: one promotion in flight at any moment, nothing
promoted while the arrival wave was still draining, and the queue never
holding more than a single picture's strips.

### The black paintings, and what they cost to find

Slicing shipped broken and the owner caught it in one look: pictures black on
the wall, and *good pictures turning black as you walked* — because walking is
what fires promotions, and every promotion swapped in a dead texture.

The cause was one missing line. Three only uploads a texture whose **version
has moved**, so a `DataTexture` that never gets `needsUpdate = true` is never
allocated: `initTexture` binds a texture object with no storage, and every
strip then writes into nothing — `GL_INVALID_VALUE` on all 214 copies of a
single walk, and a black rectangle where the painting should be.

Worth keeping because the diagnosis nearly went the wrong way. Raw WebGL2
accepted every call shape in isolation — the 9-argument `TexImageSource`
overload, `UNPACK_SKIP_ROWS`, `UNPACK_ROW_LENGTH`, cropped bitmaps, all error
0 — which ruled out the browser and the unpack parameters, where the suspicion
naturally goes first. What separated the working probe from the broken room
was that the probe happened to set `needsUpdate`.

**How to check this class of bug properly**: read the pixels back. Bind the
texture to a framebuffer and `readPixels` — "no GL error" is not evidence that
anything landed. Doing that also stopped a second wrong conclusion: two
promoted pictures still read all-zero afterwards, and fetching their source
files showed the same zeros. `p1-ex02-02` is a black-and-white composition and
`p3-supreme-movement-1` is a near-black painting. The renderer was right and
the artwork is dark.
### Measuring a picture instead of decoding it twice (2026-08-26)

The capture after the trickle landed (owner, iPad, ultra/2048) split the
remaining cost cleanly, and the split is the useful part:

| | p50 | p95 |
|---|---|---|
| frames with a decode outstanding | **48 ms** | 96 ms |
| frames with only uploads outstanding | 21 ms | 33 ms |
| frames with an empty queue | 17 ms | 18 ms |

An idle frame is 17 ms and a decoding frame is 48. Limiting concurrency had
already stopped a hundred decodes racing; what was left was that **each
picture was measured by decoding it**. `artworkTextureCut` needs the file's
intrinsic pixels to size the wall texture, so the code decoded at full size,
asked the bitmap how big it was, and then called `createImageBitmap` a second
time to resample down. The second call is not a second decode — it takes the
bitmap — but it is still a pass over every full-size pixel, on Safari's main
thread.

Every format the museum serves states its dimensions in its first bytes, so
`imagePixelsFromHeader` reads them from a slice of a blob already in memory
and the cut is known before anything is decoded. One call then decodes
straight to the size the wall wants.

**Counted in the browser on a fresh room load: 270 `createImageBitmap` calls
became 135 — one per picture, zero rescales.** The arrival queue is unchanged
at 157 entries / 89.2 MB, which is the point: the same pictures, the same
bytes, half the passes.

Three things that only showed up by measuring rather than reasoning:

- **A document's recorded shape covers about a third of the room.** 52 of 135
  pictures had `import.pixels`; the other 83 had nothing, and would have kept
  the resample forever. Reading the header covers all of them, including every
  picture anyone adds later without recording a shape.
- **Dropping the cut for unknown shapes is far worse than the resample it
  saves.** The first attempt let an unmeasured picture arrive uncut. The queue
  went from 157 entries to 467 and 89 MB to 182 — full-size arrivals landing
  on the GPU during the walk in. The fallback resample stayed.
- **A JPEG's dimensions cannot be found by searching for the marker.** An EXIF
  thumbnail is a complete JPEG of its own, frame header included, so a search
  returns the thumbnail's 160 × 120 and hangs a squashed painting. The reader
  walks the segment chain. It is checked against `sips` — macOS's own image
  reader — on all 95 pictures in the repository.

### Compiling a room's shaders without stopping the world (2026-08-26)

The same capture had two frames of about 1.1 s each, in a room that otherwise
never exceeded 54 ms. They sit at the two `compileAsync` calls, and
`compileAsync` is only asynchronous in the sense that it RESOLVES later —
where `KHR_parallel_shader_compile` is missing, the compiling happens inside
one call, and a room's whole material set went in at once.

`precompileRoom` walks the room for unique materials and compiles them
`ROOM_COMPILES_PER_FRAME` (4) at a time, yielding between batches. A long
frame becomes a run of short ones.

The yield needs care: **awaiting `requestAnimationFrame` in a tab that is not
being drawn waits forever.** A hidden tab, a background pane and a device the
compositor has given up on all serve zero frames, and a room load that awaits
one never finishes — the door would stay shut with nothing wrong. So the yield
races the frame against a 32 ms timer and takes whichever comes first.

### The decode moves off the thread the visitor is on (2026-08-26)

The capture the owner took at the DEFAULT quality for a touch device (High,
1024², iPad, 90 s) is the one that made the last cost unambiguous, because it
separated the two queues:

| frames | count | p50 | p95 |
|---|---|---|---|
| a decode outstanding | 146 | **44 ms** | 96 ms |
| uploads only | 746 | 22 ms | ~45 ms |
| queue empty | 3758 | **17 ms** | 22 ms |

The upload budget is doing its job — 22 ms is not felt. What was left is that
`createImageBitmap` is specified to work off the main thread and on Safari does
a great deal of it there anyway, so each picture cost one long frame. In the raw
samples it reads as an unmistakable alternation of ~50-70 ms and ~25 ms frames:
one painting sharpening, one long frame, over and over.

So the decode runs in a worker now. `artworkDecode.js` holds the whole of it —
fetch, the CORS cache repair, the header read, the cut, the decode — and BOTH
callers run that one module: `artworkDecodeWorker.js` in a browser, and the
renderer directly when no worker can be had. Two bodies would have been a bug
nobody could reproduce.

Three details that matter more than the idea:

- **The bitmap comes back transferred, not copied.** `postMessage(reply,
  [bitmap])` hands over ownership; without the transfer list a decoded picture
  is structured-cloned across the thread boundary, which can cost more than the
  decode saved.
- **A broken worker must not cost a picture.** A browser that refuses to
  construct one, and a worker that starts and then dies, both fall back to
  decoding here — and a dying worker REJECTS every promise waiting on it,
  because leaving them pending would hold the transit ride for its whole grace
  period and then open the door onto missing paintings.
- **Concurrency stays at two.** It was two to keep a hundred decodes off the
  main thread; it stays two because a hundred decoded bitmaps at once is
  hundreds of megabytes nobody asked for. `decodesPending()` still counts them,
  so the ride still waits for them.

Verified in the browser both ways round: on the worker path, **270 pictures
decoded with zero `createImageBitmap` calls on the main thread**; with the
worker forcibly disabled, 135 main-thread decodes and all 129 pictures still
hung. The GPU upload is unchanged either way — that cost was always budgeted,
and it is the only part that has to be on this thread.

**Confirmed on the device** (owner, iPad Safari in desktop mode — the UA says
`Macintosh`, the owner says iPad — 90 s, Ultra/2048, a HIGHER setting
than the capture that found the problem, which was High/1024):

| decoding frames | before | after |
|---|---|---|
| p50 | 44 ms | **17 ms** |
| p95 | 96 ms | 24 ms |
| average | 19.7 fps | **56.3 fps** |
| frames over 100 ms | 7 of 146 | 1 of 494 |

A decoding frame is now indistinguishable from an idle one — 17 ms is the
whole room's p50. The upload queue is unchanged and unremarkable (22 ms p50
while queued, 32 ms p95), which is the budget doing exactly its job, and the
90-second walk holds 57 fps average with 10 frames over 50 ms out of 5167.

### Which pictures get all of this, and which do not

Worth stating plainly, because it decides what a NEW work has to do to be
fast: everything above is the **artwork** path — `attachRemoteArtwork` →
`decodeCutBitmap`. Anything hung as a painting takes it automatically: a
gallery-ring entry, a plan-authored wall matrix, a resin sheet. Nobody has to
opt in, and there is no per-picture tuning to forget.

A room's OWN surfaces are a different path. `faces[].image` stills, the floor
and wall maps, curve backdrops — those go through `loadRoomImage`, which is
three's `TextureLoader`, an `<img>`, and a decode this file does not govern.
Their UPLOAD is budgeted (the residency sweep queues them like everything
else, which is why they show up as the 2–8 MB entries at the door), but their
decode is not on the worker and they are never cut to a wall: a room image is
already sized for the surface it dresses.

That split is fine at today's shape — a room has a handful of surfaces and a
hundred paintings — and it is the obvious next move if a room ever arrives
with heavy surface art: the worker takes a URL and returns a bitmap, so
pointing `loadRoomImage` at it is a small change, not a redesign.

## Painting a skinned avatar (2026-08-28)

Owner, of the paint studio on My Angel: "very slow and laggy". It was, and
none of the machinery above explains it, because this is the one screen in the
museum that raycasts a body dozens of times a second.

Painting cannot interpolate in texture space — a scanned body's UVs are
chopped into islands, so a straight line between two texture positions leaves
the surface and the stroke breaks. It therefore walks the drag ACROSS THE
SCREEN every `strokeStepPx` (4) pixels and traces each sample back onto the
model (`paintTools.js`). One drag is dozens of raycasts, and three.js raycasts
a `SkinnedMesh` by re-skinning the whole thing on the CPU:
`getVertexPosition` → `applyBoneTransform`, up to four 4×4 bone matrices per
vertex, for every triangle in the mesh, on every cast.

Measured on the same machine, one cast at the studio's own framing:

| avatar | meshes | triangles | skinned | one raycast |
| --- | --- | --- | --- | --- |
| My Angel | 1 | 34,794 | **yes** | **27–55 ms** |
| Space Baby Avi | 40 | 53,592 | no | 0.83 ms |
| Stickman | 6 | 7,328 | no | 2.01 ms |

Space Baby Avi has MORE triangles and is 60× faster, so it was never the
polygon count — it is rigid-node, and the angel is the catalog's only skinned
avatar. Raycasting the angel's identical geometry as a plain `Mesh` costs
2.46 ms, which puts **~91% of the cast in the skinning**. A 60 px drag is
about fifteen casts, so a stroke blocked the main thread for the better part
of a second, and one fast flick reached the 240-sample cap: roughly six
seconds frozen, with pointer events still queueing behind it.

The waste was total: the studio builds no `AnimationMixer`, so the skeleton
stands in bind pose for the whole session and every one of those bone
transforms recomputed a constant.

So the studio now casts against a stand-in — `bakedPickRoot`
(`src/inception/studio/pickProxy.js`) — a plain `Mesh` holding the posed
positions, computed once at open. It shares the uv and index buffers rather
than copying them, keeps each material's `side` (the only thing three's
hit test reads) and each geometry group (so `face.materialIndex` still names a
slot), and drops the normal attribute nothing here reads. Rigid meshes share
their geometry outright: they were never the problem and must not pay a copy.

| | before | after |
| --- | --- | --- |
| one raycast | 54.7 ms | 2.67 ms (**20×**) |
| 60 px brush drag | 821 ms | 40 ms |
| 240-sample flick | 13.1 s | 642 ms |
| texture RAM | 192 MB | 12 MB |
| bake, once at open | — | 180 ms |

Parity was checked before trusting it: across 47 hits spread over the body,
the stand-in and the skinned mesh agree on uv to 3e-8 — a 1024² canvas pixel
is 9.8e-4, so the brush lands in the same place to within a thirty-thousandth
of a pixel — with no hit either one found alone. Realistic input (8 px per
pointer event) now raises no long tasks at all; every such event used to be
one.

The texture line in that table is the second half: the studio has its own
loader, so it never called the museum's cap and kept three 4096² maps. The cap
moved to `src/inception/avatar/textureBudget.js`, both callers share it, and it
now walks every map slot instead of the base colour alone. It caps to the
painting resolution, which also makes the eraser's per-stamp redraw of the
factory coat a straight blit instead of a 4096²-to-1024² resample.

What is left, if it ever matters: 2.67 ms is the brute-force walk of 34,794
triangles with no acceleration structure. A BVH would cut it again, and that
is a dependency decision rather than a fix.

## If it is slow again

1. Reproduce with `&bench=30` on the device and keep the JSON.
2. Read the report's two queues first. Heavy frames under `decodeQueue`
   → the decode path: check the console for **"Artwork decode worker put
   down"** — one warning means the whole session fell back to main-thread
   decoding, and the message carries the cause. Heavy frames under
   `uploadQueue` only → the budget and rates (`artworkUploadMsPerFrame`,
   the under-cover budget, the promotion ceiling).
3. Run `&paint=off` on the same device. Recovered? It is artwork painting —
   lower `faces`, or push on `.lpr`. Still slow? It is the world: check
   draw calls and triangles in the report, and whether a room failed to
   unload (the HUD names the space you are in).
4. Compare against the last stored report of the same kind before blaming
   the newest change; `docs/benchmarks/data/` holds the engine bake-off
   captures, and museum reports belong there too.
5. Slow while PAINTING rather than walking? None of the above applies —
   `&bench` measures the museum and the studio is a different app. Time one
   `raycaster.intersectObject` against the avatar and read "Painting a skinned
   avatar" above; anything over a millisecond or two is the picking path, not
   the brush.

Worth remembering while reading any of it:

- **A capture's device line can lie.** iPad Safari in desktop mode reports a
  `Macintosh` user agent — the 2026-08-26 confirmation capture did. Identify
  the device by asking the owner, or by the canvas points (1366 × 892 at
  dpr 2 is the 12.9" iPad).
- **New paintings need nothing.** Everything hung as artwork gets the worker
  decode, header sizing, arrival cut, budgeted upload and promotion
  automatically. Keep files JPEG / PNG / WebP so the header reader knows
  them; an unknown format still works, it just pays one full-size resample.
- **Room surface images are not on that path.** `faces[].image` and the
  floor / wall / curve maps decode through `TextureLoader` on the main
  thread; their uploads are budgeted, their decodes are not.
- **The learning corridor's boards ARE on it, since 2026-09-06.** They used
  to take three's `TextureLoader` inside `threeLearningCorridor.ts` — no
  worker, no cut, and no `cache: "reload"` repair — so a picture whose cache
  entry had been poisoned (#44) failed on the board and only on the board:
  the owner's iPad opened the Van Gogh tour to three empty boards while the
  Van Gogh room hung the same set whole. The museum now hands the corridor
  `corridorArtworkTexture`, which is `decodeCutBitmap` plus `bitmapTexture`;
  `tests/corridor-artwork-shared-decode.test.js` keeps the two paths one.
- **A canvas headed into the sliced upload must be pre-flipped**
  (`flippedCanvasCopy`) — decoded bitmaps arrive bottom-up, canvases do not,
  and the strips flip nothing. Skipping it hangs lettering upside down.
- **One broken picture must never take the worker down.** The worker
  answering "this file failed" and the worker dying both surface as
  rejections; only `artworkRequestFailed` tells them apart, and the put-down
  rejects everything still owed BEFORE terminating, or an in-flight reply
  strands a promise the transit ride then waits on.
