We build BrickPlay: step-by-step 3D building instructions that open in the browser from a QR code on the box. No app, the builder just scans and builds on a phone.
Small models were fine. Then we loaded a 1,689-part model, and on phones the step animation stuttered badly. Here is what was wrong and how we fixed it with three.js.
Why it was slow
We load models with LDrawLoader. Every LDraw part becomes three objects: a mesh, its edges, and the "conditional" lines that draw the outline of curved surfaces. That is three draw calls per part, plus the shadow pass.
For 1,689 parts this gave about 5,100 draw calls and 28–31 ms of CPU per frame on a strong desktop. On a phone it was several times worse.
On desktop we already avoided this by baking: everything that is already built is merged into one buffer per colour, in world coordinates. The parts are sorted by step, so "show steps 1…N" is just a draw range. No scene rebuild, about 100 draw calls, 3.5 ms per frame.
The catch is memory. Baking copies the geometry of every part. For this model it is 146 MB. Our phone budget is far below that, so on phones the model was simply not merged and fell back to drawing part by part. That was the stutter.
The memory budget
We decide by device, not by screen width:
// bytes we allow for merged geometry phone: 16 MB × navigator.deviceMemory iPhone: 64 MB // deviceMemory is not available in Safari desktop: 32 MB × GB, up to 256 MB The fix: a hybrid of instancing and baking
When a model does not fit the budget, we split the parts in two groups:
- Parts that repeat many times are drawn with instancing: one geometry plus a 64-byte matrix per copy. No geometry copies at all.
- Rare parts (8 copies or fewer) are baked, as long as they fit into half of the budget.
The copies are ordered by step, so "steps 1…N" is just mesh.count = n — the same trick as the draw range for baked buffers.
One thing three.js does not give you: it instances meshes only. For the edges we use LineSegments with the isInstancedMesh flag, so the renderer supplies instanceMatrix itself. The conditional lines of LDraw need their own copy of the shader with instancing support. If the loader's shader ever changes and our copy fails to compile, the model falls back to part-by-part drawing instead of rendering wrong.
Results
Same model, same camera, whole frame:
| Mode | Draw calls | CPU per frame |
|---|---|---|
| Part by part (phones before) | ~5,100 | 28–31 ms |
| Hybrid, iPhone budget (phones now) | 250–290 | 4.5–5.4 ms |
| Everything instanced | 480–660 | ~8.5 ms |
| Everything baked (desktop) | ~100 | 3.5 ms, +146 MB |
The picture matches the baked version: at most 0.4% of pixels differ, and only in transparent glass, because of blending order. Building the buffers takes about 110 ms.
A side effect we liked: big models on phones now have buffers, so the intro where the model assembles itself works there too.
What we would tell ourselves earlier
- Count draw calls before anything else. Ours were hidden: one part looked like one object, but it was three.
- "Does not fit, so skip the optimisation" is the worst fallback. Have a middle path.
- Add a URL switch for each mode. We test with
?bpmerge=phone|inst|bake|offon production.
BrickPlay reads LDraw (.ldr, .mpd) and BrickLink Studio (.io) files. If you sell brick sets and want instructions like this under your brand, this is what we do at Kastomiko: 3D building instructions software.