GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Ray Marching Scenes in Canvas (No WebGL)

Ray marching finds a surface by advancing a ray step by step along a signed distance function, and every scene below runs on canvas pixel math only, no WebGL,

Auto-plays · click a tile to jump to its section · all nine in one zip

Why this order

The order follows how much a distance field has to describe before a scene reads as solid: an exact sphere and floor come first (01), then interior framing with rounded walls (02), then a curved hole and a fractal iteration (03–05), then a blended and a patterned surface (06–07), then a non-surface volume (08), and a full scene stacking shadow, fog, and reflection last (09). This whole set marches a ray forward by the current distance-field value — a different technique axis from post 307's pointer-tracked 3D scenes, which project and z-sort polygon vertices with no distance field involved. Two neighbors worth opening alongside this one: the Perlin noise terrain boards sample a height field directly instead of marching toward one, and the reaction-diffusion simulations evolve a 2D grid rather than stepping a ray through 3D space at all.

01One sphere — the first 3D a minimal marcher makes

What you're seeing

Marching a ray from the eye through each pixel along the sphere's signed distance function turns a flat circle into a real sphere lit from one side. It works as a lit hero-background sphere or a 3D intro screen icon.

One sphere — the first 3D a minimal marcher makes
Open in a new tab

01 · One sphere — the first 3D a minimal marcher makes — Measured area 13.794% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

This scene marched to a cumulative area of 13.794% and an average intensity of 19.1 across the render.

var RT_W = 64;          // render target width (buffer pixels)
var STEPS = 64;         // maximum march steps
var HIT_EPS = 0.005;    // surface-hit distance

function sdSphere(px, py, pz) {
  var dx = px, dy = py - 1.0, dz = pz;
  return Math.sqrt(dx * dx + dy * dy + dz * dz) - 1.0;
}
function sdFloor(py) { return py; }
function map(px, py, pz) {
  var dS = sdSphere(px, py, pz);
  var dF = sdFloor(py);
  return dS < dF ? dS : dF;
}

var t = 0.0, hit = 0, px = ex, py = ey, pz = ez;
for (var s = 0; s < STEPS; s++) {
  var d = map(px, py, pz);
  if (d < HIT_EPS) { hit = 1; break; }
  if (t > 12.0) break;
  t += d;
  px = ex + dx * t; py = ey + dy * t; pz = ez + dz * t;
}

02Rounded box room — a scene seen from inside

What you're seeing

Rounding the box distance and tinting walls by the surface normal gives an interior render, as if the camera stepped inside the room. It suits interior previews or a walkthrough guide for how a product looks from different angles.

Rounded box room — a scene seen from inside
Open in a new tab

02 · Rounded box room — a scene seen from inside — Measured area 52.589% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

Turning the marcher to face outward from inside a room is a cheap ray marching algorithm variant: this item reached the highest cumulative area of the set at 52.589%, at only RT_W 56 and STEPS 56 — a light budget because the interior faces are large and each step covers a long distance.

var RT_W = 56;          // render target width — long marches per step, kept light
var STEPS = 56;         // maximum march steps
var HIT_EPS = 0.01;     // wall-hit distance
var ROUND = 0.5;        // corner rounding radius

function distToWall(px, py, pz) {
  var hx = 3.0 - ROUND, hy = 1.5 - ROUND, hz = 3.0 - ROUND;
  var dx = hx - Math.abs(px), dy = hy - Math.abs(py), dz = hz - Math.abs(pz);
  return Math.min(dx, Math.min(dy, dz));
}

var t = 0.06, hit = 0, px = ex, py = ey, pz = ez;
for (var s = 0; s < STEPS; s++) {
  px = ex + dx * t; py = ey + dy * t; pz = ez + dz * t;
  var d = distToWall(px, py, pz);
  if (d < HIT_EPS) { hit = 1; break; }
  t += d;
  if (t > 9.0) break;
}

03Torus ring — the donut distance and rim light

What you're seeing

The torus distance is one short formula of two radii, yet marched it yields a holed solid orbiting with the camera — a fit for logo rings or a spinning donut in a loading scene.

Torus ring — the donut distance and rim light
Open in a new tab

03 · Torus ring — the donut distance and rim light — Measured area 11.927% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

The hole needs more steps than the render target implies: this item runs at RT_W 48 with STEPS 72, and it measured the lowest cumulative area of the nine at 11.927% with an average intensity of 22.9.

var RT_W = 48;          // render target width — 48px default, 96px via a control
var STEPS = 72;         // steps outrun resolution — the hole needs extra depth
var TOR_R = 1.05;       // ring radius
var TOR_r = 0.42;       // tube radius

function sdTorus(px, py, pz) {
  var q = Math.sqrt(px * px + pz * pz) - TOR_R;
  return Math.sqrt(q * q + py * py) - TOR_r;
}
function map(px, py, pz) { return sdTorus(px, py, pz); }

var t = 0.0, hit = 0, px = ex, py = ey, pz = ez;
for (var s = 0; s < STEPS; s++) {
  var d = map(px, py, pz);
  if (d < HIT_EPS) { hit = 1; break; }
  t += d;
  px = ex + dx * t; py = ey + dy * t; pz = ez + dz * t;
}

04Mandelbulb — the fractal distance estimation opens

What you're seeing

Lifting the Mandelbrot iteration into 3D with a distance estimator lets ray marching alone compute the wrinkled fractal bulk, pixel by pixel — good as a fractal hero background or a seed for cover art.

Mandelbulb — the fractal distance estimation opens
Open in a new tab

04 · Mandelbulb — the fractal distance estimation opens — Measured area 33.917% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

A distance estimate overshoots near sharp fractal detail, so this scene damps every step by SAFETY 0.9 to hold off tunneling; the render still reached 33.917% cumulative area and the highest average intensity of the set at 44.6.

var SAFETY = 0.9;       // DE safety factor — damps overestimation, blocks tunneling
var ITER = 6;            // fractal inner iterations
var POWER = 8;           // Mandelbulb power

function map(px, py, pz) {
  var dx = px, dy = py - 1.15, dz = pz;
  var bc = Math.sqrt(dx * dx + dy * dy + dz * dz);
  if (bc > 1.5) return bc - 1.25;   // bounding sphere — cheap distance outside it

  var zx = dx, zy = dy, zz = dz;
  var dr = 1.0, r = bc;
  for (var i = 0; i < ITER; i++) {
    r = Math.sqrt(zx * zx + zy * zy + zz * zz);
    if (r > 1.6) break;
    var drn = Math.pow(r, POWER - 1) * POWER * dr + 1.0;
    dr = drn;
    if (r < 0.01) break;
  }
  return 0.5 * Math.log(Math.max(r, 1.0001)) * r / dr;
}

// march loop damps every step: t += d * SAFETY

05Menger sponge city — the recursion of regular holes

What you're seeing

Repeating the divide-and-punch rule inside the distance function grows a hole-punched block city that stays crisp even at low render resolution, which reads well as a game background block or a geometric pattern backdrop.

Menger sponge city — the recursion of regular holes
Open in a new tab

05 · Menger sponge city — the recursion of regular holes — Measured area 52.859% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

The recursive fold only approximates the true distance, so the march trims each step to SAFETY 0.85 for extra margin — it still tied the set's highest cumulative area at 52.859%, with an average intensity of 51.6.

var LEVELS = 4;          // recursion depth — more levels, finer holes
var SAFETY = 0.85;       // approximate SDF safety factor — conservative, needs margin

function map(px, py, pz) {
  var cy = py - 1.0;
  var d = sdBox(px, cy, pz, 1.0);
  var s = 1.0;
  for (var n = 0; n < LEVELS; n++) {
    var ax = mod2(px * s) - 1.0;
    var ay = mod2(cy * s) - 1.0;
    var az = mod2(pz * s) - 1.0;
    s *= 3.0;
    var rx = Math.abs(1.0 - 3.0 * Math.abs(ax));
    var ry = Math.abs(1.0 - 3.0 * Math.abs(ay));
    var rz = Math.abs(1.0 - 3.0 * Math.abs(az));
    var da = Math.max(rx, ry);
    var db = Math.max(ry, rz);
    var dc = Math.max(rz, rx);
    var c = (Math.min(da, Math.min(db, dc)) - 1.0) / s;
    d = Math.max(d, c);
  }
  return d;   // conservative: never larger than the true distance
}

06Metaball merge — smooth union of blobs

What you're seeing

Blending two sphere distances with a small exponent makes them neck together like water drops as they touch — one smooth-union trick that changes the whole scene, useful for logo animations or organic-shaped badges.

Metaball merge — smooth union of blobs
Open in a new tab

06 · Metaball merge — smooth union of blobs — Measured area 32.783% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

Because the blend is an exact SDF rather than an estimate, this scene marches directly with no safety damping and still lands mid-pack at 32.783% cumulative area and 35.6 average intensity.

var K = 0.35;   // smin blend radius — how wide the neck grows

function smin(a, b, k) {
  var h = 0.5 + 0.5 * (b - a) / k;
  if (h < 0) h = 0; else if (h > 1) h = 1;
  return b + (a - b) * h - k * h * (1 - h);
}

function map(px, py, pz) {
  var dA = sdSphere(px, py, pz, centers[0], centers[1], centers[2], 0.72);
  var dB = sdSphere(px, py, pz, centers[3], centers[4], centers[5], 0.72);
  var dC = sdSphere(px, py, pz, centers[6], centers[7], centers[8], 0.42);
  var dAB = smin(dA, dB, K);
  var dS = smin(dAB, dC, K * 0.8);   // the small sphere necks in thinner
  var dF = py;
  return dS < dF ? dS : dF;
}

07Gyroid shell — an infinite structure written in trig

What you're seeing

Slicing the gyroid sine-cosine surface between two level sets reveals a rippled print-screen shell wrapped in fogged depth, a fit for texture backdrops or sculptural art scenes.

Gyroid shell — an infinite structure written in trig
Open in a new tab

07 · Gyroid shell — an infinite structure written in trig — Measured area 33.915% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

The gyroid's gradient can run steeper than the render can trust, so this scene sets SAFETY to 0.5 — smaller than the reciprocal of its roughly 1.8 gradient magnitude — and still measured 33.915% cumulative area at 39.3 average intensity.

var SHELL_W = 0.34;   // level-set shell thickness
var SAFETY = 0.5;     // smaller than 1/gradient (~1.8) to stay conservative

function gyroid(px, py, pz) {
  return Math.sin(px) * Math.cos(py) + Math.sin(py) * Math.cos(pz) + Math.sin(pz) * Math.cos(px);
}

function map(px, py, pz) {
  var cy = py - 1.0;
  var g = gyroid(px + phase, cy, pz - phase * 0.6);
  var lip = (Math.abs(g) - SHELL_W) / 1.8;
  var dR = Math.sqrt(px * px + cy * cy + pz * pz) - 1.78;
  return lip > dR ? lip : dR;   // intersect: clip the shell inside a bounding sphere
}

08Cloud volume — not a surface but stacking fog

What you're seeing

Instead of hunting a hard surface, accumulating noise density along the ray renders cotton-stacked clouds with no sharp boundary, which suits sky backdrops or a dreamy hero scene.

Cloud volume — not a surface but stacking fog
Open in a new tab

08 · Cloud volume — not a surface but stacking fog — Measured area 45.965% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

This item never runs a d < HIT_EPS hit test at all — it takes 40 fixed steps of length 0.16 and accumulates alpha front-to-back, reaching the set's lowest average intensity at 17.3 despite a high 45.965% cumulative area.

var STEPS = 40;        // fixed steps — a volume, no hit test
var STEP_LEN = 0.16;   // world-space length of one step
var DENSITY = 2.1;     // density multiplier

function density(px, py, pz, tSec) {
  var cy = py - 1.55;
  var hFade = 1.0 - (cy * cy) / 0.85;
  if (hFade <= 0) return 0;
  var n = fbm(px * 0.9 + tSec * DRIFT, cy * 1.5, pz * 0.9 - tSec * DRIFT * 0.7);
  var d = (n - 0.44) * hFade * DENSITY;
  return d > 0 ? d : 0;
}

// front-to-back compositing, no surface test:
var alpha = 0, px = ex, py = ey, pz = ez;
for (var s = 0; s < STEPS; s++) {
  var d = density(px, py, pz, tSec);
  if (d > 0) {
    var a = 1 - Math.exp(-d * STEP_LEN);
    alpha += a * (1 - alpha);
  }
  px += dx * STEP_LEN; py += dy * STEP_LEN; pz += dz * STEP_LEN;
}

09Checker floor — shadow, fog and reflection in one scene

What you're seeing

Adding a shadow ray, horizon fog, and a wet-floor reflection approximation on a checker floor turns the parts into one finished 3D scene — a product stage or an interactive art scene.

Checker floor — shadow, fog and reflection in one scene
Open in a new tab

09 · Checker floor — shadow, fog and reflection in one scene — Measured area 52.859% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Where to look in the code

The reflection ray runs a second, shorter march (32 steps, half the main budget) toward the mirrored direction, which is how this scene ties 05 and 02 for the highest cumulative area at 52.859% while keeping the lowest max-frame area of the set at 5.162%.

var REFLECT_STEPS = 32;  // reflection ray — half the main march, for frame budget
var REFLECT_MIX = 0.38;  // how much reflection the wet floor takes in
var FOG_K = 0.075;       // horizon fog strength

if (allowReflect && n[1] > 0.5) {
  var dot = dx * n[0] + dy * n[1] + dz * n[2];
  var rx2 = dx - 2 * dot * n[0], ry2 = dy - 2 * dot * n[1], rz2 = dz - 2 * dot * n[2];
  var t2 = 0.02, hit2 = 0, qx = px, qy = py, qz = pz;
  for (var s2 = 0; s2 < REFLECT_STEPS; s2++) {
    var d2 = map(qx, qy, qz);
    if (d2 < HIT_EPS) { hit2 = 1; break; }
    if (t2 > 7.0) break;
    t2 += d2;
    qx = px + rx2 * t2; qy = py + ry2 * t2; qz = pz + rz2 * t2;
  }
}
// exponential horizon fog on the far tail of the same march:
var fog = 1.0 - Math.exp(-FOG_K * t * t);

How does ray marching become 3D? — an eye that walks the distance field

One function, one loop, nine shapes

Every scene above shares the same three-part loop: map() returns the signed distance from a point to the nearest surface, the ray advances by t += d because that distance is always a safe step with no risk of passing through anything closer, and the loop repeats until the distance drops below a hit threshold or the ray gives up as sky. Swap only what map() measures — a sphere, a fractal iteration, a stack of gyroid trig — and the same walking loop turns it into a rendered scene, all 9 items proof that one algorithm covers spheres, fractals, and fog alike.

The surface normal is a gradient, not a stored value

None of these scenes store a normal anywhere; normalAt() samples map() six times around the hit point and takes the forward-difference gradient, because the distance field's slope at a surface always points away from that surface. That gradient is what turns a flat silhouette into something that looks lit — see iq's distance function reference for the primitive formulas items 01–07 build on.

Soft shadows are a second march toward the light

All nine items answer "is this point lit?" by marching again, this time from the surface toward the light source, and clamping the running minimum of k * d / t as the secondary ray goes — a close call near an occluder shrinks that ratio and softens the shadow's edge instead of leaving a hard binary cutoff. It is the same distance-field idea used twice in one frame: once to find the surface, once more to find what falls on it.

Where it breaks — traps in a ray marching shader

Ray tunneling when SAFETY sits too close to 1.0

Items 04, 05 and 07 do not carry an exact SDF — a fractal distance estimator and a recursive box fold both only approximate the true distance, and marching the full estimated step can occasionally step past a thin wall the way a fast car skips a speed bump. Each of those three items multiplies its step by a SAFETY constant below 1.0 (0.9, 0.85 and 0.5 respectively) to hold back from the estimate's worst case; push SAFETY toward 1.0 on any of them and thin fractal spines or gyroid ripples start to flicker through, because the ray took a step longer than the surface it was walking past.

STEPS too low for how deep the scene runs

The interior room (02) and the torus (03) both need more steps than a first guess suggests: 02 marches through a room that is mostly empty air with faraway walls, and 03's hole means a ray entering the donut has to climb out again before it can register as sky. Dropping STEPS below what a scene's own depth requires leaves a visible edge where marching just gave up mid-air — the fix is never a smaller HIT_EPS, it is enough STEPS to let a still-traveling ray actually arrive.

The finished HTML, CSS, and JavaScript for all nine scenes sit in the password archive linked below this article, and the password is x8z9uaga.

Three variants and accessibility

Knob Change What it does
Step count 01 sphere scene, max steps 48 to 96 edge silhouettes thicken and the frame gets heavier
Resolution 03 torus render target, 48px to 96px the same scene sharpens at four times the compute cost
Color $rm-lilac to $rm-peach a cold violet light shifts to a warm peach tone

Step count

Raising item 01's STEPS from 48 to 96 lets the marcher resolve a ray that grazes the sphere's silhouette one or two steps further before it gives up as sky, so the rim reads thicker and cleaner at the direct cost of frame time.

Resolution

Doubling item 03's render target from 48px to 96px quadruples the pixel count the marcher has to fill, sharpening the torus and its rim light while the underlying SDF and step logic never change.

Color

Swapping the $rm-lilac token for $rm-peach at the top of a scene's scss shifts every lit surface in that scene from cool violet to warm peach without touching a single line of the distance math.

What reduced-motion keeps

Every one of the 9 items honors prefers-reduced-motion per the MDN reduced-motion query: the requestAnimationFrame loop never starts, draw() is called exactly once at a fixed camera angle, and a .is-still class marks the frozen frame. The scene stops orbiting; the lit, shadowed surface it was orbiting around stays fully visible.

FAQ

Is ray marching the same as ray tracing?

No. Classic ray tracing intersects a ray with explicit geometry (triangles, spheres as equations) by solving for the exact hit point, while ray marching walks a ray forward in variable-length steps sized by a signed distance function until it gets close enough to call it a hit — it trades an exact solve for a general one that works on any shape you can write a distance function for, fractals included.

Do I need a GPU shader to run a ray marching sdf like this?

No — every scene here computes its distance field on the CPU with plain JavaScript, writes the result into an ImageData buffer, and hands it to the canvas with putImageData driven by requestAnimationFrame. A shadertoy sdf runs the same math per-pixel on a GPU for far higher resolution; this collection stays on a small render target so the CPU keeps up.

Can I paste this ray marching algorithm straight into a project?

Yes — the password archive holds the full HTML, CSS, and JavaScript for all nine scenes, and every file pastes in and runs with no build step and no external library. Colors move through scss token variables at the top of each file, and the step-count and resolution knobs above are plain numbers near the top of the same script.


References: MDN ImageData · MDN putImageData · MDN requestAnimationFrame · MDN prefers-reduced-motion · Inigo Quilez: distance functions

Enter the archive password

The password is inside this article. You will find it as you read.