Skip to content
04 / 103D9 min read

Shaft

The beam through the workshop window is not a post-processing pass. It's a five-quad prism from the window rectangle to its projection on the floor, rebuilt from the live sun vector every frame.

Used on this site

The window in the hero diorama on the home page. Click the window itself. It advances the sun twelve hours over 2.4 seconds, and the shaft is rebuilt on every frame of that sweep.

Paused
The same prism, sliced down the middle. Drag the sun toward the horizon and watch the travel readout, then turn the clamp off.

The shaft of light through the window is thirty vertices, and eighteen of them are somewhere new this frame.

The textbook answer to a visible beam is a screen-space pass: render the occlusion, radially blur it away from the light, add it back over the frame. It's a real technique with real costs. A full-screen target, a second pass over every pixel, and a result that only behaves when the light source is actually in shot. The sun here almost never is. It's a direction the whole room reads from, and the only disc standing in for it lives inside a shallow sky box behind the back wall, faded out until the camera pushes in at the desk. There's no bright disc for a blur to smear away from.

So the beam is built as a solid instead. Four side faces running from the window rectangle down to wherever that rectangle currently lands on the floor, plus the floor patch itself, drawn additively with a shader whose entire job is fading the volume with travel. One draw call, no render target, and the whole thing is geometry you could count on your fingers, if you had thirty of them.

Which corner is this vertex

The window doesn't move: 6 by 4 units, centred 3.5 up the back wall at z = -7. Its projection on the floor moves constantly, and it's a different quadrilateral every frame. Rebuilding a BufferGeometry per frame for a five-faced shape would mean allocating sixty times a second, so the topology is authored once and the positions are treated as scratch.

src/components/scene/LightShaft.tsx — geometry
  ] as const;
}, [win]);

const [shaftGeo, srcIdx, dustGeo, dustHome] = useMemo(() => {
  const verts: number[] = [];
  const fade: number[] = [];
  const src: number[] = []; // 0-3 = window corner, 4-7 = its floor projection
  const quad = (a: number, b: number, c: number, d: number, fa: number, fb: number, fc: number, fd: number) => {
    src.push(a, b, c, a, c, d);
    fade.push(fa, fb, fc, fa, fc, fd);
    for (let i = 0; i < 6; i++) verts.push(0, 0, 0);
  };
  // side faces of the prism (window edge -> floor edge)
  for (let i = 0; i < 4; i++) {
    const j = (i + 1) % 4;
    quad(i, j, j + 4, i + 4, 1, 1, 0, 0);
  }
  // the floor patch itself glows faintly
  quad(4, 5, 6, 7, 0.5, 0.5, 0.5, 0.5);
  const g = new THREE.BufferGeometry();
  g.setAttribute("position", new THREE.Float32BufferAttribute(verts, 3));
  g.setAttribute("aFade", new THREE.Float32BufferAttribute(fade, 1));

  // dust motes: seeded across the window rect, drifting slowly downstream
  const rand = rng(1337);
  const home = new Float32Array(DUST_N * 4); // x,y on window rect; t along shaft; speed
  const pos = new Float32Array(DUST_N * 3);
  for (let i = 0; i < DUST_N; i++) {
    home[i * 4] = winVs[0][0] + rand() * win.w;
    home[i * 4 + 1] = winVs[0][1] + rand() * win.h;
    home[i * 4 + 2] = rand();
    home[i * 4 + 3] = 0.008 + rand() * 0.02;
  }
  const dg = new THREE.BufferGeometry();
Five quads authored as corner identities, every position zero until the first frame runs. The dust is seeded in the same memo, from rng(1337), so the field is identical on every reload.

Nothing in that block knows where anything is. src records which of eight conceptual corners each vertex is (0 to 3 are the window's own, 4 to 7 their floor projections) and comes back out as a Uint8Array of thirty entries, the lookup table the frame loop walks. aFade is written alongside it and never touched again: 1 along the glass, 0 along the floor edge, a flat 0.5 across the floor patch. That attribute is also why this can't be indexed. Corner 4 needs a fade of 0 on the side face it belongs to and 0.5 on the floor patch. One position, two values, and an index buffer has no way to say that. Duplicating costs thirty vertices where eight would do, and buys a frame loop that is thirty setXYZ calls into a buffer allocated exactly once. One more honesty note: the bounding sphere three.js would cull against gets computed from that buffer once and never recomputed as it changes, so both meshes carry frustumCulled={false} rather than trust it.

Re-projected every frame

The sun vector is fetched and negated, because what the geometry needs is the direction light travels: into the room and downward. -dir.y is then the altitude, and a corner at height y meets the floor after y / -dir.y units along that ray. That's the entire projection. Four divides, then a walk over the source indices writing each vertex to either a fixed window corner or a freshly computed floor corner. The floor ones land at y = 0.01 rather than zero, because the slab's top face is at exactly zero and two coplanar surfaces arguing over one depth value is the oldest artefact in the book.

src/components/scene/LightShaft.tsx — useFrame
}
if (!lit) return;

// re-project the floor corners for the sun's current angle. the travel
// clamp stops the patch stretching to infinity near the horizon
const drop = Math.max(0.16, -dir.y);
const shaft = shaftRef.current;
if (shaft) {
  const posAttr = shaft.geometry.getAttribute("position") as THREE.BufferAttribute;
  const fx: number[] = [];
  const fz: number[] = [];
  for (let i = 0; i < 4; i++) {
    const t = Math.min(winVs[i][1] / drop, 24);
    fx.push(winVs[i][0] + dir.x * t);
    fz.push(-S + dir.z * t);
  }
  for (let v = 0; v < srcIdx.length; v++) {
    const c = srcIdx[v];
    if (c < 4) posAttr.setXYZ(v, winVs[c][0], winVs[c][1], -S);
    else posAttr.setXYZ(v, fx[c - 4], 0.01, fz[c - 4]);
The whole re-projection. Four divides, thirty writes, one dirty flag.

The window corners get rewritten every frame too, despite never moving. Branching around twelve of thirty writes would save nothing measurable and add a way for the buffer to hold stale state. drop is where the interesting failure lives. As the sun falls toward the horizon, -dir.y goes to zero and y / -dir.y goes to infinity. The floor patch stretches off the room, out past the camera, and keeps going. Math.max(0.16, -dir.y) is the floor under that divide. With it, the window's bottom edge at y = 1.5 never travels further than 9.375 units and its top edge at 5.5 never further than 34.375, at which point the second clamp trims the top to 24.

You have to read the two clamps together, because it's their interaction that stops the prism folding inside out. Take the 0.16 floor away and let the altitude reach 0.06: the bottom corners want 25 units, the top corners want 92, and Math.min(…, 24) pins both at the same 24. The floor patch, authored as bottom edge first then top edge, collapses to zero area, and below that altitude the near edge and the far edge are simply in the same place. With the floor in, the worst legal case still leaves 14.6 units between the two edges, so the quad always has a front and a back.

src/components/lab/demos/ShaftDemo.tsx
// altitude 0 is on the horizon, 1 is high overhead
const theta = (8 + p.altitude * 66) * (Math.PI / 180);
const dx = Math.cos(theta);
const dy = Math.sin(theta);

// drop is the real component's floor under dy. without it the patch
// stretches off the room as the sun grazes the horizon
const drop = p.clamp ? Math.max(0.16, dy) : Math.max(0.001, dy);
const travelFor = (y: number) => Math.min((floorY - y) / drop, W * 2.4);

const strength = Math.max(0, Math.min(1, (dy - 0.02) / 0.3));
The demo above, in cross-section. Same floor under the altitude, with a toggle on it.

Dust that is seeded, not spawned

DUST_N is 90, and they are not a particle system. Nothing spawns, nothing dies, no mote carries a lifetime. Each gets four numbers at construction, from a PRNG seeded with 1337 so the field is identical on every reload: an x and a y giving its home on the glass, a starting position along the shaft between 0 and 1, and a speed between 0.008 and 0.028. Their position buffer is 270 floats, allocated in the same useMemo as the prism.

Per frame, a mote sits at its home plus dir times Math.min(hy / drop, 24) times its own drift. Same clamped travel as the prism's corners, which is what keeps a mote inside the volume it's meant to be lighting instead of sailing out through a wall at dusk. The drift is (phase + t * speed) % 1, and that modulo is what a lifetime would have been: reaching the floor and reappearing at the glass is arithmetic, not an event. A sine wobble of ±0.08 on x and ±0.048 on z is the only thing keeping ninety motes off ninety straight lines.

Additive, and then simply gone

The material is additive, double-sided, and doesn't write depth. Additive because light adds, and a beam that can darken what's behind it isn't a beam. depthWrite: false because the prism is a closed shape whose faces overlap each other from most angles, and a volume that occludes its own far wall grows a hard straight seam exactly where it should be softest. DoubleSide because as the shaft swings, the camera spends most of the day on the inside of at least one face. It renders at order 5, after the room. The dust gets 6, so it draws over the shaft it lives in.

src/components/scene/LightShaft.tsx — material
  return [g, new Uint8Array(src), dg, home] as const;
}, [win, winVs]);

const shaderArgs = useMemo(
  () => ({
    transparent: true,
    depthWrite: false,
    side: THREE.DoubleSide,
    blending: THREE.AdditiveBlending,
    uniforms: { uOpacity: { value: 0 }, uColor: { value: new THREE.Color("#efe4c4") } },
    vertexShader: /* glsl */ `
      attribute float aFade;
      varying float vFade;
      void main() {
        vFade = aFade;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }`,
    fragmentShader: /* glsl */ `
      uniform float uOpacity;
      uniform vec3 uColor;
      varying float vFade;
      void main() {
        // quadratic falloff: bright at the glass, dissolving before the floor
        gl_FragColor = vec4(uColor, (0.04 + 0.85 * vFade * vFade) * uOpacity);
      }`,
Six lines of GLSL, one of which is the effect.

Why square the falloff? Because a linear ramp is still at half brightness halfway down the shaft, and your eye reads that as a painted wedge. 0.85 * vFade * vFade puts the midpoint at a quarter, which is roughly what real scattering does as a beam spreads. The 0.04 underneath keeps the far end from terminating in a visible edge. And above all of it, uOpacity peaks at 0.17. I tried higher values early on and the room looked like it was filling with smoke. When the beam looks like fog, this is the number to reach for.

ConstantDefault
DUST_N90motes in the shaft. Seeded once from rng(1337), so the field is byte-identical on every reload
drop floorMath.max(0.16, -dir.y)stops y / -dir.y running away as the sun grazes the horizon. Remove it and the floor patch leaves the room
travel capMath.min(…, 24)the outer bound, in world units, against a room 14 across. Only the top corners ever reach it
falloff0.04 + 0.85 * vFade²quadratic, not linear. At the shaft's midpoint that is a quarter brightness rather than a half
uOpacity0.17 × strengththe beam's ceiling. Past roughly 0.3 it stops being light in air and becomes a solid in the room
strengthaltitude / 0.30the sunset fade. Below 0.002 both meshes go invisible and the frame's work is skipped entirely

P.S. This one is running on the site right now.

WebGL · Geometry ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E