576,000 books on a mid-range phone
Who Was Remembered is a browser-based 3D art piece, a desert scattered with 576,000 books that you walk through, back in time. The previous post covers why the world looks the way it does. This one covers how it renders, because 576,000 of anything is a lot for a browser tab, and the piece had to run on a mid-range Android phone. I drove the design and the debugging documented here; Claude Code wrote substantial portions of the runtime under that direction.
The corpus is 576,000 books, and the design refuses most of the easy outs. The world is a single continuous space with no loading screens, the player can see from the dense modern centre to the horizon, and the horizon matters. A faint dust of distant books is what tells you the field goes on. Cutting the draw distance would not just save performance, it would delete the piece’s strongest image.
My development machine has a 3060 Ti, which is a problem in disguise. A GPU with that much headroom absorbs almost any bad rendering decision without complaining, so the bad decision ships and someone else’s hardware finds it after launch. The performance target was therefore my Galaxy A26, a mid-range Android phone with a Mali GPU, served over LAN from the dev machine so I could test on the real thing every few minutes.
Books near, boxes mid, dots far
The field is drawn in three tiers. Books near the player are real instanced meshes with bevelled covers and a painted page block, around 118 vertices each. Behind those, out to about a hundred units, books drop to a simplified 24-vertex box that reads identically at that distance. Everything beyond is the interesting part.
The first version of the far field was also meshes, very simple ones. On the desktop it was fine. On the A26 it ran at 19 frames per second, and toggling pieces of the scene on and off on the device pointed at the far field as the entire cost. Turning it off doubled the framerate. Switching the lighting model did nothing, and halving the render resolution did nothing either, which together said the GPU wasn’t drowning in pixel work. It was drowning in geometry, hundreds of thousands of small boxes’ worth of vertices submitted every frame.
The textbook answer is to cull, meaning draw less of the world. Culling was measured and rejected. The corpus is so recency-skewed that almost everything sits near the centre where the player starts, so a distance cut removes very little geometry while erasing the deep-field vista. Frustum culling, drawing only what the camera faces, was measured too. In the worst case, standing in the dense centre and looking out across the field, over three quarters of all books are legitimately in view. There was nothing to cull. Each book had to get cheaper instead.
One vertex per book
The far field is now a single GPU point cloud, one vertex per book instead of 36. Each point renders as a small camera-facing square, an impostor, that the shaders dress up to pass for a book at distance. This is the same pattern planetarium software and space games use for stars, billboards with a minimum on-screen size, and the deep past of the piece is structurally a star field, so the fit is closer than it first sounds.
Making a dot pass for a book took four separate tricks, each one discovered by staring at a mismatch on screen.
The colour was wrong first. Point materials in Three.js are unlit, so a point showed the book’s raw colour while the real meshes in front of it showed that colour multiplied by scene lighting, and the far field glowed five to eight times brighter than the books it was supposed to continue. The fix was to compute the lighting a real book cover would receive, an upward-facing surface under this scene’s particular warm sun and cool sky, and bake that multiplier into the point shader.
The shape was wrong at ground level. A point sprite is always a camera-facing square, but a flat book seen from a walking player’s eye height is a thin sliver, and the mismatch made the far field read as confetti standing on end. The shader now squashes each sprite vertically based on the viewing angle, keeping only a narrow horizontal band when you look out across the field and the full square when you look down from a dune.
The density was wrong in the aggregate. Every point gets a minimum on-screen size so it can’t vanish, but a point drawn at its minimum size claims more pixels than the book it represents, and half a million of them merged into a solid bright carpet. So each point’s opacity is scaled by how much of its drawn size is real, and the carpet thins back into individual specks.
And the minimum size itself was wrong in a subtle way. It was specified in framebuffer pixels, the internal render resolution, which on the phone is deliberately low. A one-pixel speck in a low-resolution buffer is a fraction of a visible pixel on the actual screen, and the far field had quietly been invisible on mobile the whole time. The floor is now set in CSS pixels, the units the player’s eye actually sees. I care about that floor beyond the engineering, because the whole BCE end of the piece depends on isolated books staying visible across a vast emptiness. A renderer that lets distant records fade below a pixel would be erasing exactly the people the piece is about.

The handoff, and the bug under all of it
Walk toward a speck and at some distance it must become a mesh. This handoff consumed more debugging time than everything else in the renderer combined, and went through several rounds of a particular kind of failure, dark crescents and rings in the field that appeared and jumped as you moved, always near the boundary where dots become books.
We chased it through fade curves and timing for a day, and the eventual root cause was none of those things. In a GPU, a transparent surface still writes to the depth buffer by default, the data structure that decides what is in front of what. Books entering the transition zone were drawn fully transparent, invisible to the eye, but they still wrote depth, so they punched invisible holes in the dot field behind them. Every crescent traced back to this. The dots were never disappearing. Meshes you could not see were eating them.
The fix inverted the architecture. No book or dot writes depth at all. The dot field draws first as a continuous floor across the whole world, and the real meshes paint over it. A solid book exactly covers its own dot, a fading book reveals its dot in proportion, and an invisible book does nothing, which is the correct behaviour that depth writes had been silently breaking. As a bonus, the crossfade now conserves brightness for free. The remaining piece was scheduling. Meshes join the scene while still fully transparent, a margin ahead of the visible fade, so a new book glides in with the player’s approach instead of popping into existence.
Two wrong theories, then a measurement
With the architecture settled, the phone still sat under 20 fps in the densest spot, and I went through two confident wrong explanations before instrumenting properly.
The first theory was vertex load, which had been true in the box era and died with the point cloud. The second was overdraw, too many transparent layers stacking at the horizon, supported by a plausible observation that the framerate depended on camera angle. The test that should have confirmed it did the opposite. Halving the fragment shader’s work changed nothing, and halving the render resolution changed nothing, so pixels were not the cost. What finally moved the number was submitting half as many points. The bottleneck was primitive throughput, the GPU’s fixed cost of pushing half a million tiny primitives through its front end every frame, regardless of how many pixels they produce. The angle dependence had fooled me because looking across the field puts far more of it inside the camera’s view than looking down at it.
You cannot shrink that cost per point, so the fix draws fewer points where it doesn’t show. In any dense cell of the far field, the carpet keeps one speck in six. The crucial choice is that it thins by a fraction rather than capping each cell to a fixed count. A cap would flatten every dense cell to the same density and erase the present-versus-past gradient, which is the entire subject of the piece. A fraction keeps the gradient intact and the antiquity cells keep every single dot, since they were never dense to begin with. Thinning is on by default only on touch devices, where the screen resolution also hides it, and it’s exposed as a quality setting either way. The honest summary is that the floor was lifted, around 25 to 30 fps in the worst spot, rather than solved.
A renderer needs a safety net
None of this refactoring would have been safe blind. The renderer has no unit tests because its correctness is a visual judgement, so the project grew a golden-image harness early. A URL flag puts the runtime in harness mode, which pins the camera to seven fixed poses chosen to cover the regression-prone ground, the dense centre, the dot-to-mesh handoff, the deep void, the sky. It also freezes the one clock that drives every animated thing in the scene, which makes a rendered frame reproducible to within two pixels out of 1.6 million. A small script captures all seven poses and diffs them pixel by pixel against approved references. Every render-touching change in the project ran before-and-after through that harness, and it caught subtle damage more than once.
The launch-day freeze
One bug deserves its own section, because it nearly shipped. On launch day itself, with everything else done, every phone froze on the first tap. The world rendered, the first frame stood there, and input did nothing, while the pause menu kept working perfectly. That last detail was the tell. Three.js only schedules the next frame after the current one returns cleanly, so a single uncaught exception inside the render loop kills rendering forever while the rest of the page lives on.
The chain took an evening to dig out. On the very first frame the clock returns a delta of exactly zero. The movement controller divided by that delta, zero divided by zero is NaN, and the NaN settled into smoothed velocity accumulators that never recover, because every blend of a NaN is a NaN. From there it flowed into the procedural wind audio as a volume parameter. The Web Audio API responds to a non-finite parameter by throwing, but only once an AudioContext exists, and browsers only allow audio after the first user gesture. So the poison sat dormant through the whole first frame and detonated on the first tap, inside the render loop. Desktop was immune by accident. Its controller returns early until you click into pointer lock, so the zero-delta frame never reached the division.
Debugging it was its own small story, because desktop DevTools in mobile-emulation mode could not reproduce it. An emulated tap is not a real user gesture, no AudioContext was created, and the NaN stayed silent. What cracked it was serving an instrumented copy of the production build over LAN with error hooks that posted everything to a tiny logging server, then reading the phone’s own stack trace. The fix floors the frame delta so a zero-length frame cannot exist, and the audio layer coerces non-finite values to zero so no parameter write can ever take down the render loop again. It was one of the last fixes to land before we could finally ship. Hours later, on the same phones, the launch build just worked.
The bug that never happened
One more launch-week trap deserves telling, because it was caught by review rather than by a crash, and not by me. The per-book label data, 576,000 names and descriptions, ships as a single blob that gzip shrinks from 34 MB down to 13. Compression on the web is normally the server’s job, but a static host gives you no say in the matter, so the file is compressed at build time and the runtime inflates it itself. The trap sits in the middle. If a host notices the file is gzipped and helpfully marks it as such, the browser inflates it on arrival, the runtime inflates it a second time, and the load throws. The original defence was a naming trick, an extension arranged so servers wouldn’t recognize the file as compressed.
The day before launch I ran a final pre-ship review with Claude, which happened to be my first session on the Fable 5 model, less than an hour after its public release. It read the loader, checked the trick against itch.io’s documentation, and came back with the catch of the week. Itch’s CDN detects gzip by reading the file’s actual content rather than its name, so the naming trick that held everywhere else would have double-inflated on the one host the launch most depended on. The game would have refused to load on itch while working perfectly on every mirror, a bug reserved exclusively for launch day. The fix dropped the bet on host behaviour entirely. The loader now reads the first two bytes of the payload and inflates only when they are the two-byte signature every gzip stream starts with, passing pre-inflated bodies straight through. The file’s own bytes are the only signal no host can rewrite.
Who Was Remembered is playable at wwr.simonsorkin.com and on itch.io, and the runtime described here is on GitHub under MIT. The design story, including the fairness correction that turned out to lie, is in the previous post.