The shaft of light through the window is thirty vertices, and eighteen of them are somewhere new this frame.
The default 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 is 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 is 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 entirely until the camera pushes in at the desk. There is no bright disc in the room 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 to fade 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 does not move: 6 by 4 units, centred 3.5 up the back wall at z = -7. Its projection on the floor moves constantly, and it is a different quadrilateral every frame. Rebuilding a BufferGeometry per frame to suit would allocate sixty times a second for a shape with five faces, so the topology is authored once and the positions are treated as scratch.
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));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, which is 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 cannot 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 with two values, which an index buffer has no way to express. Duplicating costs thirty vertices where eight would do, and buys a frame loop that is thirty setXYZ calls into a buffer allocated exactly once. The bounding sphere three.js would cull against is computed from that buffer 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 is 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.
// re-project the prism's floor corners for the sun's current angle. Near the
// horizon the rays flatten out, so the travel is clamped — otherwise the
// patch stretches to infinity and the prism folds inside out.
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]);
}
posAttr.needsUpdate = true;
}The window corners are rewritten every frame too, despite never moving — branching around twelve of thirty writes to save nothing measurable would only 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.
The two have to be read together, because it is 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 of travel between the two edges, so the quad always has a front and a back.
// sun altitude → the direction light TRAVELS, which is into the room
// and downward. 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);
// Near the horizon the rays flatten and the distance to the floor goes
// to infinity. `drop` is the real component's floor under dy: without
// it the patch stretches off the room and the prism turns itself
// inside out as the projected corners cross over.
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));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 — the same clamped travel the prism’s corners use, which is what guarantees a mote stays inside the volume it is meant to be lighting rather than 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. Under reduced motion the time term is dropped and the seeded phase is used alone, so they hold their positions spread down the shaft instead of collapsing to the window.
Additive, and then simply gone
The material is additive, double-sided and does not write depth. Additive because light adds, and a beam that can darken what is behind it is not 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.
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);
}`,
}),
[],
);The falloff is quadratic, and the square is the whole difference between a beam and a wedge. aFade is 1 at the glass and 0 at the floor edge, so a linear ramp is still at half brightness halfway down the shaft; 0.85 * vFade * vFade is at a quarter. The constant 0.04 underneath keeps the far end from terminating in a visible edge. Above all of it, uOpacity peaks at 0.17 — the beam is a suggestion, not a light source, and this is the one number to reach for when it looks like fog.
rng(1337), so the field is byte-identical on every reloady / -dir.y running away as the sun grazes the horizon. Remove it and the floor patch leaves the room0.3 it stops being light in air and becomes a solid in the room0.002 both meshes go invisible and the frame’s work is skipped entirely