Why texture upload — not paint time — is the museum's real cost, and the dials that govern it. · Rendered from docs/PERFORMANCE.md in the project repository · view as Markdown

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.

FixWhereWhat it boughtHow it was confirmed
Arrival resolution — every picture arrives at 192 px/m instead of 512artworkArrivalPixelsPerMetre, artworkTextureCutJourney of Art walk-in 446 MB → 97.7 MB; queued pictures 308.9 → 50.9 MBQueue totalled at the door, before and after
A byte-metered upload budget — the queue spends milliseconds, not entriesuploadQueuedArtworkWorst frame 9.59 MB → 1.98 MBPer-frame bytes logged across a walk
Nearest-first orderinguploadQueuedArtwork sortWhat resolves first is what the visitor is looking atOrder dumped at the door
Slicing big uploads into row stripsenqueueSlicedUpload, 1 MB threshold / 256 KB stripsNo single texture can own a frameStrip count and per-strip bytes
A budget that is larger under coverartworkUploadMsUnderCover (12 ms vs 5 ms)The entry wave finishes on frames nobody is steering: ~8.4 s → ~3.5 sWave time at the measured 29 ms/MB
The ride waits for the roomroomWorkPending(), uploadGraceSeconds 12The door opens on a room that has arrived, not one still arrivingGrace consumed, per capture
Limiting concurrent decodeswithDecodeSlot, ARTWORK_DECODES_AT_ONCE 2Removed a hundred unbudgeted main-thread lumps; frames with a decode outstanding were p50 48 ms against 17 ms idleBench decodesPending beside frame time
Sizing from the file header, not from a decodeimagePixelsFromHeaderOne createImageBitmap per picture instead of two — the full-size resample is gone. This room: 218 calls → 135createImageBitmap counted in the browser; parser agrees with sips on all 95 repository pictures
Decoding on a worker threadartworkDecodeWorker.jsThe 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 frameDevice 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 batchesprecompileRoom, 4 materials a frameTwo ~1.1 s frames per room load became a spread of short onesFrame times around compileAsync
Promotion without a distance gatepromoteNearestDetail, ARTWORK_PROMOTED_CEILINGDensity stopped being an input: the worst grid point had made 61 pictures eligible in one frameFloor swept on a 2 m grid, pictures counted per point
crossorigin="anonymous" on every <img> + zone-level Vary: Originfive <img> sites, Cloudflare ruleFixed pictures that vanished for good on a poisoned cacheOwner 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:

wherefpsp95drawstriskerr
on the glass, room in view4227.0 ms435256 272half 195k rays x0.40
a few metres from the hole2248.0 ms128175 264half 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):

changems/frame
baseline (tolerance 1e-4, budget 700, segment 0.5)23.6
segment ×2.423.4
tolerance 1e-325.6
budget 35025.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=:

passraysms/frame
x1.001024k26.3
x0.55310k12.2
x0.40164k9.5
x0.3092k8.2
x0.2041k8.2
x0.1010k7.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:

ParameterEffectWhat it tells you
&bench=30Samples 30 s and shows a JSON report you can copy or downloadThe numbers. Run it on the slow device
&paint=offFreezes 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..6Per-frame ceiling on repaintsStops a hitch from bunching several uploads into one frame
&hz=1..60How often each face repaints, in HzThe real cost dial: every repaint is also a GPU upload
&arttex=128..2048Per-face texture edge in pixelsIsolates texture size from everything else
&dpr=1Forces the device pixel ratioSeparates fill-rate cost from CPU cost
&live=0..1Scales every Live Painting's refresh rate; 0 freezes themMirrors the Menu's Live-effects slider so a benchmark can pin it
&manual=1Replaces 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 adaptWhether a slow frame is slow BECAUSE of the tracer or merely slow WITH it
&kerr=offLeaves the room drawn and the hole unpaintedThe 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:

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 p50room p95frames over 50 msroom paint p50
512²17 ms (≈59 fps)27 ms43 ms
1024²52 ms (≈19 fps)57 ms3326 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 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 GPU0.20

What to know before touching it:

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.

  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 byPanelsCut to RGBABiggest one
the outer gallery ring43138.7 MB3.25 MB
P2: Faith, Doubt, Dread1082.4 MB8.24 MB
the P1 / P3 wall matrices3047.0 MB9.6 MB
the curved portrait wall40≤ 40 MB1 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 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 inBeforeWith thumbnails
pictures queued123123
texture to upload308.9 MB197.7 MB
frames to drain9270
ring painting, each3.25 MB0.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:

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 inOriginal+ byte budget & thumbnails+ arrival resolution
texture to upload308.9 MB197.7 MB50.9 MB
frames to drain927031
worst single frame9.59 MB9.59 MB1.98 MB
pictures over 8 MB12120

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:

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:

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 thisNow
pictures308.9 MB50.9 MB
wall lettering (19)70.3 MB7.8 MB
prop labels (95, never drawn)23.8 MB0
resin sheets (6)14.4 MB14.4 MB
shell, floor, curve, panel surfaces28.6 MB24.6 MB
total446 MB97.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:

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 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:

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):

p50p95frames > 30 ms
before entry17 ms17 ms7 of 877
the ride (~1 s)72 ms143 ms54 of 60
room, first ~8 s21 ms54 ms~100
room, after +15 s17 ms18 ms0 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 sizecountMB
2–8 MB925.8
1–2 MB1323.3
256 KB–1 MB6229.9
under 256 KB717.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 reachgrid points
0–4358
5–9451
40–61150
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:

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:

p50p95
frames with a decode outstanding48 ms96 ms
frames with only uploads outstanding21 ms33 ms
frames with an empty queue17 ms18 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:

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:

framescountp50p95
a decode outstanding14644 ms96 ms
uploads only74622 ms~45 ms
queue empty375817 ms22 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:

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 framesbeforeafter
p5044 ms17 ms
p9596 ms24 ms
average19.7 fps56.3 fps
frames over 100 ms7 of 1461 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 — attachRemoteArtworkdecodeCutBitmap. 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: getVertexPositionapplyBoneTransform, 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:

avatarmeshestrianglesskinnedone raycast
My Angel134,794yes27–55 ms
Space Baby Avi4053,592no0.83 ms
Stickman67,328no2.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.

beforeafter
one raycast54.7 ms2.67 ms (20×)
60 px brush drag821 ms40 ms
240-sample flick13.1 s642 ms
texture RAM192 MB12 MB
bake, once at open180 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: