Three years ago I'd have told you raymarching was a fun toy for ShaderToy and a bad idea anywhere near a paying client. I was wrong about half of that. The technique is now genuinely viable in production, but only if you treat it like every other rendering budget item: with hard limits and honest measurement.
The demo above is the same shader I use as a starting point on every brand-site brief that calls for "a hero with weight." It's roughly 80 lines of GLSL. It runs at 60fps on an iPhone 12 and pulls under 8% CPU. Below, a walk through what's in it, what was cut, and why.
Why an SDF, and not a mesh
A signed distance function returns the distance from any point in space to the nearest surface of an implicit shape, negative if you're inside it, positive if you're outside. For rendering, it means you can describe arbitrarily complex geometry as a tiny piece of code rather than a vertex buffer.
That has two practical consequences I care about: blending is free,
and resolution is infinite. Two SDFs combine with a single
min or, smoother, a smin. There's no remeshing,
no topology issue, no LOD pop. And because the shape is sampled per-pixel
from a function, it never aliases the way a low-poly mesh does at close range.
The cost is paid in fragment shader cycles. Every saving below is about buying back those cycles.
The anatomy of the demo
The scene above does four things, in order: march rays from the camera into the scene, evaluate the SDF at each step, shade any surface that's hit, and tint with a soft fresnel. Here's the SDF, lifted verbatim from the running canvas:
float map(vec3 p) {
float d = length(p) - 1.05;
float low = sin(p.x*u_freq)*sin(p.y*u_freq)*sin(p.z*u_freq);
float high = sin(p.x*7.0) *sin(p.y*7.0) *sin(p.z*7.0);
// two octaves, low-freq carves the silhouette,
// high-freq adds surface noise that reads as material
return d - u_amp*low - u_detail*high;
}
Two octaves is the sweet spot. One octave looks too smooth, it reads as "object" rather than "thing." Three octaves and you start paying for it on the GPU without the eye picking up the extra detail at any reasonable camera distance.
The shading
The lighting is deliberately cheap: two directional lights, one warm one cool, with a fresnel rim layered on top. No shadow ray, no specular, no environment map. The fresnel does most of the heavy lifting, it's what makes the surface read as material rather than painted.
The perf budget
Every other "WebGL portfolio" online has the same failure mode: it looks great on the author's M2 MacBook, then runs at 12fps on a four-year-old Android. Here's the rule of thumb I write at the top of every shader file:
- Cap DPR at 1.5. Retina is lovely; rendering 4× the pixels for it is not. The fragment shader is the bottleneck, and DPR is a quadratic multiplier on its cost.
- Cap the raymarch loop. 80 iterations is plenty for organic shapes. 200 is a vanity metric, you'll never see the difference, but you'll burn 60% more shader time.
- Step adaptively, not uniformly. Multiply each step by
0.7–0.85of the SDF distance. You converge fast in empty space and tightrope along surfaces. - Pause when off-screen. Use an
IntersectionObserveron the canvas and skiprequestAnimationFramework when the hero is scrolled out of view. Saves 100% of the cost for 100% of the time.
The Three.js scaffolding
Inside a Three.js project I don't bother with a full mesh, camera, and
renderer for a single-quad shader. I use a ShaderMaterial on
a PlaneBufferGeometry and lock the camera to orthographic.
It's the cheapest possible setup; the shader does everything that matters.
const mat = new THREE.ShaderMaterial({
uniforms: {
u_res: { value: new THREE.Vector2() },
u_t: { value: 0 },
u_amp: { value: 0.20 },
u_freq: { value: 3.0 },
u_detail: { value: 0.04 },
},
vertexShader,
fragmentShader,
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
scene.add(quad);
That's the entire setup. From here you can layer it under product UI, composite it with regular meshes for a hybrid scene, or pipe it into a post-processing pass for bloom or tone mapping.
Three things that bit me
Mobile precision. Always declare precision highp float
at the top of the fragment shader. Some Android GPUs default to medium
precision and you'll see banding artefacts on the SDF surface that don't
reproduce on desktop.
The discard trap. It's tempting to discard
pixels that the raymarch missed. Don't, it disables early-z and hurts
more than it helps. Render the background colour and move on.
Resize without re-creating context. On layout change,
update the canvas width/height and call
gl.viewport(), don't tear down the WebGL context. WebGL
context creation is the slowest call in the whole pipeline.
If you're going to ship one shader to a client site, ship the boring one. Two octaves of noise, cheap lighting, sensible perf budget. Save the experiments for your own labs.
The full source for the demo above lives on GitHub, and the Three.js scaffolding is a 200-line gist you can paste into any new project. If you want the same idea applied to a real client build, let's talk.