My portfolio runs three real-time WebGL effects at once: a GPU fluid simulation that follows the pointer, a deep-space starfield with a procedural nebula, and smoke behind the hero. Making them look cinematic is the fun part; keeping them smooth on an old laptop or a mid-range phone is the real work. These are the techniques that made the difference.
Start with a frame budget
At 60 frames per second you have about 16.7 milliseconds per frame, and on a 120 Hz display only 8.3, shared by your JavaScript, the browser's own work and the GPU. First find out which side is the bottleneck:
- Shrink the canvas. If performance improves sharply at a lower resolution, you are fill-rate bound: the fragment shaders are doing too much work per pixel.
- If it doesn't improve, you are probably CPU bound: too many draw calls, too much per-frame JavaScript, or garbage collection.
Useful tools: the Chrome DevTools Performance panel, renderer.info in Three.js for draw calls and live GPU resources, and the Spector.js extension for inspecting a single WebGL frame.
Fill rate is usually the real bottleneck
Full-screen effects run their fragment shader once per pixel, and pixel counts explode on high-density screens. A phone with a device pixel ratio of 3 renders nine times as many pixels as the same canvas at a ratio of 1. Three rules follow:
- Cap the pixel ratio. I never render at more than 2, and lower quality tiers render below the native ratio. The difference is rarely visible behind moving content.
- Render soft things at low resolution. My nebula is a domain-warped noise field drawn into an off-screen target at a third of the canvas resolution in each dimension, roughly a ninth of the pixels, then upscaled with bilinear filtering. Gas is soft by nature, so the result is visually identical, and it cut the nebula's GPU cost several-fold.
- Scale detail, not just size. Each octave of fractal noise is another full noise evaluation per pixel. My quality tiers reduce the octave count as well as the resolution.
import * as THREE from 'three';
// Draw an expensive, soft effect at 1/3 of the canvas size, then upscale it
const SCALE = 3;
const size = renderer.getDrawingBufferSize(new THREE.Vector2());
const lowRes = new THREE.WebGLRenderTarget(
Math.ceil(size.x / SCALE),
Math.ceil(size.y / SCALE),
{ type: THREE.HalfFloatType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter }
);
function frame() {
renderer.setRenderTarget(lowRes);
renderer.render(nebulaScene, quadCamera); // heavy noise shader, about 1/9 of the pixels
renderer.setRenderTarget(null);
compositeMaterial.uniforms.uNebula.value = lowRes.texture; // bilinear upscale
renderer.render(scene, camera);
}
The half-float render target is deliberate. Smooth gradients in an 8-bit target show visible banding, and half-float precision removes it at a modest memory cost.
Keep draw calls and memory under control
- Batch identical objects with
InstancedMesh, and merge static geometry that never moves independently. - Use points for particles. My starfield is thousands of stars in a single
Pointsobject, and lower tiers simply draw fewer of them. - Compress assets. Ship models as glTF with Draco or Meshopt geometry compression, and textures as KTX2 with Basis Universal, which stays compressed in GPU memory instead of expanding like a JPEG or PNG does.
- Dispose of what you create. Three.js does not garbage-collect GPU resources. Geometries, materials, textures and render targets each need an explicit
dispose()call.
Simulations: ping-pong on the GPU
A GPU fluid simulation stores velocity, pressure and dye in textures and updates them with a sequence of full-screen shader passes. Each pass reads from one render target and writes to another, then the two swap roles, a technique often called ping-ponging. Two knobs control the cost:
- Simulation resolution. The simulation grid does not need to match the screen. Mine runs at a quarter of the screen's width and height on the higher tiers and an eighth on the lowest, and the result is smoothly upscaled for display.
- Pressure-solver iterations. Every iteration is another full-screen pass, which makes the pressure solve the dominant per-frame cost. My lower quality tiers halve the iteration count at each step, trading a little swirl accuracy for a large saving that most people will never notice.
Adapt quality to the device you are actually on
Guessing performance from hardware is unreliable; plenty of integrated GPUs handle serious work. So my site starts at high quality and lets measurements decide, through an adaptive quality governor:
- It ignores the first few seconds, when shader compilation and asset loading cause jank that says nothing about steady-state performance.
- It steps down only for a genuine, sustained stall: a low average frame rate across several seconds, or repeated long main-thread freezes. A single dropped frame is ignored.
- It moves one rung at a time, from High to Medium to Low. Only when a device is still unusable at Low does it switch whole effects off, one at a time.
- It recovers slowly after a long healthy stretch, and never restores an effect the visitor turned off.
- It discards the first sample after a tab becomes visible again, because animation frames pause in background tabs and that sample would wrongly read as a near-zero frame rate.
It also exposes the current level to CSS, so expensive backdrop blur is scaled back when the GPU is busy. Separately, when a visitor's operating system asks for reduced motion, the pointer-driven fluid and the smoke are switched off entirely, leaving only the calm space backdrop. That is about comfort, not performance.
Lifecycle: load late, pause often, release properly
- Lazy-load the engine. Three.js is a large dependency. Load every 3D component with
next/dynamicand server rendering disabled, so the page's real content renders and becomes interactive first. - Pre-compile shaders. Shader compilation can stall the main thread the first time a material is drawn. Three.js's
renderer.compileAsynclets you warm materials up before revealing the scene. - Pause when nobody is watching. Browsers already throttle animation frames in hidden tabs, but a canvas scrolled out of view in a visible tab keeps rendering. Pause it with an
IntersectionObserver. - Release WebGL contexts. Browsers cap the number of live WebGL contexts; Chrome's limit is around 16, and when it is exceeded the oldest context is lost. I hit this on my own site: canvases that had left the page were still holding contexts, and the one that got dropped was the space background. The fix is to dispose of everything and force a context loss when a canvas is really removed, but not during React Strict Mode's development remount, when the same canvas is about to be reused.
useEffect(() => {
const canvas = canvasRef.current!;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// ...build the scene and start the loop
return () => {
cancelAnimationFrame(frameId);
disposeScene(scene); // geometries, materials, textures, render targets
renderer.dispose();
// Unmounted for real (not a Strict Mode remount): hand the context back
if (!canvas.isConnected) renderer.forceContextLoss();
};
}, []);
Accessibility and fallbacks
Keep meaning out of the canvas. Headlines, navigation and calls to action should be ordinary HTML layered over the effect, so screen readers, search engines and devices without WebGL get the full content. Hide decorative canvases from assistive technology, check text contrast against the brightest frame of the animation, and provide a static fallback.
Where WebGPU fits in 2026
WebGPU is now broadly available but still not universal. Chromium browsers have shipped it on desktop since 2023 and on many Android devices since 2024, Safari added it in Safari 26, and Firefox enabled it on Windows first and has been expanding platform by platform. Older devices, some GPU drivers and some Linux setups still lack it.
Three.js's WebGPURenderer falls back to a WebGL 2 backend automatically, and its node-based shading language, TSL, lets you write a material once for both. My advice: treat WebGL 2 as the baseline, and reach for WebGPU when you genuinely need compute shaders, such as very large particle systems or heavy simulations.
Key takeaways
- Work out whether you are fill-rate or CPU bound before optimising.
- Cap the pixel ratio and render soft effects at low resolution; a nebula at a third of the resolution per axis looks the same and costs a fraction.
- Batch draw calls, compress assets and dispose of every GPU resource you create.
- Let real frame rates, not hardware guesses, drive quality, and degrade one step at a time.
- Lazy-load 3D, pause it off-screen, respect reduced motion and release WebGL contexts on unmount.
- WebGPU is worth using for compute-heavy work, with WebGL 2 as the fallback.
Interactive 3D that holds up on real devices is the kind of work I do as a full-stack web developer with a motion design and VFX background, with no hand-off between designing the effect and optimising it.
Planning a project? Book a free discovery call — I'll tell you honestly what it needs.

