sdf raymarcher
A scene made of nothing but distance functions, rendered by marching rays through them one step at a time — no meshes, no triangles, no three.js.
Two spheres and a box, unioned with a smooth minimum instead of a hard one — the same min(a,b) as always, but blended near the boundary so the surfaces fuse instead of intersecting in a sharp seam.
Every shape here is a function: feed it a point in space, and it hands back the distance to the nearest surface. A sphere's distance function is just length(p) - radius. To render a scene made of these "signed distance functions," a ray is fired from the camera through each pixel, and instead of testing for exact intersections the way a triangle-based renderer would, the ray just steps forward by whatever distance the scene says is safe — the nearest surface can't possibly be closer than that, from any direction. Close enough to zero, and it's a hit; each step gets the ray closer to a surface without ever overshooting one, which is why the technique is called raymarching rather than ray casting.
The three scenes lean on three different ways of combining distance functions. Blob unions a sphere, a box, and a sphere with a smooth minimum instead of a hard one, so the surfaces melt into each other at the seams. Menger sponge carves a cube by repeatedly intersecting it with the complement of a cross shape at a shrinking scale — three levels of that is already 20 holes deep. Repeated field wraps space itself with a modulo before evaluating a single torus, so one distance function stands in for an infinite tiled grid of them. The step count view mode swaps the shading for a heatmap of how many marching steps each pixel needed — brighter means more steps, and dragging max steps down far enough starts showing rays that give up before they ever find a surface.
None of this touches a 3D library — no mesh data, no triangles, not even three.js. The entire scene, camera, lighting, shadows, and ambient occlusion are one fragment shader evaluating those distance functions per pixel, every frame. The math was checked in plain JS first — sphere, box, and torus distances against their closed forms, the smooth minimum never exceeding the hard minimum, the Menger carving only ever removing material, and the raymarch loop's hit distance matching a closed-form ray-sphere intersection — before any of it was translated into GLSL.
view source
import { useEffect, useRef, useState } from 'react';
type SceneId = 'blob' | 'menger' | 'repeat';
type ViewMode = 'shaded' | 'steps';
const SCENES: { id: SceneId; index: number; label: string; blurb: string }[] = [
{
id: 'blob',
index: 0,
label: 'blob',
blurb:
'Two spheres and a box, unioned with a smooth minimum instead of a hard one — the same min(a,b) as always, but blended near the boundary so the surfaces fuse instead of intersecting in a sharp seam.',
},
{
id: 'menger',
index: 1,
label: 'menger sponge',
blurb:
'A cube with cross-shaped holes carved through it at shrinking scale, three levels deep. Each iteration only ever removes material — the fractal is a pure intersection with the complement of a cross, repeated.',
},
{
id: 'repeat',
index: 2,
label: 'repeated field',
blurb:
'One torus, evaluated in a coordinate space that wraps every 4 units. The distance field only ever has to think about a single tile, but the wrap makes the field — and the raymarch — behave as if it tiled to infinity.',
},
];
const VS_SOURCE = `
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
function fsSource(precision: 'highp' | 'mediump'): string {
return `
precision ${precision} float;
uniform vec2 uResolution;
uniform float uCamAngle;
uniform float uCamDist;
uniform float uCamHeight;
uniform int uScene;
uniform int uViewMode;
uniform int uMaxSteps;
const float MAX_DIST = 60.0;
const float SURF_EPS = 0.0015;
const int MENGER_ITER = 3;
const float REPEAT_CELL = 4.0;
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);
}
float sdTorus(vec3 p, float rMajor, float rMinor) {
vec2 q = vec2(length(p.xz) - rMajor, p.y);
return length(q) - rMinor;
}
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float sdMenger(vec3 p) {
float d = sdBox(p, vec3(1.0));
float s = 1.0;
for (int i = 0; i < MENGER_ITER; i++) {
vec3 a = mod(p * s, 2.0) - 1.0;
s *= 3.0;
vec3 r = 1.0 - 3.0 * abs(a);
vec3 rr = abs(r);
float da = max(rr.x, rr.y);
float db = max(rr.y, rr.z);
float dc = max(rr.z, rr.x);
float c = (min(da, min(db, dc)) - 1.0) / s;
d = max(d, c);
}
return d;
}
vec2 map(vec3 p) {
float ground = p.y + 1.0;
float obj;
if (uScene == 0) {
float s1 = sdSphere(p - vec3(-0.6, 0.1, 0.0), 0.75);
float bx = sdBox(p - vec3(0.5, -0.1, 0.3), vec3(0.55));
float s2 = sdSphere(p - vec3(0.1, 0.6, -0.4), 0.5);
obj = smin(smin(s1, bx, 0.5), s2, 0.5);
} else if (uScene == 1) {
obj = sdMenger((p - vec3(0.0, 0.55, 0.0)) * 0.85) / 0.85;
} else {
vec3 q = p;
q.xz = mod(q.xz + REPEAT_CELL * 0.5, REPEAT_CELL) - REPEAT_CELL * 0.5;
obj = sdTorus(q - vec3(0.0, -0.35, 0.0), 0.55, 0.18);
}
if (obj < ground) return vec2(obj, 1.0);
return vec2(ground, 0.0);
}
vec3 estimateNormal(vec3 p) {
vec2 e = vec2(0.001, 0.0);
return normalize(vec3(
map(p + e.xyy).x - map(p - e.xyy).x,
map(p + e.yxy).x - map(p - e.yxy).x,
map(p + e.yyx).x - map(p - e.yyx).x
));
}
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
float res = 1.0;
float t = mint;
for (int i = 0; i < 48; i++) {
if (t >= maxt) break;
float h = map(ro + rd * t).x;
if (h < 0.001) return 0.0;
res = min(res, k * h / t);
t += h;
}
return clamp(res, 0.0, 1.0);
}
float ambientOcclusion(vec3 p, vec3 n) {
float occ = 0.0;
float weight = 1.0;
for (int i = 1; i <= 5; i++) {
float h = 0.02 + 0.12 * float(i);
float d = map(p + n * h).x;
occ += (h - d) * weight;
weight *= 0.6;
}
return clamp(1.0 - occ, 0.0, 1.0);
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
vec3 target = vec3(0.0, 0.0, 0.0);
vec3 camPos = target + vec3(sin(uCamAngle) * uCamDist, uCamHeight, cos(uCamAngle) * uCamDist);
vec3 fwd = normalize(target - camPos);
vec3 right = normalize(cross(fwd, vec3(0.0, 1.0, 0.0)));
vec3 up = cross(right, fwd);
vec3 rd = normalize(fwd * 1.6 + uv.x * right + uv.y * up);
vec3 bg = mix(vec3(0.035, 0.045, 0.045), vec3(0.06, 0.09, 0.075), uv.y + 0.5);
float t = 0.0;
float matId = -1.0;
int steps = 0;
for (int i = 0; i < 256; i++) {
if (i >= uMaxSteps) break;
steps = i;
vec3 p = camPos + rd * t;
vec2 res = map(p);
if (res.x < SURF_EPS) { matId = res.y; break; }
t += res.x;
if (t > MAX_DIST) break;
}
if (uViewMode == 1) {
float g = float(steps) / float(uMaxSteps);
gl_FragColor = vec4(vec3(g), 1.0);
return;
}
if (matId < 0.0 || t > MAX_DIST) {
gl_FragColor = vec4(bg, 1.0);
return;
}
vec3 p = camPos + rd * t;
vec3 n = estimateNormal(p);
vec3 lightDir = normalize(vec3(0.55, 0.7, 0.4));
vec3 albedo = matId > 0.5
? vec3(0.486, 0.90, 0.60)
: mix(vec3(0.05, 0.06, 0.06), vec3(0.09, 0.11, 0.10), mod(floor(p.x) + floor(p.z), 2.0));
float diff = max(dot(n, lightDir), 0.0);
float shadow = softShadow(p + n * 0.01, lightDir, 0.02, 12.0, 12.0);
float occ = ambientOcclusion(p, n);
vec3 halfV = normalize(lightDir - rd);
float spec = pow(max(dot(n, halfV), 0.0), 32.0) * 0.35;
vec3 color = albedo * (0.18 * occ + diff * shadow * 0.9) + vec3(1.0) * spec * shadow;
float fog = 1.0 - exp(-0.0025 * t * t);
color = mix(color, bg, clamp(fog, 0.0, 1.0));
gl_FragColor = vec4(color, 1.0);
}
`;
}
function compileShader(
gl: WebGLRenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
}
function buildProgram(
gl: WebGLRenderingContext,
precision: 'highp' | 'mediump',
): WebGLProgram | null {
const vs = compileShader(gl, gl.VERTEX_SHADER, VS_SOURCE);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSource(precision));
if (!vs || !fs) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
return program;
}
export default function SdfRaymarcher() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [scene, setScene] = useState<SceneId>('blob');
const [viewMode, setViewMode] = useState<ViewMode>('shaded');
const [maxSteps, setMaxSteps] = useState(72);
const [rotateSpeed, setRotateSpeed] = useState(0.25);
const [playing, setPlaying] = useState(true);
const [supported, setSupported] = useState(true);
const sceneRef = useRef(scene);
const viewModeRef = useRef(viewMode);
const maxStepsRef = useRef(maxSteps);
const rotateSpeedRef = useRef(rotateSpeed);
const playingRef = useRef(playing);
const angleRef = useRef(0.4);
useEffect(() => {
sceneRef.current = scene;
}, [scene]);
useEffect(() => {
viewModeRef.current = viewMode;
}, [viewMode]);
useEffect(() => {
maxStepsRef.current = maxSteps;
}, [maxSteps]);
useEffect(() => {
rotateSpeedRef.current = rotateSpeed;
}, [rotateSpeed]);
useEffect(() => {
playingRef.current = playing;
}, [playing]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const contextAttrs: WebGLContextAttributes = {
preserveDrawingBuffer: true,
};
const gl = (canvas.getContext('webgl', contextAttrs) ||
canvas.getContext(
'experimental-webgl',
contextAttrs,
)) as WebGLRenderingContext | null;
if (!gl) {
setSupported(false);
return;
}
let program = buildProgram(gl, 'highp');
if (!program) program = buildProgram(gl, 'mediump');
if (!program) {
setSupported(false);
return;
}
gl.useProgram(program);
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
const uResolution = gl.getUniformLocation(program, 'uResolution');
const uCamAngle = gl.getUniformLocation(program, 'uCamAngle');
const uCamDist = gl.getUniformLocation(program, 'uCamDist');
const uCamHeight = gl.getUniformLocation(program, 'uCamHeight');
const uScene = gl.getUniformLocation(program, 'uScene');
const uViewMode = gl.getUniformLocation(program, 'uViewMode');
const uMaxSteps = gl.getUniformLocation(program, 'uMaxSteps');
let raf = 0;
let last = performance.now();
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
};
const draw = () => {
gl.uniform2f(uResolution, canvas.width, canvas.height);
gl.uniform1f(uCamAngle, angleRef.current);
gl.uniform1f(uCamDist, 5.5);
gl.uniform1f(uCamHeight, 2.1);
gl.uniform1i(
uScene,
SCENES.find((s) => s.id === sceneRef.current)!.index,
);
gl.uniform1i(uViewMode, viewModeRef.current === 'steps' ? 1 : 0);
gl.uniform1i(uMaxSteps, maxStepsRef.current);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
const tick = (now: number) => {
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
if (playingRef.current) angleRef.current += rotateSpeedRef.current * dt;
draw();
raf = requestAnimationFrame(tick);
};
resize();
draw();
raf = requestAnimationFrame(tick);
const onResize = () => resize();
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
}, []);
const activeScene = SCENES.find((s) => s.id === scene)!;
return (
<div className="sdf-raymarcher">
{supported ? (
<canvas
ref={canvasRef}
className="sr-canvas"
aria-label={`raymarched ${scene} scene`}
role="img"
/>
) : (
<div className="sr-unsupported">
WebGL isn't available in this browser, so the raymarcher can't render.
</div>
)}
<div className="controls">
<div className="group">
<span className="group-label">scene</span>
{SCENES.map((s) => (
<button
key={s.id}
type="button"
className={scene === s.id ? 'active' : ''}
onClick={() => setScene(s.id)}
>
{s.label}
</button>
))}
</div>
<div className="group">
<span className="group-label">view</span>
<button
type="button"
className={viewMode === 'shaded' ? 'active' : ''}
onClick={() => setViewMode('shaded')}
>
shaded
</button>
<button
type="button"
className={viewMode === 'steps' ? 'active' : ''}
onClick={() => setViewMode('steps')}
>
step count
</button>
</div>
<button type="button" onClick={() => setPlaying((p) => !p)}>
{playing ? 'pause' : 'play'}
</button>
</div>
<div className="sliders">
<label>
max steps ({maxSteps})
<input
type="range"
min={8}
max={128}
step={1}
value={maxSteps}
onChange={(e) => setMaxSteps(Number(e.target.value))}
/>
</label>
<label>
rotate speed
<input
type="range"
min={0}
max={1}
step={0.02}
value={rotateSpeed}
onChange={(e) => setRotateSpeed(Number(e.target.value))}
/>
</label>
</div>
<p className="blurb">{activeScene.blurb}</p>
<style>{`
.sdf-raymarcher {
border: 1px solid #2a2d31;
background: #0e1113;
}
.sr-canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border-bottom: 1px solid #2a2d31;
}
.sr-unsupported {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
border-bottom: 1px solid #2a2d31;
color: var(--muted);
font-size: 0.85rem;
padding: 1rem;
text-align: center;
}
.sdf-raymarcher .controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem 0.5rem;
}
.sdf-raymarcher .group {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.sdf-raymarcher .group-label {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-right: 0.15rem;
}
.sdf-raymarcher .controls button {
font-family: inherit;
font-size: 0.75rem;
color: #8a8f98;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #2a2d31;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.sdf-raymarcher .controls button:hover {
color: #e6e6e6;
}
.sdf-raymarcher .controls button.active {
color: #0b0d0f;
background: var(--accent);
border-color: var(--accent);
}
.sdf-raymarcher .sliders {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
padding: 0 1rem 0.75rem;
}
.sdf-raymarcher .sliders label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--muted);
font-size: 0.75rem;
}
.sdf-raymarcher .blurb {
color: var(--muted);
font-size: 0.8rem;
margin: 0;
padding: 0 1rem 1rem;
max-width: 60ch;
}
`}</style>
</div>
);
}