By Nabin Ghimire, Flutter Developer & Computer Engineer in Kathmandu, Nepal
Published
Updated
10 min read
The desktop is a liar. Three.js scenes that hold a comfortable 120fps in Chrome on a laptop can drop to 22fps on a three year old Android phone, and the gap is almost never about the number of triangles. It is draw calls, overdraw, texture memory and per frame allocations, in roughly that order. I learned this building the solar system portfolio, which renders nine textured planets, orbital rings and a starfield. The first version looked excellent on my machine and was unusable on a mid-range phone. This is the budget I rebuilt it around, and the order I cut things in.
Start with a frame budget, not a library
60fps means 16.6ms for everything: script, layout, raster and paint. On a mid-range Android phone in Chrome, a realistic WebGL allocation is 8 to 10ms per frame for your scene, and that is already generous. Write that number down before you write any code, because it turns every later decision into arithmetic instead of opinion. The measurement that matters is the one taken on the device. Chrome DevTools device emulation does not emulate the GPU usefully. Use remote debugging on a real phone, or the on-screen counter you add yourself and then remove before launch.
Draw calls are the number that matters
A phone GPU handles a lot of geometry and very few draw calls. Rendering 300 separate meshes with the same material is one of the most common causes of a stutter that looks like a shader problem. If the objects share a material, share the draw:
const stars = new THREE.InstancedMesh(geometry, material, 1200);
for (let i = 0; i < 1200; i += 1) {
matrix.compose(position, quaternion, scale);
stars.setMatrixAt(i, matrix);
}
stars.instanceMatrix.needsUpdate = true;Instancing turned a starfield from over a thousand draw calls into one. Merging static geometry and reusing materials does the same job for object clusters. Aim for a two digit draw call count per scene, and check it in the renderer info panel rather than guessing:
console.log(renderer.info.render.calls, renderer.info.memory.textures);Cap the pixel ratio immediately
A modern phone reports a device pixel ratio of 3. Rendering a full screen canvas at that density means nine times the fragments of a 1x canvas. Half of the visual gain is invisible on a small screen and the cost is enormous.
const dpr = Math.min(window.devicePixelRatio, 1.75);
renderer.setPixelRatio(dpr);Then go further: drop to 1.25 when a frame budget is breached, using AdaptiveDpr from drei or your own rolling average. Mine lowers the ratio before it lowers quality, because a slightly softer frame is far less noticeable than a stutter.
Textures: size them for the screen, not the design file
A 4096 pixel texture costs 64MB of GPU memory uncompressed. Four of those will end poorly on a phone with 4GB of RAM, and the phone will start evicting other tabs. Size each texture to what it actually covers in pixels and compress:
- A planet that never fills more than 400 pixels on screen gets a 512 texture, not a 4096 one.
- Prefer KTX2 or compressed formats where the pipeline supports it, and check the texture cube size once at load.
- Reuse textures across materials instead of loading per-object variants.
- Turn off mipmaps on UI-facing textures that are never minified.
Half the perceived quality of a 3D scene comes from lighting and colour grading, and almost none from texel density beyond the point where the screen can resolve it.
Do not allocate per frame
This one produces garbage collection pauses that look like random stutter. Every new THREE.Vector3() inside the render loop creates garbage, and enough garbage triggers a collection in the middle of a camera move.
// Hoist these out of useFrame entirely
const tmpVec = new THREE.Vector3();
const tmpQuat = new THREE.Quaternion();
useFrame((state, delta) => {
tmpVec.copy(target).sub(camera.position);
planet.quaternion.slerp(tmpQuat.setFromAxisAngle(axis, delta), 0.1);
});The same applies to array literals, object spreads and closures created inside the frame callback. Allocate at module scope, mutate in the loop.
Cut in this order when you are over budget
When a scene stutters, the temptation is to cut the most visible thing. That is backwards. This is the order that keeps the most visual value for the least frame time:
- Reduce the pixel ratio before anything else. It is the cheapest large win.
- Post-processing effects. Bloom and depth of field are usually the second most expensive thing in the scene.
- Shadow maps, starting with resolution and then with the count of shadow casting lights.
- Object counts behind the camera and off screen, using frustum culling properly and a distance based visibility check.
- Texture resolution. The difference is often invisible at phone viewing distance.
- Geometry detail. Last resort, because it usually only gets cheap after you have already fixed materials.
Pause the loop when nobody is watching
A portfolio scene does not need to render while it is scrolled out of view or while the tab is hidden. An IntersectionObserver that stops the render loop off screen is worth real battery on a phone, and it also stops the scene from costing frame time during scroll on the sections above it. The same observer is where the reduced-motion path lives: if a visitor asks for reduced motion, render a static frame at load and stop, or skip WebGL altogether and show the fallback.
What I check on the device before calling it done
Remote debugging on a real phone, with the network throttled, is the only measurement I trust. The four numbers I record for every 3D build:
- Average frame time over a thirty second scroll, not the best frame.
- The worst single frame, because one long frame is what a visitor actually feels.
- Draw calls and texture count from the renderer info panel.
- Memory growth across a minute of interaction, which is how a per-frame allocation bug shows itself.
If the average is fine and the worst frame is not, the problem is almost always garbage collection or a texture upload happening at the wrong moment. If both are bad, it is draw calls or pixel ratio, which are the two cheapest things to change and the two people usually leave alone.
The fallback is part of the feature
The honest position on WebGL in a portfolio is that it is decoration. It must never carry information that exists nowhere else. In my build every dashboard-facing fact, every project description and every link lives in server rendered HTML; the canvas renders a version of the idea and nothing more. That is also the performance escape hatch. If a device reports no WebGL, no WebGL2, a capped DPR or reduced motion, the scene never mounts, and the page is a few hundred kilobytes lighter. On the mid-range Android phones that used to struggle, that fallback is the best possible result.
None of these steps is exotic. Draw calls, pixel ratio, texture memory and allocation discipline account for almost every WebGL stutter I have diagnosed, and they are all cheaper to fix before launch than after. The short version, if you only remember one line: measure on a real phone, cap the pixel ratio, stop allocating inside the frame loop, and make sure the scene is optional. Everything else is tuning.
