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:
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).
<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; }— hostsrenderer.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
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)
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 isincludes-matched wins; elsedefault). 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:
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);thenscene.add(model). - Hide
.loading(display:none) and callstartAnimations().
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 stylesdisplay:inline-block; width:15px; opacity:0. - Any other char →
<span class="char">CHAR</span>withdisplay: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.
- Counter tween (0 → 100). First build a
checkpointsarray of ascending integers: start[0],numJumps = 7; loop whilecheckpoints.length < 7computingmaxJump = 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; finallypush(97); push(100);. Then:
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.
- Fade the counter out:
timeline.to(".counter", { opacity:0, duration:0.75, ease:"power2.out" }, "+=0.2");(0.2s after the counter tween ends).
- Camera 180° sweep (0.2s after the counter fades in the timeline sequence) via a proxy object so GSAP can ease the angle:
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");
- 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.
- Text scramble-in (added at
"-=1", i.e. overlapping the last second of the sweep) as a nested timeline returned fromtimeline.add(fn, "-=1"). Grabconst allChars = document.querySelectorAll(".char, .space")and run three staggered opacity passes — all animate toopacity:1,ease:"power2.inOut", withfrom:"random"staggers whoseyoyo/repeatmake characters flicker on/off before settling on:
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
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:
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
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,
mousemoveis 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.