Chris Collins

Lab · Playground

A raymarched scene

A whole 3-D scene — a blobby shape on a checkered floor, soft shadows, an orbiting camera — drawn by one fragment shader with no triangles at all.

Code blocks on this page are editable — change them and press Run.

There are no meshes here. The scene is a single function, map(p), that returns the distance from any point p to the nearest surface — a signed distance field. For each pixel the shader fires a ray from the camera and marches it: ask map() how far the nearest surface is, step that far (it is safe — nothing can be closer), and repeat until the distance is tiny (a hit) or the ray has gone too far (sky). This is "sphere tracing", popularised by Iñigo Quilez and the demoscene.

Once you hit something, everything else falls out of map() too:

  • Normal — the gradient of the distance, from four extra samples around the hit point.

  • Soft shadow — march toward the light, and keep track of how close the ray came to grazing something on the way; near misses darken the pixel a little.

  • Ambient occlusion — sample the distance a few steps along the normal; if surfaces are closer than the step, the point sits in a crevice.

The shapes are combined with a smooth minimum, which melts the sphere and the torus together where they meet. Drag on the picture to move the light.

Sphere tracing a signed distance field
// Things to change: BLEND, the shapes in map(), the camera in main(), STEPS.
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;

const int STEPS = 96;
const float BLEND = 0.35;      // smooth-min radius: 0.0 is a hard union

float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdTorus(vec3 p, vec2 t) { vec2 q = vec2(length(p.xz) - t.x, p.y); return length(q) - t.y; }
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 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);
}

mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }

// Returns (distance, material): 1 = floor, 2 = the object.
vec2 map(vec3 p) {
  float floorD = p.y + 1.0;
  vec3 q = p - vec3(0.0, 0.1 + 0.15 * sin(u_time * 1.3), 0.0);
  float s = sdSphere(q, 0.65);
  vec3 tq = q;
  tq.xy = rot(u_time * 0.7) * tq.xy;
  tq.yz = rot(u_time * 0.4) * tq.yz;
  float t = sdTorus(tq, vec2(0.95, 0.18));
  float b = sdBox(q - vec3(0.0, -0.75, 0.0), vec3(0.5, 0.12, 0.5)) - 0.05;
  float obj = smin(smin(s, t, BLEND), b, BLEND);
  return floorD < obj ? vec2(floorD, 1.0) : vec2(obj, 2.0);
}

vec3 normal(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 res = 1.0, t = 0.02;
  for (int i = 0; i < 48; i++) {
    float h = map(ro + rd * t).x;
    res = min(res, 10.0 * h / t);        // how close did we graze something?
    t += clamp(h, 0.02, 0.25);
    if (res < 0.001 || t > 8.0) break;
  }
  return clamp(res, 0.0, 1.0);
}

float ao(vec3 p, vec3 n) {
  float occ = 0.0, w = 1.0;
  for (int i = 1; i <= 5; i++) {
    float h = 0.06 * float(i);
    occ += (h - map(p + n * h).x) * w;
    w *= 0.7;
  }
  return clamp(1.0 - 2.5 * occ, 0.0, 1.0);
}

void main() {
  vec2 uv = (2.0 * gl_FragCoord.xy - u_resolution) / u_resolution.y;

  // Camera: orbit the origin.
  float a = u_time * 0.25;
  vec3 ro = vec3(3.6 * sin(a), 1.4, 3.6 * cos(a));
  vec3 fw = normalize(-ro), rt = normalize(cross(fw, vec3(0, 1, 0))), up = cross(rt, fw);
  vec3 rd = normalize(uv.x * rt + uv.y * up + 1.6 * fw);

  // Light: follows the mouse once it has moved.
  vec2 m = u_mouse.x > 0.0 ? u_mouse / u_resolution : vec2(0.75, 0.8);
  vec3 lightDir = normalize(vec3((m.x - 0.5) * 6.0, 1.0 + m.y * 3.0, 2.0));

  vec3 sky = mix(vec3(0.75, 0.82, 0.95), vec3(0.25, 0.4, 0.7), clamp(rd.y * 1.5 + 0.3, 0.0, 1.0));
  vec3 col = sky;

  float t = 0.0;
  vec2 hit = vec2(-1.0);
  for (int i = 0; i < STEPS; i++) {
    vec2 h = map(ro + rd * t);
    if (h.x < 0.001) { hit = vec2(t, h.y); break; }
    t += h.x;
    if (t > 20.0) break;
  }

  if (hit.x > 0.0) {
    vec3 p = ro + rd * hit.x, n = normal(p);
    vec3 albedo = hit.y < 1.5
      ? mix(vec3(0.25), vec3(0.85), mod(floor(p.x * 2.0) + floor(p.z * 2.0), 2.0))   // checker floor
      : vec3(0.95, 0.45, 0.25);
    float diff = max(dot(n, lightDir), 0.0) * softShadow(p + n * 0.01, lightDir);
    float spec = pow(max(dot(reflect(-lightDir, n), -rd), 0.0), 32.0) * (hit.y > 1.5 ? 1.0 : 0.2);
    float occ = ao(p, n);
    col = albedo * (0.15 * occ + 0.9 * diff) + 0.15 * sky * occ * (0.5 + 0.5 * n.y) + spec * diff;
    col = mix(col, sky, 1.0 - exp(-0.004 * hit.x * hit.x * hit.x));   // distance fog
  }

  col = pow(col, vec3(0.4545));   // gamma
  gl_FragColor = vec4(col, 1.0);
}

Things to change

  • BLEND — 0.0 for hard edges, 0.8 for one melted blob.

  • In map(), swap sdSphere(q, 0.65) for sdBox(q, vec3(0.5)), or repeat the whole object with q.xz = mod(q.xz + 2.0, 4.0) - 2.0; for an infinite field of them.

  • STEPS — drop it to 16 and watch the edges fray where rays run out of steps before they arrive.

  • The 10.0 in softShadow() sets the shadow’s softness: 2.0 is very soft, 64.0 almost hard.

  • The camera radius 3.6 and height 1.4 in main(); 1.6 is the lens (higher = more telephoto).