wireframe-portal-title.html
<!doctype html>
<html
lang="en"
data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"BREAKTHROUGH","maxLength":18},{"id":"replacementPhrase","type":"string","label":"3D replacement phrase (auto layout)","default":"Lets do this sir!","description":"Every word is preserved. Whole-word line wrapping and type size adapt to the 3D frame."},{"id":"phraseDuration","type":"number","label":"Phrase transition duration (s)","default":1.85,"min":0.65,"max":2.9,"step":0.05,"unit":"s","description":"Total time for the letter transition, including the stagger. Lower is faster. The opening and white return keep their original timing."},{"id":"phraseEasing","type":"number","label":"Transition easing (1 gentle – 8 snap)","default":8,"min":1,"max":8,"step":0.25,"description":"Higher values keep the beginning slow and concentrate acceleration at the letter swap, then settle gently. 4 is the default."},{"id":"settleGlitch","type":"number","label":"Letter settle glitch strength","default":0.2,"min":0,"max":4,"step":0.05,"description":"Scales the deterministic block glitch and letter jitter around swap and settling. 0 disables it; 4 makes it strongest."},{"id":"depthFog","type":"boolean","label":"Depth fog (early fade always on)","default":true,"description":"Dims receding outlines into the dark scene. Early opacity fades remain active when fog is off."},{"id":"subtitle","type":"string","label":"Subtitle","default":"HYPERFRAMES PORTAL TITLE","maxLength":48},{"id":"accent","type":"color","label":"Accent","default":"#F5C518"},{"id":"burstChaos","type":"number","label":"Burst chaos (0-2)","default":1,"min":0,"max":2,"step":0.01}]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Wireframe Portal Title</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/clipper-lib@6.4.2/clipper.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"
}
}
</script>
<style>
@font-face {
font-family: "Geist";
src: url("assets/fonts/Geist-Regular.ttf") format("truetype");
font-weight: 400;
}
@font-face {
font-family: "Geist";
src: url("assets/fonts/Geist-SemiBold.ttf") format("truetype");
font-weight: 600;
}
@font-face {
font-family: "Geist";
src: url("assets/fonts/Geist-Bold.ttf") format("truetype");
font-weight: 700;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}
body {
font-family: "Geist", "Inter", system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
.clip {
position: absolute;
inset: 0;
}
/* dark underlay (never on the root itself) */
#wpt-under {
position: absolute;
inset: 0;
background: #0b0b0b;
}
/* ---- flat 2D title card ---- */
#wpt-card {
position: absolute;
inset: 0;
background: #e8e8e8;
color: #0a0a0a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#wpt-kicker {
font-weight: 600;
font-size: 22px;
letter-spacing: 0;
text-indent: 0;
color: #1f1f1f;
margin-bottom: 34px;
display: flex;
align-items: center;
gap: 18px;
}
#wpt-headline {
font-weight: 700;
font-size: 176px;
line-height: 1;
letter-spacing: 0.03em;
white-space: nowrap;
color: #0a0a0a;
}
#wpt-headline span {
display: inline-block;
}
#wpt-subtitle {
margin-top: 40px;
font-weight: 400;
font-size: 34px;
letter-spacing: 0.02em;
color: #3d3d3d;
}
/* ---- canvas layers (intentionally cover the 2D card once the portal opens) ---- */
#wpt-three,
#wpt-warp {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
display: block;
}
#wpt-white {
position: absolute;
inset: 0;
background: #ffffff;
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div
data-hf-id="hf-ek69"
id="root"
data-composition-id="wireframe-portal-title"
data-start="0"
data-duration="8"
data-width="1920"
data-height="1080"
>
<section
data-hf-id="hf-50tz"
id="wpt-scene"
class="clip"
data-start="0"
data-duration="8"
data-track-index="1"
>
<div data-hf-id="hf-6av1" id="wpt-under"></div>
<div data-hf-id="hf-r32l" id="wpt-card" data-layout-allow-occlusion="">
<div data-hf-id="hf-0bgu" id="wpt-kicker">
<span data-hf-id="hf-m027" id="wpt-kicker-text">HYPERFRAMES PRESENTS</span>
</div>
<h1 data-hf-id="hf-pwyf" id="wpt-headline"></h1>
<div data-hf-id="hf-jivp" id="wpt-subtitle"></div>
</div>
<div data-hf-id="hf-dugk" id="wpt-white" data-layout-allow-occlusion=""></div>
</section>
</div>
<script>
// ---- variables (safe fallback when API absent) ----
(function () {
var vars = {};
try {
if (window.__hyperframes && window.__hyperframes.getVariables) {
vars = window.__hyperframes.getVariables() || {};
}
} catch (e) {}
var title =
String(vars.title || "BREAKTHROUGH")
.trim()
.toUpperCase() || "BREAKTHROUGH";
var subtitle = String(vars.subtitle || "HYPERFRAMES PORTAL TITLE");
// Preserve every word. The 3D layout wraps whole words and scales to fit.
window.__wptPhrase =
String(vars.replacementPhrase || "BEYOND LIMITS")
.trim()
.toUpperCase()
.replace(/\s+/g, " ") || "BEYOND LIMITS";
function numberVar(id, fallback, min, max) {
var value = vars[id] === undefined ? fallback : Number(vars[id]);
return isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
}
window.__wptTransition = {
start: 2.85,
duration: numberVar("phraseDuration", 1.85, 0.65, 2.9),
easing: numberVar("phraseEasing", 4, 1, 8),
glitch: numberVar("settleGlitch", 1, 0, 4),
fog: vars.depthFog !== false,
};
var accent = String(vars.accent || "#F5C518");
document.documentElement.style.setProperty("--wpt-accent", accent);
window.__wptAccent = accent;
var bc = Number(vars.burstChaos);
window.__wptBurstChaos = isFinite(bc) ? Math.max(0, Math.min(2, bc)) : 1;
function fillHeadline(id, text, prefix) {
var hl = document.getElementById(id);
for (var i = 0; i < text.length; i++) {
var ch = text[i];
var span = document.createElement("span");
if (ch === " ") {
span.innerHTML = " ";
} else {
span.textContent = ch;
span.setAttribute("data-3d-char", prefix + i);
}
hl.appendChild(span);
}
}
fillHeadline("wpt-headline", title, "t-");
document.getElementById("wpt-subtitle").textContent = subtitle;
// Runtime canvases keep the Studio variables parser intact.
["wpt-three", "wpt-warp"].forEach(function (id) {
var canvas = document.createElement("canvas");
canvas.id = id;
canvas.width = 1920;
canvas.height = 1080;
document
.getElementById("wpt-scene")
.insertBefore(canvas, document.getElementById("wpt-white"));
});
})();
</script>
<script>
// ---- state proxy + single paused timeline ----
window.__timelines = window.__timelines || {};
const S = {
portal: 0, // 0 = flat 2D page, 1 = 3D wireframe world
push: 0, // exit camera push through the letterforms
glitch: 0, // block-glitch tick amount
warp: 0, // warp-dot visibility
travel: 0, // warp-dot accumulated z travel (world units)
exitFx: 0, // zoom-blur / brightness lift on the way out
};
window.__wptState = S;
const tl = gsap.timeline({ paused: true });
// IN — the flat 2D card settles first (the 3D module measures letter
// positions via offset* geometry, so these transforms don't skew it)
tl.from("#wpt-kicker", { y: -24, opacity: 0, duration: 0.45, ease: "power3.out" }, 0);
tl.from(
"#wpt-headline span",
{ y: 44, opacity: 0, duration: 0.5, stagger: 0.025, ease: "power3.out" },
0.03,
);
tl.from("#wpt-subtitle", { y: 18, opacity: 0, duration: 0.45, ease: "power3.out" }, 0.22);
// IN — portal irises open (p = 0.5 around t ~= 1.47s)
tl.to(S, { portal: 1, duration: 2.05, ease: "power2.inOut" }, 0.45);
// The 3D layer owns the copy by now. Keep the original card background
// under the portal feather, while retiring its covered DOM text.
tl.set(["#wpt-headline", "#wpt-kicker", "#wpt-subtitle"], { autoAlpha: 0 }, 2.5);
// warp dots: burst through the transition, settle to a slow stream in the hold
tl.to(S, { warp: 1, duration: 1.0, ease: "sine.in" }, 0.45);
tl.to(S, { warp: 0.28, duration: 1.2, ease: "sine.out" }, 1.5);
tl.to(S, { warp: 0.85, duration: 0.8, ease: "power1.in" }, 6.0);
tl.to(S, { travel: 16, duration: 2.05, ease: "sine.inOut" }, 0.45);
tl.to(S, { travel: 22, duration: 3.5, ease: "none" }, 2.5);
tl.to(S, { travel: 38, duration: 2.0, ease: "power2.in" }, 6.0);
// faint glitch ticks on two beats
tl.to(S, { glitch: 0.55, duration: 0.09, ease: "power1.in" }, 3.8);
tl.to(S, { glitch: 0, duration: 0.24, ease: "power2.out" }, 3.89);
tl.to(S, { glitch: 0.55, duration: 0.09, ease: "power1.in" }, 5.2);
tl.to(S, { glitch: 0, duration: 0.24, ease: "power2.out" }, 5.29);
// OUT — camera pushes through and past the letterforms, frame lifts to white
tl.to(S, { push: 1, duration: 1.6, ease: "power2.in" }, 6.0);
tl.to(S, { exitFx: 1, duration: 1.6, ease: "power2.in" }, 6.4);
tl.to("#wpt-white", { opacity: 1, duration: 0.95, ease: "sine.inOut" }, 6.9);
// The white plate is intentionally empty and fully opaque for the last hold.
tl.addLabel("light-card", 0.8);
tl.addLabel("portal-alignment", 1.47);
tl.addLabel(
"phrase-handoff",
window.__wptTransition.start + window.__wptTransition.duration * 0.6,
);
tl.addLabel("phrase-hold", window.__wptTransition.start + window.__wptTransition.duration);
tl.addLabel("white-empty", 7.9);
tl.eventCallback("onUpdate", function () {
if (window.__wptRender) window.__wptRender(tl.time());
});
/* Studio's player seeks with GSAP events suppressed and dispatches an
hf-seek CustomEvent instead — re-sync and repaint so the stage follows. */
window.addEventListener("hf-seek", function (event) {
var d = event && event.detail;
if (d && typeof d.time === "number")
tl.totalTime(Math.min(d.time, tl.totalDuration()), true);
var fn = tl.eventCallback("onUpdate");
if (fn) fn();
});
window.__timelines["wireframe-portal-title"] = tl;
</script>
<script type="module">
import * as THREE from "three";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TTFLoader } from "three/addons/loaders/TTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
// ---------- seeded PRNG (replaces the experiment's Math.random) ----------
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(20260824);
const phraseRand = mulberry32(20260831);
// ---------- params (preset 31 defaults from the experiment config) ----------
const P = {
gridLineColor: "#ffffff",
gridSpacing: 29,
gridSize: 500,
gridOpacity: 0.15,
gridFadeStart: 1,
gridFadeEnd: 175,
gridOffsetX: -14.5,
gridGlowSpeed: 8.7,
gridGlowIntensity: 1.3,
textDepthMin: 0.28,
textDepthMax: 2,
letterZSpread: 4, // experiment: 9 — tightened so perspective shifts don't split the word or crop the frame
outlineColor: "#ffffff",
outlineWidth: 1.5,
starburstPoints: 24,
portalChromatic: 0,
portalEdgeBlur: 0.15,
portalIrregularity: window.__wptBurstChaos !== undefined ? window.__wptBurstChaos : 1,
portalStartColor: "#e8e8e8",
portalScale: 0.8,
portalColorSpeed: 1.7,
warpDotCount: 1000,
warpDotSize: 2,
warpDotOpacity: 0.7,
warpDotColor: "#888888",
transChromatic: 0.01,
transZoomBlur: 3.2,
transDistortion: 0.05,
transBrightness: 1.6,
transContrast: 1.25,
transSaturation: 0,
cameraDistance: 22,
initialFOV: 100,
cameraDistance3D: 66, // pulled back from the experiment's 48.5 so the full word composes in-frame at 1920x1080
finalFOV: 72,
contentZOffset: 30,
vignetteIntensity: 1.7,
chromaticAberration: 0.006,
scanlineIntensity: 0.25,
edgeBlur: 50,
edgeBlurSpread: 0.15,
glitchPower: 0.2,
glitchSpeed: 3,
glitchBlockSize: 15,
glitchColorRate: 0.009,
ambientIntensity: 0.5,
keyLightIntensity: 4.25,
keyLightX: 2,
keyLightY: -2.5,
keyLightZ: 4.5,
bgColor: "#000000",
};
const W = 1920;
const H = 1080;
const ACCENT = window.__wptAccent || "#F5C518";
// ---------- shaders (ported verbatim from the experiment) ----------
const PortalCompositeShader = {
uniforms: {
tDiffuse: { value: null },
uPortalRadius: { value: 0.0 },
uPortalCenter: { value: new THREE.Vector2(0.5, 0.5) },
uStarburstPoints: { value: P.starburstPoints },
uChromaticStrength: { value: P.portalChromatic },
uEdgeBlur: { value: P.portalEdgeBlur },
uIrregularity: { value: P.portalIrregularity },
uStartColor: { value: new THREE.Color(P.portalStartColor) },
uPortalProgress: { value: 0.0 },
uTime: { value: 0 },
uOuterBlur: { value: 0.0 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uPortalRadius;
uniform vec2 uPortalCenter;
uniform int uStarburstPoints;
uniform float uChromaticStrength;
uniform float uEdgeBlur;
uniform float uIrregularity;
uniform vec3 uStartColor;
uniform float uPortalProgress;
uniform float uTime;
uniform float uOuterBlur;
varying vec2 vUv;
float hash(float n) { return fract(sin(n) * 43758.5453); }
void main() {
vec2 uv = vUv;
vec2 center = uPortalCenter;
vec2 delta = uv - center;
float dist = length(delta);
float angle = atan(delta.y, delta.x);
float points = float(uStarburstPoints);
float spikeLength = 0.2 * uPortalRadius;
float valleyDepth = 0.08 * uPortalRadius;
float baseR = uPortalRadius - valleyDepth;
float seed = floor(uTime * 0.4) * 7.13;
// Low-frequency undulation: the base ellipse itself wobbles.
float wob = sin(angle * 2.0 + seed * 1.7)
* 0.45 + sin(angle * 3.0 - seed * 2.3 + 1.9) * 0.33
+ sin(angle * 5.0 + seed * 0.9 + 4.2) * 0.22;
baseR *= 1.0 + wob * 0.16 * uIrregularity;
// Warp the angle before segmenting: spikes cluster and gap
// instead of sitting on an even sunburst lattice.
float aw = angle
+ (sin(angle * 3.0 + seed + 0.7) * 0.55
+ sin(angle * 5.0 - seed * 1.3 + 2.3) * 0.35)
* 0.45 * uIrregularity;
float segAngle = 6.28318 / points;
float localAngle = mod(aw + 3.14159, segAngle);
float segIndex = floor((aw + 3.14159) / segAngle);
float t = localAngle / segAngle;
// Per-spike character: off-centre tips, fat-to-needle sharpness,
// broad length spread with occasional dominant spikes.
float h1 = hash(segIndex * 17.3 + seed);
float h2 = hash(segIndex * 31.7 + seed + 5.1);
float h3 = hash(segIndex * 47.9 + seed + 9.7);
float tip = mix(0.5, mix(0.2, 0.8, h2), min(uIrregularity, 1.0));
float spike = t < tip ? t / max(tip, 1e-3)
: (1.0 - t) / max(1.0 - tip, 1e-3);
spike = pow(clamp(spike, 0.0, 1.0),
mix(1.0, mix(0.55, 3.2, h3), min(uIrregularity, 1.0)));
float lenVar = mix(0.35, 1.15, h1) + pow(h2, 6.0) * 1.8;
float spikeScale = mix(1.0, lenVar, min(uIrregularity, 1.5));
float thisSpike = spike * spikeLength * spikeScale;
float starShape = baseR + thisSpike;
float feather = max(0.002, uEdgeBlur);
float mask = smoothstep(starShape + feather, starShape - feather, dist);
if (mask < 0.001) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
float edgeDist = abs(dist - starShape);
float edgeFactor = smoothstep(feather * 3.0, 0.0, edgeDist) * uChromaticStrength;
vec2 chrDir = normalize(delta + 0.001) * edgeFactor;
float r = texture2D(tDiffuse, uv + chrDir).r;
float g = texture2D(tDiffuse, uv).g;
float b = texture2D(tDiffuse, uv - chrDir).b;
vec3 sceneColor = vec3(r, g, b);
gl_FragColor = vec4(sceneColor, mask);
}
`,
};
const TransitionFXShader = {
uniforms: {
tDiffuse: { value: null },
uTransAmount: { value: 0.0 },
uChromatic: { value: P.transChromatic },
uZoomBlur: { value: P.transZoomBlur },
uDistortion: { value: P.transDistortion },
uBrightness: { value: P.transBrightness },
uContrast: { value: P.transContrast },
uSaturation: { value: P.transSaturation },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uTransAmount;
uniform float uChromatic;
uniform float uZoomBlur;
uniform float uDistortion;
uniform float uBrightness;
uniform float uContrast;
uniform float uSaturation;
varying vec2 vUv;
void main() {
if (uTransAmount < 0.001) {
gl_FragColor = texture2D(tDiffuse, vUv);
return;
}
vec2 uv = vUv;
vec2 center = uv - 0.5;
float dist = length(center);
vec2 distortedUV = uv + center * dist * dist * uDistortion * uTransAmount;
distortedUV = clamp(distortedUV, 0.0, 1.0);
vec3 color = vec3(0.0);
float totalWeight = 0.0;
float blurAmount = uZoomBlur * uTransAmount * 0.1;
for (int i = 0; i < 10; i++) {
float t = float(i) / 9.0;
float weight = 1.0 - t * 0.6;
vec2 sampleUV = clamp(distortedUV + center * blurAmount * t, 0.0, 1.0);
color += texture2D(tDiffuse, sampleUV).rgb * weight;
totalWeight += weight;
}
color /= totalWeight;
float caAmount = uChromatic * uTransAmount;
vec2 caDir = normalize(center + 0.001) * caAmount;
color.r = mix(color.r, texture2D(tDiffuse, clamp(distortedUV + caDir, 0.0, 1.0)).r, uTransAmount);
color.b = mix(color.b, texture2D(tDiffuse, clamp(distortedUV - caDir, 0.0, 1.0)).b, uTransAmount);
float brightness = mix(1.0, uBrightness, uTransAmount);
color *= brightness;
float contrast = mix(1.0, uContrast, uTransAmount);
color = (color - 0.5) * contrast + 0.5;
float saturation = mix(1.0, uSaturation, uTransAmount);
float lum = dot(color, vec3(0.299, 0.587, 0.114));
color = mix(vec3(lum), color, saturation);
gl_FragColor = vec4(clamp(color, 0.0, 1.0), texture2D(tDiffuse, uv).a);
}
`,
};
const GlitchShader = {
uniforms: {
tDiffuse: { value: null },
uTime: { value: 0 },
uShakePower: { value: 0.0 },
uShakeRate: { value: 0.0 },
uShakeSpeed: { value: P.glitchSpeed },
uShakeBlockSize: { value: P.glitchBlockSize },
uShakeColorRate: { value: 0.0 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uShakePower;
uniform float uShakeRate;
uniform float uShakeSpeed;
uniform float uShakeBlockSize;
uniform float uShakeColorRate;
varying vec2 vUv;
float random(float seed) {
return fract(543.2543 * sin(dot(vec2(seed, seed), vec2(3525.46, -54.3415))));
}
void main() {
float enable_shift = float(
random(trunc(uTime * uShakeSpeed)) < uShakeRate
);
vec2 fixed_uv = vUv;
fixed_uv.x += (
random(
(trunc(vUv.y * uShakeBlockSize) / uShakeBlockSize) + uTime
) - 0.5
) * uShakePower * enable_shift;
vec4 pixel_color = texture2D(tDiffuse, fixed_uv);
pixel_color.r = mix(
pixel_color.r,
texture2D(tDiffuse, fixed_uv + vec2(uShakeColorRate, 0.0)).r,
enable_shift
);
pixel_color.b = mix(
pixel_color.b,
texture2D(tDiffuse, fixed_uv + vec2(-uShakeColorRate, 0.0)).b,
enable_shift
);
gl_FragColor = pixel_color;
}
`,
};
const EdgeBlurShader = {
uniforms: {
tDiffuse: { value: null },
uDirection: { value: new THREE.Vector2(1, 0) },
uBlurAmount: { value: 0.0 },
uSpread: { value: P.edgeBlurSpread },
uResolution: { value: new THREE.Vector2(W, H) },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform vec2 uDirection;
uniform float uBlurAmount;
uniform float uSpread;
uniform vec2 uResolution;
varying vec2 vUv;
void main() {
vec2 center = vUv - 0.5;
float dist = length(center);
float blurStart = (1.0 - uSpread) * 0.5;
float edgeFactor = smoothstep(blurStart, blurStart + 0.3, dist);
float radius = edgeFactor * uBlurAmount;
if (radius < 0.5) {
gl_FragColor = texture2D(tDiffuse, vUv);
return;
}
vec2 texelSize = 1.0 / uResolution;
vec2 step = uDirection * texelSize * radius;
vec4 result = vec4(0.0);
result += texture2D(tDiffuse, vUv - step * 6.0) * 0.002;
result += texture2D(tDiffuse, vUv - step * 5.0) * 0.009;
result += texture2D(tDiffuse, vUv - step * 4.0) * 0.028;
result += texture2D(tDiffuse, vUv - step * 3.0) * 0.066;
result += texture2D(tDiffuse, vUv - step * 2.0) * 0.121;
result += texture2D(tDiffuse, vUv - step * 1.0) * 0.175;
result += texture2D(tDiffuse, vUv) * 0.198;
result += texture2D(tDiffuse, vUv + step * 1.0) * 0.175;
result += texture2D(tDiffuse, vUv + step * 2.0) * 0.121;
result += texture2D(tDiffuse, vUv + step * 3.0) * 0.066;
result += texture2D(tDiffuse, vUv + step * 4.0) * 0.028;
result += texture2D(tDiffuse, vUv + step * 5.0) * 0.009;
result += texture2D(tDiffuse, vUv + step * 6.0) * 0.002;
gl_FragColor = result;
}
`,
};
const PostFXShader = {
uniforms: {
tDiffuse: { value: null },
uVignetteIntensity: { value: 0.0 },
uChromaticAberration: { value: 0.0 },
uScanlineIntensity: { value: 0.0 },
uTime: { value: 0.0 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uVignetteIntensity;
uniform float uChromaticAberration;
uniform float uScanlineIntensity;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
vec2 center = uv - 0.5;
float dist = length(center);
float dist2 = dot(center, center);
float edgeFactor = smoothstep(0.2, 0.6, dist);
float caStrength = uChromaticAberration * edgeFactor;
vec2 caDir = normalize(center + 0.001) * caStrength;
vec3 color;
color.r = texture2D(tDiffuse, clamp(uv + caDir, 0.0, 1.0)).r;
color.g = texture2D(tDiffuse, uv).g;
color.b = texture2D(tDiffuse, clamp(uv - caDir, 0.0, 1.0)).b;
float scanline = sin(uv.y * 800.0 + uTime * 2.0) * 0.5 + 0.5;
scanline = pow(scanline, 8.0) * uScanlineIntensity;
color -= scanline * 0.08;
float noiseLine = fract(sin(floor(uv.y * 400.0 + uTime * 5.0) * 43758.5453)) * uScanlineIntensity * 0.03;
color += noiseLine;
float vignette = 1.0 - dist2 * uVignetteIntensity;
vignette = smoothstep(0.0, 0.5, clamp(vignette, 0.0, 1.0));
color *= vignette;
gl_FragColor = vec4(color, texture2D(tDiffuse, uv).a);
}
`,
};
const GridLineShader = {
uniforms: {
uColor: { value: new THREE.Color(P.gridLineColor) },
uOpacity: { value: P.gridOpacity },
uFadeStart: { value: P.gridFadeStart },
uFadeEnd: { value: P.gridFadeEnd },
uCameraPos: { value: new THREE.Vector3() },
uTime: { value: 0.0 },
uGlowSpeed: { value: P.gridGlowSpeed },
uGlowIntensity: { value: P.gridGlowIntensity },
},
vertexShader: `
varying vec3 vWorldPos;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPos = worldPos.xyz;
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
uniform float uFadeStart;
uniform float uFadeEnd;
uniform vec3 uCameraPos;
uniform float uTime;
uniform float uGlowSpeed;
uniform float uGlowIntensity;
varying vec3 vWorldPos;
float hash(float n) { return fract(sin(n) * 43758.5453); }
void main() {
float dist = distance(vWorldPos, uCameraPos);
float fade = 1.0 - smoothstep(uFadeStart, uFadeEnd, dist);
if (fade < 0.01) discard;
float dotGlow = 0.0;
float dotRadius = 0.4;
for (int d = 0; d < 5; d++) {
float di = float(d);
float seedX = hash(floor(vWorldPos.y * 10.0) * 137.0 + floor(vWorldPos.z * 10.0) * 311.0 + di * 73.0);
float dotPosX = mod(uTime * uGlowSpeed * (0.5 + seedX * 2.0) + seedX * 200.0, 300.0) - 150.0;
dotGlow += exp(-pow((vWorldPos.x - dotPosX) / dotRadius, 2.0)) * 0.5;
float seedY = hash(floor(vWorldPos.x * 10.0) * 173.0 + floor(vWorldPos.z * 10.0) * 271.0 + di * 91.0);
float dotPosY = mod(uTime * uGlowSpeed * (0.5 + seedY * 2.0) + seedY * 200.0, 300.0) - 150.0;
dotGlow += exp(-pow((vWorldPos.y - dotPosY) / dotRadius, 2.0)) * 0.5;
float seedZ = hash(floor(vWorldPos.x * 10.0) * 251.0 + floor(vWorldPos.y * 10.0) * 197.0 + di * 59.0);
float dotPosZ = mod(uTime * uGlowSpeed * (0.5 + seedZ * 2.0) + seedZ * 200.0, 300.0) - 150.0;
dotGlow += exp(-pow((vWorldPos.z - dotPosZ) / dotRadius, 2.0)) * 0.5;
}
dotGlow *= uGlowIntensity;
float finalAlpha = (uOpacity + dotGlow) * fade;
vec3 finalColor = uColor * (1.0 + dotGlow * 3.0);
gl_FragColor = vec4(finalColor, min(finalAlpha, 1.0));
}
`,
};
// ---------- font: local Geist Bold TTF -> three Font (blocks the load event) ----------
// Beside this file when mounted; a runtime without assetUrl keeps the plain path.
const assetUrl = (path) =>
typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
? __hyperframes.assetUrl(path)
: path;
const ttfBuffer = await fetch(assetUrl("assets/fonts/Geist-Bold.ttf")).then((r) =>
r.arrayBuffer(),
);
const font = new FontLoader().parse(new TTFLoader().parse(ttfBuffer));
try {
await document.fonts.ready; // DOM letter rects must be measured with the real font
} catch (e) {}
// Shrink the DOM headline if a long title overflows, BEFORE measuring spans
for (const id of ["wpt-headline"]) {
const el = document.getElementById(id);
if (el.scrollWidth > 1760) {
el.style.fontSize = Math.floor(176 * (1760 / el.scrollWidth)) + "px";
}
}
// ---------- renderer / scenes / camera ----------
const canvas = document.getElementById("wpt-three");
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
stencil: true,
preserveDrawingBuffer: true, // audit pixel-hashing + deterministic capture read the buffer back
});
renderer.setSize(W, H, false);
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.toneMapping = THREE.NoToneMapping;
const scene = new THREE.Scene();
const gridScene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(P.initialFOV, W / H, 0.1, 500);
camera.position.set(0, 0, P.cameraDistance);
camera.lookAt(0, 0, 0);
// Lighting (for the floating extruded shapes)
scene.add(new THREE.AmbientLight(0xffffff, P.ambientIntensity));
const dirLight = new THREE.DirectionalLight(0xffffff, P.keyLightIntensity);
dirLight.position.set(P.keyLightX, P.keyLightY, P.keyLightZ);
scene.add(dirLight);
// ---------- background plane + volumetric glow-dot grid ----------
const bgPlaneMat = new THREE.MeshBasicMaterial({
color: P.portalStartColor,
side: THREE.DoubleSide,
});
const bgPlane = new THREE.Mesh(new THREE.PlaneGeometry(500, 500), bgPlaneMat);
bgPlane.position.z = -100;
bgPlane.renderOrder = -10;
gridScene.add(bgPlane);
const gridPositions = [];
{
const count = Math.floor(P.gridSize / P.gridSpacing);
const he = P.gridSize;
for (let a = -count; a <= count; a++) {
for (let b = -count; b <= count; b++) {
const ca = a * P.gridSpacing;
const cb = b * P.gridSpacing;
gridPositions.push(-he, ca, cb, he, ca, cb);
gridPositions.push(ca, -he, cb, ca, he, cb);
gridPositions.push(ca, cb, -he, ca, cb, he);
}
}
}
const gridGeo = new THREE.BufferGeometry();
gridGeo.setAttribute("position", new THREE.Float32BufferAttribute(gridPositions, 3));
const gridMat = new THREE.ShaderMaterial({
uniforms: THREE.UniformsUtils.clone(GridLineShader.uniforms),
vertexShader: GridLineShader.vertexShader,
fragmentShader: GridLineShader.fragmentShader,
transparent: true,
depthWrite: false,
});
const gridLines = new THREE.LineSegments(gridGeo, gridMat);
gridLines.position.x = P.gridOffsetX;
gridLines.visible = false;
gridScene.add(gridLines);
// ---------- floating extruded triangles (experiment's decorative shapes) ----------
function triangleShape(size) {
const shape = new THREE.Shape();
const h = (size * Math.sqrt(3)) / 2;
shape.moveTo(0, h * 0.66);
shape.lineTo(-size / 2, -h * 0.33);
shape.lineTo(size / 2, -h * 0.33);
shape.closePath();
return shape;
}
const floatingShapes = [];
const shapeConfigs = [
{ size: 1.8, basePos: [-8, 5, 2], color: ACCENT },
{ size: 1.4, basePos: [7, -3, 1], color: ACCENT },
{ size: 1.0, basePos: [-6, -6, 3], color: "#1a1a1a" },
{ size: 1.2, basePos: [9, 6, 2], color: ACCENT },
];
for (const cfg of shapeConfigs) {
const geo = new THREE.ExtrudeGeometry(triangleShape(cfg.size), {
depth: 0.3,
bevelEnabled: true,
bevelThickness: 0.02,
bevelSize: 0.02,
bevelSegments: 2,
});
geo.center();
const mesh = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({ color: cfg.color, roughness: 0.4, metalness: 0.1 }),
);
mesh.position.set(cfg.basePos[0], cfg.basePos[1], cfg.basePos[2]);
mesh.userData.basePos = new THREE.Vector3(...cfg.basePos);
scene.add(mesh);
floatingShapes.push(mesh);
}
// ---------- 3D page content: extruded letters + flat outlined lines ----------
const contentGroup = new THREE.Group();
contentGroup.visible = false;
scene.add(contentGroup);
const textMeshes = [];
// Screen px -> world units at the 2D camera (FOV 100, z 22)
const fovRad = (P.initialFOV * Math.PI) / 180;
const visH = 2 * P.cameraDistance * Math.tan(fovRad / 2);
const visW = visH * (W / H);
function rectToWorld(r) {
return {
x: ((r.left + r.width / 2) / W - 0.5) * visW,
y: (0.5 - (r.top + r.height / 2) / H) * visH,
w: (r.width / W) * visW,
h: (r.height / H) * visH,
};
}
// Transform-immune element rect (GSAP intro tweens offset elements at t=0)
function offsetRect(el) {
let left = el.offsetLeft;
let top = el.offsetTop;
let node = el.offsetParent;
while (node && node.id !== "wpt-card" && node !== document.body) {
left += node.offsetLeft;
top += node.offsetTop;
node = node.offsetParent;
}
return { left, top, width: el.offsetWidth, height: el.offsetHeight };
}
const lineResolution = new THREE.Vector2(W, H);
// Geist glyphs contain self-overlapping junction notches (fine for nonzero-winding
// browser fills, fatal for extrusion triangulation + EdgesGeometry). Resolve the
// overlaps with a nonzero polygon union before extruding.
function cleanShapes(shapes) {
const SC = 1000;
const cleaned = [];
for (const shape of shapes) {
const pts = shape.extractPoints(12);
const paths = [pts.shape, ...pts.holes].map((ring) =>
ring.map((p) => ({ X: Math.round(p.x * SC), Y: Math.round(p.y * SC) })),
);
let simp;
try {
simp = ClipperLib.Clipper.SimplifyPolygons(paths, ClipperLib.PolyFillType.pftNonZero);
} catch (e) {
cleaned.push(shape);
continue;
}
const outerRings = [];
const holeRings = [];
for (const ring of simp) {
if (ring.length < 3) continue;
(ClipperLib.Clipper.Orientation(ring) ? outerRings : holeRings).push(ring);
}
const outShapes = outerRings.map(
(r) => new THREE.Shape(r.map((p) => new THREE.Vector2(p.X / SC, p.Y / SC))),
);
for (const hr of holeRings) {
let owner = outShapes[0];
for (let oi = 0; oi < outerRings.length; oi++) {
if (ClipperLib.Clipper.PointInPolygon(hr[0], outerRings[oi]) !== 0) {
owner = outShapes[oi];
break;
}
}
if (owner)
owner.holes.push(
new THREE.Path(hr.map((p) => new THREE.Vector2(p.X / SC, p.Y / SC))),
);
}
cleaned.push(...outShapes);
}
return cleaned;
}
function cleanTextGeometry(text, size, depth) {
const shapes = cleanShapes(font.generateShapes(text, size));
return new THREE.ExtrudeGeometry(shapes, { depth, bevelEnabled: false });
}
function createLetterAt(char, wr, random = rand) {
const g = new THREE.Group();
// A shared geometry size lets a reused glyph scale continuously between
// source and target layouts, even when a long phrase needs smaller type.
const size = 10;
const geo = cleanTextGeometry(char, size, P.textDepthMax);
geo.computeBoundingBox();
const bb = geo.boundingBox;
const geoW = bb.max.x - bb.min.x;
const geoH = bb.max.y - bb.min.y;
geo.center();
const xyScale = Math.min(wr.w / (geoW || 1), wr.h / (geoH || 1));
g.add(
new THREE.Mesh(
geo,
new THREE.MeshBasicMaterial({
color: "#050505",
side: THREE.DoubleSide,
transparent: true,
}),
),
);
const edges = new THREE.EdgesGeometry(geo, 20);
const lineGeo = new LineSegmentsGeometry();
lineGeo.setPositions(edges.attributes.position.array);
const lineMat = new LineMaterial({
color: new THREE.Color(P.outlineColor).getHex(),
linewidth: P.outlineWidth,
transparent: true,
resolution: lineResolution,
});
g.add(new LineSegments2(lineGeo, lineMat));
edges.dispose();
g.scale.set(xyScale, xyScale, 0.01);
g.position.set(wr.x, wr.y, 0);
g.userData.char = char;
g.userData.baseX = wr.x;
g.userData.baseY = wr.y;
g.userData.xyScale = xyScale;
g.userData.targetZ = (random() - 0.5) * P.letterZSpread;
const ratio = P.textDepthMin / P.textDepthMax;
g.userData.depthScale = ratio + random() * (1 - ratio);
g.userData.isLetter = true;
contentGroup.add(g);
textMeshes.push(g);
return g;
}
function createFlatLine(text, wr, widthMult, opacity) {
const geo = cleanTextGeometry(text, 1, 0.02);
geo.computeBoundingBox();
const bb = geo.boundingBox;
const geoW = bb.max.x - bb.min.x;
const geoH = bb.max.y - bb.min.y;
geo.center();
const s = Math.min(wr.h / (geoH || 1), wr.w / (geoW || 1));
const g = new THREE.Group();
g.add(
new THREE.Mesh(
geo,
new THREE.MeshBasicMaterial({
color: "#050505",
side: THREE.DoubleSide,
transparent: true,
}),
),
);
const edges = new THREE.EdgesGeometry(geo, 40);
const lineGeo = new LineSegmentsGeometry();
lineGeo.setPositions(edges.attributes.position.array);
const lineMat = new LineMaterial({
color: new THREE.Color(P.outlineColor).getHex(),
linewidth: P.outlineWidth * widthMult,
transparent: true,
opacity: opacity,
resolution: lineResolution,
});
g.add(new LineSegments2(lineGeo, lineMat));
edges.dispose();
g.scale.set(s, s, 1);
g.position.set(wr.x, wr.y, 0);
g.userData.baseY = wr.y;
g.userData.isSupporting = true;
g.userData.faceColor = g.children[0].material.color.clone();
contentGroup.add(g);
textMeshes.push(g);
}
// Headline letters from the DOM spans (2D card and 3D letters stay aligned)
document.querySelectorAll("#wpt-headline [data-3d-char]").forEach((span) => {
const ch = span.textContent;
if (!ch || ch.trim() === "") return;
const r = offsetRect(span);
if (r.width < 1 || r.height < 1) return;
createLetterAt(ch, rectToWorld(r));
});
const sourceLetters = textMeshes.slice();
// Kicker + subtitle as flat outlined lines
const kickEl = document.getElementById("wpt-kicker-text");
if (kickEl && kickEl.textContent.trim()) {
createFlatLine(kickEl.textContent, rectToWorld(offsetRect(kickEl)), 0.7, 0.6);
}
const subEl = document.getElementById("wpt-subtitle");
if (subEl && subEl.textContent.trim()) {
createFlatLine(subEl.textContent, rectToWorld(offsetRect(subEl)), 0.7, 0.6);
}
const supportingLines = textMeshes.filter((obj) => obj.userData.isSupporting);
const replacementLetters = [];
const transition = window.__wptTransition;
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const smooth = (v) => {
const x = clamp01(v);
return x * x * (3 - 2 * x);
};
// Typeset from font metrics, independent of any final DOM card. Search
// whole-word wraps, maximizing readable type size in a safe 1520 x 620 box.
// A long unbroken word shrinks intact; no words or glyphs are discarded.
function layoutPhrase(text) {
const measure = document.createElement("canvas").getContext("2d");
measure.font = "700 176px Geist";
const tracking = 176 * 0.03;
const advance = (char) => measure.measureText(char).width + tracking;
const width = (line) =>
Array.from(line).reduce((sum, char) => sum + advance(char), -tracking);
const words = text.split(" ");
const widest = Math.max(...words.map(width));
const total = width(text);
let best = null;
// Trying each possible break-width gives several balanced candidates
// without restricting the result to two words or a fixed line count.
const candidates = new Set([total, widest]);
for (let first = 0; first < words.length; first++) {
let line = "";
for (let last = first; last < words.length; last++) {
line += (line ? " " : "") + words[last];
candidates.add(width(line));
}
}
for (const limit of candidates) {
if (limit < widest) continue;
const lines = [];
let line = "";
for (const word of words) {
const next = line ? line + " " + word : word;
if (line && width(next) > limit + 0.01) {
lines.push(line);
line = word;
} else line = next;
}
if (line) lines.push(line);
const widths = lines.map(width);
const scale = Math.min(1, 1520 / Math.max(...widths), 620 / (lines.length * 176 * 1.18));
const balance = Math.min(...widths) / Math.max(...widths);
const score = scale * (0.94 + 0.06 * balance);
if (!best || score > best.score) best = { lines, widths, scale, score };
}
const glyphs = [];
const lineHeight = 176 * 1.18 * best.scale;
best.lines.forEach((line, row) => {
let left = (W - best.widths[row] * best.scale) / 2;
for (const char of Array.from(line)) {
const step = advance(char) * best.scale;
if (char.trim())
glyphs.push({
char,
row,
rect: {
left,
top: H / 2 + (row - (best.lines.length - 1) / 2) * lineHeight - 88 * best.scale,
width: (advance(char) - tracking) * best.scale,
height: 176 * best.scale,
},
});
left += step;
}
});
return { ...best, glyphs };
}
const phraseLayout = layoutPhrase(window.__wptPhrase);
for (const { char, rect, row } of phraseLayout.glyphs) {
const letter = createLetterAt(char, rectToWorld(rect), phraseRand);
// Keep spacing consistent at the hold camera while retaining real depth.
const distance = P.cameraDistance3D - P.contentZOffset;
const perspective = (distance - letter.userData.targetZ) / distance;
letter.userData.baseX *= perspective;
letter.userData.baseY *= perspective;
letter.userData.xyScale *= perspective;
letter.userData.row = row;
replacementLetters.push(letter);
}
// Reuse matching letter identities first, then pair remaining glyphs by
// spatial proximity. Extra target letters have their own distant Z origin;
// unused source letters depart early instead of lingering until a swap.
const slots = replacementLetters.map((to) => ({ from: null, to }));
const remaining = new Set(sourceLetters);
const distance = (a, b) => Math.hypot(a.baseX - b.baseX, a.baseY - b.baseY);
for (const exactOnly of [true, false]) {
for (const slot of slots) {
if (slot.from) continue;
let closest = null,
cost = Infinity;
for (const letter of remaining) {
if (exactOnly && letter.userData.char !== slot.to.userData.char) continue;
const candidateCost = distance(letter.userData, slot.to.userData);
if (candidateCost < cost) {
closest = letter;
cost = candidateCost;
}
}
if (closest) {
slot.from = closest;
remaining.delete(closest);
}
}
}
for (const from of remaining) slots.push({ from, to: null });
slots.forEach((slot, i) => {
const target = slot.to || slot.from;
slot.start = slot.from
? slot.from.userData
: {
baseX: target.userData.baseX * 1.25,
baseY: target.userData.baseY * 1.25,
targetZ: -60 - phraseRand() * 65,
xyScale: target.userData.xyScale,
};
slot.end = (slot.to || slot.from).userData;
const rank = i / Math.max(1, replacementLetters.length - 1);
slot.delay = clamp01(rank) * transition.duration * 0.26;
// Keep the same object alive when the character itself does not change.
slot.sameGlyph = !!(
slot.from &&
slot.to &&
slot.from.userData.char === slot.to.userData.char
);
if (slot.sameGlyph) slot.to.visible = false;
});
// The adjustable power concentrates speed around the edge-on letter swap
// at 62% of local time: slow preparation, fast handoff, then a soft settle.
function phraseEase(progress) {
const x = clamp01(progress);
const a = Math.pow(x * 0.38, transition.easing);
const b = Math.pow((1 - x) * 0.62, transition.easing);
return a / (a + b);
}
function opacityFor(obj, opacity) {
for (const child of obj.children) {
child.material.opacity = opacity;
child.material.depthWrite = opacity > 0.995;
}
}
function depthFade(obj, amount, baseOpacity = 1) {
const opacity = 1 - smooth((amount - 0.005) / 0.13);
opacityFor(obj, opacity);
const haze = transition.fog ? 1 - smooth(amount / 0.15) : 1;
obj.children[1].material.color.set(P.outlineColor).lerp(endCol, 1 - haze);
obj.children[1].material.opacity = baseOpacity * opacity;
obj.visible = opacity > 0.001;
return opacity;
}
function handoffLetters(t, portal) {
let glitchEnvelope = 0;
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
const progress = clamp01(
(t - transition.start - slot.delay) / (transition.duration * 0.74),
);
const motion = phraseEase(progress);
const incoming = motion >= 0.5;
const start = slot.start,
end = slot.end;
// Spare source letters fog out during the first fraction of recession,
// before the active letters turn. This path also runs with fog disabled.
if (!slot.to) {
const depart = smooth(
(t - transition.start - (i % 3) * 0.025) / Math.min(0.75, transition.duration * 0.45),
);
const obj = slot.from;
obj.position.set(start.baseX, start.baseY, start.targetZ * portal - depart * 125);
obj.rotation.set(0, depart * 0.3, 0);
obj.scale.set(
start.xyScale,
start.xyScale,
THREE.MathUtils.lerp(0.01, start.depthScale, portal),
);
depthFade(obj, depart);
continue;
}
const x = THREE.MathUtils.lerp(start.baseX, end.baseX, motion);
const y =
THREE.MathUtils.lerp(start.baseY, end.baseY, motion) +
Math.sin(t * 0.6 + i * 0.7) * 0.12 * portal;
// Keep the approved forward rush: the original camera pushes into
// the wireframe world as the resolved letters accelerate toward it.
// A small reading-order stagger carries each letter past the lens;
// there is no backward Z travel or opacity dissolve on this exit.
const exitDelay = (i / Math.max(1, replacementLetters.length - 1)) * 0.2;
const flyPast = Math.pow(clamp01((t - 6.0 - exitDelay) / 1.25), 3);
const z =
THREE.MathUtils.lerp(start.targetZ, end.targetZ, motion) * portal -
Math.sin(Math.PI * motion) * 1.8 +
flyPast * 38;
// Retire a glyph only after its full extrusion is behind the camera.
// Before then, clipping and perspective carry the fly-through.
const cameraSpace = new THREE.Vector3(x, y, z + P.contentZOffset * portal).applyMatrix4(
camera.matrixWorldInverse,
);
const passedCamera = cameraSpace.z > P.textDepthMax * 2;
const edge = Math.pow(Math.abs(2 * motion - 1), 0.65);
const swapPulse = Math.exp(-Math.pow((progress - 0.62) / 0.045, 2));
const settlePulse = Math.exp(-Math.pow((progress - 0.86) / 0.065, 2));
glitchEnvelope = Math.max(glitchEnvelope, swapPulse * 0.65, settlePulse);
const tick =
Math.sin(Math.floor(t * 36) * 17.1 + i * 4.7) * settlePulse * transition.glitch;
for (const [obj, isIncoming] of [
[slot.from, false],
[slot.to, true],
]) {
if (!obj) continue;
const isActive = slot.sameGlyph ? !isIncoming : incoming === isIncoming;
// New glyphs fly up from deep Z throughout the move. They are not
// duplicates split out of a source letter, and never disappear at the seam.
const extra = !slot.from;
const entrance = extra ? smooth(motion / 0.34) : 1;
const opacity = entrance;
obj.visible = (extra || isActive) && opacity > 0.001 && !passedCamera;
opacityFor(obj, opacity);
obj.position.set(x + tick * 0.12, y + tick * 0.04, z);
obj.rotation.set(
0,
slot.sameGlyph
? Math.sin(Math.PI * motion) * 0.55
: extra
? -Math.PI * 0.75 * (1 - motion)
: incoming
? -Math.PI * (1 - motion)
: Math.PI * motion,
tick * 0.012,
);
const xyScale = THREE.MathUtils.lerp(start.xyScale, end.xyScale, motion);
obj.scale.set(
xyScale,
xyScale,
THREE.MathUtils.lerp(0.01, end.depthScale, portal) *
(extra || slot.sameGlyph ? 1 : 0.015 + 0.985 * edge),
);
}
}
return glitchEnvelope;
}
function recedeSupportingLines(t) {
supportingLines.forEach((obj, i) => {
const travel = smooth((t - 2.65 - i * 0.09) / 1.3);
obj.position.z = -145 * travel;
depthFade(obj, travel, 0.6);
});
}
// ---------- post-processing chain (experiment pass order + glitch pass) ----------
const renderTarget = new THREE.WebGLRenderTarget(W, H, {
format: THREE.RGBAFormat,
type: THREE.HalfFloatType,
depthBuffer: true,
samples: 4,
});
const composer = new EffectComposer(renderer, renderTarget);
composer.setSize(W, H);
const gridRenderPass = new RenderPass(gridScene, camera);
gridRenderPass.clearAlpha = 0;
composer.addPass(gridRenderPass);
const contentRenderPass = new RenderPass(scene, camera);
contentRenderPass.clear = false;
composer.addPass(contentRenderPass);
const blurPassH = new ShaderPass(EdgeBlurShader);
blurPassH.uniforms.uDirection.value.set(1, 0);
composer.addPass(blurPassH);
const blurPassV = new ShaderPass(EdgeBlurShader);
blurPassV.uniforms.uDirection.value.set(0, 1);
composer.addPass(blurPassV);
const postfxPass = new ShaderPass(PostFXShader);
composer.addPass(postfxPass);
const glitchPass = new ShaderPass(GlitchShader);
composer.addPass(glitchPass);
const transFXPass = new ShaderPass(TransitionFXShader);
composer.addPass(transFXPass);
const portalPass = new ShaderPass(PortalCompositeShader);
portalPass.material.transparent = true;
composer.addPass(portalPass);
composer.addPass(new OutputPass());
// ---------- warp dots (2D overlay canvas, pure function of state) ----------
const warpCanvas = document.getElementById("wpt-warp");
// Keep warp-dot rasterization identical on fresh and repeated seeks.
const wctx = warpCanvas.getContext("2d", { willReadFrequently: true });
const warpDots = [];
{
const count = P.warpDotCount;
const depthLayers = Math.ceil(Math.cbrt(count) * 2);
const sideCount = Math.ceil(Math.sqrt(count / depthLayers));
outer: for (let iz = 0; iz < depthLayers; iz++) {
for (let iy = 0; iy < sideCount; iy++) {
for (let ix = 0; ix < sideCount; ix++) {
if (warpDots.length >= count) break outer;
warpDots.push({
x: (ix - (sideCount - 1) / 2) * 1.0,
y: (iy - (sideCount - 1) / 2) * 1.0,
z: iz * 1.0 + 0.5,
});
}
}
}
}
const WARP_MAX_Z = Math.max(10, Math.ceil(Math.cbrt(P.warpDotCount) * 2));
function drawWarpDots(S) {
wctx.clearRect(0, 0, W, H);
const vis = S.warp;
if (vis < 0.01) return;
const cx = W / 2;
const cy = H / 2;
for (let i = 0; i < warpDots.length; i++) {
const d = warpDots[i];
let z = (((d.z - S.travel) % WARP_MAX_Z) + WARP_MAX_Z) % WARP_MAX_Z;
if (z < 0.05) z += WARP_MAX_Z;
const scale = 300 / z;
const sx = cx + d.x * scale;
const sy = cy + d.y * scale;
const size = (P.warpDotSize / z) * 3;
if (size < 0.3) continue;
if (sx < -10 || sx > W + 10 || sy < -10 || sy > H + 10) continue;
const depthAlpha = Math.min(1, 2 / z);
const alpha = depthAlpha * vis * P.warpDotOpacity;
wctx.globalAlpha = alpha;
wctx.fillStyle = P.warpDotColor;
wctx.beginPath();
wctx.arc(sx, sy, size, 0, Math.PI * 2);
wctx.fill();
if (z < 2 && size > 1) {
const streakLen = (2 - z) * 20 * vis;
const dx = sx - cx;
const dy = sy - cy;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
wctx.globalAlpha = alpha * 0.3;
wctx.beginPath();
wctx.moveTo(sx, sy);
wctx.lineTo(sx + (dx / dist) * streakLen, sy + (dy / dist) * streakLen);
wctx.strokeStyle = P.warpDotColor;
wctx.lineWidth = size * 0.5;
wctx.stroke();
}
}
wctx.globalAlpha = 1;
}
// ---------- render: everything derived from timeline time + state proxy ----------
const startCol = new THREE.Color(P.portalStartColor);
const endCol = new THREE.Color(P.bgColor);
function renderScene(t) {
const S = window.__wptState;
const portal = S.portal;
const push = S.push;
const transAmount = 1.0 - Math.abs(portal * 2 - 1);
const effTrans = Math.min(1, Math.max(transAmount, S.exitFx * 0.85));
// Camera: FOV/distance lerp (2D -> 3D) plus exit push through the letters
camera.fov = THREE.MathUtils.lerp(P.initialFOV, P.finalFOV, portal) + push * 10;
camera.position.z =
THREE.MathUtils.lerp(P.cameraDistance, P.cameraDistance3D, portal) -
push * 52 +
Math.sin(t * 0.34) * 1.6 * portal * (1 - push);
const driftAmp = portal * (1 - 0.7 * push);
camera.rotation.x = Math.cos(t * 0.47) * 0.04 * driftAmp;
camera.rotation.y = Math.sin(t * 0.62 + 1.2) * 0.06 * driftAmp;
camera.position.x = Math.sin(t * 0.41) * 1.4 * driftAmp;
camera.position.y = Math.cos(t * 0.29) * 1.0 * driftAmp;
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
// Portal composite
portalPass.uniforms.uPortalRadius.value = portal * P.portalScale;
portalPass.uniforms.uPortalProgress.value = portal;
portalPass.uniforms.uTime.value = t;
// Vignette / scanline / CA scale with portal
postfxPass.uniforms.uVignetteIntensity.value = P.vignetteIntensity * portal;
postfxPass.uniforms.uChromaticAberration.value = P.chromaticAberration * portal;
postfxPass.uniforms.uScanlineIntensity.value = P.scanlineIntensity * portal;
postfxPass.uniforms.uTime.value = t;
const blurAmount = P.edgeBlur * portal;
blurPassH.uniforms.uBlurAmount.value = blurAmount;
blurPassV.uniforms.uBlurAmount.value = blurAmount;
// Transition FX peak at portal 0.5 + on the exit push
transFXPass.uniforms.uTransAmount.value = effTrans;
// Block glitch ticks (the handoff envelope below follows duration/easing).
glitchPass.uniforms.uTime.value = t;
glitchPass.uniforms.uShakePower.value = P.glitchPower * S.glitch;
glitchPass.uniforms.uShakeRate.value = S.glitch > 0.001 ? 1.0 : 0.0;
glitchPass.uniforms.uShakeColorRate.value = P.glitchColorRate * Math.min(1, S.glitch * 1.8);
// Grid + background plane
const showContent = portal > 0.01;
gridLines.visible = showContent;
contentGroup.visible = showContent;
bgPlane.visible = showContent;
const colorT = Math.min(1, Math.max(0, (portal - 0.15) * P.portalColorSpeed));
bgPlaneMat.color.copy(startCol).lerp(endCol, colorT);
gridMat.uniforms.uCameraPos.value.copy(camera.position);
gridMat.uniforms.uTime.value = t;
// Floating shapes drift
for (let i = 0; i < floatingShapes.length; i++) {
const mesh = floatingShapes[i];
const base = mesh.userData.basePos;
const phase = i * 2.17;
mesh.position.x =
base.x + Math.sin(t * 0.15 + phase) * 1.2 + Math.sin(t * 0.07 + phase * 3) * 0.5;
mesh.position.y =
base.y + Math.sin(t * 0.12 + phase * 1.4) * 0.8 + Math.cos(t * 0.09 + phase * 2) * 0.3;
mesh.position.z =
base.z + Math.sin(t * 0.1 + phase * 0.7) * 0.6 + P.contentZOffset * portal;
mesh.rotation.x = Math.sin(t * 0.08 + phase) * 0.4;
mesh.rotation.y = t * 0.05 * (i % 2 === 0 ? 1 : -1) + phase;
mesh.rotation.z = Math.sin(t * 0.06 + phase * 1.3) * 0.3;
}
// Content: flat page -> per-letter depth and z-spread
contentGroup.position.z = P.contentZOffset * portal;
for (let i = 0; i < textMeshes.length; i++) {
const obj = textMeshes[i];
if (obj.userData.isLetter) {
obj.position.z = obj.userData.targetZ * portal;
const zScale = THREE.MathUtils.lerp(0.01, obj.userData.depthScale, portal);
obj.scale.z = zScale;
}
obj.position.y = obj.userData.baseY + Math.sin(t * 0.6 + i * 0.7) * 0.12 * portal;
}
const handoffGlitch = handoffLetters(t, portal);
recedeSupportingLines(t);
const glitch = Math.max(S.glitch * 0.5, handoffGlitch * 0.35) * transition.glitch;
glitchPass.uniforms.uShakePower.value = P.glitchPower * glitch;
glitchPass.uniforms.uShakeRate.value = glitch > 0.001 ? 1 : 0;
glitchPass.uniforms.uShakeColorRate.value = P.glitchColorRate * glitch * 1.8;
if (portal < 0.001) {
renderer.clear();
wctx.clearRect(0, 0, W, H);
return;
}
composer.render();
drawWarpDots(S);
}
window.__wptRender = renderScene;
const tl = window.__timelines["wireframe-portal-title"];
renderScene(tl ? tl.time() : 0);
/* The portal is closed at t=0 and draws nothing, so the first real draw would
link every shader mid-playback. Draw across the timeline and flush the GPU
before ready instead. */
window.__hf = window.__hf || {};
window.__hf.buildReady = window.__hf.buildReady || {};
window.__hf.buildReady["wireframe-portal-title"] = (async () => {
const warmDur = tl ? tl.duration() : 0;
for (const f of [0.3, 0.5, 0.7]) {
await new Promise((resolve) => setTimeout(resolve, 0));
renderScene(warmDur * f);
}
renderScene(tl ? tl.time() : 0);
renderer.getContext().finish();
})();
</script>
</body>
</html>