Setting the file. One moment.
Wireframe Portal Title · Wireframe Portal Title · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Raw file
wireframe-portal-title.html
HTML · 1,597 lines · 63 KB
1 <! doctype html >
2 < html
3 lang = "en"
4 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}]'
5 >
6
<
head
>
7 < meta charset = "UTF-8" />
8 < meta name = "viewport" content = "width=1920, height=1080" />
9 < title >Wireframe Portal Title</ title >
10 < script src = "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" ></ script >
11 < script src = "https://cdn.jsdelivr.net/npm/clipper-lib@6.4.2/clipper.js" ></ script >
12 < script type = "importmap" >
13 {
14 "imports": {
15 "three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js",
16 "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"
17 }
18 }
19 </ script >
20 < style >
21 @font-face {
22 font-family : "Geist" ;
23 src : url ( "assets/fonts/Geist-Regular.ttf" ) format ( "truetype" );
24 font-weight : 400 ;
25 }
26 @font-face {
27 font-family : "Geist" ;
28 src : url ( "assets/fonts/Geist-SemiBold.ttf" ) format ( "truetype" );
29 font-weight : 600 ;
30 }
31 @font-face {
32 font-family : "Geist" ;
33 src : url ( "assets/fonts/Geist-Bold.ttf" ) format ( "truetype" );
34 font-weight : 700 ;
35 }
36 * {
37 margin : 0 ;
38 padding : 0 ;
39 box-sizing : border-box ;
40 }
41 html ,
42 body {
43 margin : 0 ;
44 width : 1920 px ;
45 height : 1080 px ;
46 overflow : hidden ;
47 background : #000 ;
48 }
49 body {
50 font-family : "Geist" , "Inter" , system-ui , sans-serif ;
51 }
52 #root {
53 position : relative ;
54 width : 1920 px ;
55 height : 1080 px ;
56 overflow : hidden ;
57 }
58 .clip {
59 position : absolute ;
60 inset : 0 ;
61 }
62 /* dark underlay (never on the root itself) */
63 #wpt-under {
64 position : absolute ;
65 inset : 0 ;
66 background : #0b0b0b ;
67 }
68 /* ---- flat 2D title card ---- */
69 #wpt-card {
70 position : absolute ;
71 inset : 0 ;
72 background : #e8e8e8 ;
73 color : #0a0a0a ;
74 display : flex ;
75 flex-direction : column ;
76 align-items : center ;
77 justify-content : center ;
78 }
79 #wpt-kicker {
80 font-weight : 600 ;
81 font-size : 22 px ;
82 letter-spacing : 0 ;
83 text-indent : 0 ;
84 color : #1f1f1f ;
85 margin-bottom : 34 px ;
86 display : flex ;
87 align-items : center ;
88 gap : 18 px ;
89 }
90 #wpt-headline {
91 font-weight : 700 ;
92 font-size : 176 px ;
93 line-height : 1 ;
94 letter-spacing : 0.03 em ;
95 white-space : nowrap ;
96 color : #0a0a0a ;
97 }
98 #wpt-headline span {
99 display : inline-block ;
100 }
101 #wpt-subtitle {
102 margin-top : 40 px ;
103 font-weight : 400 ;
104 font-size : 34 px ;
105 letter-spacing : 0.02 em ;
106 color : #3d3d3d ;
107 }
108 /* ---- canvas layers (intentionally cover the 2D card once the portal opens) ---- */
109 #wpt-three ,
110 #wpt-warp {
111 position : absolute ;
112 inset : 0 ;
113 width : 1920 px ;
114 height : 1080 px ;
115 display : block ;
116 }
117 #wpt-white {
118 position : absolute ;
119 inset : 0 ;
120 background : #ffffff ;
121 opacity : 0 ;
122 display : flex ;
123 align-items : center ;
124 justify-content : center ;
125 }
126 </ style >
127 </ head >
128 < body >
129 < div
130 data-hf-id = "hf-ek69"
131 id = "root"
132 data-composition-id = "wireframe-portal-title"
133 data-start = "0"
134 data-duration = "8"
135 data-width = "1920"
136 data-height = "1080"
137 >
138 < section
139 data-hf-id = "hf-50tz"
140 id = "wpt-scene"
141 class = "clip"
142 data-start = "0"
143 data-duration = "8"
144 data-track-index = "1"
145 >
146 < div data-hf-id = "hf-6av1" id = "wpt-under" ></ div >
147
148 < div data-hf-id = "hf-r32l" id = "wpt-card" data-layout-allow-occlusion = "" >
149 < div data-hf-id = "hf-0bgu" id = "wpt-kicker" >
150 < span data-hf-id = "hf-m027" id = "wpt-kicker-text" >HYPERFRAMES PRESENTS</ span >
151 </ div >
152 < h1 data-hf-id = "hf-pwyf" id = "wpt-headline" ></ h1 >
153 < div data-hf-id = "hf-jivp" id = "wpt-subtitle" ></ div >
154 </ div >
155
156 < div data-hf-id = "hf-dugk" id = "wpt-white" data-layout-allow-occlusion = "" ></ div >
157 </ section >
158 </ div >
159
160 < script >
161 // ---- variables (safe fallback when API absent) ----
162 ( function () {
163 var vars = {};
164 try {
165 if (window.__hyperframes && window.__hyperframes.getVariables) {
166 vars = window.__hyperframes. getVariables () || {};
167 }
168 } catch (e) {}
169 var title =
170 String (vars.title || "BREAKTHROUGH" )
171 . trim ()
172 . toUpperCase () || "BREAKTHROUGH" ;
173 var subtitle = String (vars.subtitle || "HYPERFRAMES PORTAL TITLE" );
174 // Preserve every word. The 3D layout wraps whole words and scales to fit.
175 window.__wptPhrase =
176 String (vars.replacementPhrase || "BEYOND LIMITS" )
177 . trim ()
178 . toUpperCase ()
179 . replace ( / \s + / g , " " ) || "BEYOND LIMITS" ;
180 function numberVar ( id , fallback , min , max ) {
181 var value = vars[id] === undefined ? fallback : Number (vars[id]);
182 return isFinite (value) ? Math. max (min, Math. min (max, value)) : fallback;
183 }
184 window.__wptTransition = {
185 start: 2.85 ,
186 duration: numberVar ( "phraseDuration" , 1.85 , 0.65 , 2.9 ),
187 easing: numberVar ( "phraseEasing" , 4 , 1 , 8 ),
188 glitch: numberVar ( "settleGlitch" , 1 , 0 , 4 ),
189 fog: vars.depthFog !== false ,
190 };
191 var accent = String (vars.accent || "#F5C518" );
192 document.documentElement.style. setProperty ( "--wpt-accent" , accent);
193 window.__wptAccent = accent;
194 var bc = Number (vars.burstChaos);
195 window.__wptBurstChaos = isFinite (bc) ? Math. max ( 0 , Math. min ( 2 , bc)) : 1 ;
196
197 function fillHeadline ( id , text , prefix ) {
198 var hl = document. getElementById (id);
199 for ( var i = 0 ; i < text. length ; i ++ ) {
200 var ch = text[i];
201 var span = document. createElement ( "span" );
202 if (ch === " " ) {
203 span.innerHTML = " " ;
204 } else {
205 span.textContent = ch;
206 span. setAttribute ( "data-3d-char" , prefix + i);
207 }
208 hl. appendChild (span);
209 }
210 }
211 fillHeadline ( "wpt-headline" , title, "t-" );
212 document. getElementById ( "wpt-subtitle" ).textContent = subtitle;
213 // Runtime canvases keep the Studio variables parser intact.
214 [ "wpt-three" , "wpt-warp" ]. forEach ( function ( id ) {
215 var canvas = document. createElement ( "canvas" );
216 canvas.id = id;
217 canvas.width = 1920 ;
218 canvas.height = 1080 ;
219 document
220 . getElementById ( "wpt-scene" )
221 . insertBefore (canvas, document. getElementById ( "wpt-white" ));
222 });
223 })();
224 </ script >
225
226 < script >
227 // ---- state proxy + single paused timeline ----
228 window.__timelines = window.__timelines || {};
229 const S = {
230 portal: 0 , // 0 = flat 2D page, 1 = 3D wireframe world
231 push: 0 , // exit camera push through the letterforms
232 glitch: 0 , // block-glitch tick amount
233 warp: 0 , // warp-dot visibility
234 travel: 0 , // warp-dot accumulated z travel (world units)
235 exitFx: 0 , // zoom-blur / brightness lift on the way out
236 };
237 window.__wptState = S ;
238
239 const tl = gsap. timeline ({ paused: true });
240
241 // IN — the flat 2D card settles first (the 3D module measures letter
242 // positions via offset* geometry, so these transforms don't skew it)
243 tl. from ( "#wpt-kicker" , { y: - 24 , opacity: 0 , duration: 0.45 , ease: "power3.out" }, 0 );
244 tl. from (
245 "#wpt-headline span" ,
246 { y: 44 , opacity: 0 , duration: 0.5 , stagger: 0.025 , ease: "power3.out" },
247 0.03 ,
248 );
249 tl. from ( "#wpt-subtitle" , { y: 18 , opacity: 0 , duration: 0.45 , ease: "power3.out" }, 0.22 );
250
251 // IN — portal irises open (p = 0.5 around t ~= 1.47s)
252 tl. to ( S , { portal: 1 , duration: 2.05 , ease: "power2.inOut" }, 0.45 );
253 // The 3D layer owns the copy by now. Keep the original card background
254 // under the portal feather, while retiring its covered DOM text.
255 tl. set ([ "#wpt-headline" , "#wpt-kicker" , "#wpt-subtitle" ], { autoAlpha: 0 }, 2.5 );
256
257 // warp dots: burst through the transition, settle to a slow stream in the hold
258 tl. to ( S , { warp: 1 , duration: 1.0 , ease: "sine.in" }, 0.45 );
259 tl. to ( S , { warp: 0.28 , duration: 1.2 , ease: "sine.out" }, 1.5 );
260 tl. to ( S , { warp: 0.85 , duration: 0.8 , ease: "power1.in" }, 6.0 );
261 tl. to ( S , { travel: 16 , duration: 2.05 , ease: "sine.inOut" }, 0.45 );
262 tl. to ( S , { travel: 22 , duration: 3.5 , ease: "none" }, 2.5 );
263 tl. to ( S , { travel: 38 , duration: 2.0 , ease: "power2.in" }, 6.0 );
264
265 // faint glitch ticks on two beats
266 tl. to ( S , { glitch: 0.55 , duration: 0.09 , ease: "power1.in" }, 3.8 );
267 tl. to ( S , { glitch: 0 , duration: 0.24 , ease: "power2.out" }, 3.89 );
268 tl. to ( S , { glitch: 0.55 , duration: 0.09 , ease: "power1.in" }, 5.2 );
269 tl. to ( S , { glitch: 0 , duration: 0.24 , ease: "power2.out" }, 5.29 );
270
271 // OUT — camera pushes through and past the letterforms, frame lifts to white
272 tl. to ( S , { push: 1 , duration: 1.6 , ease: "power2.in" }, 6.0 );
273 tl. to ( S , { exitFx: 1 , duration: 1.6 , ease: "power2.in" }, 6.4 );
274 tl. to ( "#wpt-white" , { opacity: 1 , duration: 0.95 , ease: "sine.inOut" }, 6.9 );
275 // The white plate is intentionally empty and fully opaque for the last hold.
276 tl. addLabel ( "light-card" , 0.8 );
277 tl. addLabel ( "portal-alignment" , 1.47 );
278 tl. addLabel (
279 "phrase-handoff" ,
280 window.__wptTransition.start + window.__wptTransition.duration * 0.6 ,
281 );
282 tl. addLabel ( "phrase-hold" , window.__wptTransition.start + window.__wptTransition.duration);
283 tl. addLabel ( "white-empty" , 7.9 );
284
285 tl. eventCallback ( "onUpdate" , function () {
286 if (window.__wptRender) window. __wptRender (tl. time ());
287 });
288 /* Studio's player seeks with GSAP events suppressed and dispatches an
289 hf-seek CustomEvent instead — re-sync and repaint so the stage follows. */
290 window. addEventListener ( "hf-seek" , function ( event ) {
291 var d = event && event.detail;
292 if (d && typeof d.time === "number" )
293 tl. totalTime (Math. min (d.time, tl. totalDuration ()), true );
294 var fn = tl. eventCallback ( "onUpdate" );
295 if (fn) fn ();
296 });
297
298 window.__timelines[ "wireframe-portal-title" ] = tl;
299 </ script >
300
301 < script type = "module" >
302 import * as THREE from "three" ;
303 import { FontLoader } from "three/addons/loaders/FontLoader.js" ;
304 import { TTFLoader } from "three/addons/loaders/TTFLoader.js" ;
305 import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js" ;
306 import { RenderPass } from "three/addons/postprocessing/RenderPass.js" ;
307 import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js" ;
308 import { OutputPass } from "three/addons/postprocessing/OutputPass.js" ;
309 import { LineSegments2 } from "three/addons/lines/LineSegments2.js" ;
310 import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js" ;
311 import { LineMaterial } from "three/addons/lines/LineMaterial.js" ;
312
313 // ---------- seeded PRNG (replaces the experiment's Math.random) ----------
314 function mulberry32 ( a ) {
315 return function () {
316 a |= 0 ;
317 a = (a + 0x6d2b79f5 ) | 0 ;
318 let t = Math. imul (a ^ (a >>> 15 ), 1 | a);
319 t = (t + Math. imul (t ^ (t >>> 7 ), 61 | t)) ^ t;
320 return ((t ^ (t >>> 14 )) >>> 0 ) / 4294967296 ;
321 };
322 }
323 const rand = mulberry32 ( 20260824 );
324 const phraseRand = mulberry32 ( 20260831 );
325
326 // ---------- params (preset 31 defaults from the experiment config) ----------
327 const P = {
328 gridLineColor: "#ffffff" ,
329 gridSpacing: 29 ,
330 gridSize: 500 ,
331 gridOpacity: 0.15 ,
332 gridFadeStart: 1 ,
333 gridFadeEnd: 175 ,
334 gridOffsetX: - 14.5 ,
335 gridGlowSpeed: 8.7 ,
336 gridGlowIntensity: 1.3 ,
337 textDepthMin: 0.28 ,
338 textDepthMax: 2 ,
339 letterZSpread: 4 , // experiment: 9 — tightened so perspective shifts don't split the word or crop the frame
340 outlineColor: "#ffffff" ,
341 outlineWidth: 1.5 ,
342 starburstPoints: 24 ,
343 portalChromatic: 0 ,
344 portalEdgeBlur: 0.15 ,
345 portalIrregularity: window.__wptBurstChaos !== undefined ? window.__wptBurstChaos : 1 ,
346 portalStartColor: "#e8e8e8" ,
347 portalScale: 0.8 ,
348 portalColorSpeed: 1.7 ,
349 warpDotCount: 1000 ,
350 warpDotSize: 2 ,
351 warpDotOpacity: 0.7 ,
352 warpDotColor: "#888888" ,
353 transChromatic: 0.01 ,
354 transZoomBlur: 3.2 ,
355 transDistortion: 0.05 ,
356 transBrightness: 1.6 ,
357 transContrast: 1.25 ,
358 transSaturation: 0 ,
359 cameraDistance: 22 ,
360 initialFOV: 100 ,
361 cameraDistance3D: 66 , // pulled back from the experiment's 48.5 so the full word composes in-frame at 1920x1080
362 finalFOV: 72 ,
363 contentZOffset: 30 ,
364 vignetteIntensity: 1.7 ,
365 chromaticAberration: 0.006 ,
366 scanlineIntensity: 0.25 ,
367 edgeBlur: 50 ,
368 edgeBlurSpread: 0.15 ,
369 glitchPower: 0.2 ,
370 glitchSpeed: 3 ,
371 glitchBlockSize: 15 ,
372 glitchColorRate: 0.009 ,
373 ambientIntensity: 0.5 ,
374 keyLightIntensity: 4.25 ,
375 keyLightX: 2 ,
376 keyLightY: - 2.5 ,
377 keyLightZ: 4.5 ,
378 bgColor: "#000000" ,
379 };
380 const W = 1920 ;
381 const H = 1080 ;
382 const ACCENT = window.__wptAccent || "#F5C518" ;
383
384 // ---------- shaders (ported verbatim from the experiment) ----------
385 const PortalCompositeShader = {
386 uniforms: {
387 tDiffuse: { value: null },
388 uPortalRadius: { value: 0.0 },
389 uPortalCenter: { value: new THREE . Vector2 ( 0.5 , 0.5 ) },
390 uStarburstPoints: { value: P .starburstPoints },
391 uChromaticStrength: { value: P .portalChromatic },
392 uEdgeBlur: { value: P .portalEdgeBlur },
393 uIrregularity: { value: P .portalIrregularity },
394 uStartColor: { value: new THREE . Color ( P .portalStartColor) },
395 uPortalProgress: { value: 0.0 },
396 uTime: { value: 0 },
397 uOuterBlur: { value: 0.0 },
398 },
399 vertexShader: `
400 varying vec2 vUv;
401 void main() {
402 vUv = uv;
403 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
404 }
405 ` ,
406 fragmentShader: `
407 uniform sampler2D tDiffuse;
408 uniform float uPortalRadius;
409 uniform vec2 uPortalCenter;
410 uniform int uStarburstPoints;
411 uniform float uChromaticStrength;
412 uniform float uEdgeBlur;
413 uniform float uIrregularity;
414 uniform vec3 uStartColor;
415 uniform float uPortalProgress;
416 uniform float uTime;
417 uniform float uOuterBlur;
418 varying vec2 vUv;
419
420 float hash(float n) { return fract(sin(n) * 43758.5453); }
421
422 void main() {
423 vec2 uv = vUv;
424 vec2 center = uPortalCenter;
425 vec2 delta = uv - center;
426 float dist = length(delta);
427 float angle = atan(delta.y, delta.x);
428
429 float points = float(uStarburstPoints);
430 float spikeLength = 0.2 * uPortalRadius;
431 float valleyDepth = 0.08 * uPortalRadius;
432 float baseR = uPortalRadius - valleyDepth;
433
434 float seed = floor(uTime * 0.4) * 7.13;
435
436 // Low-frequency undulation: the base ellipse itself wobbles.
437 float wob = sin(angle * 2.0 + seed * 1.7)
438 * 0.45 + sin(angle * 3.0 - seed * 2.3 + 1.9) * 0.33
439 + sin(angle * 5.0 + seed * 0.9 + 4.2) * 0.22;
440 baseR *= 1.0 + wob * 0.16 * uIrregularity;
441
442 // Warp the angle before segmenting: spikes cluster and gap
443 // instead of sitting on an even sunburst lattice.
444 float aw = angle
445 + (sin(angle * 3.0 + seed + 0.7) * 0.55
446 + sin(angle * 5.0 - seed * 1.3 + 2.3) * 0.35)
447 * 0.45 * uIrregularity;
448
449 float segAngle = 6.28318 / points;
450 float localAngle = mod(aw + 3.14159, segAngle);
451 float segIndex = floor((aw + 3.14159) / segAngle);
452
453 float t = localAngle / segAngle;
454
455 // Per-spike character: off-centre tips, fat-to-needle sharpness,
456 // broad length spread with occasional dominant spikes.
457 float h1 = hash(segIndex * 17.3 + seed);
458 float h2 = hash(segIndex * 31.7 + seed + 5.1);
459 float h3 = hash(segIndex * 47.9 + seed + 9.7);
460 float tip = mix(0.5, mix(0.2, 0.8, h2), min(uIrregularity, 1.0));
461 float spike = t < tip ? t / max(tip, 1e-3)
462 : (1.0 - t) / max(1.0 - tip, 1e-3);
463 spike = pow(clamp(spike, 0.0, 1.0),
464 mix(1.0, mix(0.55, 3.2, h3), min(uIrregularity, 1.0)));
465 float lenVar = mix(0.35, 1.15, h1) + pow(h2, 6.0) * 1.8;
466 float spikeScale = mix(1.0, lenVar, min(uIrregularity, 1.5));
467
468 float thisSpike = spike * spikeLength * spikeScale;
469 float starShape = baseR + thisSpike;
470
471 float feather = max(0.002, uEdgeBlur);
472 float mask = smoothstep(starShape + feather, starShape - feather, dist);
473
474 if (mask < 0.001) {
475 gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
476 return;
477 }
478
479 float edgeDist = abs(dist - starShape);
480 float edgeFactor = smoothstep(feather * 3.0, 0.0, edgeDist) * uChromaticStrength;
481 vec2 chrDir = normalize(delta + 0.001) * edgeFactor;
482
483 float r = texture2D(tDiffuse, uv + chrDir).r;
484 float g = texture2D(tDiffuse, uv).g;
485 float b = texture2D(tDiffuse, uv - chrDir).b;
486 vec3 sceneColor = vec3(r, g, b);
487
488 gl_FragColor = vec4(sceneColor, mask);
489 }
490 ` ,
491 };
492
493 const TransitionFXShader = {
494 uniforms: {
495 tDiffuse: { value: null },
496 uTransAmount: { value: 0.0 },
497 uChromatic: { value: P .transChromatic },
498 uZoomBlur: { value: P .transZoomBlur },
499 uDistortion: { value: P .transDistortion },
500 uBrightness: { value: P .transBrightness },
501 uContrast: { value: P .transContrast },
502 uSaturation: { value: P .transSaturation },
503 },
504 vertexShader: `
505 varying vec2 vUv;
506 void main() {
507 vUv = uv;
508 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
509 }
510 ` ,
511 fragmentShader: `
512 uniform sampler2D tDiffuse;
513 uniform float uTransAmount;
514 uniform float uChromatic;
515 uniform float uZoomBlur;
516 uniform float uDistortion;
517 uniform float uBrightness;
518 uniform float uContrast;
519 uniform float uSaturation;
520 varying vec2 vUv;
521
522 void main() {
523 if (uTransAmount < 0.001) {
524 gl_FragColor = texture2D(tDiffuse, vUv);
525 return;
526 }
527
528 vec2 uv = vUv;
529 vec2 center = uv - 0.5;
530 float dist = length(center);
531
532 vec2 distortedUV = uv + center * dist * dist * uDistortion * uTransAmount;
533 distortedUV = clamp(distortedUV, 0.0, 1.0);
534
535 vec3 color = vec3(0.0);
536 float totalWeight = 0.0;
537 float blurAmount = uZoomBlur * uTransAmount * 0.1;
538 for (int i = 0; i < 10; i++) {
539 float t = float(i) / 9.0;
540 float weight = 1.0 - t * 0.6;
541 vec2 sampleUV = clamp(distortedUV + center * blurAmount * t, 0.0, 1.0);
542 color += texture2D(tDiffuse, sampleUV).rgb * weight;
543 totalWeight += weight;
544 }
545 color /= totalWeight;
546
547 float caAmount = uChromatic * uTransAmount;
548 vec2 caDir = normalize(center + 0.001) * caAmount;
549 color.r = mix(color.r, texture2D(tDiffuse, clamp(distortedUV + caDir, 0.0, 1.0)).r, uTransAmount);
550 color.b = mix(color.b, texture2D(tDiffuse, clamp(distortedUV - caDir, 0.0, 1.0)).b, uTransAmount);
551
552 float brightness = mix(1.0, uBrightness, uTransAmount);
553 color *= brightness;
554
555 float contrast = mix(1.0, uContrast, uTransAmount);
556 color = (color - 0.5) * contrast + 0.5;
557
558 float saturation = mix(1.0, uSaturation, uTransAmount);
559 float lum = dot(color, vec3(0.299, 0.587, 0.114));
560 color = mix(vec3(lum), color, saturation);
561
562 gl_FragColor = vec4(clamp(color, 0.0, 1.0), texture2D(tDiffuse, uv).a);
563 }
564 ` ,
565 };
566
567 const GlitchShader = {
568 uniforms: {
569 tDiffuse: { value: null },
570 uTime: { value: 0 },
571 uShakePower: { value: 0.0 },
572 uShakeRate: { value: 0.0 },
573 uShakeSpeed: { value: P .glitchSpeed },
574 uShakeBlockSize: { value: P .glitchBlockSize },
575 uShakeColorRate: { value: 0.0 },
576 },
577 vertexShader: `
578 varying vec2 vUv;
579 void main() {
580 vUv = uv;
581 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
582 }
583 ` ,
584 fragmentShader: `
585 uniform sampler2D tDiffuse;
586 uniform float uTime;
587 uniform float uShakePower;
588 uniform float uShakeRate;
589 uniform float uShakeSpeed;
590 uniform float uShakeBlockSize;
591 uniform float uShakeColorRate;
592 varying vec2 vUv;
593
594 float random(float seed) {
595 return fract(543.2543 * sin(dot(vec2(seed, seed), vec2(3525.46, -54.3415))));
596 }
597
598 void main() {
599 float enable_shift = float(
600 random(trunc(uTime * uShakeSpeed)) < uShakeRate
601 );
602
603 vec2 fixed_uv = vUv;
604 fixed_uv.x += (
605 random(
606 (trunc(vUv.y * uShakeBlockSize) / uShakeBlockSize) + uTime
607 ) - 0.5
608 ) * uShakePower * enable_shift;
609
610 vec4 pixel_color = texture2D(tDiffuse, fixed_uv);
611 pixel_color.r = mix(
612 pixel_color.r,
613 texture2D(tDiffuse, fixed_uv + vec2(uShakeColorRate, 0.0)).r,
614 enable_shift
615 );
616 pixel_color.b = mix(
617 pixel_color.b,
618 texture2D(tDiffuse, fixed_uv + vec2(-uShakeColorRate, 0.0)).b,
619 enable_shift
620 );
621
622 gl_FragColor = pixel_color;
623 }
624 ` ,
625 };
626
627 const EdgeBlurShader = {
628 uniforms: {
629 tDiffuse: { value: null },
630 uDirection: { value: new THREE . Vector2 ( 1 , 0 ) },
631 uBlurAmount: { value: 0.0 },
632 uSpread: { value: P .edgeBlurSpread },
633 uResolution: { value: new THREE . Vector2 ( W , H ) },
634 },
635 vertexShader: `
636 varying vec2 vUv;
637 void main() {
638 vUv = uv;
639 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
640 }
641 ` ,
642 fragmentShader: `
643 uniform sampler2D tDiffuse;
644 uniform vec2 uDirection;
645 uniform float uBlurAmount;
646 uniform float uSpread;
647 uniform vec2 uResolution;
648 varying vec2 vUv;
649
650 void main() {
651 vec2 center = vUv - 0.5;
652 float dist = length(center);
653 float blurStart = (1.0 - uSpread) * 0.5;
654 float edgeFactor = smoothstep(blurStart, blurStart + 0.3, dist);
655 float radius = edgeFactor * uBlurAmount;
656
657 if (radius < 0.5) {
658 gl_FragColor = texture2D(tDiffuse, vUv);
659 return;
660 }
661
662 vec2 texelSize = 1.0 / uResolution;
663 vec2 step = uDirection * texelSize * radius;
664
665 vec4 result = vec4(0.0);
666 result += texture2D(tDiffuse, vUv - step * 6.0) * 0.002;
667 result += texture2D(tDiffuse, vUv - step * 5.0) * 0.009;
668 result += texture2D(tDiffuse, vUv - step * 4.0) * 0.028;
669 result += texture2D(tDiffuse, vUv - step * 3.0) * 0.066;
670 result += texture2D(tDiffuse, vUv - step * 2.0) * 0.121;
671 result += texture2D(tDiffuse, vUv - step * 1.0) * 0.175;
672 result += texture2D(tDiffuse, vUv) * 0.198;
673 result += texture2D(tDiffuse, vUv + step * 1.0) * 0.175;
674 result += texture2D(tDiffuse, vUv + step * 2.0) * 0.121;
675 result += texture2D(tDiffuse, vUv + step * 3.0) * 0.066;
676 result += texture2D(tDiffuse, vUv + step * 4.0) * 0.028;
677 result += texture2D(tDiffuse, vUv + step * 5.0) * 0.009;
678 result += texture2D(tDiffuse, vUv + step * 6.0) * 0.002;
679
680 gl_FragColor = result;
681 }
682 ` ,
683 };
684
685 const PostFXShader = {
686 uniforms: {
687 tDiffuse: { value: null },
688 uVignetteIntensity: { value: 0.0 },
689 uChromaticAberration: { value: 0.0 },
690 uScanlineIntensity: { value: 0.0 },
691 uTime: { value: 0.0 },
692 },
693 vertexShader: `
694 varying vec2 vUv;
695 void main() {
696 vUv = uv;
697 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
698 }
699 ` ,
700 fragmentShader: `
701 uniform sampler2D tDiffuse;
702 uniform float uVignetteIntensity;
703 uniform float uChromaticAberration;
704 uniform float uScanlineIntensity;
705 uniform float uTime;
706 varying vec2 vUv;
707
708 void main() {
709 vec2 uv = vUv;
710 vec2 center = uv - 0.5;
711 float dist = length(center);
712 float dist2 = dot(center, center);
713
714 float edgeFactor = smoothstep(0.2, 0.6, dist);
715 float caStrength = uChromaticAberration * edgeFactor;
716 vec2 caDir = normalize(center + 0.001) * caStrength;
717 vec3 color;
718 color.r = texture2D(tDiffuse, clamp(uv + caDir, 0.0, 1.0)).r;
719 color.g = texture2D(tDiffuse, uv).g;
720 color.b = texture2D(tDiffuse, clamp(uv - caDir, 0.0, 1.0)).b;
721
722 float scanline = sin(uv.y * 800.0 + uTime * 2.0) * 0.5 + 0.5;
723 scanline = pow(scanline, 8.0) * uScanlineIntensity;
724 color -= scanline * 0.08;
725 float noiseLine = fract(sin(floor(uv.y * 400.0 + uTime * 5.0) * 43758.5453)) * uScanlineIntensity * 0.03;
726 color += noiseLine;
727
728 float vignette = 1.0 - dist2 * uVignetteIntensity;
729 vignette = smoothstep(0.0, 0.5, clamp(vignette, 0.0, 1.0));
730 color *= vignette;
731
732 gl_FragColor = vec4(color, texture2D(tDiffuse, uv).a);
733 }
734 ` ,
735 };
736
737 const GridLineShader = {
738 uniforms: {
739 uColor: { value: new THREE . Color ( P .gridLineColor) },
740 uOpacity: { value: P .gridOpacity },
741 uFadeStart: { value: P .gridFadeStart },
742 uFadeEnd: { value: P .gridFadeEnd },
743 uCameraPos: { value: new THREE . Vector3 () },
744 uTime: { value: 0.0 },
745 uGlowSpeed: { value: P .gridGlowSpeed },
746 uGlowIntensity: { value: P .gridGlowIntensity },
747 },
748 vertexShader: `
749 varying vec3 vWorldPos;
750 void main() {
751 vec4 worldPos = modelMatrix * vec4(position, 1.0);
752 vWorldPos = worldPos.xyz;
753 gl_Position = projectionMatrix * viewMatrix * worldPos;
754 }
755 ` ,
756 fragmentShader: `
757 uniform vec3 uColor;
758 uniform float uOpacity;
759 uniform float uFadeStart;
760 uniform float uFadeEnd;
761 uniform vec3 uCameraPos;
762 uniform float uTime;
763 uniform float uGlowSpeed;
764 uniform float uGlowIntensity;
765 varying vec3 vWorldPos;
766
767 float hash(float n) { return fract(sin(n) * 43758.5453); }
768
769 void main() {
770 float dist = distance(vWorldPos, uCameraPos);
771 float fade = 1.0 - smoothstep(uFadeStart, uFadeEnd, dist);
772 if (fade < 0.01) discard;
773
774 float dotGlow = 0.0;
775 float dotRadius = 0.4;
776
777 for (int d = 0; d < 5; d++) {
778 float di = float(d);
779 float seedX = hash(floor(vWorldPos.y * 10.0) * 137.0 + floor(vWorldPos.z * 10.0) * 311.0 + di * 73.0);
780 float dotPosX = mod(uTime * uGlowSpeed * (0.5 + seedX * 2.0) + seedX * 200.0, 300.0) - 150.0;
781 dotGlow += exp(-pow((vWorldPos.x - dotPosX) / dotRadius, 2.0)) * 0.5;
782
783 float seedY = hash(floor(vWorldPos.x * 10.0) * 173.0 + floor(vWorldPos.z * 10.0) * 271.0 + di * 91.0);
784 float dotPosY = mod(uTime * uGlowSpeed * (0.5 + seedY * 2.0) + seedY * 200.0, 300.0) - 150.0;
785 dotGlow += exp(-pow((vWorldPos.y - dotPosY) / dotRadius, 2.0)) * 0.5;
786
787 float seedZ = hash(floor(vWorldPos.x * 10.0) * 251.0 + floor(vWorldPos.y * 10.0) * 197.0 + di * 59.0);
788 float dotPosZ = mod(uTime * uGlowSpeed * (0.5 + seedZ * 2.0) + seedZ * 200.0, 300.0) - 150.0;
789 dotGlow += exp(-pow((vWorldPos.z - dotPosZ) / dotRadius, 2.0)) * 0.5;
790 }
791
792 dotGlow *= uGlowIntensity;
793
794 float finalAlpha = (uOpacity + dotGlow) * fade;
795 vec3 finalColor = uColor * (1.0 + dotGlow * 3.0);
796
797 gl_FragColor = vec4(finalColor, min(finalAlpha, 1.0));
798 }
799 ` ,
800 };
801
802 // ---------- font: local Geist Bold TTF -> three Font (blocks the load event) ----------
803 const ttfBuffer = await fetch ( "assets/fonts/Geist-Bold.ttf" ). then (( r ) => r. arrayBuffer ());
804 const font = new FontLoader (). parse ( new TTFLoader (). parse (ttfBuffer));
805 try {
806 await document.fonts.ready; // DOM letter rects must be measured with the real font
807 } catch (e) {}
808
809 // Shrink the DOM headline if a long title overflows, BEFORE measuring spans
810 for ( const id of [ "wpt-headline" ]) {
811 const el = document. getElementById (id);
812 if (el.scrollWidth > 1760 ) {
813 el.style.fontSize = Math. floor ( 176 * ( 1760 / el.scrollWidth)) + "px" ;
814 }
815 }
816
817 // ---------- renderer / scenes / camera ----------
818 const canvas = document. getElementById ( "wpt-three" );
819 const renderer = new THREE . WebGLRenderer ({
820 canvas,
821 antialias: true ,
822 alpha: true ,
823 stencil: true ,
824 preserveDrawingBuffer: true , // audit pixel-hashing + deterministic capture read the buffer back
825 });
826 renderer. setSize ( W , H , false );
827 renderer. setPixelRatio ( 1 );
828 renderer. setClearColor ( 0x000000 , 0 );
829 renderer.toneMapping = THREE .NoToneMapping;
830
831 const scene = new THREE . Scene ();
832 const gridScene = new THREE . Scene ();
833 const camera = new THREE . PerspectiveCamera ( P .initialFOV, W / H , 0.1 , 500 );
834 camera.position. set ( 0 , 0 , P .cameraDistance);
835 camera. lookAt ( 0 , 0 , 0 );
836
837 // Lighting (for the floating extruded shapes)
838 scene. add ( new THREE . AmbientLight ( 0xffffff , P .ambientIntensity));
839 const dirLight = new THREE . DirectionalLight ( 0xffffff , P .keyLightIntensity);
840 dirLight.position. set ( P .keyLightX, P .keyLightY, P .keyLightZ);
841 scene. add (dirLight);
842
843 // ---------- background plane + volumetric glow-dot grid ----------
844 const bgPlaneMat = new THREE . MeshBasicMaterial ({
845 color: P .portalStartColor,
846 side: THREE .DoubleSide,
847 });
848 const bgPlane = new THREE . Mesh ( new THREE . PlaneGeometry ( 500 , 500 ), bgPlaneMat);
849 bgPlane.position.z = - 100 ;
850 bgPlane.renderOrder = - 10 ;
851 gridScene. add (bgPlane);
852
853 const gridPositions = [];
854 {
855 const count = Math. floor ( P .gridSize / P .gridSpacing);
856 const he = P .gridSize;
857 for ( let a = - count; a <= count; a ++ ) {
858 for ( let b = - count; b <= count; b ++ ) {
859 const ca = a * P .gridSpacing;
860 const cb = b * P .gridSpacing;
861 gridPositions. push ( - he, ca, cb, he, ca, cb);
862 gridPositions. push (ca, - he, cb, ca, he, cb);
863 gridPositions. push (ca, cb, - he, ca, cb, he);
864 }
865 }
866 }
867 const gridGeo = new THREE . BufferGeometry ();
868 gridGeo. setAttribute ( "position" , new THREE . Float32BufferAttribute (gridPositions, 3 ));
869 const gridMat = new THREE . ShaderMaterial ({
870 uniforms: THREE .UniformsUtils. clone (GridLineShader.uniforms),
871 vertexShader: GridLineShader.vertexShader,
872 fragmentShader: GridLineShader.fragmentShader,
873 transparent: true ,
874 depthWrite: false ,
875 });
876 const gridLines = new THREE . LineSegments (gridGeo, gridMat);
877 gridLines.position.x = P .gridOffsetX;
878 gridLines.visible = false ;
879 gridScene. add (gridLines);
880
881 // ---------- floating extruded triangles (experiment's decorative shapes) ----------
882 function triangleShape ( size ) {
883 const shape = new THREE . Shape ();
884 const h = (size * Math. sqrt ( 3 )) / 2 ;
885 shape. moveTo ( 0 , h * 0.66 );
886 shape. lineTo ( - size / 2 , - h * 0.33 );
887 shape. lineTo (size / 2 , - h * 0.33 );
888 shape. closePath ();
889 return shape;
890 }
891 const floatingShapes = [];
892 const shapeConfigs = [
893 { size: 1.8 , basePos: [ - 8 , 5 , 2 ], color: ACCENT },
894 { size: 1.4 , basePos: [ 7 , - 3 , 1 ], color: ACCENT },
895 { size: 1.0 , basePos: [ - 6 , - 6 , 3 ], color: "#1a1a1a" },
896 { size: 1.2 , basePos: [ 9 , 6 , 2 ], color: ACCENT },
897 ];
898 for ( const cfg of shapeConfigs) {
899 const geo = new THREE . ExtrudeGeometry ( triangleShape (cfg.size), {
900 depth: 0.3 ,
901 bevelEnabled: true ,
902 bevelThickness: 0.02 ,
903 bevelSize: 0.02 ,
904 bevelSegments: 2 ,
905 });
906 geo. center ();
907 const mesh = new THREE . Mesh (
908 geo,
909 new THREE . MeshStandardMaterial ({ color: cfg.color, roughness: 0.4 , metalness: 0.1 }),
910 );
911 mesh.position. set (cfg.basePos[ 0 ], cfg.basePos[ 1 ], cfg.basePos[ 2 ]);
912 mesh.userData.basePos = new THREE . Vector3 ( ... cfg.basePos);
913 scene. add (mesh);
914 floatingShapes. push (mesh);
915 }
916
917 // ---------- 3D page content: extruded letters + flat outlined lines ----------
918 const contentGroup = new THREE . Group ();
919 contentGroup.visible = false ;
920 scene. add (contentGroup);
921 const textMeshes = [];
922
923 // Screen px -> world units at the 2D camera (FOV 100, z 22)
924 const fovRad = ( P .initialFOV * Math. PI ) / 180 ;
925 const visH = 2 * P .cameraDistance * Math. tan (fovRad / 2 );
926 const visW = visH * ( W / H );
927 function rectToWorld ( r ) {
928 return {
929 x: ((r.left + r.width / 2 ) / W - 0.5 ) * visW,
930 y: ( 0.5 - (r.top + r.height / 2 ) / H ) * visH,
931 w: (r.width / W ) * visW,
932 h: (r.height / H ) * visH,
933 };
934 }
935 // Transform-immune element rect (GSAP intro tweens offset elements at t=0)
936 function offsetRect ( el ) {
937 let left = el.offsetLeft;
938 let top = el.offsetTop;
939 let node = el.offsetParent;
940 while (node && node.id !== "wpt-card" && node !== document.body) {
941 left += node.offsetLeft;
942 top += node.offsetTop;
943 node = node.offsetParent;
944 }
945 return { left, top, width: el.offsetWidth, height: el.offsetHeight };
946 }
947 const lineResolution = new THREE . Vector2 ( W , H );
948
949 // Geist glyphs contain self-overlapping junction notches (fine for nonzero-winding
950 // browser fills, fatal for extrusion triangulation + EdgesGeometry). Resolve the
951 // overlaps with a nonzero polygon union before extruding.
952 function cleanShapes ( shapes ) {
953 const SC = 1000 ;
954 const cleaned = [];
955 for ( const shape of shapes) {
956 const pts = shape. extractPoints ( 12 );
957 const paths = [pts.shape, ... pts.holes]. map (( ring ) =>
958 ring. map (( p ) => ({ X: Math. round (p.x * SC ), Y: Math. round (p.y * SC ) })),
959 );
960 let simp;
961 try {
962 simp = ClipperLib.Clipper. SimplifyPolygons (paths, ClipperLib.PolyFillType.pftNonZero);
963 } catch (e) {
964 cleaned. push (shape);
965 continue ;
966 }
967 const outerRings = [];
968 const holeRings = [];
969 for ( const ring of simp) {
970 if (ring. length < 3 ) continue ;
971 (ClipperLib.Clipper. Orientation (ring) ? outerRings : holeRings). push (ring);
972 }
973 const outShapes = outerRings. map (
974 ( r ) => new THREE . Shape (r. map (( p ) => new THREE . Vector2 (p. X / SC , p. Y / SC ))),
975 );
976 for ( const hr of holeRings) {
977 let owner = outShapes[ 0 ];
978 for ( let oi = 0 ; oi < outerRings. length ; oi ++ ) {
979 if (ClipperLib.Clipper. PointInPolygon (hr[ 0 ], outerRings[oi]) !== 0 ) {
980 owner = outShapes[oi];
981 break ;
982 }
983 }
984 if (owner)
985 owner.holes. push (
986 new THREE . Path (hr. map (( p ) => new THREE . Vector2 (p. X / SC , p. Y / SC ))),
987 );
988 }
989 cleaned. push ( ... outShapes);
990 }
991 return cleaned;
992 }
993
994 function cleanTextGeometry ( text , size , depth ) {
995 const shapes = cleanShapes (font. generateShapes (text, size));
996 return new THREE . ExtrudeGeometry (shapes, { depth, bevelEnabled: false });
997 }
998
999 function createLetterAt ( char , wr , random = rand) {
1000 const g = new THREE . Group ();
1001 // A shared geometry size lets a reused glyph scale continuously between
1002 // source and target layouts, even when a long phrase needs smaller type.
1003 const size = 10 ;
1004 const geo = cleanTextGeometry (char, size, P .textDepthMax);
1005 geo. computeBoundingBox ();
1006 const bb = geo.boundingBox;
1007 const geoW = bb.max.x - bb.min.x;
1008 const geoH = bb.max.y - bb.min.y;
1009 geo. center ();
1010 const xyScale = Math. min (wr.w / (geoW || 1 ), wr.h / (geoH || 1 ));
1011
1012 g. add (
1013 new THREE . Mesh (
1014 geo,
1015 new THREE . MeshBasicMaterial ({
1016 color: "#050505" ,
1017 side: THREE .DoubleSide,
1018 transparent: true ,
1019 }),
1020 ),
1021 );
1022
1023 const edges = new THREE . EdgesGeometry (geo, 20 );
1024 const lineGeo = new LineSegmentsGeometry ();
1025 lineGeo. setPositions (edges.attributes.position.array);
1026 const lineMat = new LineMaterial ({
1027 color: new THREE . Color ( P .outlineColor). getHex (),
1028 linewidth: P .outlineWidth,
1029 transparent: true ,
1030 resolution: lineResolution,
1031 });
1032 g. add ( new LineSegments2 (lineGeo, lineMat));
1033 edges. dispose ();
1034
1035 g.scale. set (xyScale, xyScale, 0.01 );
1036 g.position. set (wr.x, wr.y, 0 );
1037 g.userData.char = char;
1038 g.userData.baseX = wr.x;
1039 g.userData.baseY = wr.y;
1040 g.userData.xyScale = xyScale;
1041 g.userData.targetZ = ( random () - 0.5 ) * P .letterZSpread;
1042 const ratio = P .textDepthMin / P .textDepthMax;
1043 g.userData.depthScale = ratio + random () * ( 1 - ratio);
1044 g.userData.isLetter = true ;
1045 contentGroup. add (g);
1046 textMeshes. push (g);
1047 return g;
1048 }
1049
1050 function createFlatLine ( text , wr , widthMult , opacity ) {
1051 const geo = cleanTextGeometry (text, 1 , 0.02 );
1052 geo. computeBoundingBox ();
1053 const bb = geo.boundingBox;
1054 const geoW = bb.max.x - bb.min.x;
1055 const geoH = bb.max.y - bb.min.y;
1056 geo. center ();
1057 const s = Math. min (wr.h / (geoH || 1 ), wr.w / (geoW || 1 ));
1058
1059 const g = new THREE . Group ();
1060 g. add (
1061 new THREE . Mesh (
1062 geo,
1063 new THREE . MeshBasicMaterial ({
1064 color: "#050505" ,
1065 side: THREE .DoubleSide,
1066 transparent: true ,
1067 }),
1068 ),
1069 );
1070 const edges = new THREE . EdgesGeometry (geo, 40 );
1071 const lineGeo = new LineSegmentsGeometry ();
1072 lineGeo. setPositions (edges.attributes.position.array);
1073 const lineMat = new LineMaterial ({
1074 color: new THREE . Color ( P .outlineColor). getHex (),
1075 linewidth: P .outlineWidth * widthMult,
1076 transparent: true ,
1077 opacity: opacity,
1078 resolution: lineResolution,
1079 });
1080 g. add ( new LineSegments2 (lineGeo, lineMat));
1081 edges. dispose ();
1082
1083 g.scale. set (s, s, 1 );
1084 g.position. set (wr.x, wr.y, 0 );
1085 g.userData.baseY = wr.y;
1086 g.userData.isSupporting = true ;
1087 g.userData.faceColor = g.children[ 0 ].material.color. clone ();
1088 contentGroup. add (g);
1089 textMeshes. push (g);
1090 }
1091
1092 // Headline letters from the DOM spans (2D card and 3D letters stay aligned)
1093 document. querySelectorAll ( "#wpt-headline [data-3d-char]" ). forEach (( span ) => {
1094 const ch = span.textContent;
1095 if ( ! ch || ch. trim () === "" ) return ;
1096 const r = offsetRect (span);
1097 if (r.width < 1 || r.height < 1 ) return ;
1098 createLetterAt (ch, rectToWorld (r));
1099 });
1100 const sourceLetters = textMeshes. slice ();
1101 // Kicker + subtitle as flat outlined lines
1102 const kickEl = document. getElementById ( "wpt-kicker-text" );
1103 if (kickEl && kickEl.textContent. trim ()) {
1104 createFlatLine (kickEl.textContent, rectToWorld ( offsetRect (kickEl)), 0.7 , 0.6 );
1105 }
1106 const subEl = document. getElementById ( "wpt-subtitle" );
1107 if (subEl && subEl.textContent. trim ()) {
1108 createFlatLine (subEl.textContent, rectToWorld ( offsetRect (subEl)), 0.7 , 0.6 );
1109 }
1110
1111 const supportingLines = textMeshes. filter (( obj ) => obj.userData.isSupporting);
1112 const replacementLetters = [];
1113 const transition = window.__wptTransition;
1114 const clamp01 = ( v ) => Math. max ( 0 , Math. min ( 1 , v));
1115 const smooth = ( v ) => {
1116 const x = clamp01 (v);
1117 return x * x * ( 3 - 2 * x);
1118 };
1119
1120 // Typeset from font metrics, independent of any final DOM card. Search
1121 // whole-word wraps, maximizing readable type size in a safe 1520 x 620 box.
1122 // A long unbroken word shrinks intact; no words or glyphs are discarded.
1123 function layoutPhrase ( text ) {
1124 const measure = document. createElement ( "canvas" ). getContext ( "2d" );
1125 measure.font = "700 176px Geist" ;
1126 const tracking = 176 * 0.03 ;
1127 const advance = ( char ) => measure. measureText (char).width + tracking;
1128 const width = ( line ) =>
1129 Array. from (line). reduce (( sum , char ) => sum + advance (char), - tracking);
1130 const words = text. split ( " " );
1131 const widest = Math. max ( ... words. map (width));
1132 const total = width (text);
1133 let best = null ;
1134 // Trying each possible break-width gives several balanced candidates
1135 // without restricting the result to two words or a fixed line count.
1136 const candidates = new Set ([total, widest]);
1137 for ( let first = 0 ; first < words. length ; first ++ ) {
1138 let line = "" ;
1139 for ( let last = first; last < words. length ; last ++ ) {
1140 line += (line ? " " : "" ) + words[last];
1141 candidates. add ( width (line));
1142 }
1143 }
1144 for ( const limit of candidates) {
1145 if (limit < widest) continue ;
1146 const lines = [];
1147 let line = "" ;
1148 for ( const word of words) {
1149 const next = line ? line + " " + word : word;
1150 if (line && width (next) > limit + 0.01 ) {
1151 lines. push (line);
1152 line = word;
1153 } else line = next;
1154 }
1155 if (line) lines. push (line);
1156 const widths = lines. map (width);
1157 const scale = Math. min ( 1 , 1520 / Math. max ( ... widths), 620 / (lines. length * 176 * 1.18 ));
1158 const balance = Math. min ( ... widths) / Math. max ( ... widths);
1159 const score = scale * ( 0.94 + 0.06 * balance);
1160 if ( ! best || score > best.score) best = { lines, widths, scale, score };
1161 }
1162 const glyphs = [];
1163 const lineHeight = 176 * 1.18 * best.scale;
1164 best.lines. forEach (( line , row ) => {
1165 let left = ( W - best.widths[row] * best.scale) / 2 ;
1166 for ( const char of Array. from (line)) {
1167 const step = advance (char) * best.scale;
1168 if (char. trim ())
1169 glyphs. push ({
1170 char,
1171 row,
1172 rect: {
1173 left,
1174 top: H / 2 + (row - (best.lines. length - 1 ) / 2 ) * lineHeight - 88 * best.scale,
1175 width: ( advance (char) - tracking) * best.scale,
1176 height: 176 * best.scale,
1177 },
1178 });
1179 left += step;
1180 }
1181 });
1182 return { ... best, glyphs };
1183 }
1184 const phraseLayout = layoutPhrase (window.__wptPhrase);
1185 for ( const { char , rect , row } of phraseLayout.glyphs) {
1186 const letter = createLetterAt (char, rectToWorld (rect), phraseRand);
1187 // Keep spacing consistent at the hold camera while retaining real depth.
1188 const distance = P .cameraDistance3D - P .contentZOffset;
1189 const perspective = (distance - letter.userData.targetZ) / distance;
1190 letter.userData.baseX *= perspective;
1191 letter.userData.baseY *= perspective;
1192 letter.userData.xyScale *= perspective;
1193 letter.userData.row = row;
1194 replacementLetters. push (letter);
1195 }
1196
1197 // Reuse matching letter identities first, then pair remaining glyphs by
1198 // spatial proximity. Extra target letters have their own distant Z origin;
1199 // unused source letters depart early instead of lingering until a swap.
1200 const slots = replacementLetters. map (( to ) => ({ from: null , to }));
1201 const remaining = new Set (sourceLetters);
1202 const distance = ( a , b ) => Math. hypot (a.baseX - b.baseX, a.baseY - b.baseY);
1203 for ( const exactOnly of [ true , false ]) {
1204 for ( const slot of slots) {
1205 if (slot.from) continue ;
1206 let closest = null ,
1207 cost = Infinity ;
1208 for ( const letter of remaining) {
1209 if (exactOnly && letter.userData.char !== slot.to.userData.char) continue ;
1210 const candidateCost = distance (letter.userData, slot.to.userData);
1211 if (candidateCost < cost) {
1212 closest = letter;
1213 cost = candidateCost;
1214 }
1215 }
1216 if (closest) {
1217 slot.from = closest;
1218 remaining. delete (closest);
1219 }
1220 }
1221 }
1222 for ( const from of remaining) slots. push ({ from, to: null });
1223 slots. forEach (( slot , i ) => {
1224 const target = slot.to || slot.from;
1225 slot.start = slot.from
1226 ? slot.from.userData
1227 : {
1228 baseX: target.userData.baseX * 1.25 ,
1229 baseY: target.userData.baseY * 1.25 ,
1230 targetZ: - 60 - phraseRand () * 65 ,
1231 xyScale: target.userData.xyScale,
1232 };
1233 slot.end = (slot.to || slot.from).userData;
1234 const rank = i / Math. max ( 1 , replacementLetters. length - 1 );
1235 slot.delay = clamp01 (rank) * transition.duration * 0.26 ;
1236 // Keep the same object alive when the character itself does not change.
1237 slot.sameGlyph = !! (
1238 slot.from &&
1239 slot.to &&
1240 slot.from.userData.char === slot.to.userData.char
1241 );
1242 if (slot.sameGlyph) slot.to.visible = false ;
1243 });
1244
1245 // The adjustable power concentrates speed around the edge-on letter swap
1246 // at 62% of local time: slow preparation, fast handoff, then a soft settle.
1247 function phraseEase ( progress ) {
1248 const x = clamp01 (progress);
1249 const a = Math. pow (x * 0.38 , transition.easing);
1250 const b = Math. pow (( 1 - x) * 0.62 , transition.easing);
1251 return a / (a + b);
1252 }
1253 function opacityFor ( obj , opacity ) {
1254 for ( const child of obj.children) {
1255 child.material.opacity = opacity;
1256 child.material.depthWrite = opacity > 0.995 ;
1257 }
1258 }
1259 function depthFade ( obj , amount , baseOpacity = 1 ) {
1260 const opacity = 1 - smooth ((amount - 0.005 ) / 0.13 );
1261 opacityFor (obj, opacity);
1262 const haze = transition.fog ? 1 - smooth (amount / 0.15 ) : 1 ;
1263 obj.children[ 1 ].material.color. set ( P .outlineColor). lerp (endCol, 1 - haze);
1264 obj.children[ 1 ].material.opacity = baseOpacity * opacity;
1265 obj.visible = opacity > 0.001 ;
1266 return opacity;
1267 }
1268
1269 function handoffLetters ( t , portal ) {
1270 let glitchEnvelope = 0 ;
1271 for ( let i = 0 ; i < slots. length ; i ++ ) {
1272 const slot = slots[i];
1273 const progress = clamp01 (
1274 (t - transition.start - slot.delay) / (transition.duration * 0.74 ),
1275 );
1276 const motion = phraseEase (progress);
1277 const incoming = motion >= 0.5 ;
1278 const start = slot.start,
1279 end = slot.end;
1280 // Spare source letters fog out during the first fraction of recession,
1281 // before the active letters turn. This path also runs with fog disabled.
1282 if ( ! slot.to) {
1283 const depart = smooth (
1284 (t - transition.start - (i % 3 ) * 0.025 ) / Math. min ( 0.75 , transition.duration * 0.45 ),
1285 );
1286 const obj = slot.from;
1287 obj.position. set (start.baseX, start.baseY, start.targetZ * portal - depart * 125 );
1288 obj.rotation. set ( 0 , depart * 0.3 , 0 );
1289 obj.scale. set (
1290 start.xyScale,
1291 start.xyScale,
1292 THREE .MathUtils. lerp ( 0.01 , start.depthScale, portal),
1293 );
1294 depthFade (obj, depart);
1295 continue ;
1296 }
1297 const x = THREE .MathUtils. lerp (start.baseX, end.baseX, motion);
1298 const y =
1299 THREE .MathUtils. lerp (start.baseY, end.baseY, motion) +
1300 Math. sin (t * 0.6 + i * 0.7 ) * 0.12 * portal;
1301 // Keep the approved forward rush: the original camera pushes into
1302 // the wireframe world as the resolved letters accelerate toward it.
1303 // A small reading-order stagger carries each letter past the lens;
1304 // there is no backward Z travel or opacity dissolve on this exit.
1305 const exitDelay = (i / Math. max ( 1 , replacementLetters. length - 1 )) * 0.2 ;
1306 const flyPast = Math. pow ( clamp01 ((t - 6.0 - exitDelay) / 1.25 ), 3 );
1307 const z =
1308 THREE .MathUtils. lerp (start.targetZ, end.targetZ, motion) * portal -
1309 Math. sin (Math. PI * motion) * 1.8 +
1310 flyPast * 38 ;
1311 // Retire a glyph only after its full extrusion is behind the camera.
1312 // Before then, clipping and perspective carry the fly-through.
1313 const cameraSpace = new THREE . Vector3 (x, y, z + P .contentZOffset * portal). applyMatrix4 (
1314 camera.matrixWorldInverse,
1315 );
1316 const passedCamera = cameraSpace.z > P .textDepthMax * 2 ;
1317 const edge = Math. pow (Math. abs ( 2 * motion - 1 ), 0.65 );
1318 const swapPulse = Math. exp ( - Math. pow ((progress - 0.62 ) / 0.045 , 2 ));
1319 const settlePulse = Math. exp ( - Math. pow ((progress - 0.86 ) / 0.065 , 2 ));
1320 glitchEnvelope = Math. max (glitchEnvelope, swapPulse * 0.65 , settlePulse);
1321 const tick =
1322 Math. sin (Math. floor (t * 36 ) * 17.1 + i * 4.7 ) * settlePulse * transition.glitch;
1323 for ( const [ obj , isIncoming ] of [
1324 [slot.from, false ],
1325 [slot.to, true ],
1326 ]) {
1327 if ( ! obj) continue ;
1328 const isActive = slot.sameGlyph ? ! isIncoming : incoming === isIncoming;
1329 // New glyphs fly up from deep Z throughout the move. They are not
1330 // duplicates split out of a source letter, and never disappear at the seam.
1331 const extra = ! slot.from;
1332 const entrance = extra ? smooth (motion / 0.34 ) : 1 ;
1333 const opacity = entrance;
1334 obj.visible = (extra || isActive) && opacity > 0.001 && ! passedCamera;
1335 opacityFor (obj, opacity);
1336 obj.position. set (x + tick * 0.12 , y + tick * 0.04 , z);
1337 obj.rotation. set (
1338 0 ,
1339 slot.sameGlyph
1340 ? Math. sin (Math. PI * motion) * 0.55
1341 : extra
1342 ? - Math. PI * 0.75 * ( 1 - motion)
1343 : incoming
1344 ? - Math. PI * ( 1 - motion)
1345 : Math. PI * motion,
1346 tick * 0.012 ,
1347 );
1348 const xyScale = THREE .MathUtils. lerp (start.xyScale, end.xyScale, motion);
1349 obj.scale. set (
1350 xyScale,
1351 xyScale,
1352 THREE .MathUtils. lerp ( 0.01 , end.depthScale, portal) *
1353 (extra || slot.sameGlyph ? 1 : 0.015 + 0.985 * edge),
1354 );
1355 }
1356 }
1357 return glitchEnvelope;
1358 }
1359
1360 function recedeSupportingLines ( t ) {
1361 supportingLines. forEach (( obj , i ) => {
1362 const travel = smooth ((t - 2.65 - i * 0.09 ) / 1.3 );
1363 obj.position.z = - 145 * travel;
1364 depthFade (obj, travel, 0.6 );
1365 });
1366 }
1367
1368 // ---------- post-processing chain (experiment pass order + glitch pass) ----------
1369 const renderTarget = new THREE . WebGLRenderTarget ( W , H , {
1370 format: THREE .RGBAFormat,
1371 type: THREE .HalfFloatType,
1372 depthBuffer: true ,
1373 samples: 4 ,
1374 });
1375 const composer = new EffectComposer (renderer, renderTarget);
1376 composer. setSize ( W , H );
1377
1378 const gridRenderPass = new RenderPass (gridScene, camera);
1379 gridRenderPass.clearAlpha = 0 ;
1380 composer. addPass (gridRenderPass);
1381
1382 const contentRenderPass = new RenderPass (scene, camera);
1383 contentRenderPass.clear = false ;
1384 composer. addPass (contentRenderPass);
1385
1386 const blurPassH = new ShaderPass (EdgeBlurShader);
1387 blurPassH.uniforms.uDirection.value. set ( 1 , 0 );
1388 composer. addPass (blurPassH);
1389 const blurPassV = new ShaderPass (EdgeBlurShader);
1390 blurPassV.uniforms.uDirection.value. set ( 0 , 1 );
1391 composer. addPass (blurPassV);
1392
1393 const postfxPass = new ShaderPass (PostFXShader);
1394 composer. addPass (postfxPass);
1395
1396 const glitchPass = new ShaderPass (GlitchShader);
1397 composer. addPass (glitchPass);
1398
1399 const transFXPass = new ShaderPass (TransitionFXShader);
1400 composer. addPass (transFXPass);
1401
1402 const portalPass = new ShaderPass (PortalCompositeShader);
1403 portalPass.material.transparent = true ;
1404 composer. addPass (portalPass);
1405
1406 composer. addPass ( new OutputPass ());
1407
1408 // ---------- warp dots (2D overlay canvas, pure function of state) ----------
1409 const warpCanvas = document. getElementById ( "wpt-warp" );
1410 // Keep warp-dot rasterization identical on fresh and repeated seeks.
1411 const wctx = warpCanvas. getContext ( "2d" , { willReadFrequently: true });
1412 const warpDots = [];
1413 {
1414 const count = P .warpDotCount;
1415 const depthLayers = Math. ceil (Math. cbrt (count) * 2 );
1416 const sideCount = Math. ceil (Math. sqrt (count / depthLayers));
1417 outer : for ( let iz = 0 ; iz < depthLayers; iz ++ ) {
1418 for ( let iy = 0 ; iy < sideCount; iy ++ ) {
1419 for ( let ix = 0 ; ix < sideCount; ix ++ ) {
1420 if (warpDots. length >= count) break outer ;
1421 warpDots. push ({
1422 x: (ix - (sideCount - 1 ) / 2 ) * 1.0 ,
1423 y: (iy - (sideCount - 1 ) / 2 ) * 1.0 ,
1424 z: iz * 1.0 + 0.5 ,
1425 });
1426 }
1427 }
1428 }
1429 }
1430 const WARP_MAX_Z = Math. max ( 10 , Math. ceil (Math. cbrt ( P .warpDotCount) * 2 ));
1431
1432 function drawWarpDots ( S ) {
1433 wctx. clearRect ( 0 , 0 , W , H );
1434 const vis = S .warp;
1435 if (vis < 0.01 ) return ;
1436 const cx = W / 2 ;
1437 const cy = H / 2 ;
1438 for ( let i = 0 ; i < warpDots. length ; i ++ ) {
1439 const d = warpDots[i];
1440 let z = (((d.z - S .travel) % WARP_MAX_Z ) + WARP_MAX_Z ) % WARP_MAX_Z ;
1441 if (z < 0.05 ) z += WARP_MAX_Z ;
1442
1443 const scale = 300 / z;
1444 const sx = cx + d.x * scale;
1445 const sy = cy + d.y * scale;
1446 const size = ( P .warpDotSize / z) * 3 ;
1447 if (size < 0.3 ) continue ;
1448 if (sx < - 10 || sx > W + 10 || sy < - 10 || sy > H + 10 ) continue ;
1449
1450 const depthAlpha = Math. min ( 1 , 2 / z);
1451 const alpha = depthAlpha * vis * P .warpDotOpacity;
1452
1453 wctx.globalAlpha = alpha;
1454 wctx.fillStyle = P .warpDotColor;
1455 wctx. beginPath ();
1456 wctx. arc (sx, sy, size, 0 , Math. PI * 2 );
1457 wctx. fill ();
1458
1459 if (z < 2 && size > 1 ) {
1460 const streakLen = ( 2 - z) * 20 * vis;
1461 const dx = sx - cx;
1462 const dy = sy - cy;
1463 const dist = Math. sqrt (dx * dx + dy * dy) || 1 ;
1464 wctx.globalAlpha = alpha * 0.3 ;
1465 wctx. beginPath ();
1466 wctx. moveTo (sx, sy);
1467 wctx. lineTo (sx + (dx / dist) * streakLen, sy + (dy / dist) * streakLen);
1468 wctx.strokeStyle = P .warpDotColor;
1469 wctx.lineWidth = size * 0.5 ;
1470 wctx. stroke ();
1471 }
1472 }
1473 wctx.globalAlpha = 1 ;
1474 }
1475
1476 // ---------- render: everything derived from timeline time + state proxy ----------
1477 const startCol = new THREE . Color ( P .portalStartColor);
1478 const endCol = new THREE . Color ( P .bgColor);
1479
1480 function renderScene ( t ) {
1481 const S = window.__wptState;
1482 const portal = S .portal;
1483 const push = S .push;
1484 const transAmount = 1.0 - Math. abs (portal * 2 - 1 );
1485 const effTrans = Math. min ( 1 , Math. max (transAmount, S .exitFx * 0.85 ));
1486
1487 // Camera: FOV/distance lerp (2D -> 3D) plus exit push through the letters
1488 camera.fov = THREE .MathUtils. lerp ( P .initialFOV, P .finalFOV, portal) + push * 10 ;
1489 camera.position.z =
1490 THREE .MathUtils. lerp ( P .cameraDistance, P .cameraDistance3D, portal) -
1491 push * 52 +
1492 Math. sin (t * 0.34 ) * 1.6 * portal * ( 1 - push);
1493 const driftAmp = portal * ( 1 - 0.7 * push);
1494 camera.rotation.x = Math. cos (t * 0.47 ) * 0.04 * driftAmp;
1495 camera.rotation.y = Math. sin (t * 0.62 + 1.2 ) * 0.06 * driftAmp;
1496 camera.position.x = Math. sin (t * 0.41 ) * 1.4 * driftAmp;
1497 camera.position.y = Math. cos (t * 0.29 ) * 1.0 * driftAmp;
1498 camera. updateProjectionMatrix ();
1499 camera. updateMatrixWorld ();
1500
1501 // Portal composite
1502 portalPass.uniforms.uPortalRadius.value = portal * P .portalScale;
1503 portalPass.uniforms.uPortalProgress.value = portal;
1504 portalPass.uniforms.uTime.value = t;
1505
1506 // Vignette / scanline / CA scale with portal
1507 postfxPass.uniforms.uVignetteIntensity.value = P .vignetteIntensity * portal;
1508 postfxPass.uniforms.uChromaticAberration.value = P .chromaticAberration * portal;
1509 postfxPass.uniforms.uScanlineIntensity.value = P .scanlineIntensity * portal;
1510 postfxPass.uniforms.uTime.value = t;
1511
1512 const blurAmount = P .edgeBlur * portal;
1513 blurPassH.uniforms.uBlurAmount.value = blurAmount;
1514 blurPassV.uniforms.uBlurAmount.value = blurAmount;
1515
1516 // Transition FX peak at portal 0.5 + on the exit push
1517 transFXPass.uniforms.uTransAmount.value = effTrans;
1518
1519 // Block glitch ticks (the handoff envelope below follows duration/easing).
1520 glitchPass.uniforms.uTime.value = t;
1521 glitchPass.uniforms.uShakePower.value = P .glitchPower * S .glitch;
1522 glitchPass.uniforms.uShakeRate.value = S .glitch > 0.001 ? 1.0 : 0.0 ;
1523 glitchPass.uniforms.uShakeColorRate.value = P .glitchColorRate * Math. min ( 1 , S .glitch * 1.8 );
1524
1525 // Grid + background plane
1526 const showContent = portal > 0.01 ;
1527 gridLines.visible = showContent;
1528 contentGroup.visible = showContent;
1529 bgPlane.visible = showContent;
1530 const colorT = Math. min ( 1 , Math. max ( 0 , (portal - 0.15 ) * P .portalColorSpeed));
1531 bgPlaneMat.color. copy (startCol). lerp (endCol, colorT);
1532 gridMat.uniforms.uCameraPos.value. copy (camera.position);
1533 gridMat.uniforms.uTime.value = t;
1534
1535 // Floating shapes drift
1536 for ( let i = 0 ; i < floatingShapes. length ; i ++ ) {
1537 const mesh = floatingShapes[i];
1538 const base = mesh.userData.basePos;
1539 const phase = i * 2.17 ;
1540 mesh.position.x =
1541 base.x + Math. sin (t * 0.15 + phase) * 1.2 + Math. sin (t * 0.07 + phase * 3 ) * 0.5 ;
1542 mesh.position.y =
1543 base.y + Math. sin (t * 0.12 + phase * 1.4 ) * 0.8 + Math. cos (t * 0.09 + phase * 2 ) * 0.3 ;
1544 mesh.position.z =
1545 base.z + Math. sin (t * 0.1 + phase * 0.7 ) * 0.6 + P .contentZOffset * portal;
1546 mesh.rotation.x = Math. sin (t * 0.08 + phase) * 0.4 ;
1547 mesh.rotation.y = t * 0.05 * (i % 2 === 0 ? 1 : - 1 ) + phase;
1548 mesh.rotation.z = Math. sin (t * 0.06 + phase * 1.3 ) * 0.3 ;
1549 }
1550
1551 // Content: flat page -> per-letter depth and z-spread
1552 contentGroup.position.z = P .contentZOffset * portal;
1553 for ( let i = 0 ; i < textMeshes. length ; i ++ ) {
1554 const obj = textMeshes[i];
1555 if (obj.userData.isLetter) {
1556 obj.position.z = obj.userData.targetZ * portal;
1557 const zScale = THREE .MathUtils. lerp ( 0.01 , obj.userData.depthScale, portal);
1558 obj.scale.z = zScale;
1559 }
1560 obj.position.y = obj.userData.baseY + Math. sin (t * 0.6 + i * 0.7 ) * 0.12 * portal;
1561 }
1562 const handoffGlitch = handoffLetters (t, portal);
1563 recedeSupportingLines (t);
1564 const glitch = Math. max ( S .glitch * 0.5 , handoffGlitch * 0.35 ) * transition.glitch;
1565 glitchPass.uniforms.uShakePower.value = P .glitchPower * glitch;
1566 glitchPass.uniforms.uShakeRate.value = glitch > 0.001 ? 1 : 0 ;
1567 glitchPass.uniforms.uShakeColorRate.value = P .glitchColorRate * glitch * 1.8 ;
1568
1569 if (portal < 0.001 ) {
1570 renderer. clear ();
1571 wctx. clearRect ( 0 , 0 , W , H );
1572 return ;
1573 }
1574 composer. render ();
1575 drawWarpDots ( S );
1576 }
1577
1578 window.__wptRender = renderScene;
1579 const tl = window.__timelines[ "wireframe-portal-title" ];
1580 renderScene (tl ? tl. time () : 0 );
1581 /* The portal is closed at t=0 and draws nothing, so the first real draw would
1582 link every shader mid-playback. Draw across the timeline and flush the GPU
1583 before ready instead. */
1584 window.__hf = window.__hf || {};
1585 window.__hf.buildReady = window.__hf.buildReady || {};
1586 window.__hf.buildReady[ "wireframe-portal-title" ] = ( async () => {
1587 const warmDur = tl ? tl. duration () : 0 ;
1588 for ( const f of [ 0.3 , 0.5 , 0.7 ]) {
1589 await new Promise (( resolve ) => setTimeout (resolve, 0 ));
1590 renderScene (warmDur * f);
1591 }
1592 renderScene (tl ? tl. time () : 0 );
1593 renderer. getContext (). finish ();
1594 })();
1595 </ script >
1596 </ body >
1597 </ html >