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.
] 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();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.
}
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 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.
// 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));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.
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);
}`,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.
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