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.
distance functions
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 signed distance function is the shortest one there is,
and the sign is the useful part: means the point is inside the shape, means it is exactly on the surface. To render a scene made of these, a ray is fired from the camera through each pixel, and instead of solving for exact intersections the way a triangle-based renderer would, the ray steps forward by whatever distance the scene reports as safe — the nearest surface cannot possibly be closer than that, in any direction. Close enough to zero counts as a hit. Each step gets the ray nearer a surface without ever overshooting one, which is why the technique is called raymarching, or sphere tracing, rather than ray casting.
the three scenes
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 — the real fractal, described by Karl Menger in 1926, keeps 20 of every 27 subcubes and recurses forever; three levels of it 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.
fractals come free
That fractals come free is not a coincidence: an SDF describes a surface by a rule rather than by stored vertices, so a shape with infinite detail costs no more memory than a sphere. It is the same property that makes the procedural fields on the noise generator page evaluable at any point on demand, without a grid of samples stored anywhere behind them.
no 3d library
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;
}
`}</style>
</div>
);
}