Tags: programming all
Last Edit: 2026-02-23
How I made a WebGL wave mesh run smoothly as a persistent background element
I recently added an animated wireframe wave mesh to my personal site as a subtle background effect. It runs on every page (except individual blog posts) and persists across navigation without re-mounting. Getting it to feel smooth without destroying battery life on laptops took some deliberate choices. Here's what I did.
meshBasicMaterial instead of meshStandardMaterial — Basic material doesn't require lighting calculations. Since the wave is a translucent wireframe overlay, there's no need for normals, shadows, or light sources. This eliminates an entire lighting pass from the GPU pipeline.
Low vertex count (64x64 segments) — The plane geometry uses args={[14, 14, 64, 64]}, giving 4,225 vertices. This is enough for smooth-looking sine waves but far fewer than the 128x128 (16,641 vertices) I started with. The visual difference is negligible at low opacity.
Capped DPR with dpr={[1, 1.5]} — Device pixel ratio is clamped to a max of 1.5x. Retina displays would otherwise render at 2x or 3x, quadrupling or more the number of fragments the GPU has to shade. For a subtle background effect, 1.5x is indistinguishable from native resolution.
powerPreference: "low-power" — This WebGL context hint tells the browser to prefer the integrated GPU over a discrete one. On MacBooks with both Intel/AMD integrated and discrete GPUs, this prevents the system from spinning up the power-hungry discrete GPU just for a background animation.
resize={{ scroll: false }} — Prevents the canvas from recalculating its dimensions on scroll events. Mobile browsers resize the viewport as the address bar hides/shows during scroll, which would trigger expensive canvas resize operations on every scroll frame.
Pure Math.sin/Math.cos vertex displacement — The wave animation is three layered sine/cosine functions evaluated per-vertex in JavaScript. No shaders, no noise textures, no GPU compute. The math is simple enough that V8 optimizes the hot loop well, and the 4,225 vertices complete in under 0.5ms per frame.
Persistent mount in layout, opacity toggle for visibility — Instead of mounting/unmounting the Three.js canvas when navigating between pages, the WaveBackground component lives in the root layout and uses opacity: 0/1 with a CSS transition to hide on blog post pages. This avoids destroying and recreating the WebGL context on every navigation, which is the most expensive Three.js operation.
Dynamic import with ssr: false — The entire Three.js bundle is dynamically imported and excluded from server-side rendering. This means the ~140KB of Three.js doesn't block initial page load or hydration, and the canvas initializes after the page is interactive.
alpha: true with transparent background — The canvas renders with a transparent background, composited over the page via CSS. This avoids needing a separate render pass for a background color and lets the page content show through naturally.
On my M1 MacBook, the animation runs at a locked 60fps using roughly 2-3% GPU. On mobile, it's smooth with no perceptible battery impact. The key insight is that for decorative background effects, you can get away with surprisingly low fidelity — the animation is at 7% opacity, so any visual shortcuts are invisible.
Thanks for reading!
More Posts