TCdev.studio
WebGL, GLSL Published Apr 2026 12 min read

Raymarched SDFs in production.
A field guide for shipping shaders that don't melt phones.

Every other portfolio runs a 240k-particle shader that drops to 4fps on a Pixel 6. Here's the actual playbook I use to ship raymarched scenes to client sites: sub-200kb of GLSL, 60fps on a four-year-old phone, and a Three.js scaffolding I reuse on every build.

Topic
Real-time rendering
Stack
GLSL · Three.js · React
Difficulty
Intermediate
Demo
Interactive · 60fps
Live render, drag to rotate
Raymarched displaced sphere
Two-pass smin SDF, fresnel rim, configurable in real time. Source below.

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:

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.