# 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:

- `three`
- `three/examples/jsm/loaders/GLTFLoader.js`
- `three/examples/jsm/environments/RoomEnvironment.js`
- A local `./shaders.js` module 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.

```html
<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.domElement` into `.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 from `scene.environment`.

### Shared state / config

```js
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:

1. **Center it:** `const box = new THREE.Box3().setFromObject(model); model.position.sub(box.getCenter(new THREE.Vector3()));`
2. **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.
3. **Patch every mesh material** (see next section).
4. `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>` append `varying vec3 vWPos;`; after `#include <worldpos_vertex>` append `vWPos = (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):**

```js
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 center `uHitPoint`.
- `reveal` = `1 − smoothstep(uRadius, uRadius+uSoftness, d)` → `1` inside radius `0.15`, smoothly ramping to `0` across the `0.35` softness band, `0` beyond. 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 from `0.95` (matte) toward `0.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's `getBoundingClientRect()` —
  `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`: set `active = false`.

### The rAF lerp loop (the actual "animation")

```js
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 = 0` world plane; the intersection is the *desired* spotlight world position `target`.
- `uHit.lerp(target, 0.05)` each frame gives the **smooth trailing lag** — the highlight chases the cursor rather than snapping to it.
- `uActive` eases from `0→1` on enter and `1→0` on leave with the same `0.05` factor, 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 `#dddcd7` consistent between CSS and `setClearColor`.
