类型:Site模板 · 收录:2026-11-05 01:13:02 · 源码:会员可见
# Recreate this site as a single HTML file: Clarix
You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, sections, visuals, motion, and
interaction. Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. Use ES modules
with a CDN importmap for the libraries actually used (Three.js r0.184 and Lenis r1.3 from unpkg).
Hardcode every value given here as a fixed constant. All the CSS below lives in one `<style>` block
in `<head>`; the script in one `<script type="module">` block before `</body>`.
## What it is
A single-page, white-background AI-agency landing site ("IrisAI") built around **one continuous,
cinematic scroll** that is `2800vh` tall. The whole experience is choreographed against a single
normalized scroll progress `0 → 1`. Two stacked full-screen WebGL canvases drive the visuals: a
**fixed background canvas** running a procedural animated "ASCII grid" shader (rounded-box cells
that bloom in a rainbow gradient and react to the mouse), and a **fixed foreground canvas** holding
a dark 3D model wrapped in a holographic procedural shader that itself is dithered into an ASCII
half-tone, plus a particle system that assembles the IrisAI logo out of thousands of points.
The flow: an **odometer preloader** counts `000 → 100` in stepped jumps while a wobbling blurred
blob pulses on the model's spot; at 100 the blob shatters outward and the hero reveals — the title
flips up letter-by-letter (3D rotateX + blur), and the model is carved into view by an expanding,
glowing screen-space reveal circle. Then as you scroll: the model drops and spins into frame; giant
HTML "flying words" (Tools / kill / growth) sweep across in 3D perspective; a frosted-glass stats
panel (gradient image + 200+ / 97% / 10X) slides up and away; a split feature block fades in; the
camera flies *into* the scene as the logo particles spin-assemble; the assembled logo holds, then
shatters as the model flies back to center for a finale title ("The future of digital art"); and a
glassmorphism footer slides up. Palette: white `#ffffff` / near-white `#f6fafe` page, black text, and
a five-stop rainbow accent — blue `#0091ff`, cyan `#00aaff`, purple `#8000ff`, hot-pink `#ff0099`,
red `#ff0000`. Font: Google "Sarabun" (weights 200/400, italic 400).
## Page shell & libraries
In `<head>`:
```html
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D Model</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Sarabun:ital,wght@0,200;0,400;1,400&display=swap" rel="stylesheet">
```
Importmap + module entry before `</body>`:
```html
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.184.0/build/three.module.js",
"three/examples/jsm/": "https://unpkg.com/three@0.184.0/examples/jsm/",
"lenis": "https://unpkg.com/lenis@1.3.23/dist/lenis.mjs"
}
}
</script>
<script type="module"> /* all the JS below */ </script>
```
Module imports actually used:
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import Lenis from 'lenis';
```
(The original also imports SVGLoader and MeshSurfaceSampler but they are unused — omit them. There
is no control-panel library; omit it entirely.)
### Lenis smooth scroll
```js
const lenis = new Lenis({
duration: 1.5,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
});
```
Lenis is driven from inside the rAF loop with `lenis.raf(time)` (see Animation loop). Native scroll
is what the `scroll` listener reads, so do NOT pass `smoothWheel:false`; let Lenis run normally.
### Global resets & base CSS (verbatim — put in the head `<style>`)
```css
/* Hide scrollbar for WebKit browsers */
::-webkit-scrollbar { display: none; }
html {
/* Base 16px at 1920px width. Clamps at 12px for smaller screens to maintain readability */
font-size: max(12px, 0.833333vw);
}
body {
margin: 0;
overflow-x: hidden;
-ms-overflow-style: none;
scrollbar-width: none;
background-color: #ffffff;
height: 2800vh; /* Long cinematic scroll */
}
.preload-hidden { opacity: 0 !important; pointer-events: none !important; }
.reveal-anim {
transition: transform 1.6s cubic-bezier(0.2, 0.8, 0.2, 1) 0.6s, opacity 1.6s ease 0.6s;
will-change: transform, opacity;
}
.preload-hidden.reveal-anim.from-top { transform: translateY(-20px); }
.preload-hidden.reveal-anim.from-bottom { transform: translateY(20px); }
canvas { display: block; }
```
The two WebGL canvases are created in JS and appended to `<body>` as `position:fixed; top:0; left:0`
elements. Background canvas `z-index:1`, foreground canvas `z-index:3`. The hero title sits at
`z-index:2` (between them) so it reads as embedded in the scene.
## Layout & sections (in order)
All text uses `font-family: 'Sarabun', sans-serif` unless noted. Copy is verbatim — reproduce
exactly including punctuation and `<br>` placement.
### CSS for all DOM overlays (verbatim)
```css
.hero-logo { position: absolute; top: 2.5rem; left: 3.125rem; z-index: 5; height: 1.75rem; width: auto; }
.hero-title {
position: absolute; top: 43%; transform: translateY(-50%); left: 3.125rem;
font-family: 'Sarabun', sans-serif; font-weight: 200;
font-size: max(2.5rem, 7vw); color: #000000; margin: 0; z-index: 2;
pointer-events: none; line-height: 1.0; white-space: nowrap;
}
.char-mask {
display: inline-block; overflow: hidden; vertical-align: bottom;
padding-top: 0.2em; margin-top: -0.2em;
padding-bottom: 1.2em; margin-bottom: -1.2em; perspective: 500px;
}
.hero-char {
display: inline-block;
transform: translateY(110%) translateZ(-60px) rotateX(-65deg);
transform-origin: bottom center; filter: blur(14px); opacity: 0;
will-change: transform, filter, opacity;
transition: transform 1.6s cubic-bezier(0.2, 0.8, 0.2, 1), filter 1.6s ease, opacity 1.2s ease;
}
.hero-char.revealed { transform: translateY(0) translateZ(0) rotateX(0deg); filter: blur(0px); opacity: 1; }
/* Non-hero titles animate much faster for snappy scrolling */
.animated-title .hero-char {
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1), filter 0.8s ease, opacity 0.6s ease;
}
.hero-nav {
position: absolute; top: 2.5rem; right: 3.125rem; z-index: 5;
font-family: 'Sarabun', sans-serif; font-weight: 400;
display: flex; flex-direction: column; align-items: flex-end; gap: 0.75rem;
}
.hero-nav a {
color: #000000; text-decoration: none; font-size: 1.1rem; font-weight: 400;
text-transform: uppercase; display: flex; align-items: center; gap: 0.5rem;
opacity: 1; transition: transform 0.2s ease; cursor: pointer;
}
.hero-nav a:hover { transform: translateX(-4px); }
.hero-nav a .nav-arrow { font-size: 1.2rem; font-weight: 400; line-height: 1; }
.hero-subtitle { font-weight: 400; color: #000000; }
.hero-info-block {
position: absolute; bottom: 3.125rem; left: 3.125rem; z-index: 5;
font-family: 'Sarabun', sans-serif; color: #000000; width: 20rem;
display: flex; flex-direction: column;
}
.hi-top { display: flex; justify-content: space-between; font-size: 1.1rem; font-weight: 400; text-transform: uppercase; margin-bottom: 2rem; }
.hi-text { font-size: 1.0625rem; line-height: 1.5; opacity: 0.7; margin-bottom: 2.5rem; }
.hi-btn {
align-self: flex-start; padding: 0.875rem 3rem; border: 1px solid #000000;
border-radius: 2.5rem; background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
color: #000; text-decoration: none; font-size: 0.95rem; font-weight: 400;
letter-spacing: 1px; transition: background 0.2s, transform 0.2s;
}
.hi-btn:hover { background: rgba(255, 255, 255, 0.3); transform: translateY(-2px); }
.scroll-content-wrapper {
position: absolute; top: calc(100vh + 34.375rem); left: 0; width: 100%;
z-index: 2; display: flex; flex-direction: column;
}
.scroll-title-container { padding-left: 3.125rem; margin-bottom: 37.5rem; }
.scroll-title-container h3 {
font-family: 'Sarabun', sans-serif; font-weight: 200; font-size: max(2.625rem, 5.5vw);
letter-spacing: -0.02em; white-space: nowrap; color: #000; margin: 0; line-height: 1.1;
}
.clients-container { width: 100%; display: flex; flex-direction: column; margin-bottom: 37.5rem; }
.clients-subtitle { font-family: 'Sarabun', sans-serif; font-size: 1.1rem; font-weight: 400; text-transform: uppercase; padding-left: 3.125rem; margin-bottom: 1rem; color: #000; }
.clients-line {
height: 1px; margin-left: 3.125rem; margin-right: 3.125rem;
background: linear-gradient(to right, #0091ff, #00aaff, #8000ff, #ff0099, #ff0000);
margin-bottom: 3rem;
}
.marquee-container { width: 100%; overflow: hidden; white-space: nowrap; filter: grayscale(100%); opacity: 0.8; }
.marquee-content { display: inline-flex; align-items: center; gap: 6rem; padding-right: 6rem; animation: marquee 25s linear infinite; }
@keyframes marquee { 0% { transform: translateX(-50%); } 100% { transform: translateX(0); } }
.marquee-logo { height: 3.75rem; width: auto; object-fit: contain; }
.feature-container { width: 100%; display: flex; flex-direction: column; }
.feature-title {
font-family: 'Sarabun', sans-serif; font-weight: 200; font-size: max(2.625rem, 5.5vw);
letter-spacing: -0.02em; white-space: nowrap; color: #000; margin: 0; line-height: 1.1;
}
.split-feature-container {
width: 100%; display: flex; flex-direction: column;
padding-left: 3.125rem; padding-right: 3.125rem; box-sizing: border-box; margin-top: 37.5rem;
}
.pill-badge {
display: inline-block; font-family: 'Sarabun', sans-serif; font-size: max(0.875rem, 1vw);
font-weight: 400; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 50px;
padding: 0.3rem 1.2rem; color: #000;
}
.split-bottom { display: flex; flex-direction: column; align-items: flex-start; max-width: 45rem; }
.feature-text { font-family: 'Sarabun', sans-serif; font-size: max(1.1rem, 1.25vw); line-height: 1.5; font-weight: 300; color: #000; opacity: 0.8; }
.phase4-container {
position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 5;
pointer-events: none; display: flex; align-items: flex-end; justify-content: flex-end;
opacity: 0; transform: translateY(50px);
}
.phase4-container .split-feature-container {
pointer-events: auto; margin-top: 0; padding-top: 4rem; padding-bottom: 4rem;
height: 100vh; justify-content: space-between; width: auto; align-items: flex-start;
}
.phase5-container { position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 4; pointer-events: none; opacity: 0; }
.phase5-title-left { position: absolute; top: 2.5rem; left: 3.125rem; text-align: left; margin: 0; }
.phase5-title-right { position: absolute; bottom: 2.5rem; right: 3.125rem; text-align: right; margin: 0; }
.phase5-desc-left { position: absolute; bottom: 2.5rem; left: 3.125rem; text-align: left; max-width: 25rem; margin: 0; }
.phase5-label-right { position: absolute; top: 2.5rem; right: 3.125rem; width: 12rem; margin: 0; }
.phase6-container { position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 2; pointer-events: none; opacity: 0; display: flex; align-items: center; justify-content: center; }
.phase6-title {
position: absolute; top: 30vh; transform: translateY(-50%); left: 0; width: 100%;
font-family: 'Sarabun', sans-serif; font-weight: 200; font-size: max(2.5rem, 7vw);
color: #000; margin: 0; line-height: 1.0; text-align: center; white-space: nowrap; pointer-events: none;
}
.phase6-footer-wrapper { position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 4; pointer-events: none; transform: translateY(100vh); }
.phase6-footer-wrapper * { pointer-events: auto; }
.glass-footer {
position: absolute; bottom: 2.5rem; left: 3.125rem; right: 3.125rem; padding: 3rem;
background: rgba(255, 255, 255, 0.4); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-radius: 2rem; border: 1px solid rgba(255, 255, 255, 0.5); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.05);
display: flex; flex-direction: column; color: #000; font-family: 'Sarabun', sans-serif;
}
.footer-top { display: flex; justify-content: space-between; }
.footer-left { width: 35%; display: flex; flex-direction: column; }
.footer-heading { font-size: 1rem; font-weight: 500; margin-bottom: 1.5rem; }
.footer-input-group { display: flex; background: rgba(255, 255, 255, 0.6); border-radius: 2rem; padding: 0.3rem; margin-bottom: 1rem; border: 1px solid rgba(0, 0, 0, 0.1); max-width: 22rem; }
.footer-input { flex: 1; background: transparent; border: none; outline: none; padding-left: 1rem; font-family: 'Sarabun', sans-serif; font-size: 0.9rem; }
.footer-submit { background: #000; color: #fff; border: none; border-radius: 2rem; padding: 0.6rem 1.5rem; font-family: 'Sarabun', sans-serif; cursor: pointer; transition: transform 0.2s; }
.footer-submit:hover { transform: scale(1.05); }
.footer-subtext { font-size: 0.75rem; opacity: 0.6; line-height: 1.4; max-width: 22rem; }
.footer-right { display: flex; gap: 5rem; padding-right: 2rem; }
.footer-col { display: flex; flex-direction: column; gap: 1rem; }
.footer-col-title { font-size: 0.9rem; font-weight: 500; margin-bottom: 0.5rem; }
.footer-col a { color: #000; opacity: 0.7; text-decoration: none; font-size: 0.85rem; transition: opacity 0.2s; }
.footer-col a:hover { opacity: 1; }
.footer-line { height: 1px; background: rgba(0, 0, 0, 0.1); margin-top: 3rem; margin-bottom: 1.5rem; }
.footer-bottom { text-align: center; font-size: 0.8rem; opacity: 0.6; }
.words-container { position: fixed; top: 0; left: 0; width: 100%; height: 100vh; perspective: 1500px; z-index: 2; pointer-events: none; }
.flying-word {
position: absolute; top: 50%; left: 50%; font-family: 'Sarabun', sans-serif; font-weight: 400;
font-size: max(80px, 25vw); color: #000000; margin: 0; padding: 0 0.2em; opacity: 0;
white-space: nowrap; transform: translate3d(-50%, -50%, 0) rotateY(0deg);
}
.flying-word .char { display: inline-block; padding: 0 0.15em; margin: 0 -0.15em; }
.glass-container {
position: fixed; bottom: 0; left: 0; width: 100%; height: 100vh;
background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
border-top: 1px solid rgba(255, 255, 255, 0.4); z-index: 10; transform: translateY(100vh); pointer-events: none;
}
.glass-gradient-wrapper {
position: absolute; top: 50%; left: 50%; width: calc(100% - 8rem); height: calc(100% - 8rem);
transform: translate(-50%, -50%) scale(0.65); border-radius: 0px; overflow: hidden; pointer-events: auto;
}
.glass-gradient-img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; opacity: 0.5; }
.gradient-content {
position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2; padding: 2rem;
box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between;
color: #000000; font-family: 'Sarabun', sans-serif;
}
.gc-top-left { font-size: max(2.625rem, 5.5vw); font-weight: 200; line-height: 1.1; letter-spacing: -0.02em; color: #000000; max-width: none; white-space: nowrap; }
.gc-bottom { display: flex; justify-content: space-between; align-items: flex-end; }
.gc-bottom-left { font-size: 14px; font-weight: 400; opacity: 0.8; }
.gc-bottom-right { display: flex; gap: 3.75rem; align-items: flex-end; }
.stat-col { position: relative; padding-left: 20px; display: flex; flex-direction: column; gap: 16px; }
.stat-col-tall { margin-bottom: 120px; }
.stat-col::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 1px;
background: linear-gradient(to bottom, rgba(0,0,0,0.0), rgba(0,0,0,0.2) 10%, rgba(0,0,0,0.2) 90%, rgba(0,0,0,0.0));
}
.stat-col-tall::before { bottom: -120px; }
.stat-num { font-size: max(42px, 5.5vw); font-weight: 200; line-height: 1; }
.stat-desc { font-size: 13px; line-height: 1.5; opacity: 0.85; }
.stat-sub { opacity: 0.5; font-size: 11px; }
.stat-btn {
width: 44px; height: 44px; border-radius: 50%; background: rgba(0, 0, 0, 0.05);
display: flex; align-items: center; justify-content: center; margin-top: 8px; cursor: pointer;
transition: background 0.2s; border: 1px solid rgba(0,0,0,0.1);
}
.stat-btn:hover { background: rgba(0, 0, 0, 0.15); }
```
### DOM body (verbatim — route images through ASSET_BASE_URL, see Assets)
```html
<!-- PRELOADER ODOMETER -->
<div id="preloader" style="position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 9999; pointer-events: none; transition: opacity 1s ease;">
<div style="position: absolute; top: 2.5rem; left: 3.125rem; color: #000; font-family: 'Sarabun', sans-serif; font-weight: 200; font-size: 4rem; letter-spacing: -0.02em; line-height: 1;">
[
<div style="height: 4rem; overflow: hidden; position: relative; width: 6.5rem; display: inline-block; vertical-align: top;">
<div id="odometer-numbers" style="position: absolute; top: 0; left: 0; width: 100%; transition: transform 0.05s linear; text-align: center;"></div>
</div>
/ 100 ]
</div>
</div>
<img src="ASSET(logo.svg)" class="hero-logo preload-hidden reveal-anim from-top" alt="IrisAI Logo" />
<h1 class="hero-title" id="animated-hero-title">The clarity your business<br><span class="hero-subtitle">has been missing</span></h1>
<div class="hero-nav preload-hidden reveal-anim from-top">
<a href="#">home <span class="nav-arrow">↗</span></a>
<a href="#">plans <span class="nav-arrow">↗</span></a>
<a href="#">services <span class="nav-arrow">↗</span></a>
<a href="#">contact us <span class="nav-arrow">↗</span></a>
</div>
<div class="hero-info-block preload-hidden reveal-anim from-bottom">
<div class="hi-top"><span>( A )</span><span>[ 001 /004 ]</span></div>
<div class="hi-text">IrisAI is where your boldest ideas meet their full potential. We give you the tools not just to see the future.</div>
<a href="#" class="hi-btn">GET STARTED</a>
</div>
<div class="scroll-content-wrapper">
<div class="scroll-title-container" id="scroll-title">
<h3 class="animated-title flow-title">Empowering visionaries<br>to shape tomorrow's<br><span style="font-weight: 500;">digital landscapes</span></h3>
</div>
<div class="clients-container">
<div class="clients-subtitle">OUR CLIENTS</div>
<div class="clients-line"></div>
<div class="marquee-container">
<div class="marquee-content">
<!-- Two identical sets of 11 logos so the -50%→0 loop is seamless -->
<!-- Set 1 and Set 2, each: Discord, Facebook, Snapchat, Spotify, TikTok, WhatsApp, YouTube, dribbble, ebay, instagram, twitter -->
<img class="marquee-logo" src="ASSET(companies/Discord.png)" alt="Discord"> ... (repeat all 11, twice)
</div>
</div>
</div>
<div class="feature-container" style="align-items: flex-end; padding-right: 3.125rem; padding-left: 3.125rem; box-sizing: border-box;">
<h3 class="feature-title animated-title flow-title" style="text-align: right;">Redefining digital experiences<br>through art and<br><span style="font-weight: 500;">technology</span></h3>
</div>
</div>
<div class="phase4-container" id="phase4-container" style="font-family: 'Sarabun', sans-serif;">
<div class="split-feature-container">
<div class="hi-top" style="width: 100%; min-width: 15rem; max-width: 20rem; margin-bottom: 0;">
<span>( A )</span><span>[ 001 /004 ]</span>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-start;">
<div class="split-top" style="text-align: left; display: inline-block;">
<h3 class="feature-title animated-title" id="phase4-title" style="text-align: left; margin-top: 0;">Iris unlocks a faster,<br><span style="font-weight: 500;">smarter way to create</span></h3>
<div class="clients-line" style="margin-top: 3rem; margin-bottom: 3rem; width: 100%; margin-left: 0; margin-right: 0;"></div>
</div>
<div class="split-bottom" style="align-items: flex-start;">
<div class="hi-text" style="text-align: left; max-width: 30rem;">With just a prompt, you can bring any idea to life instantly, cut down production time, eliminate repetitive tasks, and stay fully in control of your creative vision.</div>
<a href="#" class="hi-btn" style="margin-top: 1.5rem; text-transform: uppercase;">LAUNCH NOW</a>
</div>
</div>
</div>
</div>
<div class="words-container">
<div class="flying-word" id="word1"><span class="char">T</span><span class="char">o</span><span class="char">o</span><span class="char">l</span><span class="char">s</span></div>
<div class="flying-word" id="word2"><span class="char">k</span><span class="char">i</span><span class="char">l</span><span class="char">l</span></div>
<div class="flying-word" id="word3"><span class="char">g</span><span class="char">r</span><span class="char">o</span><span class="char">w</span><span class="char">t</span><span class="char">h</span></div>
</div>
<div class="glass-container" id="glass-container">
<div id="glass-gradient" class="glass-gradient-wrapper">
<img src="ASSET(gradient.png)" class="glass-gradient-img" alt="Gradient" />
<div class="gradient-content">
<div class="gc-top-left animated-title" id="phase3-title">Every project is a fusion of strategy,<br>creativity and passion</div>
<div class="gc-bottom">
<div class="gc-bottom-left">Innovation philosophy</div>
<div class="gc-bottom-right">
<div class="stat-col stat-col-tall">
<div class="stat-num">200+</div>
<div class="stat-desc">universes created<br><span class="stat-sub">*or maybe more</span></div>
<div class="stat-btn">→</div>
</div>
<div class="stat-col">
<div class="stat-num">97%</div>
<div class="stat-desc">clients trust us for their<br>next project</div>
<div class="stat-btn">→</div>
</div>
<div class="stat-col">
<div class="stat-num">10X</div>
<div class="stat-desc">team turnover<br><span class="stat-sub">*or maybe more</span></div>
<div class="stat-btn">→</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="phase5-container" id="phase5-container" style="font-family: 'Sarabun', sans-serif;">
<div class="hi-top phase5-label-right"><span>( A )</span><span>[ 001 /004 ]</span></div>
<h3 class="feature-title phase5-title-left animated-title" id="phase5-title-1">The pinnacle of generative art</h3>
<div class="hi-text phase5-desc-left">Experience a new paradigm where your imagination meets our computational engine. Build entire worlds without writing a single line of code.</div>
<h3 class="feature-title phase5-title-right animated-title" id="phase5-title-2">Designed for<br><span style="font-weight: 500;">visionaries</span></h3>
</div>
<div class="phase6-container" id="phase6-container">
<h3 class="phase6-title animated-title" id="phase6-title">The future of<br><span style="font-weight: 400;">digital art</span></h3>
</div>
<div class="phase6-footer-wrapper" id="phase6-footer">
<div class="glass-footer">
<div class="footer-top">
<div class="footer-left">
<h2 style="margin: 0 0 2rem 0; font-weight: 500; font-size: 1.5rem; letter-spacing: -0.02em;">IrisAI</h2>
<div class="footer-heading">Sign up to receive updates.</div>
<div class="footer-input-group">
<input type="email" placeholder="Enter your email" class="footer-input">
<button class="footer-submit">Submit</button>
</div>
<div class="footer-subtext">By subscribing you agree to our Privacy Policy and provide consent to receive updates from our company.</div>
</div>
<div class="footer-right">
<div class="footer-col"><div class="footer-col-title">Products</div><a href="#">Studio</a><a href="#">Engine</a><a href="#">Enterprise</a></div>
<div class="footer-col"><div class="footer-col-title">Learn</div><a href="#">Blog</a><a href="#">Research</a><a href="#">Documentation</a></div>
<div class="footer-col"><div class="footer-col-title">About</div><a href="#">Company</a><a href="#">Careers</a><a href="#">Contact</a></div>
<div class="footer-col"><div class="footer-col-title">Legal</div><a href="#">Terms & Conditions</a><a href="#">Privacy Policy</a></div>
</div>
</div>
<div class="footer-line"></div>
<div class="footer-bottom">© 2026 IrisAI, All rights reserved.</div>
</div>
</div>
```
## The WebGL scene
### Two scenes, two renderers, camera, lights, controls (verbatim)
```js
const bgScene = new THREE.Scene();
bgScene.background = new THREE.Color(0xf6fafe);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1, 5);
const rendererBg = new THREE.WebGLRenderer({ antialias: true });
rendererBg.setSize(window.innerWidth, window.innerHeight);
rendererBg.setPixelRatio(window.devicePixelRatio);
rendererBg.domElement.style.position = 'fixed';
rendererBg.domElement.style.top = '0';
rendererBg.domElement.style.left = '0';
rendererBg.domElement.style.zIndex = '1';
document.body.appendChild(rendererBg.domElement);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.style.position = 'fixed';
renderer.domElement.style.top = '0';
renderer.domElement.style.left = '0';
renderer.domElement.style.zIndex = '3';
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enabled = false;
controls.enableDamping = true;
controls.dampingFactor = 0.05;
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.0);
directionalLight.position.set(5, 10, 5);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight2.position.set(-5, -5, -5);
scene.add(directionalLight2);
```
### Shared uniforms (verbatim)
```js
let mixer;
const clock = new THREE.Clock();
const targetMouse = new THREE.Vector2(0.5, 0.5);
const _revealVec = new THREE.Vector3();
const shaderUniforms = {
time: { value: 0 },
mouse: { value: new THREE.Vector2(0.5, 0.5) },
resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
bgColor: { value: new THREE.Color(0xf6fafe) },
colorCyan: { value: new THREE.Color(0x00aaff) },
colorPurple: { value: new THREE.Color(0x8000ff) },
colorBlue: { value: new THREE.Color(0x0091ff) },
colorPeach: { value: new THREE.Color(0xff0000) },
colorHotPink: { value: new THREE.Color(0xff0099) },
edgeBlurAmount: { value: 0.5 },
uRevealProgress: { value: 0.0 },
uRevealOrigin: { value: new THREE.Vector2(0.5, 0.5) }
};
```
(There is no GUI/control panel — drop it entirely; the constants it once edited are baked above and
in the model placement below: `phase4X = -1.35`, `phase4Y = -5.4`, `phase4Z = 5.67`,
`phase4Scale = 0.85`, `phase4RotX = 0`, `phase4RotY = 0.6723`, `phase4RotZ = 0`. Store these in a
plain object `const guiParams = { phase4X:-1.35, phase4Y:-5.4, phase4Z:5.67, phase4Scale:0.85,
phase4RotX:0, phase4RotY:0.6723, phase4RotZ:0 };`.)
### A. Background ASCII grid (full-screen shader plane in `bgScene`)
A `PlaneGeometry(2,2)` mesh whose vertex shader writes clip-space directly so it ignores the camera;
rendered by `rendererBg`. `depthWrite:false, depthTest:false`, `renderOrder = -1`,
`frustumCulled = false`.
```js
const bgGeo = new THREE.PlaneGeometry(2, 2);
const bgMat = new THREE.ShaderMaterial({
uniforms: {
time: shaderUniforms.time, mouse: shaderUniforms.mouse, bgColor: shaderUniforms.bgColor,
resolution: shaderUniforms.resolution, colorCyan: shaderUniforms.colorCyan,
colorBlue: shaderUniforms.colorBlue, colorHotPink: shaderUniforms.colorHotPink, colorPeach: shaderUniforms.colorPeach
},
vertexShader: `...`, fragmentShader: `...`, depthWrite: false, depthTest: false
});
const bgMesh = new THREE.Mesh(bgGeo, bgMat);
bgMesh.renderOrder = -1; bgMesh.frustumCulled = false;
bgScene.add(bgMesh);
```
Vertex shader (verbatim):
```glsl
varying vec2 vUv;
void main() {
vUv = uv;
// Lock the plane to the screen completely ignoring the camera
gl_Position = vec4(position.xy, 1.0, 1.0);
}
```
Fragment shader (verbatim):
```glsl
uniform float time;
uniform vec2 mouse;
uniform vec3 bgColor;
uniform vec2 resolution;
uniform vec3 colorCyan;
uniform vec3 colorBlue;
uniform vec3 colorHotPink;
uniform vec3 colorPeach;
varying vec2 vUv;
// 2D Rotation for organic noise
mat2 rotate2d(float _angle){
return mat2(cos(_angle),-sin(_angle),
sin(_angle),cos(_angle));
}
void main() {
float pixelSize = 15.0;
vec2 screenUv = vUv;
float aspect = resolution.x / resolution.y;
vec2 aspectUv = vec2(screenUv.x * aspect, screenUv.y);
vec2 gridUv = aspectUv * (resolution.y / pixelSize);
vec2 localUv = fract(gridUv) - 0.5;
// --- 1. Procedural Background Colors ---
float wave1 = sin(aspectUv.x * 4.0 - time * 0.8);
float wave2 = cos(aspectUv.y * 3.0 + time * 0.6);
float combinedWave = smoothstep(-0.8, 0.8, (wave1 + wave2) * 0.5);
float wavePeach = sin(aspectUv.y * 3.0 - aspectUv.x * 2.0 + time * 0.9) * 0.5 + 0.5;
vec3 mixColor = mix(colorBlue, colorCyan, combinedWave);
vec3 pinkTransition = mix(mixColor, colorHotPink, smoothstep(0.1, 0.7, wavePeach));
mixColor = mix(pinkTransition, colorPeach, smoothstep(0.5, 1.0, wavePeach));
vec3 lightGridColor = mix(mixColor, vec3(1.0), 0.6);
// --- 2. ASCII Grid ---
float boxSize = 0.3 + sin(time * 2.0 + gridUv.x * 0.2 + gridUv.y * 0.2) * 0.1;
float radius = 0.12;
float d = length(max(abs(localUv) - (boxSize - radius), 0.0)) - radius;
float alpha = smoothstep(0.05, 0.0, d);
// --- 3. Interactive Organic Masking ---
// Parallax radial mask: center shifts slightly towards the mouse
vec2 radialCenter = mix(vec2(0.5, 0.5), mouse, 0.3);
float radialMask = smoothstep(0.7, 0.1, distance(screenUv, radialCenter));
vec2 p = aspectUv * 3.0;
vec2 mouseAspect = vec2(mouse.x * aspect, mouse.y);
float mouseDist = distance(aspectUv, mouseAspect);
// Multi-layered rotated sine waves for blobs
float noise = 0.0;
noise += sin(p.x + time * 0.4) * sin(p.y + time * 0.3);
p *= rotate2d(1.1);
noise += sin(p.x * 1.5 - time * 0.5) * sin(p.y * 1.5 + time * 0.2);
p *= rotate2d(2.3);
noise += sin(p.x * 2.0 + time * 0.3) * sin(p.y * 2.0 - time * 0.4);
// Dynamic shape morphing instead of space warping (prevents stretched ray artifacts)
// Creates an organic ripple/push effect that morphs the blobs near the cursor
noise += cos(mouseDist * 12.0 - time * 3.0) * smoothstep(0.6, 0.0, mouseDist) * 1.2;
float blobMask = smoothstep(0.0, 1.5, noise);
// Extra interactive element: reveal ASCII grid brightly right at the cursor
float cursorHighlight = smoothstep(0.2, 0.0, mouseDist);
float finalMask = clamp((radialMask * blobMask) + (cursorHighlight * 0.5), 0.0, 1.0);
float finalAlpha = alpha * finalMask * 0.85;
vec3 finalColor = mix(bgColor, lightGridColor, finalAlpha);
gl_FragColor = vec4(finalColor, 1.0);
}
```
### B. The 3D model + holographic ASCII shader
Load via GLTFLoader + DRACOLoader (DRACO decoder from gstatic):
```js
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/');
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load('ASSET(model.glb)', (gltf) => { /* … */ });
```
On load (verbatim logic): auto-center+scale the model so its max dimension becomes 5 units
(`scale = 5 / maxDim`, save `window.baseModelScale = scale`), re-center via the scaled bounding box,
then override placement:
```js
model.position.set(0.1, -3.93, 4.17);
model.rotation.set(-0.05026, -0.92781, -0.12566);
window.mainModel = model;
window.mainModel.visible = false; // hidden until preloader finishes
camera.position.set(-1.8, -1.0, 6.5);
camera.lookAt(-1.8, 0.5, 0);
controls.target.set(-1.8, 0.5, 0);
controls.update();
```
Every mesh gets a `MeshPhysicalMaterial` and the holographic onBeforeCompile shader patch:
```js
const baseMat = new THREE.MeshPhysicalMaterial({
color: 0x010103, metalness: 0.9, roughness: 0.2, clearcoat: 1.0, clearcoatRoughness: 0.1,
transparent: true, opacity: 0.95, side: THREE.DoubleSide
});
window.applyHolographicShader(baseMat);
child.material = baseMat;
```
After adding the model to `scene`, save `window.mainModelMaterial` (one mesh's material) for the
fade logic. Set up the animation mixer if `gltf.animations.length > 0` (play every clip).
`window.applyHolographicShader` injects uniforms (all the shared ones above) and patches the
material's GLSL. Vertex patch: prepend `varying vec3 vLocalPosition;` and after
`#include <begin_vertex>` add (verbatim):
```glsl
#ifdef USE_INSTANCING
vLocalPosition = (instanceMatrix * vec4(position, 1.0)).xyz;
#else
vLocalPosition = position;
#endif
```
Fragment patch: prepend the uniform/varying block, then replace `#include <dithering_fragment>`
with `#include <dithering_fragment>` followed by this block (verbatim):
```glsl
vec3 vDir = normalize( vViewPosition );
float fresnel = 1.0 - max(dot(vDir, normal), 0.0);
float fresnelPow = pow(fresnel, 2.0); // Softer fresnel falloff
// Procedural waves (softer, slower)
// Bind to local position so waves stick to the geometry during rotation
float wave1 = sin(vLocalPosition.x * 2.0 - time * 1.0);
float wave2 = cos(vLocalPosition.y * 1.5 + time * 0.8);
float wave3 = sin(vLocalPosition.z * 2.5 + time * 0.5);
float combinedWave = (wave1 + wave2 + wave3) / 3.0;
combinedWave = smoothstep(-0.5, 0.5, combinedWave); // Softer mixing
float wavePeachRaw = sin(vLocalPosition.y * 2.0 - vLocalPosition.x * 1.0 + time * 1.2);
// Normalize to 0..1 for clean blending
float wavePeach = wavePeachRaw * 0.5 + 0.5;
// 1. Base cold colors (Blue -> Cyan)
vec3 mixColor = mix(colorBlue, colorCyan, combinedWave);
// 2. Clean transition to warm colors
// Smooth, gentle transitions between the pastel tones
vec3 pinkTransition = mix(mixColor, colorHotPink, smoothstep(0.2, 0.8, wavePeach));
mixColor = mix(pinkTransition, colorPeach, smoothstep(0.5, 0.95, wavePeach));
// 3. Purple rim light
mixColor = mix(mixColor, colorPurple, fresnel);
// Glowing rim effect (softer)
vec3 glow = colorCyan * fresnelPow * 1.5;
// Curved, softer lines
// Add distortion using X and Z to make the lines wavy/curved
float distortion = sin(vLocalPosition.x * 2.5 + time * 0.5) * 0.4 + cos(vLocalPosition.z * 2.0) * 0.4;
float curvedY = vLocalPosition.y + distortion;
// Less quantity (multiplier 1.2), slower movement
float lines = fract((curvedY - time * 0.2) * 1.2);
// Very soft transition for the lines
float lineIntensity = smoothstep(0.2, 0.6, lines) * smoothstep(1.0, 0.6, lines);
vec3 lineGlow = vec3(0.2, 0.8, 1.0) * lineIntensity * (0.3 + fresnel * 1.0);
vec3 finalEmission = mixColor * 0.6 + glow + lineGlow;
// --- ASCII Dynamic Shapes Effect ---
float pixelSize = 8.0;
vec2 localUv = fract(gl_FragCoord.xy / pixelSize) - 0.5;
float luma = dot(finalEmission, vec3(0.2126, 0.7152, 0.0722));
// 1. Dynamic Size: scale dramatically based on brightness
float currentSize = clamp(luma * 1.2, 0.05, 0.45);
// 2. Dynamic Shape: morph from square to circle based on color!
// wavePeach controls the warm colors.
// 0.0 (Cold blue/cyan) = Sharp Squares
// 1.0 (Warm pink/peach) = Perfect Circles
float currentRadius = mix(0.0, currentSize, wavePeach);
// SDF for dynamic rounded box
float d = length(max(abs(localUv) - (currentSize - currentRadius), 0.0)) - currentRadius;
float shapeAlpha = smoothstep(1.5 / pixelSize, 0.0, d);
// Keep discard for the ASCII grid so depth sorting works correctly
if (shapeAlpha < 0.1) discard;
// Soften/blur the geometric edges of the model using the fresnel angle
// Dynamic blur controlled by the slider (0.0 = sharp, 1.0 = extreme blur)
float edgeStart = mix(1.0, 0.7, edgeBlurAmount);
float edgeEnd = mix(0.99, 0.1, edgeBlurAmount);
float edgeBlur = smoothstep(edgeStart, edgeEnd, fresnel);
finalEmission *= 1.2; // Boost brightness a bit
// --- SPECTACULAR 2D SCREEN-SPACE REVEAL ---
// We use screen coordinates so the reveal is perfectly consistent
// regardless of how the 3D model is scaled or offset internally!
vec2 screenUv = gl_FragCoord.xy / resolution.xy;
screenUv.x *= resolution.x / resolution.y; // correct aspect ratio
// Only carve out the reveal DURING the intro animation. Once it completes the discard is
// fully disabled, so the model can NEVER be hidden again no matter where it flies during
// the scroll choreography (fixes the model vanishing when scrolling into the finale).
if (uRevealProgress < 0.999) {
// Reveal emanates from the model's ACTUAL on-screen center (updated every frame on the CPU).
vec2 revealOrigin = uRevealOrigin;
float distFromCenter2D = distance(screenUv, revealOrigin);
// Expand radius generously so the whole model is guaranteed to be revealed by the end.
float currentRadius2D = uRevealProgress * 3.0;
// Add digital grid noise based on pixel coordinates for a dissolving edge.
float gridRandom = fract(sin(dot(floor(gl_FragCoord.xy / 8.0), vec2(12.9898, 78.233))) * 43758.5453);
// Fade the noise out as the reveal completes so nothing stays permanently carved away.
float noisyRadius2D = currentRadius2D - gridRandom * 0.15 * (1.0 - uRevealProgress);
if (distFromCenter2D > noisyRadius2D) {
discard;
}
// Glowing edge at the expansion border
float revealEdge = smoothstep(noisyRadius2D - 0.05, noisyRadius2D, distFromCenter2D);
vec3 revealGlow = colorHotPink * revealEdge * 3.0 + colorCyan * pow(revealEdge, 4.0) * 8.0;
finalEmission += revealGlow;
}
// Multiply alpha by edgeBlur to make the silhouette soft and blurry
gl_FragColor = vec4(gl_FragColor.rgb + finalEmission, gl_FragColor.a * shapeAlpha * edgeBlur);
```
The full `applyHolographicShader` skeleton (verbatim structure):
```js
window.applyHolographicShader = function(mat) {
mat.onBeforeCompile = (shader) => {
shader.uniforms.time = shaderUniforms.time;
shader.uniforms.colorCyan = shaderUniforms.colorCyan;
shader.uniforms.colorPurple = shaderUniforms.colorPurple;
shader.uniforms.colorBlue = shaderUniforms.colorBlue;
shader.uniforms.colorPeach = shaderUniforms.colorPeach;
shader.uniforms.colorHotPink = shaderUniforms.colorHotPink;
shader.uniforms.edgeBlurAmount = shaderUniforms.edgeBlurAmount;
shader.uniforms.uRevealProgress = shaderUniforms.uRevealProgress;
shader.uniforms.uRevealOrigin = shaderUniforms.uRevealOrigin;
shader.uniforms.resolution = shaderUniforms.resolution;
shader.vertexShader = `varying vec3 vLocalPosition;n` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(`#include <begin_vertex>`, `#include <begin_vertex>n` + /* vertex patch above */);
shader.fragmentShader = `uniform float time;nuniform float edgeBlurAmount;nuniform float uRevealProgress;nuniform vec2 uRevealOrigin;nuniform vec2 resolution;nuniform vec3 colorCyan;nuniform vec3 colorPurple;nuniform vec3 colorBlue;nuniform vec3 colorPeach;nuniform vec3 colorHotPink;nvarying vec3 vLocalPosition;n` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(`#include <dithering_fragment>`, `#include <dithering_fragment>n` + /* fragment patch above */);
};
};
```
### C. Logo particle system (built from logo.svg via canvas alpha sampling)
After the model loads, build a particle `Points` cloud. Load the same logo SVG into an `Image`, draw
it to a `450 × (450*aspect)` canvas, read pixel alpha, and emit points for every pixel with
`alpha > 128`. Construction (verbatim logic):
- `thickness = 0.25`. For each opaque pixel push front `z:+thickness/2` and back `z:-thickness/2`
points; if it's an edge pixel (any 4-neighbor alpha < 128) push `sidePoints = 3` extra z-layer
points. `isAmbient: 0.0`.
- Ambient depth points: `ambientCount = 3500`, spread `x = w/2 + (rand-0.5)*w*3.0`,
`y = h/2 + (rand-0.5)*h*10.0`, `z = (rand-0.5)*20.0`, `isAmbient: 1.0`.
- `logoScale = 5.7 / canvas.width`.
- Per point: jitter `±0.6` X/Y and `±0.05` Z; target = `(pt.x+jx - w/2)*logoScale`,
`-(pt.y+jy - h/2)*logoScale`, `pt.z+jz`. Random origin = cylinder: `angle = rand*2π`,
`radius = 25 + rand*25`, `randX = cos(angle)*radius`, `randY = (rand-0.5)*45`,
`randZ = sin(angle)*radius`. Delay `= rand*0.5 + normalizedDistFromCenter*0.5`.
Buffer attributes: `position`(3), `aRandomPosition`(3), `aDelay`(1), `aIsAmbient`(1).
```js
const particleMat = new THREE.ShaderMaterial({
uniforms: {
time: shaderUniforms.time, uProgress: { value: 0.0 }, uScale: { value: 1.0 }, uShatterFade: { value: 0.0 },
colorCyan: shaderUniforms.colorCyan, colorBlue: shaderUniforms.colorBlue,
colorPeach: shaderUniforms.colorPeach, colorHotPink: shaderUniforms.colorHotPink
},
vertexShader: `...`, fragmentShader: `...`,
transparent: true, depthWrite: false, blending: THREE.NormalBlending
});
window.logoParticles = new THREE.Points(particleGeo, particleMat);
window.logoParticles.position.set(-0.6, -2.0, -3.0);
window.logoParticles.frustumCulled = false;
window.logoParticles.visible = false;
scene.add(window.logoParticles);
```
Particle vertex shader (verbatim):
```glsl
uniform float time;
uniform float uProgress;
uniform float uScale;
attribute vec3 aRandomPosition;
attribute float aDelay;
attribute float aIsAmbient;
varying vec3 vLocalPosition;
varying float vIsAmbient;
void main() {
vLocalPosition = position;
vIsAmbient = aIsAmbient;
float safeProgress = clamp(uProgress, 0.0, 1.0);
float startThreshold = aDelay * 0.4;
float particleProgress = clamp((safeProgress - startThreshold) / (1.0 - startThreshold), 0.0, 1.0);
// Sharper ease out so they snap firmly into place
float rushEase = 1.0 - pow(1.0 - particleProgress, 4.0);
// Clean Spin: rotate the random position around Y axis (Local Space)
float spin = (1.0 - particleProgress) * 10.0;
float s = sin(spin);
float c = cos(spin);
vec3 spiraledPos = aRandomPosition;
spiraledPos.x = aRandomPosition.x * c - aRandomPosition.z * s;
spiraledPos.z = aRandomPosition.x * s + aRandomPosition.z * c;
// FLATTEN the depth of shattered particles so they stay close to the camera
// and don't shrink into tiny specks due to 3D perspective!
spiraledPos.z *= 0.15;
// Smooth vertical convergence without crazy noise
spiraledPos.y = mix(spiraledPos.y, position.y, rushEase);
vec3 currentPos = mix(spiraledPos, position, rushEase);
// Add continuous slow drift to ambient particles AND shattered logo particles!
// rushEase is 1.0 when assembled, 0.0 when scattered.
// So (1.0 - rushEase) makes them drift only when scattered!
float driftFactor = (aIsAmbient > 0.5) ? 1.0 : (1.0 - rushEase);
if (driftFactor > 0.01) {
currentPos.x += sin(time * 0.4 + aRandomPosition.y) * 1.5 * driftFactor;
currentPos.y += cos(time * 0.3 + aRandomPosition.x) * 1.5 * driftFactor;
currentPos.z += sin(time * 0.5 + aRandomPosition.z) * 1.5 * driftFactor;
}
vec4 mvPosition = modelViewMatrix * vec4(currentPos, 1.0);
// Expand the base size of shattered logo particles by 4x to counteract any depth shrinking
float shatterBoost = mix(4.5, 1.0, rushEase);
float baseSize = (aIsAmbient > 0.5 ? 40.0 : (16.0 * shatterBoost)) * uScale;
// Point size attenuation with less aggressive clamping
gl_PointSize = clamp(baseSize / max(0.5, -mvPosition.z), 4.0, 120.0);
gl_Position = projectionMatrix * mvPosition;
}
```
Particle fragment shader (verbatim):
```glsl
uniform float time;
uniform float uProgress;
uniform float uShatterFade;
uniform vec3 colorCyan;
uniform vec3 colorBlue;
uniform vec3 colorPeach;
uniform vec3 colorHotPink;
varying vec3 vLocalPosition;
varying float vIsAmbient;
void main() {
vec2 cxy = 2.0 * gl_PointCoord - 1.0;
float r = dot(cxy, cxy);
if (r > 1.0) discard;
float dist = sqrt(r);
// Stronger core, softer edge for a denser "juicy" look without additive washout
float glow = smoothstep(1.0, 0.2, dist);
// Spatial Gradient + Flowing Time Animation
float waveX = sin(time * 1.5) * 1.5;
float waveY = cos(time * 1.2) * 0.8;
// Map ambient particles into the gradient bounds so they shimmer too
float effX = vIsAmbient > 0.5 ? mod(vLocalPosition.x + time, 6.0) - 3.0 : vLocalPosition.x;
float effY = vIsAmbient > 0.5 ? mod(vLocalPosition.y + time, 2.0) - 1.0 : vLocalPosition.y;
float tX = smoothstep(-3.0, 3.0, effX + waveX);
float tY = smoothstep(-1.0, 1.0, effY + waveY);
// Bilinear blend of 4 colors
vec3 leftColor = mix(colorBlue, colorCyan, tY);
vec3 rightColor = mix(colorHotPink, colorPeach, tY);
// Boost the vibrancy of the colors to make them "juicy"
vec3 finalColor = mix(leftColor, rightColor, tX) * 1.4;
// Fade out logo particles during the final shatter phase, but keep ambient particles fully visible
float alphaMultiplier = vIsAmbient > 0.5 ? 1.0 : (1.0 - uShatterFade);
// Global fade-in to prevent abrupt popping when they first appear
float globalFadeIn = smoothstep(0.0, 0.15, uProgress);
gl_FragColor = vec4(finalColor, glow * alphaMultiplier * globalFadeIn);
}
```
## The loader / reveal
### Preloader blob mesh (in `scene`, drawn by foreground renderer)
A `PlaneGeometry(4,4)` mesh with a wobbling-blob shader, positioned to sit exactly over the model's
spot and face the camera:
```js
const preloaderPointsMat = new THREE.ShaderMaterial({
uniforms: {
time: shaderUniforms.time, colorCyan: shaderUniforms.colorCyan, colorPurple: shaderUniforms.colorPurple,
colorBlue: shaderUniforms.colorBlue, colorPeach: shaderUniforms.colorPeach, colorHotPink: shaderUniforms.colorHotPink,
uShatter: { value: 0.0 }
},
vertexShader: `...`, fragmentShader: `...`, transparent: true, depthWrite: false
});
const preloaderPoints = new THREE.Mesh(new THREE.PlaneGeometry(4,4), preloaderPointsMat);
preloaderPoints.position.set(-1.8, -0.25, 4.5);
preloaderPoints.lookAt(-1.8, -1.0, 6.5);
scene.add(preloaderPoints);
```
Preloader vertex shader (verbatim):
```glsl
uniform float uShatter;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 pos = position;
// Expand while scattering
pos *= (1.0 + uShatter * 2.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
```
Preloader fragment shader (verbatim):
```glsl
uniform float time;
uniform float uShatter;
uniform vec3 colorCyan;
uniform vec3 colorPurple;
uniform vec3 colorBlue;
uniform vec3 colorPeach;
uniform vec3 colorHotPink;
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.5);
vec2 st = vUv - center;
float angle = atan(st.y, st.x);
float radius = length(st);
// Dynamic fluid wobble
float wobble = sin(angle * 3.0 + time * 1.5) * 0.03 + cos(angle * 2.0 - time) * 0.04;
float maxRadius = 0.35 + wobble;
// Scatter/Expand logic
float currentRadius = maxRadius * (1.0 + uShatter * 1.5);
// Soft alpha edge
float alpha = smoothstep(currentRadius + 0.08, currentRadius - 0.02, radius);
// Dissolve noise during shatter
float shatterNoise = fract(sin(dot(vUv * 10.0, vec2(12.9898, 78.233))) * 43758.5453);
alpha -= (shatterNoise * uShatter * 2.0);
alpha *= (1.0 - uShatter); // Fade out completely
if (alpha <= 0.01) discard;
// Organic color gradients
vec2 p = vUv * 2.0 - 1.0;
float wave1 = sin(p.x * 2.0 - time * 1.0);
float wave2 = cos(p.y * 1.5 + time * 0.8);
float combinedWave = smoothstep(-0.5, 0.5, (wave1 + wave2)/2.0);
float wavePeach = sin(p.y * 2.0 - p.x * 1.0 + time * 1.2) * 0.5 + 0.5;
vec3 mixColor = mix(colorBlue, colorCyan, combinedWave);
vec3 pinkTransition = mix(mixColor, colorHotPink, smoothstep(0.2, 0.8, wavePeach));
mixColor = mix(pinkTransition, colorPeach, smoothstep(0.5, 0.95, wavePeach));
// Purple edge
mixColor = mix(mixColor, colorPurple, smoothstep(0.2, 0.45, radius));
// Soften colors by mixing towards white
mixColor = mix(mixColor, vec3(1.0), 0.35); // 35% lighter/pastel
gl_FragColor = vec4(mixColor, alpha);
}
```
### Odometer numbers & stepped loader
Build the odometer column: for `i = 0..100`, a `4rem`-tall centered div containing `String(i).padStart(3,'0')`,
all injected into `#odometer-numbers`. Stepped target sequence:
```js
const loaderSteps = [25, 50, 75, 100];
let loaderStepIndex = 0;
function advanceLoader() {
if (loaderStepIndex >= loaderSteps.length) return;
preloaderTargetPercent = loaderSteps[loaderStepIndex];
loaderStepIndex++;
setTimeout(advanceLoader, 300);
}
setTimeout(advanceLoader, 200);
```
State vars: `preloaderDone=false`, `preloaderCurrentPercent=0`, `preloaderTargetPercent=0`,
`preloaderShatter=0.0`.
### Hero title split
`initTitles()` (run on DOMContentLoaded or immediately) walks `#animated-hero-title` and every
`.animated-title`, splitting their text nodes into per-character `<span class="char-mask"><span
class="hero-char">X</span></span>`, preserving `<br>` and inner styled spans. Whitespace stays as
text nodes. An `IntersectionObserver` (`threshold:0.3`) reveals chars of any `.animated-title.flow-title`
when it scrolls into view, staggering `.revealed` at `index*10`ms. A `triggerTextReveal(selector)`
helper (guarded by a `Set` so each fires once) reveals chars for the phase titles that are driven by
scroll progress.
### Preloader → reveal sequence (inside the animate loop, verbatim)
```js
if (!preloaderDone) {
if (preloaderCurrentPercent < preloaderTargetPercent) {
preloaderCurrentPercent += delta * 180;
if (preloaderCurrentPercent > preloaderTargetPercent) preloaderCurrentPercent = preloaderTargetPercent;
if (odoNumbers) odoNumbers.style.transform = `translateY(-${preloaderCurrentPercent * 4}rem)`;
}
if (preloaderCurrentPercent >= 100) {
preloaderShatter += delta * 2.0;
const easeInShatter = Math.pow(preloaderShatter, 3.0);
preloaderPointsMat.uniforms.uShatter.value = easeInShatter;
if (preloaderShatter > 1.0) {
preloaderDone = true;
preloaderPoints.visible = false;
if (window.mainModel) window.mainModel.visible = true;
const preloaderHTML = document.getElementById('preloader');
if (preloaderHTML) preloaderHTML.style.opacity = '0';
document.querySelectorAll('.preload-hidden').forEach(el => el.classList.remove('preload-hidden'));
const chars = document.querySelectorAll('#animated-hero-title .hero-char');
setTimeout(() => {
chars.forEach((char, index) => setTimeout(() => char.classList.add('revealed'), index * 25));
}, 150);
}
}
window.scrollTo(0, 0); // lock scroll during preload
scrollProgress = 0;
}
```
The model reveal circle animates only after the preloader finishes:
```js
if (preloaderDone && shaderUniforms.uRevealProgress.value < 0.999) {
if (!window.revealStartTime) window.revealStartTime = shaderUniforms.time.value + 0.5;
const elapsed = shaderUniforms.time.value - window.revealStartTime;
if (elapsed > 0) {
let progress = Math.min(1.0, elapsed / 2.3);
shaderUniforms.uRevealProgress.value = 1 - Math.pow(1 - progress, 3);
}
}
```
## Scroll choreography & animation loop
### Scroll progress mapping (verbatim)
```js
let scrollProgress = 0;
let _ratio = window.innerWidth / 1200;
let responsiveScale = _ratio <= 1.0 ? _ratio : 1.0 + (_ratio - 1.0) * 0.4;
window.addEventListener('scroll', () => {
const maxScroll = document.body.scrollHeight - window.innerHeight;
const pauseScroll = window.innerHeight * 1; // reserve last 100vh as a 100% pause
const animationMaxScroll = Math.max(1, maxScroll - pauseScroll);
scrollProgress = window.scrollY / animationMaxScroll;
scrollProgress = Math.max(0, Math.min(scrollProgress, 1.0));
});
```
### Phase windows (all derived from `scrollProgress`, verbatim)
```js
const transitionProgress = Math.min(1.0, scrollProgress / 0.12); // hero → model in frame
const phase3Progress = Math.max(0, Math.min(1.0, (scrollProgress - 0.40) / 0.14)); // glass slides up
const phase4Progress = Math.max(0, Math.min(1.0, (scrollProgress - 0.54) / 0.14)); // glass leaves, split feature
const phase5Progress = Math.max(0, Math.min(1.0, (scrollProgress - 0.68) / 0.12)); // camera dives, particles assemble
const modelFlyInProgress = Math.max(0, Math.min(1.0, (scrollProgress - 0.86) / 0.14)); // model returns
const logoShatterProgress= Math.max(0, Math.min(1.0, (scrollProgress - 0.86) / 0.14)); // logo shatters
```
### Camera path (verbatim)
```js
const targetCamX = THREE.MathUtils.lerp(-1.8, -0.6, transitionProgress);
const targetFocusX = THREE.MathUtils.lerp(-1.8, -0.6, transitionProgress);
const targetCamY = THREE.MathUtils.lerp(-1.0, -2.0, transitionProgress);
const targetFocusY = THREE.MathUtils.lerp(0.5, -2.0, transitionProgress);
const phase3CamZ = THREE.MathUtils.lerp(7.5, 7.15, phase3Progress);
const targetCamZ = THREE.MathUtils.lerp(6.5, phase3CamZ, transitionProgress);
const finalCamZ = THREE.MathUtils.lerp(targetCamZ, 0.5, phase5Progress); // deep push
const snapLerp = (current, target, speed) => {
if (Math.abs(target - current) < 0.0001) return target;
return THREE.MathUtils.lerp(current, target, speed);
};
const lerpSpeed = delta * 6.0;
// apply: camera.position.{x,y,z} = snapLerp(..., {targetCamX,targetCamY,finalCamZ}, lerpSpeed);
// controls.target.{x,y} = snapLerp(..., {targetFocusX,targetFocusY}, lerpSpeed);
```
### Model path (verbatim, inside `if (window.mainModel)`)
```js
const baseModelX = THREE.MathUtils.lerp(-0.6, -0.5, transitionProgress);
const baseModelY = THREE.MathUtils.lerp(-3.93, -6.15, transitionProgress);
const baseModelZ = THREE.MathUtils.lerp(4.17, 7.0, transitionProgress);
const targetModelX = THREE.MathUtils.lerp(baseModelX, guiParams.phase4X, phase4Progress);
const targetModelY = THREE.MathUtils.lerp(baseModelY, guiParams.phase4Y, phase4Progress);
const targetModelZ = THREE.MathUtils.lerp(baseModelZ, guiParams.phase4Z, phase4Progress);
const p6Ease = 1.0 - Math.pow(1.0 - modelFlyInProgress, 3);
const p6ModelX = THREE.MathUtils.lerp(targetModelX, -0.4, p6Ease);
const p6ModelY = THREE.MathUtils.lerp(targetModelY, -5.2, p6Ease);
const p6ModelZ = THREE.MathUtils.lerp(targetModelZ, -1.0, p6Ease);
const pointerX = targetMouse.x * 2 - 1, pointerY = targetMouse.y * 2 - 1;
const mouseRotX = pointerY * 0.05, mouseRotY = pointerX * 0.10;
const rotProgress = Math.max(0, Math.min(1.0, (scrollProgress - 0.12) / 0.28));
const targetModelRotX = THREE.MathUtils.lerp(-0.05026, -0.2, transitionProgress);
const finalRotX = THREE.MathUtils.lerp(targetModelRotX, guiParams.phase4RotX, phase4Progress);
const p6RotX = THREE.MathUtils.lerp(finalRotX, 0.0 + mouseRotX, p6Ease);
const initialRotY = -0.92781;
const transitionRotY = THREE.MathUtils.lerp(initialRotY, initialRotY + Math.PI, transitionProgress);
const baseRotY = THREE.MathUtils.lerp(transitionRotY, Math.PI * 2, rotProgress);
const finalRotY = THREE.MathUtils.lerp(baseRotY, guiParams.phase4RotY, phase4Progress);
const p6RotY = THREE.MathUtils.lerp(finalRotY, 0.0 + mouseRotY, p6Ease);
const targetModelRotZ = THREE.MathUtils.lerp(-0.12566, 0.0, transitionProgress);
const finalRotZ = THREE.MathUtils.lerp(targetModelRotZ, guiParams.phase4RotZ, phase4Progress);
const p6RotZ = THREE.MathUtils.lerp(finalRotZ, 0.0, p6Ease);
// snapLerp position & rotation toward p6* values.
const baseScale = window.baseModelScale || 1.0;
const finalScale = baseScale * guiParams.phase4Scale;
const targetScale = THREE.MathUtils.lerp(baseScale, finalScale, phase4Progress);
if (!window.currentModelScale) window.currentModelScale = baseScale;
window.currentModelScale = snapLerp(window.currentModelScale, targetScale, lerpSpeed);
window.mainModel.scale.setScalar(window.currentModelScale);
// Opacity: fade out in phase 5, fade back in during model fly-in
let targetOpacity = 0.95;
if (modelFlyInProgress > 0) targetOpacity = THREE.MathUtils.lerp(0.0, 0.95, Math.min(1.0, modelFlyInProgress * 2.0));
else if (phase5Progress > 0) targetOpacity = THREE.MathUtils.lerp(0.95, 0.0, Math.min(1.0, phase5Progress * 2.0));
if (window.mainModelMaterial) window.mainModelMaterial.opacity = targetOpacity;
// Keep the intro reveal centered on the model's real screen position
if (shaderUniforms.uRevealProgress.value < 0.999) {
window.mainModel.getWorldPosition(_revealVec);
_revealVec.project(camera);
shaderUniforms.uRevealOrigin.value.set(
(_revealVec.x * 0.5 + 0.5) * (window.innerWidth / window.innerHeight),
_revealVec.y * 0.5 + 0.5
);
}
```
### Logo particle animation (verbatim, inside `if (window.logoParticles)`)
When `phase5Progress > 0`: make visible; `targetProgress = phase5Progress`, `targetFade = 0`. During
shatter (`logoShatterProgress > 0`): `explodeEase = THREE.MathUtils.smoothstep(logoShatterProgress,0,1)`,
`targetProgress = 1 - Math.pow(explodeEase, 0.3)` (counteracts the vertex `pow(4)` assembly curve so
the explosion is buttery), keep `targetFade = 0` (never fully fade). snapLerp `uProgress` and
`uShatterFade` at `lerpSpeed*1.5`. Mouse rotation: `rotX = pointerY*0.15*phase5Progress`,
`rotY = pointerX*0.25*phase5Progress`. Scale: `p6Ease = smoothstep(modelFlyInProgress,0,1)`,
`targetScale = lerp(1.0,1.4,p6Ease) * responsiveScale`, snapLerp the scale and mirror it into
`uScale`. Else hidden, reset `uProgress=0`, rotation 0, scale 1, `uScale=1`.
### Flying words (verbatim driver)
```js
updateWordAnimation(word1, scrollProgress, 0.14, 0.26);
updateWordAnimation(word2, scrollProgress, 0.22, 0.34);
updateWordAnimation(word3, scrollProgress, 0.30, 0.40, true); // word3 stops at center then flies up
```
`updateWordAnimation(el, p, start, end, stopAtCenter=false)` (verbatim — reproduce exactly):
```js
function updateWordAnimation(el, p, start, end, stopAtCenter = false) {
if (!el) return;
if (p <= start) { el.style.opacity = '0'; return; }
let localP = (p - start) / (end - start);
if (!stopAtCenter && localP >= 1.0) { el.style.opacity = '0'; return; }
let xOff, yOff, rotY, zOff;
if (stopAtCenter && localP > 1.0) {
const extraP = Math.min(1.0, (localP - 1.0) / 1.25);
const easeIn = extraP * extraP;
xOff = 0; yOff = 0 - (easeIn * 100); rotY = 0; zOff = -800;
el.style.opacity = '1';
if (el.children.length > 0) {
const speedMultipliers = [0.8, 1.4, 0.5, 1.7, 0.9, 1.2];
for (let i = 0; i < el.children.length; i++) {
const child = el.children[i];
const speed = speedMultipliers[i % speedMultipliers.length];
const extraY = -(easeIn * 60 * speed);
const blurAmount = easeIn * 24 * speed;
child.style.transform = `translate3d(0, ${extraY}vh, 0)`;
child.style.opacity = '1';
child.style.filter = blurAmount > 0.1 ? `blur(${blurAmount}px)` : 'none';
}
}
} else {
const sineInOut = (t) => 0.5 - Math.cos(t * Math.PI) / 2;
if (stopAtCenter) {
xOff = 1400 - sineInOut(localP) * 1400;
const yEaseOut = 1 - Math.pow(1 - localP, 3);
yOff = 80 - (yEaseOut * 80);
} else {
xOff = 1400 - sineInOut(localP) * 3100;
const yProgress = Math.min(1.0, localP / 0.55);
const yEaseOut = 1 - Math.pow(1 - yProgress, 3);
yOff = 80 - (yEaseOut * 80);
}
rotY = -(xOff / 800) * 60;
zOff = -800;
if (el.children.length > 0) {
const totalChars = el.children.length;
for (let i = 0; i < totalChars; i++) {
const child = el.children[i];
const delay = (i / totalChars) * 0.25;
const flyInDuration = 0.35;
let childP = (localP - delay) / flyInDuration;
childP = Math.max(0, Math.min(1.0, childP));
let childOpacity = Math.min(1.0, childP / 0.5);
const yEase = 1 - Math.pow(1 - childP, 3);
let childY = 60 - (yEase * 60);
let childBlur = 24 - (yEase * 24);
if (!stopAtCenter) {
const outDelay = (i / totalChars) * 0.15;
let outP = (localP - (0.50 + outDelay)) / 0.25;
outP = Math.max(0, Math.min(1.0, outP));
if (outP > 0) {
const outEase = outP * outP;
childY -= outEase * 60;
childBlur += outEase * 24;
childOpacity = Math.min(childOpacity, 1.0 - outP);
}
}
child.style.transform = `translate3d(0, ${childY}vh, 0)`;
child.style.opacity = childOpacity.toString();
child.style.filter = childBlur > 0.1 ? `blur(${childBlur}px)` : 'none';
}
el.style.opacity = '1';
} else {
let opacity = 1.0;
if (localP < 0.1) opacity = localP / 0.1;
else if (!stopAtCenter && localP > 0.8) opacity = 1.0 - (localP - 0.8) / 0.2;
el.style.opacity = opacity.toString();
}
}
el.style.transform = `translate3d(calc(-50% + ${xOff}px), calc(-50% + ${yOff}vh), ${zOff}px) rotateY(${rotY}deg)`;
}
```
### Glass stats panel (verbatim)
```js
if (glassContainer) {
const glassGradient = document.getElementById('glass-gradient');
if (scrollProgress <= 0.54) {
const easeIn = phase3Progress * phase3Progress;
const glassY = 100 - (easeIn * 100);
glassContainer.style.transform = `translateY(${glassY}vh)`;
if (glassY < 60) triggerTextReveal('#phase3-title');
if (glassGradient) {
const currentScale = 0.65 + (0.35 * phase3Progress);
glassGradient.style.transform = `translate(-50%, -50%) scale(${currentScale})`;
glassGradient.style.borderRadius = `${phase3Progress * 24}px`;
}
} else {
const easeOut = 1 - Math.pow(1 - phase4Progress, 3);
glassContainer.style.transform = `translateY(${-(easeOut * 100)}vh)`;
if (glassGradient) { glassGradient.style.transform = `translate(-50%, -50%) scale(1)`; glassGradient.style.borderRadius = `24px`; }
}
}
```
### Phase 4 / 5 / 6 HTML containers
- **Phase 4** (`#phase4-container`): when `0 < phase4Progress < 1`, fade in over first 20%
(`opacity=p`, `translateY(50*(1-p))px`), hold, fade out over last 20% (`translateY(-50*p)px`);
`pointerEvents='auto'` when opacity>0.5; `triggerTextReveal('#phase4-title')` when opacity>0.7.
Else opacity 0.
- **Phase 5** (`#phase5-container`): opacity ramp keyed on raw `scrollProgress` — 0 until 0.74,
fade in 0.74→0.78, hold 0.78→0.82, fade out 0.82→0.86; reveal `#phase5-title-1` & `#phase5-title-2`
when opacity>0.7.
- **Phase 6** (`#phase6-container` + `#phase6-footer`): from `scrollProgress ≥ 0.86`,
`opacity = (scrollProgress-0.86)/0.14` (clamped); footer slides up:
`footerY = 100*(1 - (1-Math.pow(1-opacity,3)))` → `translateY(footerY vh)`; container
`scale = 0.95 + 0.05*opacity`; reveal `#phase6-title` when opacity>0.7.
### Mouse, resize, loop tail (verbatim)
```js
window.addEventListener('mousemove', (event) => {
targetMouse.x = event.clientX / window.innerWidth;
targetMouse.y = 1.0 - (event.clientY / window.innerHeight);
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
rendererBg.setSize(window.innerWidth, window.innerHeight);
renderer.setSize(window.innerWidth, window.innerHeight);
shaderUniforms.resolution.value.set(window.innerWidth, window.innerHeight);
let _newRatio = window.innerWidth / 1200;
responsiveScale = _newRatio <= 1.0 ? _newRatio : 1.0 + (_newRatio - 1.0) * 0.4;
});
function animate(time) {
requestAnimationFrame(animate);
if (time) lenis.raf(time);
const delta = clock.getDelta();
shaderUniforms.time.value += delta;
preloaderPointsMat.uniforms.time.value = shaderUniforms.time.value;
shaderUniforms.mouse.value.lerp(targetMouse, delta * 3.0);
/* … all phase logic above … */
if (mixer) mixer.update(delta);
controls.update();
rendererBg.render(bgScene, camera);
renderer.render(scene, camera);
}
animate();
```
## Fixed parameters (bake these in)
- Page: `body { height: 2800vh; background:#ffffff }`, `html { font-size: max(12px, 0.833333vw) }`.
- Palette: bg `#f6fafe`, page `#ffffff`, text `#000`; accents blue `#0091ff`, cyan `#00aaff`,
purple `#8000ff`, hot-pink `#ff0099`, red `#ff0000`. Gradient line stops, in order: those five.
- Font: `'Sarabun'` weights 200 / 400 (+ italic 400).
- Lenis: `duration:1.5`, easing `t => Math.min(1, 1.001 - 2^(-10t))`.
- Camera: `PerspectiveCamera(75, aspect, 0.1, 1000)`; intro pos `(-1.8,-1.0,6.5)`, lookAt `(-1.8,0.5,0)`.
- Lights: ambient `0xffffff @1.0`; dir1 `0xffffff @2.0` at `(5,10,5)`; dir2 `0xffffff @1.0` at `(-5,-5,-5)`.
- Model material: `MeshPhysicalMaterial(color:0x010103, metalness:0.9, roughness:0.2, clearcoat:1.0,
clearcoatRoughness:0.1, transparent, opacity:0.95, DoubleSide)`; auto-scaled so maxDim → 5 units;
initial pos `(0.1,-3.93,4.17)`, rot `(-0.05026,-0.92781,-0.12566)`.
- Phase-4 target: pos `(-1.35,-5.4,5.67)`, scale ×`0.85`, rotY `0.6723`. Finale pos `(-0.4,-5.2,-1.0)`.
- Reveal: starts `+0.5s` after preloader, duration `2.3s`, cubic ease-out, radius `progress*3.0`.
- Bg shader: `pixelSize 15.0`, box radius `0.12`, `finalAlpha ×0.85`. Model ASCII: `pixelSize 8.0`,
`edgeBlurAmount 0.5`.
- Particles: logo canvas `450px` wide, alpha threshold `128`, thickness `0.25`, `logoScale 5.7/width`,
`ambientCount 3500`, group pos `(-0.6,-2.0,-3.0)`. Point sizes: ambient 40, logo 16 (×shatterBoost
up to 4.5), clamp `gl_PointSize` 4–120. Finale logo scale `1.0→1.4`.
- DRACO decoder: `https://www.gstatic.com/draco/versioned/decoders/1.5.7/`.
- Marquee: `25s linear infinite`, 22 logos total (two sets of 11), gap `6rem`, grayscale.
- Phase windows: transition `0→0.12`; rotProgress `0.12→0.40`; words `0.14/0.22/0.30`; phase3
`0.40→0.54`; phase4 `0.54→0.68`; phase5 `0.68→0.80` (+hold to 0.86); finale/shatter `0.86→1.00`;
last 100vh is a 100% pause.
## Assets
Route every local asset through:
`ASSET_BASE_URL = https://api.getlayers.ai/storage/v1/object/public/public/assets/clarix-281ac6c399/<filename>`
Define `const ASSET_BASE_URL = 'https://api.getlayers.ai/storage/v1/object/public/public/assets/clarix-281ac6c399';`
and replace each `ASSET(name)` placeholder with `` `${ASSET_BASE_URL}/name` ``.
- `logo.svg` — `…/assets/clarix-281ac6c399/logo.svg` — used twice: the hero `<img class="hero-logo">`, and
loaded into an `Image()` to canvas-sample the IrisAI logo particle cloud.
- `gradient.png` — `…/assets/clarix-281ac6c399/gradient.png` — the colorful gradient image inside the glass
stats panel (`.glass-gradient-img`, `object-fit:cover; opacity:0.5`).
- `model.glb` — `…/assets/clarix-281ac6c399/model.glb` — the hero 3D model (DRACO-compressed; load with
DRACOLoader as above).
- Company marquee logos (11 PNGs, in the `companies/` subfolder), each `…/assets/clarix-281ac6c399/companies/<file>`:
`Discord.png`, `Facebook.png`, `Snapchat.png`, `Spotify.png`, `TikTok.png`, `WhatsApp.png`,
`YouTube.png`, `dribbble.png`, `ebay.png`, `instagram.png`, `twitter.png`. Render two identical
sets (22 `<img>` total) so the `-50% → 0%` marquee loop is seamless.[/hidecontent]