Graphics Lab · Performance
Making the 3D home fast
Seen from outside, the 3D coastal home on my homepage ran at about ten frames a second on my laptop. I measured where each frame went, cut what it actually spent, and checked that the picture stayed the same. This page shows what was slow, what each change saved, what I tried and dropped, and what I would do next.
Outside, each frame spent about 90 ms of script just handing draws to the GPU, and full screen asked for an 8.3-megapixel buffer.
Five changes, each measured against the same code without it. Outside, script per frame fell from 89.8 to 56.9 ms.
Every draw counted by render pass, runs that alternate before and after, and still frames compared pixel by pixel.
Where the frame went
Before cutting anything, I counted. A script opens the home in a fresh headless browser, fixes its clock at 1:20 on a Friday afternoon, and samples three views for six seconds each: the occupied study, the authored view from outside, and the coast from 160 m. It assigns every WebGL draw to a render pass by the size of the image it draws into, and times the script inside each frame.
Outside, one frame made 1,554 draws. About 800 drew the view itself and a full copy of it: the onsen’s water and the coffee hopper’s glass refract, so three.js first redraws every opaque object into a second full-size image. About 400 went to the sea’s mirror, and 353 to the sun’s shadow map. Every mesh here owns its material, so nearly every draw switched shaders and uploaded its uniforms again.
A CPU profile then found the largest single cost where the draw counts did not point. Thirteen uniforms were arrays of vectors, for the light installations and the traced light field, and three.js flattened each of them again on every material switch: about 100,000 calls per outside frame, with 19.9% of the main thread in Vector3.toArray and 7.9% in three’s flatten.
Five changes, each measured
Each change ran against the same code without it, base and change alternating, so a busy laptop slowed both sides alike. Taking one change back out at a time shows what each one carries.
Script per frame from outside, in milliseconds (two runs each)
- Before the changes 76–82
- All five changes 42–47
- All but the flat uniform arrays 80
- All but the draw grouping 59
- All but the cached shadow 45–50
| Change | What it does | What it saved |
|---|---|---|
| Flat uniform arrays | Stores the thirteen vector arrays as flat typed arrays (Float32Array), so three.js passes them through untouched. The GPU gets the same float32 values. | The largest single cost of an outside frame: held back, script stays at 80 ms. |
| Draws grouped by shader | Opaque draws that share a shader program run together, ranked so the sea still draws before the beach. | Shader switches per outside frame, 579 to 173; script outside, 59 to 44 ms. |
| A cached sun shadow | Casters that never move go into the 2,048-pixel shadow map once. Each frame copies that map and draws only the moving casters over it. | Shadow-map draws per frame, 353 to 91 outside and 268 to 38 in the study. |
| One decoder pool | Every model on the page decodes through one shared pool of up to four Draco workers. | The house shell decodes in 118 ms instead of 323, the avatar in 130 instead of 315. |
| A pixel budget | Caps the drawing buffer at 2.1 megapixels; a large buffer steps down only while frames wait on the GPU. | Full screen at 2560 × 1440 and a pixel ratio of 1.5: 4.4 to 11.9 frames a second in the study, 2.7 to 6.1 outside. |
All five together, in three alternating pairs:
| View | Script | Draws | Shadow-map draws | Shader switches |
|---|---|---|---|---|
| The occupied study | 20.2 → 15.3 ms | 541 → 311 | 268 → 38 | 96 → 52 |
| Outside, from 48 m | 89.8 → 56.9 ms | 1,554 → 1,292 | 353 → 91 | 579 → 173 |
| The coast from 160 m | 86.9 → 50.7 ms | 1,694 → 1,371 | 404 → 91 | 605 → 176 |
Two more pairs on a later scene, after the oak-and-marble kitchen, gave the same picture: outside 69.3 to 48.8 ms, the coast 101.8 to 49.1 ms.
The picture stayed the same
A change meant only to save time should not change a pixel. A second script captures still frames, with motion reduced and randomness seeded, in the order base, change, change, base, so the two base frames show how much the capture itself varies. With every change but the draw grouping, the study and the outside view stayed identical in all 251,790 pixels. At 160 m, 1,261 pixels moved by at most 2 levels of 255, within the base pair’s own noise of up to 1,532.
The draw grouping does change 39 pixels outside, by up to 40 levels, and a handful elsewhere (6 in the study, 4 at 160 m). Where two surfaces with different shaders sit at exactly the same depth, the new order draws the other one on top. They sit on the clifftop planting and the edges of the glass front. Adding ?draw-order=material to the page’s address brings back three.js’s own order if any of them matters.
The cached shadow map matched a full redraw to the texel in 7 of 8 checks. In the eighth, 1 of 4,194,304 texels differed, an exact tie between a caster that stands still and one that moves.
Opening without freezing the page
The slow frame was the second problem. The first was opening the house at all. Three.js compiles a shader the first time it draws a material, and that draw waits for the driver. The first view needed 22 programs, and on a desktop with an RTX 3080 Ti, opening 3D froze the page for 13.5 seconds. Since October 12, every program the first view needs is created ahead of time and compiled on the driver’s worker threads, so the page keeps scrolling while the house compiles: the longest frame during startup fell to 0.2 s.
Even then, the page stuttered in steps of up to 0.7 s on my laptop. The code was not slow. The graphics process was running up to 21 shader compiles at once on 22 cores, and the page’s own thread waited for a turn. Starting a new batch only while no more than half the cores are compiling cut the longest task from 658 to 209 ms and the time lost to long tasks from 2.45 to 0.78 s. The view now takes about half a second longer to be ready, 5.17 s instead of 4.66.
The coastal footer and its miniature now compile the same way before their first frame. At sunset the footer draws its first frame in 2.3 s instead of 2.7, and its longest task is 0.74 s instead of 1.33.
The 2D pages too
In Lighthouse’s desktop test on October 18 (median of three runs), the homepage scored 61, with 903 ms of total blocking time. Of that, 826 ms was the Research Focus field, a WebGL sketch about 1.7 screens down, which mounted at load whether anyone scrolled to it or not. It now waits until the reader is within 600 px of it, and then for an idle moment.
On the DesignWeaver page, the whole article shifted once when Inter replaced the fallback font, a layout shift of 0.167. An “Inter Fallback” face now scales Arial to Inter’s measured widths and heights (size 107.32%, ascent 90.26%, descent 22.48%), which brought that shift to 0.014 and the homepage’s phone layout from 0.049 to 0.033. Web Vitals counts a shift under 0.1 as good.
The portrait is the homepage’s largest paint (1.85 s), so it now loads with fetchpriority="high", as web.dev’s guide recommends for the image a page leads with.
Tried and set aside
- A gate for three.js’s refraction copy. It asked the GPU whether the onsen water or the hopper glass could be seen and skipped the copy when neither could. It was correct but never engaged: the padded test boxes always showed through the cedar slats, over the pool rim or through the glass, so all 13 queries in a 12-angle sweep said visible. I removed it.
- Hiding the onsen water from the coast. In a sweep of 108 coast cameras the water showed from 21, at 3 to 143 pixels, so it could not simply go.
- A sea mirror without the interiors. 13 of 108 coast stills changed, by a median of 126 pixels. That is a look change, so it waits for my call.
- Fewer pixels on the ordinary canvas. Outside frames are bound by handing draws to the GPU, not by pixels, so a lower resolution only blurred them.
- Merging static meshes. Only 12 of the 390 drawn meshes are static, opaque and share a material, which would save 3 draws. Everything else can be clicked, moves, sways in the wind or is skinned.
- Temporal antialiasing. Every character, animal, plant and wave moves, and without motion vectors for each object it would trade a little shimmer for ghosting. 4× multisampling already holds the edges; 8× looked the same and cost about 3 ms of GPU time.
Why not WebGPU yet
WebGPU is the obvious next renderer, so I priced the move in October. Not yet:
- Three.js r186, from September 2026, still calls its WebGPU renderer experimental and recommends the WebGL renderer for WebGL 2 work. It runs neither the string patches in my materials nor the post-processing passes, so the procedural surfaces, the light field, contact lighting, skin, the light installations, the light well, the plant and ocean motion, and the contact, bloom and output passes would all be rewritten in three’s shading language, TSL.
- Its per-object uniform buffers run several times slower than the WebGL renderer when a scene has many distinct objects, about 15 frames a second against 60 for 20,000 cubes (three.js issue 30560, open since February 2025). Many distinct objects is exactly this scene.
- Visitors without WebGPU would get its WebGL 2 fallback, which a September 2026 comment on that issue measured at 31% more render time on the CPU and 80% more GL calls in a draw-heavy app.
What WebGPU would buy is render bundles, which replay static draws without per-draw script, and culling on the GPU. Both aim at the per-draw cost this scene is bound by. I will look again when that issue is fixed and the scene has moved from three.js r164 to a current release on the WebGL renderer. Then the materials can move one at a time, and the renderer switches only if an A/B wins on both backends.
What I would try next
In priority order, with the expected win and the effort for one agent, estimated from these measurements. The technical record keeps the numbers behind each one and the sources I checked.
- Share identical materials between meshes, so grouped draws skip three.js’s material refresh: a further 20 to 40% of outside script time, in half a day. It touches the renovation’s materials, so it needs my decision.
- Stand in for distant refraction. A non-refracting material for the hopper glass and the onsen water seen from outside removes three’s opaque copy, about a quarter of the frame’s draws, in hours. A look change of a few dozen pixels.
- A lighter sea mirror, without the interiors or redrawn every other frame while the camera rests: up to half of its 400 draws, in hours.
- Batch the static scenery with three’s
BatchedMeshand multi-draw: the hinterland’s 156 static pieces and the planting’s 23, for 100 to 150 fewer draws per pass outside, in a day. - Meshopt instead of Draco. It decoded 3.4 to 7.5 times faster here, about 0.3 to 0.5 s less on a first visit, for about 0.9 MB more; half a day.
- Minify the ES modules: the 3D modules from 330 to 201 KB gzipped, and the site’s other 31 from 110 to 72 KB, in hours.
- A content-hashed Service Worker cache, because GitHub Pages sends about 10 MB of unchanged models again after each deploy; a day.
- Occlusion culling from the coast. The rooms behind the cliff still draw about 130 meshes per pass; two to three days, and pop-in while moving needs a decision.
- Level of detail for distant planting, then impostors: a large cut in triangles at a distance, in one to two days, and three to five for impostors.
- Faster shader compiles at startup. About 70 programs take 12 to 19 s to the first frame on my laptop; measure each one before choosing what to cut.
- Temporal antialiasing in place of the multisampled buffers, only with correct motion vectors, most easily under WebGPU.
- A 60 fps cap on high-refresh screens while nobody is dragging: an hour, and a smoothness decision.
How the numbers were measured
- Machine. One shared Windows laptop, an Intel Core Ultra 7 155H with 22 logical cores and Arc integrated graphics, running headless Chromium on ANGLE and Direct3D 11 in a 1440 × 1000 window at a device pixel ratio of 1, which gives the scene a 545 × 461 canvas. Other processes used about half the CPU, so draw counts, script time and pixel parity carry the results; frame rates only illustrate them. The 13.5-second freeze was measured on a desktop with an RTX 3080 Ti.
- Frames.
bin/measure_coastal_views.cjsruns each variant in a fresh browser with seeded randomness and the routine's clock at Friday 13:20, samples each view for 6 s and reports the median of the runs' medians with their range. Three alternating pairs ran on main94755609f, and two more on049dba240. - Stills.
bin/compare_coastal_stills.cjscaptures reduced-motion stills base, change, change, base. The decoder pool was held out of that comparison, because changing the load order exposes an older race in how the wildlife pose under reduced motion. - Decoding. Eight alternating rounds in one page with warmed decoders, median per model.
- Pages. Lighthouse 12.8.2 on the live site, desktop preset, median of three runs.
- Comparison flags.
?shadow-cache=0,?draw-order=materialand?resolution=fixedturn each change off for comparison. - Credit. Sirui Tao, with AI coding agents: the measurements, the changes and their tests. Anthropic's Claude compiled this page from the Graphics Lab's record and the performance notes for Sirui to review.
Sources and credits
- Kevin Myers, Shadows of Cold War: A Scalable Approach to Shadowing, GDC 2021: the two-stage shadow cache, the same split as Unity HDRP’s cached shadows and Unreal Engine’s virtual shadow map cache.
- Doug Binks, Dynamic Resolution Rendering, Intel, GDC 2011, and the hysteresis of drei’s PerformanceMonitor: the pixel budget’s adaptive step.
- Khronos, KHR_parallel_shader_compile: compiling on the driver’s threads. Brendan Gregg’s off-CPU analysis in Systems Performance (2nd edition, 2020) explained the stutter: a starved thread, not slow code.
- Three.js’s WebGPURenderer manual and issue 30560: the WebGPU decision.
- Philip Walton, Web Vitals, and Addy Osmani and colleagues, Optimize resource loading with the Fetch Priority API, on web.dev.