# Brutalist Sci-fi Corridor — WebGL Preloader Reveal

## Goal
Build a full-viewport WebGL hero: a **brutalist concrete corridor** rendered in Three.js with **UnrealBloom + a custom film-grain shader**, revealed by an on-load cinematic sequence. First a **preloader counter climbs 0 → 100** over a solid black overlay while the GLTF model loads; then a single **GSAP timeline** fades the counter and overlay out, **sweeps the camera 180° around** the corridor to its resting angle, and **scrambles the nav + heading text in** via randomized per-character opacity flickers. Once the intro finishes, the camera **follows the mouse** with a lerped parallax sway. The overlaid HTML text uses `mix-blend-mode: difference`, so it reads as black over the bright, bloom-blown white scene and inverts as the camera moves.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** and **`three` (npm)**. **No GSAP plugins** (no ScrollTrigger, no SplitText, no CustomEase — the text split is hand-rolled). No Lenis / no smooth-scroll (the page never scrolls). Import from three's examples/addons:
```js
import gsap from "gsap";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
```

## Layout / HTML
Four stacked layers over the canvas host. Fictional brand name is **"Astrolume"** (use it or neutral text — no real brands).
```html
<body>
  <div class="corridor"></div>            <!-- WebGL canvas is appended here by JS -->

  <div class="loading">Loading Scene</div> <!-- shown until GLTF finishes loading -->

  <div class="overlay">                     <!-- opaque black cover over the whole viewport -->
    <div class="counter"><p>0</p></div>     <!-- preloader number -->
  </div>

  <div class="hero">                        <!-- HTML UI layer, mix-blend-mode: difference -->
    <nav>
      <div class="logo"><a href="#">Astrolume</a></div>
      <div class="nav-items">
        <a href="#">Apparel</a><a href="#">Events</a><a href="#">Archive</a>
      </div>
      <div class="site-year"><p>2024 [N]</p></div>
    </nav>
    <h1>Blending contemporary minimalism with futuristic innovation to create designs that transcend trends and define elegance.</h1>
    <div class="footer"><p>/ Made by Astrolume</p></div>
  </div>

  <script type="module" src="./script.js"></script>
</body>
```

## Styling
- Reset `* { margin:0; padding:0; box-sizing:border-box; }`. `html, body { width:100%; height:100%; background-color:#0f0f0f; }` (the CSS body bg only shows for a blink; the WebGL scene background is white).
- `.corridor { position:absolute; top:0; left:0; width:100vw; height:100vh; }` — hosts `renderer.domElement`.
- `.loading { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); text-transform:uppercase; font-family:"PP Neue Montreal"; font-size:13px; color:#fff; }` (any clean sans fallback is fine).
- `.overlay { position:fixed; inset:0; width:100vw; height:100vh; background-color:#000; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:1em; }` — the black preloader cover. `.counter p { color:#fff; }`.
- `h1 { position:absolute; bottom:1em; left:1em; width:60%; text-transform:uppercase; font-family:"LomoCopy Lt Std"; color:#fff; user-select:none; }` — big display serif/display face bottom-left (any bold display fallback is fine).
- `p, a { text-decoration:none; text-transform:uppercase; font-family:"Akkurat Mono", monospace; font-size:12px; color:#fff; }` — small mono UI text.
- `.hero { position:absolute; inset:0; width:100vw; height:100vh; z-index:2; mix-blend-mode:difference; }` — **critical**: this is what makes the white UI text invert to black over the white/bloomed scene and shift as the camera pans.
- `nav { position:absolute; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; }`. `.nav-items { display:flex; gap:2em; }`. `.footer { position:absolute; right:2em; bottom:2em; }`.

## The effect (be exhaustive)

### Renderer / camera / scene
```js
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);            // bright white backdrop (bloom blows it out)
const camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ powerPreference:"high-performance", antialias:false, stencil:false, depth:false });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.NoToneMapping;
document.querySelector(".corridor").appendChild(renderer.domElement);
```

### Lights
- `AmbientLight(0xffffff, 0.5)`.
- `DirectionalLight(0xffffff, 0.5)` at `(5,8,5)`, `castShadow = true` (key).
- `DirectionalLight(0x000000, 0.5)` at `(-5,3,-5)` (black fill → effectively no fill, kept for parity).
- `PointLight(0xffffff, 2, 1)` at `(2,3,2)` and another at `(-2,3,-2)` (distance 1, so very localized hot spots).

### Camera orbit geometry (constants)
```js
const initialAngle = Math.PI / 4;      // 45° — the resting angle
const radius       = Math.sqrt(50);    // ≈ 7.071
let currentAngle   = initialAngle + Math.PI;  // start 180° opposite (≈225°)
let targetAngle    = initialAngle;
let currentY = 0, targetY = 0;
```
Camera always sits on a horizontal circle of `radius` around the origin and looks at `(0,0,0)`:
`camera.position.x = Math.cos(currentAngle)*radius; camera.position.z = Math.sin(currentAngle)*radius; camera.position.y = currentY; camera.lookAt(0,0,0);`
So the resting shot is `(5, 0, 5)` and the start shot is `(-5, 0, -5)`.

### GLTF load + material rebuild
Load a brutalist corridor GLTF (path `/c/brutalist-corridor/scene.gltf`). In the callback, `model.traverse` every mesh:
- `child.castShadow = child.receiveShadow = true`.
- Pick an **emissive color by mesh-name substring** from `{ screen:0x00ff00, lamp:0xffaa00, light:0xffffff, default:0xffffff }` (first key whose lowercase name is `includes`-matched wins; else `default`). This single-mesh corridor matches none, so all meshes get white — and emissiveIntensity is 0 anyway, so emissive is inert here; keep the logic for parity.
- Replace each material with:
```js
new THREE.MeshStandardMaterial({
  color: child.material.color,
  map: child.material.map,
  emissive: emissiveColor,
  emissiveIntensity: 0,
  roughness: 5.0,     // clamped to 1 internally → fully matte concrete
  metalness: 0.125,
});
```
- If there's a `map`: `map.encoding = THREE.sRGBEncoding; map.flipY = false;`.
- After traversal, **recenter** the model: `const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center);` then `scene.add(model)`.
- Hide `.loading` (`display:none`) and call `startAnimations()`.

### Hand-rolled text split (run once at module load, before the model finishes)
`splitText()` over `document.querySelectorAll("nav a, nav p, h1, .footer p")`. For each element: uppercase its text, clear it, then rebuild character-by-character:
- Space → `<span class="space">` with inline styles `display:inline-block; width:15px; opacity:0`.
- Any other char → `<span class="char">CHAR</span>` with `display:inline-block; opacity:0`.
Every glyph therefore starts invisible; the reveal animates these `.char`/`.space` spans.

### The intro GSAP timeline (`startAnimations`, fired after GLTF load)
`const timeline = gsap.timeline({ onComplete: () => { animationComplete = true; } });` — the `onComplete` flag is what later enables mouse parallax.

1. **Counter tween (0 → 100).** First build a `checkpoints` array of ascending integers: start `[0]`, `numJumps = 7`; loop while `checkpoints.length < 7` computing `maxJump = Math.floor((100 - currentValue)/(numJumps - checkpoints.length + 1)) * 2`, `jump = 5 + Math.floor(Math.random()*(maxJump - 5))`, `currentValue += jump`, and push it only if `< 97`; finally `push(97); push(100);`. Then:
```js
timeline.to({}, {
  duration: 4, ease: "none",
  onUpdate: function () {
    const p = this.progress();
    const idx = Math.floor(p * checkpoints.length);
    if (idx !== currentIndex && idx < checkpoints.length) { currentIndex = idx; counter.textContent = checkpoints[idx]; }
  },
  onComplete: function () { counter.textContent = "100"; },
});
```
So over 4s of linear time the number jumps through the random checkpoints and lands on 100.

2. **Fade the counter out:** `timeline.to(".counter", { opacity:0, duration:0.75, ease:"power2.out" }, "+=0.2");` (0.2s after the counter tween ends).

3. **Camera 180° sweep** (0.2s after the counter fades in the timeline sequence) via a proxy object so GSAP can ease the angle:
```js
const rotationProxy = { angle: currentAngle };      // ≈225°
timeline.to(rotationProxy, {
  angle: initialAngle,                               // → 45°
  duration: 2, ease: "power2.inOut",
  onUpdate: () => {
    currentAngle = rotationProxy.angle;
    camera.position.x = Math.cos(currentAngle)*radius;
    camera.position.z = Math.sin(currentAngle)*radius;
    camera.lookAt(0,0,0);
  },
}, "+=0.2");
```

4. **Overlay fade-out, concurrent with the sweep** (position `"<"` = start together): `timeline.to(overlay, { opacity:0, duration:1.5, ease:"power2.inOut", onComplete:()=>overlay.remove() }, "<");` — the black cover dissolves to reveal the corridor as the camera orbits.

5. **Text scramble-in** (added at `"-=1"`, i.e. overlapping the last second of the sweep) as a **nested timeline** returned from `timeline.add(fn, "-=1")`. Grab `const allChars = document.querySelectorAll(".char, .space")` and run three staggered opacity passes — all animate to `opacity:1`, `ease:"power2.inOut"`, with `from:"random"` staggers whose `yoyo/repeat` make characters flicker on/off before settling on:
```js
tl.to(allChars, { duration:0.1, opacity:1, ease:"power2.inOut", stagger:{ amount:1, each:0.1, from:"random", repeat:2, yoyo:true } });
tl.to(allChars, { duration:0.1, opacity:1, ease:"power2.inOut", stagger:{ amount:1, each:0.1, from:"random", repeat:1, yoyo:true } });
tl.to(allChars, { duration:0.15, opacity:1, ease:"power2.inOut", stagger:{ amount:1, each:0.2, from:"random" } });
```
The first two passes (with `yoyo:true` + `repeat`) make glyphs blink on and off in random order (a "boot-up / scramble" feel); the final pass with no yoyo latches them all fully visible.

### Post-processing chain
```js
const renderScene = new RenderPass(scene, camera);
const bloomPass   = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 2.0, 0.25, 0.5); // strength 2.0, radius 0.25, threshold 0.5
const composer    = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(filmGrainPass);   // renderToScreen = true
```
The strong bloom (strength 2.0) over the white background gives the corridor its hazy, overexposed sci-fi glow.

**Custom film-grain `ShaderPass`** (`filmGrainPass.renderToScreen = true`) — uniforms `{ tDiffuse:null, time:0, amount:0.15, speed:2.0, size:1.0 }`; passthrough vertex shader; fragment adds animated noise:
```glsl
float random(vec2 co){ return fract(sin(dot(co.xy, vec2(12.9898,78.233))) * 43758.5453); }
void main(){
  vec4 color = texture2D(tDiffuse, vUv);
  vec2 position = vUv * size;
  float grain = random(position * time * speed);
  color.rgb += grain * amount;          // additive grain
  gl_FragColor = color;
}
```

### Render loop + mouse parallax
```js
function lerp(a,b,t){ return a + (b-a)*t; }
function animate(){
  requestAnimationFrame(animate);
  filmGrainPass.uniforms.time.value = performance.now() * 0.001;   // grain always animates
  if (animationComplete) {                                         // parallax only after intro
    currentAngle = lerp(currentAngle, targetAngle, 0.025);
    currentY     = lerp(currentY, targetY, 0.025);
    camera.position.x = Math.cos(currentAngle)*radius;
    camera.position.z = Math.sin(currentAngle)*radius;
    camera.position.y = lerp(camera.position.y, currentY, 0.05);
  }
  camera.lookAt(0,0,0);
  composer.render();
}
animate();
```
Mouse handler (guarded by `if (!animationComplete) return;`): `mouseX = (clientX - innerWidth/2)/(innerWidth/2)`, `mouseY = (clientY - innerHeight/2)/(innerHeight/2)`, then `targetAngle = initialAngle + (-mouseX * 0.35)` (≈ ±0.35 rad horizontal sway) and `targetY = -mouseY * 1.5` (vertical rise/fall). The lerp factors (0.025 / 0.05) give the parallax a heavy, floaty follow.

### Resize
On `window.resize`: `camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); composer.setSize(innerWidth, innerHeight);`.

## Assets / images
No photographic images. The only asset is a **single GLTF model of a brutalist concrete corridor / interior** (`scene.gltf` + `scene.bin` + a `textures/` folder), one mesh, one PBR material named "Concrete Tiles" with four maps: **baseColor** (grey cast-concrete tiles), **metallicRoughness**, **normal**, and an **emissive** map. Point the loader at `/c/brutalist-corridor/scene.gltf`. Any comparable interior/corridor GLTF with concrete tile textures works; the material is rebuilt to matte (`roughness 5.0`, `metalness 0.125`) so surface detail comes from the normal/baseColor maps, not gloss. The scene is auto-centered at the origin, so the exact model bounds don't matter.

## Behavior notes
- **Autoplay once on load.** Sequence: "Loading Scene" until the GLTF resolves → counter 0→100 (~4s) → counter fade → 2s camera sweep with concurrent overlay dissolve → text scramble-in (overlapping the last 1s) → `animationComplete = true`. Full intro is roughly 8–9s.
- **Mouse parallax is enabled only after the intro** completes; before that, `mousemove` is ignored.
- **Desktop-oriented, WebGL-heavy** (GLTF + UnrealBloom + custom shader pass): not mobile-safe; the parallax is pointer-driven. No touch fallback in the original.
- **No scroll, no scroll hijack, no ScrollTrigger** — the page is a single fixed viewport.
- No reduced-motion branch in the original; grain + parallax run continuously via `requestAnimationFrame`.
