# 3D CRT Display

## Goal
Build a full-screen hero where a **3D CRT monitor** (an external `.glb` model) floats center-stage, its screen driven by a **custom GLSL shader** that fakes a real cathode-ray tube: scanlines, an RGB aperture-grille mask, a vignette, warm phosphor tint and chromatic aberration. A row of clickable project pills sits at the bottom. **Hovering a pill swaps the on-screen texture and fires a GSAP-driven glitch burst** — horizontal line-tearing, RGB split and static noise that spike to full and **decay to zero over 0.75s**. The whole monitor lazily **parallax-rotates toward the mouse** via a per-frame lerp. The star effect is the shader-based CRT + the glitch-on-swap.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) and `three` (npm). No GSAP plugins are needed — GSAP is used only for a single `gsap.to()` tween and for `gsap.utils.interpolate()`. Three.js supplies the WebGL scene and the GLTF loader. Plain Vite-style imports:
```js
import gsap from "gsap";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
```
Put the two shader source strings in a sibling module `shaders.js` and import them: `import { vertexShader, fragmentShader } from "./shaders.js";`

## Layout / HTML
Minimal DOM — the visual is a WebGL canvas that JS appends into the hero.
```html
<section class="hero">
  <ul class="projects">
    <li data-img="/path/project-img-1.jpg">District</li>
    <li data-img="/path/project-img-2.jpg">Waypoint</li>
    <li data-img="/path/project-img-3.jpg">Corridor</li>
    <li data-img="/path/project-img-4.jpg">Archive</li>
    <li data-img="/path/project-img-5.jpg">Terminal</li>
  </ul>
</section>
<script type="module" src="./script.js"></script>
```
- `.hero` is the WebGL container (the renderer's `<canvas>` is appended here).
- `.projects` is a horizontal list of five pills. Each `<li>` carries a `data-img` attribute pointing at the texture to display when hovered. Labels are neutral demo words (District, Waypoint, Corridor, Archive, Terminal) — invent your own if you like.

## Styling
Font import: `@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap");`

Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`

- `.hero`: `position:relative; width:100%; height:100svh; background-color:#b0b0b0; overflow:hidden;` — a flat mid-grey backdrop, canvas fills it.
- `.projects`: `position:absolute; left:50%; bottom:4rem; transform:translateX(-50%); width:100%; display:flex; justify-content:center; gap:0.5rem; list-style:none; z-index:2;`
- `.projects li`: the pill look — `text-transform:uppercase; font-family:"Geist Mono", Arial, sans-serif; font-size:0.7rem; font-weight:450; color:#000; width:max-content; padding:0.5rem 1rem; background-color:#fff; border:1px solid #000; box-shadow:4px 4px 0px -1px rgba(0,0,0,1); cursor:pointer;` — white chip, 1px black border, hard offset drop shadow (neo-brutalist sticker).
- `.projects li:hover`: `color:#fff; background-color:#000;` (invert to black on hover).
- Responsive: `@media (max-width:1000px) { .projects { flex-wrap:wrap; padding:0 4rem; } }`

## Three.js scene setup (the stage the shader lives on)
All inside `DOMContentLoaded`.

**Scene / camera / renderer:**
- `scene = new THREE.Scene();`
- `camera = new THREE.PerspectiveCamera(30, innerWidth/innerHeight, 0.1, 1000);` then `camera.position.set(0, 0.15, 1);` and `camera.lookAt(0,0,0);`
- `renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });` `renderer.setSize(innerWidth, innerHeight);` `renderer.setPixelRatio(Math.min(devicePixelRatio, 2));` `renderer.toneMapping = THREE.ACESFilmicToneMapping;` `renderer.toneMappingExposure = 1.25;` then `hero.appendChild(renderer.domElement);`
- After the animate loop starts, override zoom for small screens: `camera.position.z = Math.max(1, 768/innerWidth);` (pushes the camera back on narrow viewports so the monitor still fits).

**Lights** (bright, so the ACES tone-map has range to compress):
- `new THREE.AmbientLight(0xffffff, 5)`
- `const dirLight = new THREE.DirectionalLight(0xffffff, 2.5); dirLight.position.set(15, 10, -5);`
- `const topLight = new THREE.PointLight(0xffffff, 5, 10); topLight.position.set(-5, -2.5, 0); topLight.decay = 0.3;`

**Monitor model:**
- `const monitorGroup = new THREE.Group(); scene.add(monitorGroup);`
- `new GLTFLoader().load("/path/monitor.glb", (gltf) => { ... })` — inside the callback, compute the model's bounding-box center with `new THREE.Box3().setFromObject(model).getCenter(...)`, subtract it from `model.position` to recenter on the origin, then `monitorGroup.add(model);`.

**Screen plane geometry** — a rounded-rectangle plane with custom UVs so the shader can sample edge-to-edge. Write a helper `createScreenGeometry(w, h, r)`:
- Build a `THREE.Shape()` as a rounded rect of width `w`, height `h`, corner radius `r`, centered on origin (use `moveTo`/`lineTo`/`quadraticCurveTo` for the four rounded corners).
- `const geometry = new THREE.ShapeGeometry(shape);`
- **Recompute UVs manually**: for each position, `u = (x - xMin)/w`, `v = (y - yMin)/h`, and set them as a new `uv` BufferAttribute (`ShapeGeometry`'s default UVs are in world units, not 0–1 — this normalization is required for the shader).
- Call it as `createScreenGeometry(1, 1, 0.03)`.

**Screen mesh + material:**
```js
const displayMaterial = new THREE.ShaderMaterial({
  uniforms: {
    map:            { value: defaultTexture },
    imageAspect:    { value: 1 },
    planeAspect:    { value: 0.28 / 0.235 },   // ≈ 1.191
    iResolution:    { value: new THREE.Vector2(512, 512) },
    glitchIntensity:{ value: 0.0 },
    time:           { value: 0.0 },
  },
  vertexShader,
  fragmentShader,
});
const displayPlane = new THREE.Mesh(createScreenGeometry(1, 1, 0.03), displayMaterial);
displayPlane.scale.set(0.28, 0.235, 1);
displayPlane.position.set(-0.008, 0.005, 0.041);   // nudge it into the bezel opening
displayPlane.rotation.set(-0.18, 0, 0);            // slight backward tilt to match the CRT face
monitorGroup.add(displayPlane);
```

**Texture loader** — a small cache keyed by src:
```js
function loadTexture(src) {
  if (cache[src]) return cache[src];
  const texture = textureLoader.load(src, () => {
    displayMaterial.uniforms.imageAspect.value = texture.image.width / texture.image.height;
  });
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  cache[src] = texture;
  return texture;
}
```
Load `default.jpg` up front as the idle screen: `const defaultTexture = loadTexture("/path/default.jpg");`

## Shaders (`shaders.js`)

**Vertex shader** — passthrough that forwards UVs:
```glsl
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```

**Fragment shader** — this is the CRT + glitch. Uniforms: `sampler2D map; float imageAspect, planeAspect, glitchIntensity, time; vec2 iResolution; varying vec2 vUv;`

Helpers:
```glsl
float hash(float n) { return fract(sin(n) * 43758.5453123); }
// cover-fit: rescale uv so the image fills the plane without stretching (like CSS object-fit: cover)
vec2 coverUV(vec2 uv) {
  if (planeAspect > imageAspect) {
    float s = imageAspect / planeAspect;
    uv.y = uv.y * s + (1.0 - s) * 0.5;
  } else {
    float s = planeAspect / imageAspect;
    uv.x = uv.x * s + (1.0 - s) * 0.5;
  }
  return uv;
}
```
Main, in order (let `gi = glitchIntensity`, `uv = vUv`):
1. **Horizontal line-tear (glitch):** `uv.x += (hash(floor(uv.y*20.0 + time*80.0) + time*7.0) - 0.5) * 2.0 * gi * 0.15;` — per-scanline random x-shift, animated fast, scaled by glitch.
2. **Vertical jitter (glitch):** `uv.y += (hash(floor(time*50.0)) - 0.5) * gi * 0.06;`
3. **Chromatic aberration / RGB split:** `float rs = 0.001 + gi*0.025;` (always a hair of split, more during glitch). Sample each channel from a different offset of `coverUV`, and add a 0.05 lift:
   - `col.r = texture2D(map, coverUV(vec2(uv.x + rs, uv.y + rs))).r + 0.05;`
   - `col.g = texture2D(map, coverUV(vec2(uv.x, uv.y - rs*2.0))).g + 0.05;`
   - `col.b = texture2D(map, coverUV(vec2(uv.x - rs*2.0, uv.y))).b + 0.05;`
4. **Ghost smear** — add faint fixed-offset re-samples per channel: `col.r += 0.08 * texture2D(map, coverUV(vec2(uv.x + 0.026, uv.y - 0.026))).r;` `col.g += 0.05 * texture2D(map, coverUV(vec2(uv.x - 0.022, uv.y - 0.022))).g;` `col.b += 0.08 * texture2D(map, coverUV(vec2(uv.x - 0.022, uv.y - 0.018))).b;`
5. **Soft contrast curve:** `col = clamp(col*0.93 + 0.07*col*col, 0.0, 1.0);`
6. **Vignette (barrel-corner darkening):** `col *= vec3(pow(16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y), 0.12));`
7. **Phosphor tint + boost:** `col *= vec3(0.95, 1.05, 0.95) * 2.5;` (greenish CRT cast, 2.5× gain that the tone-map reins back in).
8. **Horizontal scanlines:** `col *= vec3(0.6 + 0.4*pow(clamp(0.35 + 0.35*sin(uv.y*iResolution.y*1.5), 0.0, 1.0), 1.2));`
9. **Vertical RGB aperture-grille mask:** `col *= 1.0 - 0.65*vec3(clamp((mod(vUv.x*iResolution.x, 2.0) - 1.0)*2.0, 0.0, 1.0));` (darkens every other column → the pixel-grid shimmer).
10. **Static noise (glitch):** `col += vec3(hash(uv.x*100.0 + uv.y*1000.0 + time*300.0) * gi * 0.3);`
11. `gl_FragColor = vec4(col, 1.0);`

Steps 1, 2, 10 and the `gi` term in `rs` are the only parts gated by `glitchIntensity`; steps 5–9 are the always-on CRT look.

## GSAP effect (the important part — be exact)

### 1) Continuous render loop with mouse-parallax lerp
A `requestAnimationFrame` loop drives both the shader clock and the monitor's lazy rotation. Use a `THREE.Timer` for elapsed time and `gsap.utils.interpolate` for the smoothing:
```js
const mouse = { x: 0, y: 0 };
const lerpedMouse = { x: 0, y: 0 };
const timer = new THREE.Timer();

function animate() {
  requestAnimationFrame(animate);
  timer.update();
  displayMaterial.uniforms.time.value = timer.getElapsed();

  // ease the tracked mouse toward the target by 5% each frame
  lerpedMouse.x = gsap.utils.interpolate(lerpedMouse.x, mouse.x, 0.05);
  lerpedMouse.y = gsap.utils.interpolate(lerpedMouse.y, mouse.y, 0.05);
  monitorGroup.rotation.x = lerpedMouse.y * 0.15;
  monitorGroup.rotation.y = lerpedMouse.x * 0.3;

  renderer.render(scene, camera);
}
animate();
```
- **Lerp factor `0.05`** → heavy inertia; the monitor drifts toward the cursor over ~1s, never snaps.
- Mouse mapping (in the `mousemove` listener): `mouse.x = (e.clientX/innerWidth - 0.5) * 10;` and `mouse.y = (e.clientY/innerHeight - 0.5) * 5;` — so raw range is ±5 (x) / ±2.5 (y).
- Applied rotation range: `rotation.y` ≈ ±1.5 rad max theoretically but clamped by real cursor travel; the `*0.3` (yaw) and `*0.15` (pitch) multipliers keep it a gentle tilt, yaw twice as strong as pitch.

### 2) Glitch burst on texture swap (the GSAP tween)
There is **one** GSAP tween in the whole component. When the displayed image changes, spike a plain-object `glitchState.intensity` to 1 and animate it back to 0, piping the value into the `glitchIntensity` uniform every frame:
```js
const glitchState = { intensity: 0 };
let glitchAnimation = null;

function setDisplayImage(src) {
  const texture = loadTexture(src);
  displayMaterial.uniforms.map.value = texture;

  if (glitchAnimation) glitchAnimation.kill();   // restart cleanly if hovering fast
  glitchState.intensity = 1.0;                    // spike to full immediately
  glitchAnimation = gsap.to(glitchState, {
    intensity: 0,
    duration: 0.75,
    ease: "power3.out",
    onUpdate() {
      displayMaterial.uniforms.glitchIntensity.value = glitchState.intensity;
    },
  });

  // keep imageAspect correct once the new texture has decoded
  const updateAspect = () => {
    displayMaterial.uniforms.imageAspect.value = texture.image.width / texture.image.height;
  };
  texture.image ? updateAspect() : texture.addEventListener("load", updateAspect);
}
```
- Target: `glitchState.intensity` **1 → 0**.
- `duration: 0.75`, `ease: "power3.out"` → violent at the instant of swap, then a fast tail-off, most of the tear/noise gone in the first ~0.3s.
- No delay, no stagger, no timeline — a fresh tween per swap. `glitchAnimation.kill()` before re-spiking prevents overlapping tweens when the user sweeps across pills quickly.

### 3) Triggers (hover)
```js
document.querySelectorAll(".projects li").forEach((li) => {
  li.addEventListener("mouseenter", () => {
    const img = li.getAttribute("data-img");
    if (img) setDisplayImage(img);
  });
});
document.querySelector(".projects").addEventListener("mouseleave", () => {
  setDisplayImage(defaultDisplayImg);   // revert to the idle screen when the cursor leaves the whole list
});
```
- **`mouseenter` on each pill** → swap to that pill's `data-img` + glitch burst.
- **`mouseleave` on the `.projects` UL** (not the individual li) → swap back to the default idle texture + glitch burst.

### Resize handler
```js
window.addEventListener("resize", () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
```

## Assets / images
- **1 GLB model** — a stylized 3D **CRT/tube television or vintage computer monitor** with a chunky bezel and a recessed screen opening. Any low-poly retro monitor model works; the code recenters it automatically. The screen plane is positioned/tilted (`rotation.x = -0.18`) to sit inside the bezel face, so pick a model whose screen faces roughly +Z.
- **6 screen texture files**, all **landscape 1200×857 px (~7:5, ≈1.40:1)**. They're cover-fit onto a 0.28×0.235 plane, so exact aspect isn't critical — the shader crops.
  - **1 idle/default texture** (`default.jpg`) — a flat, fully saturated **electric-blue** field filling the frame, with a white **two-line pixel/bitmap-font wordmark** centered on it (placeholder text). Shown on load and whenever the cursor leaves the pill row. Use a neutral wordmark, no real brand.
  - **5 project textures** (`project-img-1..5.jpg`), one per pill in list order — a cohesive set of moody, **cinematic cyberpunk / retro-futuristic night scenes** all dominated by **violet–purple haze and magenta/pink neon with warm amber–orange accents, fog and wet reflective ground**:
    - *img-1* — a rainy neon downtown street at night: a boxy retro sedan with lit headlights heading toward the camera, crowds on the sidewalks, glowing storefront signage, dense purple haze.
    - *img-2* — a foggy plaza where two helmeted figures flank a tall **glowing orange-red circuit-etched monolith**; dark high-rises and a lit neon-ringed transit pod behind, heavy violet mist and light-streaked wet road.
    - *img-3* — a tree-lined boulevard seen through overhanging branches, **pink/purple neon** throughout: rounded futuristic cars with glowing ring headlights on wet cobblestones, rows of glowing lamp-posts, a neon-crowned tower catching a pink sky in the distance.
    - *img-4* — a lone silhouetted figure facing a large **glowing orange/pink neon shop window** full of chrome machinery, set against a deep-purple foggy cyberpunk cityscape with wet pavement.
    - *img-5* — a second rainy neon street scene, wider and differently framed than *img-1*: a boxy retro sedan lower-left with headlights on, an overhead traffic signal, a brightly lit **magenta-fronted storefront** at right, pedestrians crossing, thick violet fog. (Similar mood and subject to *img-1* but a distinct image.)
  - Each texture is just the on-screen picture; described generically by subject and color, no brands. The five project scenes share one palette so any swap glitch keeps the CRT looking cohesive.

## Behavior notes
- **Desktop-first, mouse-driven.** The parallax rotation and the pill hovers assume a pointer; on touch, the monitor simply sits still at its idle image (nothing autoplays besides the always-running shader clock, which animates scanline shimmer subtly via `time`).
- The shader `time` uniform runs continuously, so the CRT has a faint living shimmer even at rest; the *glitch* only appears on swap.
- On narrow viewports the camera is pushed back (`camera.position.z = Math.max(1, 768/innerWidth)`) and the pill row wraps (`flex-wrap` at ≤1000px).
- `pixelRatio` is capped at 2 for performance. No reduced-motion branch in the original.
