A signed distance field trades memory for arithmetic. On a GPU with far more compute than bandwidth, that is usually the right trade — until you put twelve of them on one page and discover the real constraint is contexts, not shading.
The objects scattered across the Field are not models. There is no geometry, no vertex buffer, no mesh anywhere in the pipeline. Each one is a function that answers a single question — how far is the nearest surface from this point? — evaluated a few million times a second by a fragment shader.
This is worth understanding because it inverts the usual tradeoff. Meshes cost memory and bandwidth and are cheap to shade. Distance fields cost almost no memory and a great deal of arithmetic. Modern GPUs have compute to spare and bandwidth they do not, which is why a technique that sounds wasteful is often faster.
Sphere tracing#
A signed distance function returns the distance from a point to the nearest surface — negative inside, positive outside. For a sphere it is embarrassingly simple:
float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
The rendering algorithm follows from one observation: if the nearest surface is
d away, you can step d along the ray without any risk of passing through
anything. Step, re-evaluate, repeat.
float march(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * t;
float d = map(p); // the scene SDF
if (d < EPS * t) return t; // hit — tolerance scales with distance
t += d;
if (t > MAX_DIST) break; // escaped
}
return -1.0;
}
The EPS * t is the detail worth stealing. A fixed epsilon over-tessellates near
the camera and produces shimmering artefacts far away, because a pixel covers more
world space the further it is. Scaling the hit tolerance with distance keeps the
error roughly constant in screen space and removes a whole class of aliasing.
Normals come from the gradient, which you can approximate with four extra evaluations using the tetrahedron trick rather than the six a naive central difference needs:
vec3 normal(vec3 p) {
const vec2 k = vec2(1.0, -1.0);
return normalize(
k.xyy * map(p + k.xyy * EPS) + k.yyx * map(p + k.yyx * EPS) +
k.yxy * map(p + k.yxy * EPS) + k.xxx * map(p + k.xxx * EPS));
}
Why fractals come out for free#
The reason SDF renderers are full of fractals is not aesthetics — it is that recursive domain folding is nearly free and produces infinite detail from a few lines.
float mandelbox(vec3 p, float scale, int iters) {
vec3 z = p;
float dr = 1.0;
for (int i = 0; i < iters; i++) {
z = clamp(z, -1.0, 1.0) * 2.0 - z; // box fold
float r2 = dot(z, z);
if (r2 < 0.25) { z *= 4.0; dr *= 4.0; } // sphere fold, inner
else if (r2 < 1.0) { z /= r2; dr /= r2; } // sphere fold, outer
z = z * scale + p;
dr = dr * abs(scale) + 1.0;
}
return length(z) / abs(dr); // conformal distance estimate
}
Change scale and you get a structurally different object. Change the iteration
count and you trade detail for milliseconds, continuously, with one integer. That
is why the Forge Lab can expose two sliders and produce a genuinely
different object rather than a parameter tweak — the entire object is those
numbers.
The distance estimate for a folded fractal is a lower bound, not the true distance. Overstep and you punch through surfaces. If you see holes in thin features, multiply your step by 0.7–0.9 before blaming the fold.
The part that actually decides your frame budget#
Sphere tracing has a specific performance profile, and it is not "how complex is the object".
Rays that miss are the expensive ones. A ray that hits converges in 20–40 steps. A ray that grazes a silhouette takes tiny steps alongside the surface and burns the entire step budget without hitting anything. Silhouette pixels are your worst case, and a complex outline is more expensive than a complex interior — the opposite of the mesh intuition.
Divergence within a warp costs real time. GPUs execute a group of pixels in lockstep. If one pixel in the group needs 200 steps, the whole group pays for 200 steps. This is why an SDF scene can be fast at one camera angle and slow at another with the same pixel count.
Fill rate dominates. Cost scales with rendered pixels, not scene complexity. Halving the resolution and upscaling is close to a 4× speedup and, for the soft monochrome objects on this site, visually almost free.
Practical mitigations, in the order we apply them:
Render the SDF layer at 50–70% and upscale. The single largest win available.
Intersect the ray with a bounding sphere analytically first, and start marching at the entry point. Rays that miss the bound cost one quadratic solve instead of a full march.
Fewer steps and fewer fold iterations when the object is small on screen or the frame budget is tight. Detail nobody can see is pure cost.
An IntersectionObserver that stops the loop for off-screen objects. Obvious, and routinely forgotten.
The web-specific constraint: one context#
Here is the thing that does not appear in shader tutorials and dominated the architecture of this site.
Browsers limit the number of live WebGL contexts — commonly around 8 to 16 — and silently kill the oldest when you exceed it. Each context also carries real memory and driver overhead. A page with a dozen independent canvases, each running its own renderer and its own animation loop, will be slow before it is wrong, and then it will be wrong.
The fix is one canvas, one context, one loop, and scissor rectangles:
// each registered object is a DOM element that reserves space; the shared
// renderer draws into that element's rect and nothing else
for (const obj of registry.values()) {
const r = obj.el.getBoundingClientRect();
if (r.bottom < 0 || r.top > innerHeight) continue; // off-screen: skip
const x = r.left, y = innerHeight - r.bottom; // GL origin is bottom-left
renderer.setViewport(x, y, r.width, r.height);
renderer.setScissor(x, y, r.width, r.height);
renderer.setScissorTest(true);
renderer.render(obj.scene, obj.camera);
}
N objects become N draw calls in one context instead of N contexts. The page can carry as many as the fill rate allows, the memory cost is one framebuffer, and nothing gets silently evicted.
Two details make it work in practice: the loop must be demand-driven rather than free-running — wake on pointer movement, scroll, or an explicit request, and idle otherwise — and the whole layer should be gated off below a viewport size and pointer type where it would not be visible anyway. A phone should never allocate the context at all, which is a stronger statement than hiding the canvas with CSS.
When to reach for this#
SDFs are the right tool when the form is procedural, when you want continuous control over shape via parameters, when exact surfaces beat tessellated approximations, or when you need constructive solid geometry — union, difference, smooth blend — as a first-class operation. They are the wrong tool for authored content, for anything with detailed textures and UVs, and for scenes where an artist needs to move a vertex.
For a studio identity that should feel generated rather than drawn, they are close to ideal: a few hundred lines of shader, no assets, infinite variation, and every object on the page is genuinely being computed rather than played back.