Skip to content

devlog

A level-of-detail rule that measures better than the shipped one, and has never drawn in a browser

2026-10-10

Someone sent me a splat demo that loads more smoothly than mine. Taking its manifest apart turned out to be a long way round to finding out what is wrong with my own.

Somebody pointed me at a Gaussian splat demo that loads more smoothly than this one does, so I pulled its manifest apart to find out what it knows. The answer was uncomfortable in a useful way: it is doing the thing I already built, measured, and did not ship.

The scene is 34,546,011 splats at full detail, which is more than twice the largest capture here. It is indexed by a binary tree with 1008 leaves at depths between 8 and 19, and every leaf carries all seven of its levels as a file, an offset and a count. The levels halve cleanly: 34.5 million, 17.5, 8.7, 4.3, 2.1, 1.07, 0.53. The data sits in 128 bundles produced by the same open tool I use, and one bundle I measured came to 8.5 megabytes for 527,060 splats, which is 16.2 bytes per splat with spherical harmonics included.

The packing is the part worth copying. Coarse levels live in very few whole files, fine levels in many: sixty files hold the finest level, two hold the coarsest. So the opening fetch is two files, about 8.5 megabytes plus a 457 kilobyte manifest, and that is the entire scene visible at low detail against roughly half a gigabyte at full detail. Everything after that is pulled only where the camera is pointed.

That is why it reads as smooth, and it is not a better codec or a faster decoder. It is that level of detail is chosen PER NODE. The wall in front of you is at the finest level while the far side of the town is at the coarsest, and the leaves in between pick for themselves.

Mine is chosen per scene. The shipped path holds one entity per rung of a ladder and swaps the whole thing: Aix opens on 3.49 million splats, and climbing one rung doubles that to 6.99 million everywhere at once. Behind the camera, three hundred metres away, inside buildings nobody can see into. Every one of those splats is paid for in bytes, in memory and in sort time, and most of them are not in the picture.

Here is the part that stung. The per-node version exists. It picks a level for every node from the error that upgrading would remove times the pixels that node covers, divided by the splats it would add, and it buys upgrades best value first until a budget is spent. Nodes behind the camera count at a reduced weight so a turn does not arrive at nothing. On the bench it beat the shipped path in all seven scenarios, by 3 to 12 per cent at equal bytes and by 25 per cent on a long slow link. On its own renderer it held 0 to 1 frames over 50 milliseconds across every scenario and produced the better picture in six of the seven.

And it has never drawn a single frame in a browser. It sits behind a flag, the shipped path is still the ladder, and the comment in the commit that added the occlusion work says so plainly: reachable, and does not yet draw.

So I went looking for why, and the first suspect was memory. The pool is sized once and never reallocated, which is the whole reason the frame rate holds while a town streams, and it is sized from the splat budget: budget times 1.5, plus eight pages of slack. On the default that is 18.5 million splats, which at 32 bytes each is 566 megabytes, against a browser that will refuse a storage buffer binding larger than whatever the adapter reports. That looked like the answer for about ten minutes.

It was not. The pool is already clamped to what the device will bind before the renderer ever sees it, so the number can never be requested. What I found instead was one step over, and it is the kind of bug that only exists because two correct pieces of arithmetic meet. The renderer rounds its capacity UP to a whole page after that clamp. Chrome reports a maximum binding of 2,147,483,644 bytes on a desktop adapter, which is 67,108,863 splats, which rounds up to 1024 whole pages, which is 2,147,483,648 bytes. Four bytes over the limit. The renderer throws, the mount catches it, and the engine quietly falls back to the ladder.

Four bytes, and only reachable when the budget is large enough to hit the clamp in the first place, which is why nobody had seen it. It is fixed by aligning down before clamping rather than up afterwards, so the rounding cannot overshoot.

What I actually take from the week is not the arithmetic. It is that a renderer which measured better than the shipped one in seven out of seven scenarios sat unused for two weeks, and the reason was a silent fallback: when it fails it does not fail, it just quietly becomes the old path, and the old path works. That is the second time this month something has shipped dead and said nothing, after a climb ceiling that ran for five days without ever being reached. So the fix that mattered most was the smallest one. The pool now reports its size, the device limit, and which of the two decided, down every channel including the one that survives a production build stripping the console.

None of this is AI, and it is worth saying so given how much of the rest of the roadmap is. The transport is the part I want to stay deterministic: it wins or loses by numbers I can read off a bench, and a number I can read is the only reason any of the above got found.