Cursor-Driven Material Spotlight
Goal
Build a full-viewport WebGL scene showing a single monochrome 3D sculpture sitting on a flat warm-grey backdrop. The star effect: as the cursor moves over the canvas, an invisible spotlight trails the mouse with a smooth lag and, wherever it lands on the model's surface, the material locally turns glossier and darker — a soft round "wet/polished" patch that reveals reflections and follows the pointer. The reveal fades in when the cursor enters and fades out when it leaves. There is no scroll, no click state, no page chrome — just the sculpture and the roaming highlight.
Tech
Vanilla HTML/CSS/JS with ES module imports. No GSAP, no Lenis, no scroll library. The only runtime dependency is three (npm). The motion is produced entirely by a requestAnimationFrame loop that linearly interpolates (lerps) values into custom shader uniforms.
Imports needed:
threethree/examples/jsm/loaders/GLTFLoader.jsthree/examples/jsm/environments/RoomEnvironment.js- A local
./shaders.jsmodule that exports four GLSL string snippets (see below).
Layout / HTML
Minimal. One full-viewport section; the WebGL <canvas> is created in JS and appended into it.
<section class="spotlight"></section>
<script type="module" src="./script.js"></script>
Styling
Tiny stylesheet — the visuals come from WebGL, not CSS.
- Global reset:
* { margin:0; padding:0; box-sizing:border-box; } .spotlight { width:100%; height:100svh; background:#dddcd7; }— warm off-white/grey (#dddcd7), same value used as the renderer clear color so canvas and page blend seamlessly.canvas { display:block; }
No fonts, no text, no other elements.
Core effect (be exhaustive — this is the whole component)
Renderer / scene / camera setup
THREE.Scene,THREE.PerspectiveCamera(45, containerW / containerH, 0.1, 100),THREE.WebGLRenderer({ antialias: true }).renderer.setSize(container.clientWidth, container.clientHeight).renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)).renderer.setClearColor(0xdddcd7)— matches the CSS background.renderer.toneMapping = THREE.ACESFilmicToneMapping;renderer.toneMappingExposure = 0.65(slightly under-exposed so the glossy reveal reads as a darker, richer patch).- Append
renderer.domElementinto.spotlight. - Environment lighting via PMREM:
const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment()).texture; pmrem.dispose();— this image-based lighting is what makes the low-roughness reveal show reflections. There are no explicit lights; all shading comes fromscene.environment.
Shared state / config
const config = { radius: 0.15, softness: 0.35, lerp: 0.05 };
const shaders = []; // collected patched shaders
const uHit = new THREE.Vector3(0, 100, 0); // current spotlight world pos (starts far off-model)
const target = new THREE.Vector3(0, 100, 0); // desired spotlight world pos
const mouse = new THREE.Vector2(); // NDC pointer
const raycaster = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); // z=0 world plane
const planeHit = new THREE.Vector3();
let uActive = 0; // smoothed 0→1 hover intensity fed to shader
let active = false; // raw hover boolean (true on mousemove, false on mouseleave)
uHit and target start at (0,100,0) — high above the model — so no reveal is visible until the pointer moves.
Model load + framing (GLTFLoader)
Load a single .glb model (see Assets). In the load callback:
- Center it:
const box = new THREE.Box3().setFromObject(model); model.position.sub(box.getCenter(new THREE.Vector3())); - Frame the camera to fit: compute
size = box.getSize(...), then
dist = Math.max(size.x, size.y, size.z) / (2 * Math.tan((camera.fov * Math.PI/180) / 2)); Set camera.position.set(0, 0, dist * 1.75) and camera.lookAt(0, 0, 0). The 1.75 factor leaves comfortable margin around the sculpture.
- Patch every mesh material (see next section).
scene.add(model).
Material patching via onBeforeCompile (the shader trick)
Traverse the model; for each node.isMesh:
- Force
node.material.roughness = 0.95(matte base so the reveal contrast is strong). - Set
node.material.onBeforeCompile = (shader) => { ... }and inside it: - Add uniforms:
``js shader.uniforms.uHitPoint = { value: uHit }; // shared Vector3, live-updated in the rAF loop shader.uniforms.uActive = { value: 0 }; shader.uniforms.uRadius = { value: config.radius }; // 0.15 shader.uniforms.uSoftness = { value: config.softness }; // 0.35 ``
- Inject into the vertex shader: after
#include <common>appendvarying vec3 vWPos;; after#include <worldpos_vertex>appendvWPos = (modelMatrix * vec4(transformed, 1.0)).xyz;(world-space position passed to the fragment stage). - Inject into the fragment shader: after
#include <common>append the uniform/varying declarations; after#include <roughnessmap_fragment>append the reveal math (below). shaders.push(shader);so the loop can update all patched materials.node.material.needsUpdate = true;
shaders.js exports (exact GLSL — load-bearing):
export const vertexPars = `varying vec3 vWPos;`;
export const vertexMain = `vWPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`;
export const fragmentPars = `
uniform vec3 uHitPoint;
uniform float uActive, uRadius, uSoftness;
varying vec3 vWPos;
`;
export const fragmentMain = `
float d = distance(vWPos, uHitPoint);
float reveal = 1.0 - smoothstep(uRadius, uRadius + uSoftness, d);
float mask = reveal * uActive;
roughnessFactor = mix(0.95, 0.45, mask);
diffuseColor.rgb *= mix(1.0, 0.5, mask);
`;
What the fragment math does, per fragment:
d= world-space distance from this surface point to the spotlight centeruHitPoint.reveal=1 − smoothstep(uRadius, uRadius+uSoftness, d)→1inside radius0.15, smoothly ramping to0across the0.35softness band,0beyond. This is the soft round falloff.mask = reveal * uActive→ gates the whole effect by the fade-in/out hover intensity.roughnessFactor = mix(0.95, 0.45, mask)→ roughness drops from0.95(matte) toward0.45(glossy) at the center, so environment reflections appear.diffuseColor.rgb *= mix(1.0, 0.5, mask)→ diffuse darkened to 50% at the center, reinforcing the "polished/wet" look.
Pointer input
On .spotlight:
mousemove: convert to NDC using the element'sgetBoundingClientRect()—
mouse.x = ((e.clientX − r.left)/r.width)*2 − 1; mouse.y = −((e.clientY − r.top)/r.height)*2 + 1; and set active = true.
mouseleave: setactive = false.
The rAF lerp loop (the actual "animation")
function animate() {
requestAnimationFrame(animate);
raycaster.setFromCamera(mouse, camera);
raycaster.ray.intersectPlane(plane, planeHit); // project pointer onto z=0 world plane
target.copy(planeHit);
uHit.lerp(target, config.lerp); // spotlight trails toward target (factor 0.05)
uActive += ((active ? 1 : 0) - uActive) * config.lerp; // hover intensity eases in/out (factor 0.05)
for (const s of shaders) {
s.uniforms.uHitPoint.value.copy(uHit);
s.uniforms.uActive.value = uActive;
}
renderer.render(scene, camera);
}
animate();
- The pointer is raycast against a fixed
z = 0world plane; the intersection is the *desired* spotlight world positiontarget. uHit.lerp(target, 0.05)each frame gives the smooth trailing lag — the highlight chases the cursor rather than snapping to it.uActiveeases from0→1on enter and1→0on leave with the same0.05factor, producing a soft fade of the whole reveal.- Both smoothed values are copied into every patched shader's uniforms right before
renderer.render.
Resize
On window resize: camera.aspect = container.clientWidth/container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight);
Assets / images
One .glb model only:
- A single classical figure sculpture (e.g. a nude athletic human figure in a dynamic pose), monochrome, one matte grey PBR material covering the whole mesh (no textures/colors needed — the material is recolored/roughened by the shader). Binary glTF, roughly 5–6 MB. It should be a solid mesh with real surface detail so the glossy reveal has geometry to reflect off. Load it from an assets path such as
./model.glb. Center and auto-frame it as described above; no ground plane, no other objects.
Behavior notes
- Desktop / pointer-driven only. Effect is entirely
mousemove-based; there is no touch, click, or scroll interaction. On leave, the reveal fades out but the render loop keeps running continuously. - No reduced-motion branch in the original; the loop always runs.
- Initial state shows the plain matte sculpture (spotlight parked off-model at world
(0,100,0),uActive = 0). - The clear color, CSS background, and tone-mapping exposure together give a calm, slightly muted studio look; keep
#dddcd7consistent between CSS andsetClearColor.