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,604 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 // Beside this file when mounted; a runtime without assetUrl keeps the plain path.
804 const assetUrl = ( path ) =>
805 typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
806 ? __hyperframes. assetUrl (path)
807 : path;
808 const ttfBuffer = await fetch ( assetUrl ( "assets/fonts/Geist-Bold.ttf" )). then (( r ) =>
809 r. arrayBuffer (),
810 );
811 const font = new FontLoader (). parse ( new TTFLoader (). parse (ttfBuffer));
812 try {
813 await document.fonts.ready; // DOM letter rects must be measured with the real font
814 } catch (e) {}
815
816 // Shrink the DOM headline if a long title overflows, BEFORE measuring spans
817 for ( const id of [ "wpt-headline" ]) {
818 const el = document. getElementById (id);
819 if (el.scrollWidth > 1760 ) {
820 el.style.fontSize = Math. floor ( 176 * ( 1760 / el.scrollWidth)) + "px" ;
821 }
822 }
823
824 // ---------- renderer / scenes / camera ----------
825 const canvas = document. getElementById ( "wpt-three" );
826 const renderer = new THREE . WebGLRenderer ({
827 canvas,
828 antialias: true ,
829 alpha: true ,
830 stencil: true ,
831 preserveDrawingBuffer: true , // audit pixel-hashing + deterministic capture read the buffer back
832 });
833 renderer. setSize ( W , H , false );
834 renderer. setPixelRatio ( 1 );
835 renderer. setClearColor ( 0x000000 , 0 );
836 renderer.toneMapping = THREE .NoToneMapping;
837
838 const scene = new THREE . Scene ();
839 const gridScene = new THREE . Scene ();
840 const camera = new THREE . PerspectiveCamera ( P .initialFOV, W / H , 0.1 , 500 );
841 camera.position. set ( 0 , 0 , P .cameraDistance);
842 camera. lookAt ( 0 , 0 , 0 );
843
844 // Lighting (for the floating extruded shapes)
845 scene. add ( new THREE . AmbientLight ( 0xffffff , P .ambientIntensity));
846 const dirLight = new THREE . DirectionalLight ( 0xffffff , P .keyLightIntensity);
847 dirLight.position. set ( P .keyLightX, P .keyLightY, P .keyLightZ);
848 scene. add (dirLight);
849
850 // ---------- background plane + volumetric glow-dot grid ----------
851 const bgPlaneMat = new THREE . MeshBasicMaterial ({
852 color: P .portalStartColor,
853 side: THREE .DoubleSide,
854 });
855 const bgPlane = new THREE . Mesh ( new THREE . PlaneGeometry ( 500 , 500 ), bgPlaneMat);
856 bgPlane.position.z = - 100 ;
857 bgPlane.renderOrder = - 10 ;
858 gridScene. add (bgPlane);
859
860 const gridPositions = [];
861 {
862 const count = Math. floor ( P .gridSize / P .gridSpacing);
863 const he = P .gridSize;
864 for ( let a = - count; a <= count; a ++ ) {
865 for ( let b = - count; b <= count; b ++ ) {
866 const ca = a * P .gridSpacing;
867 const cb = b * P .gridSpacing;
868 gridPositions. push ( - he, ca, cb, he, ca, cb);
869 gridPositions. push (ca, - he, cb, ca, he, cb);
870 gridPositions. push (ca, cb, - he, ca, cb, he);
871 }
872 }
873 }
874 const gridGeo = new THREE . BufferGeometry ();
875 gridGeo. setAttribute ( "position" , new THREE . Float32BufferAttribute (gridPositions, 3 ));
876 const gridMat = new THREE . ShaderMaterial ({
877 uniforms: THREE .UniformsUtils. clone (GridLineShader.uniforms),
878 vertexShader: GridLineShader.vertexShader,
879 fragmentShader: GridLineShader.fragmentShader,
880 transparent: true ,
881 depthWrite: false ,
882 });
883 const gridLines = new THREE . LineSegments (gridGeo, gridMat);
884 gridLines.position.x = P .gridOffsetX;
885 gridLines.visible = false ;
886 gridScene. add (gridLines);
887
888 // ---------- floating extruded triangles (experiment's decorative shapes) ----------
889 function triangleShape ( size ) {
890 const shape = new THREE . Shape ();
891 const h = (size * Math. sqrt ( 3 )) / 2 ;
892 shape. moveTo ( 0 , h * 0.66 );
893 shape. lineTo ( - size / 2 , - h * 0.33 );
894 shape. lineTo (size / 2 , - h * 0.33 );
895 shape. closePath ();
896 return shape;
897 }
898 const floatingShapes = [];
899 const shapeConfigs = [
900 { size: 1.8 , basePos: [ - 8 , 5 , 2 ], color: ACCENT },
901 { size: 1.4 , basePos: [ 7 , - 3 , 1 ], color: ACCENT },
902 { size: 1.0 , basePos: [ - 6 , - 6 , 3 ], color: "#1a1a1a" },
903 { size: 1.2 , basePos: [ 9 , 6 , 2 ], color: ACCENT },
904 ];
905 for ( const cfg of shapeConfigs) {
906 const geo = new THREE . ExtrudeGeometry ( triangleShape (cfg.size), {
907 depth: 0.3 ,
908 bevelEnabled: true ,
909 bevelThickness: 0.02 ,
910 bevelSize: 0.02 ,
911 bevelSegments: 2 ,
912 });
913 geo. center ();
914 const mesh = new THREE . Mesh (
915 geo,
916 new THREE . MeshStandardMaterial ({ color: cfg.color, roughness: 0.4 , metalness: 0.1 }),
917 );
918 mesh.position. set (cfg.basePos[ 0 ], cfg.basePos[ 1 ], cfg.basePos[ 2 ]);
919 mesh.userData.basePos = new THREE . Vector3 ( ... cfg.basePos);
920 scene. add (mesh);
921 floatingShapes. push (mesh);
922 }
923
924 // ---------- 3D page content: extruded letters + flat outlined lines ----------
925 const contentGroup = new THREE . Group ();
926 contentGroup.visible = false ;
927 scene. add (contentGroup);
928 const textMeshes = [];
929
930 // Screen px -> world units at the 2D camera (FOV 100, z 22)
931 const fovRad = ( P .initialFOV * Math. PI ) / 180 ;
932 const visH = 2 * P .cameraDistance * Math. tan (fovRad / 2 );
933 const visW = visH * ( W / H );
934 function rectToWorld ( r ) {
935 return {
936 x: ((r.left + r.width / 2 ) / W - 0.5 ) * visW,
937 y: ( 0.5 - (r.top + r.height / 2 ) / H ) * visH,
938 w: (r.width / W ) * visW,
939 h: (r.height / H ) * visH,
940 };
941 }
942 // Transform-immune element rect (GSAP intro tweens offset elements at t=0)
943 function offsetRect ( el ) {
944 let left = el.offsetLeft;
945 let top = el.offsetTop;
946 let node = el.offsetParent;
947 while (node && node.id !== "wpt-card" && node !== document.body) {
948 left += node.offsetLeft;
949 top += node.offsetTop;
950 node = node.offsetParent;
951 }
952 return { left, top, width: el.offsetWidth, height: el.offsetHeight };
953 }
954 const lineResolution = new THREE . Vector2 ( W , H );
955
956 // Geist glyphs contain self-overlapping junction notches (fine for nonzero-winding
957 // browser fills, fatal for extrusion triangulation + EdgesGeometry). Resolve the
958 // overlaps with a nonzero polygon union before extruding.
959 function cleanShapes ( shapes ) {
960 const SC = 1000 ;
961 const cleaned = [];
962 for ( const shape of shapes) {
963 const pts = shape. extractPoints ( 12 );
964 const paths = [pts.shape, ... pts.holes]. map (( ring ) =>
965 ring. map (( p ) => ({ X: Math. round (p.x * SC ), Y: Math. round (p.y * SC ) })),
966 );
967 let simp;
968 try {
969 simp = ClipperLib.Clipper. SimplifyPolygons (paths, ClipperLib.PolyFillType.pftNonZero);
970 } catch (e) {
971 cleaned. push (shape);
972 continue ;
973 }
974 const outerRings = [];
975 const holeRings = [];
976 for ( const ring of simp) {
977 if (ring. length < 3 ) continue ;
978 (ClipperLib.Clipper. Orientation (ring) ? outerRings : holeRings). push (ring);
979 }
980 const outShapes = outerRings. map (
981 ( r ) => new THREE . Shape (r. map (( p ) => new THREE . Vector2 (p. X / SC , p. Y / SC ))),
982 );
983 for ( const hr of holeRings) {
984 let owner = outShapes[ 0 ];
985 for ( let oi = 0 ; oi < outerRings. length ; oi ++ ) {
986 if (ClipperLib.Clipper. PointInPolygon (hr[ 0 ], outerRings[oi]) !== 0 ) {
987 owner = outShapes[oi];
988 break ;
989 }
990 }
991 if (owner)
992 owner.holes. push (
993 new THREE . Path (hr. map (( p ) => new THREE . Vector2 (p. X / SC , p. Y / SC ))),
994 );
995 }
996 cleaned. push ( ... outShapes);
997 }
998 return cleaned;
999 }
1000
1001 function cleanTextGeometry ( text , size , depth ) {
1002 const shapes = cleanShapes (font. generateShapes (text, size));
1003 return new THREE . ExtrudeGeometry (shapes, { depth, bevelEnabled: false });
1004 }
1005
1006 function createLetterAt ( char , wr , random = rand) {
1007 const g = new THREE . Group ();
1008 // A shared geometry size lets a reused glyph scale continuously between
1009 // source and target layouts, even when a long phrase needs smaller type.
1010 const size = 10 ;
1011 const geo = cleanTextGeometry (char, size, P .textDepthMax);
1012 geo. computeBoundingBox ();
1013 const bb = geo.boundingBox;
1014 const geoW = bb.max.x - bb.min.x;
1015 const geoH = bb.max.y - bb.min.y;
1016 geo. center ();
1017 const xyScale = Math. min (wr.w / (geoW || 1 ), wr.h / (geoH || 1 ));
1018
1019 g. add (
1020 new THREE . Mesh (
1021 geo,
1022 new THREE . MeshBasicMaterial ({
1023 color: "#050505" ,
1024 side: THREE .DoubleSide,
1025 transparent: true ,
1026 }),
1027 ),
1028 );
1029
1030 const edges = new THREE . EdgesGeometry (geo, 20 );
1031 const lineGeo = new LineSegmentsGeometry ();
1032 lineGeo. setPositions (edges.attributes.position.array);
1033 const lineMat = new LineMaterial ({
1034 color: new THREE . Color ( P .outlineColor). getHex (),
1035 linewidth: P .outlineWidth,
1036 transparent: true ,
1037 resolution: lineResolution,
1038 });
1039 g. add ( new LineSegments2 (lineGeo, lineMat));
1040 edges. dispose ();
1041
1042 g.scale. set (xyScale, xyScale, 0.01 );
1043 g.position. set (wr.x, wr.y, 0 );
1044 g.userData.char = char;
1045 g.userData.baseX = wr.x;
1046 g.userData.baseY = wr.y;
1047 g.userData.xyScale = xyScale;
1048 g.userData.targetZ = ( random () - 0.5 ) * P .letterZSpread;
1049 const ratio = P .textDepthMin / P .textDepthMax;
1050 g.userData.depthScale = ratio + random () * ( 1 - ratio);
1051 g.userData.isLetter = true ;
1052 contentGroup. add (g);
1053 textMeshes. push (g);
1054 return g;
1055 }
1056
1057 function createFlatLine ( text , wr , widthMult , opacity ) {
1058 const geo = cleanTextGeometry (text, 1 , 0.02 );
1059 geo. computeBoundingBox ();
1060 const bb = geo.boundingBox;
1061 const geoW = bb.max.x - bb.min.x;
1062 const geoH = bb.max.y - bb.min.y;
1063 geo. center ();
1064 const s = Math. min (wr.h / (geoH || 1 ), wr.w / (geoW || 1 ));
1065
1066 const g = new THREE . Group ();
1067 g. add (
1068 new THREE . Mesh (
1069 geo,
1070 new THREE . MeshBasicMaterial ({
1071 color: "#050505" ,
1072 side: THREE .DoubleSide,
1073 transparent: true ,
1074 }),
1075 ),
1076 );
1077 const edges = new THREE . EdgesGeometry (geo, 40 );
1078 const lineGeo = new LineSegmentsGeometry ();
1079 lineGeo. setPositions (edges.attributes.position.array);
1080 const lineMat = new LineMaterial ({
1081 color: new THREE . Color ( P .outlineColor). getHex (),
1082 linewidth: P .outlineWidth * widthMult,
1083 transparent: true ,
1084 opacity: opacity,
1085 resolution: lineResolution,
1086 });
1087 g. add ( new LineSegments2 (lineGeo, lineMat));
1088 edges. dispose ();
1089
1090 g.scale. set (s, s, 1 );
1091 g.position. set (wr.x, wr.y, 0 );
1092 g.userData.baseY = wr.y;
1093 g.userData.isSupporting = true ;
1094 g.userData.faceColor = g.children[ 0 ].material.color. clone ();
1095 contentGroup. add (g);
1096 textMeshes. push (g);
1097 }
1098
1099 // Headline letters from the DOM spans (2D card and 3D letters stay aligned)
1100 document. querySelectorAll ( "#wpt-headline [data-3d-char]" ). forEach (( span ) => {
1101 const ch = span.textContent;
1102 if ( ! ch || ch. trim () === "" ) return ;
1103 const r = offsetRect (span);
1104 if (r.width < 1 || r.height < 1 ) return ;
1105 createLetterAt (ch, rectToWorld (r));
1106 });
1107 const sourceLetters = textMeshes. slice ();
1108 // Kicker + subtitle as flat outlined lines
1109 const kickEl = document. getElementById ( "wpt-kicker-text" );
1110 if (kickEl && kickEl.textContent. trim ()) {
1111 createFlatLine (kickEl.textContent, rectToWorld ( offsetRect (kickEl)), 0.7 , 0.6 );
1112 }
1113 const subEl = document. getElementById ( "wpt-subtitle" );
1114 if (subEl && subEl.textContent. trim ()) {
1115 createFlatLine (subEl.textContent, rectToWorld ( offsetRect (subEl)), 0.7 , 0.6 );
1116 }
1117
1118 const supportingLines = textMeshes. filter (( obj ) => obj.userData.isSupporting);
1119 const replacementLetters = [];
1120 const transition = window.__wptTransition;
1121 const clamp01 = ( v ) => Math. max ( 0 , Math. min ( 1 , v));
1122 const smooth = ( v ) => {
1123 const x = clamp01 (v);
1124 return x * x * ( 3 - 2 * x);
1125 };
1126
1127 // Typeset from font metrics, independent of any final DOM card. Search
1128 // whole-word wraps, maximizing readable type size in a safe 1520 x 620 box.
1129 // A long unbroken word shrinks intact; no words or glyphs are discarded.
1130 function layoutPhrase ( text ) {
1131 const measure = document. createElement ( "canvas" ). getContext ( "2d" );
1132 measure.font = "700 176px Geist" ;
1133 const tracking = 176 * 0.03 ;
1134 const advance = ( char ) => measure. measureText (char).width + tracking;
1135 const width = ( line ) =>
1136 Array. from (line). reduce (( sum , char ) => sum + advance (char), - tracking);
1137 const words = text. split ( " " );
1138 const widest = Math. max ( ... words. map (width));
1139 const total = width (text);
1140 let best = null ;
1141 // Trying each possible break-width gives several balanced candidates
1142 // without restricting the result to two words or a fixed line count.
1143 const candidates = new Set ([total, widest]);
1144 for ( let first = 0 ; first < words. length ; first ++ ) {
1145 let line = "" ;
1146 for ( let last = first; last < words. length ; last ++ ) {
1147 line += (line ? " " : "" ) + words[last];
1148 candidates. add ( width (line));
1149 }
1150 }
1151 for ( const limit of candidates) {
1152 if (limit < widest) continue ;
1153 const lines = [];
1154 let line = "" ;
1155 for ( const word of words) {
1156 const next = line ? line + " " + word : word;
1157 if (line && width (next) > limit + 0.01 ) {
1158 lines. push (line);
1159 line = word;
1160 } else line = next;
1161 }
1162 if (line) lines. push (line);
1163 const widths = lines. map (width);
1164 const scale = Math. min ( 1 , 1520 / Math. max ( ... widths), 620 / (lines. length * 176 * 1.18 ));
1165 const balance = Math. min ( ... widths) / Math. max ( ... widths);
1166 const score = scale * ( 0.94 + 0.06 * balance);
1167 if ( ! best || score > best.score) best = { lines, widths, scale, score };
1168 }
1169 const glyphs = [];
1170 const lineHeight = 176 * 1.18 * best.scale;
1171 best.lines. forEach (( line , row ) => {
1172 let left = ( W - best.widths[row] * best.scale) / 2 ;
1173 for ( const char of Array. from (line)) {
1174 const step = advance (char) * best.scale;
1175 if (char. trim ())
1176 glyphs. push ({
1177 char,
1178 row,
1179 rect: {
1180 left,
1181 top: H / 2 + (row - (best.lines. length - 1 ) / 2 ) * lineHeight - 88 * best.scale,
1182 width: ( advance (char) - tracking) * best.scale,
1183 height: 176 * best.scale,
1184 },
1185 });
1186 left += step;
1187 }
1188 });
1189 return { ... best, glyphs };
1190 }
1191 const phraseLayout = layoutPhrase (window.__wptPhrase);
1192 for ( const { char , rect , row } of phraseLayout.glyphs) {
1193 const letter = createLetterAt (char, rectToWorld (rect), phraseRand);
1194 // Keep spacing consistent at the hold camera while retaining real depth.
1195 const distance = P .cameraDistance3D - P .contentZOffset;
1196 const perspective = (distance - letter.userData.targetZ) / distance;
1197 letter.userData.baseX *= perspective;
1198 letter.userData.baseY *= perspective;
1199 letter.userData.xyScale *= perspective;
1200 letter.userData.row = row;
1201 replacementLetters. push (letter);
1202 }
1203
1204 // Reuse matching letter identities first, then pair remaining glyphs by
1205 // spatial proximity. Extra target letters have their own distant Z origin;
1206 // unused source letters depart early instead of lingering until a swap.
1207 const slots = replacementLetters. map (( to ) => ({ from: null , to }));
1208 const remaining = new Set (sourceLetters);
1209 const distance = ( a , b ) => Math. hypot (a.baseX - b.baseX, a.baseY - b.baseY);
1210 for ( const exactOnly of [ true , false ]) {
1211 for ( const slot of slots) {
1212 if (slot.from) continue ;
1213 let closest = null ,
1214 cost = Infinity ;
1215 for ( const letter of remaining) {
1216 if (exactOnly && letter.userData.char !== slot.to.userData.char) continue ;
1217 const candidateCost = distance (letter.userData, slot.to.userData);
1218 if (candidateCost < cost) {
1219 closest = letter;
1220 cost = candidateCost;
1221 }
1222 }
1223 if (closest) {
1224 slot.from = closest;
1225 remaining. delete (closest);
1226 }
1227 }
1228 }
1229 for ( const from of remaining) slots. push ({ from, to: null });
1230 slots. forEach (( slot , i ) => {
1231 const target = slot.to || slot.from;
1232 slot.start = slot.from
1233 ? slot.from.userData
1234 : {
1235 baseX: target.userData.baseX * 1.25 ,
1236 baseY: target.userData.baseY * 1.25 ,
1237 targetZ: - 60 - phraseRand () * 65 ,
1238 xyScale: target.userData.xyScale,
1239 };
1240 slot.end = (slot.to || slot.from).userData;
1241 const rank = i / Math. max ( 1 , replacementLetters. length - 1 );
1242 slot.delay = clamp01 (rank) * transition.duration * 0.26 ;
1243 // Keep the same object alive when the character itself does not change.
1244 slot.sameGlyph = !! (
1245 slot.from &&
1246 slot.to &&
1247 slot.from.userData.char === slot.to.userData.char
1248 );
1249 if (slot.sameGlyph) slot.to.visible = false ;
1250 });
1251
1252 // The adjustable power concentrates speed around the edge-on letter swap
1253 // at 62% of local time: slow preparation, fast handoff, then a soft settle.
1254 function phraseEase ( progress ) {
1255 const x = clamp01 (progress);
1256 const a = Math. pow (x * 0.38 , transition.easing);
1257 const b = Math. pow (( 1 - x) * 0.62 , transition.easing);
1258 return a / (a + b);
1259 }
1260 function opacityFor ( obj , opacity ) {
1261 for ( const child of obj.children) {
1262 child.material.opacity = opacity;
1263 child.material.depthWrite = opacity > 0.995 ;
1264 }
1265 }
1266 function depthFade ( obj , amount , baseOpacity = 1 ) {
1267 const opacity = 1 - smooth ((amount - 0.005 ) / 0.13 );
1268 opacityFor (obj, opacity);
1269 const haze = transition.fog ? 1 - smooth (amount / 0.15 ) : 1 ;
1270 obj.children[ 1 ].material.color. set ( P .outlineColor). lerp (endCol, 1 - haze);
1271 obj.children[ 1 ].material.opacity = baseOpacity * opacity;
1272 obj.visible = opacity > 0.001 ;
1273 return opacity;
1274 }
1275
1276 function handoffLetters ( t , portal ) {
1277 let glitchEnvelope = 0 ;
1278 for ( let i = 0 ; i < slots. length ; i ++ ) {
1279 const slot = slots[i];
1280 const progress = clamp01 (
1281 (t - transition.start - slot.delay) / (transition.duration * 0.74 ),
1282 );
1283 const motion = phraseEase (progress);
1284 const incoming = motion >= 0.5 ;
1285 const start = slot.start,
1286 end = slot.end;
1287 // Spare source letters fog out during the first fraction of recession,
1288 // before the active letters turn. This path also runs with fog disabled.
1289 if ( ! slot.to) {
1290 const depart = smooth (
1291 (t - transition.start - (i % 3 ) * 0.025 ) / Math. min ( 0.75 , transition.duration * 0.45 ),
1292 );
1293 const obj = slot.from;
1294 obj.position. set (start.baseX, start.baseY, start.targetZ * portal - depart * 125 );
1295 obj.rotation. set ( 0 , depart * 0.3 , 0 );
1296 obj.scale. set (
1297 start.xyScale,
1298 start.xyScale,
1299 THREE .MathUtils. lerp ( 0.01 , start.depthScale, portal),
1300 );
1301 depthFade (obj, depart);
1302 continue ;
1303 }
1304 const x = THREE .MathUtils. lerp (start.baseX, end.baseX, motion);
1305 const y =
1306 THREE .MathUtils. lerp (start.baseY, end.baseY, motion) +
1307 Math. sin (t * 0.6 + i * 0.7 ) * 0.12 * portal;
1308 // Keep the approved forward rush: the original camera pushes into
1309 // the wireframe world as the resolved letters accelerate toward it.
1310 // A small reading-order stagger carries each letter past the lens;
1311 // there is no backward Z travel or opacity dissolve on this exit.
1312 const exitDelay = (i / Math. max ( 1 , replacementLetters. length - 1 )) * 0.2 ;
1313 const flyPast = Math. pow ( clamp01 ((t - 6.0 - exitDelay) / 1.25 ), 3 );
1314 const z =
1315 THREE .MathUtils. lerp (start.targetZ, end.targetZ, motion) * portal -
1316 Math. sin (Math. PI * motion) * 1.8 +
1317 flyPast * 38 ;
1318 // Retire a glyph only after its full extrusion is behind the camera.
1319 // Before then, clipping and perspective carry the fly-through.
1320 const cameraSpace = new THREE . Vector3 (x, y, z + P .contentZOffset * portal). applyMatrix4 (
1321 camera.matrixWorldInverse,
1322 );
1323 const passedCamera = cameraSpace.z > P .textDepthMax * 2 ;
1324 const edge = Math. pow (Math. abs ( 2 * motion - 1 ), 0.65 );
1325 const swapPulse = Math. exp ( - Math. pow ((progress - 0.62 ) / 0.045 , 2 ));
1326 const settlePulse = Math. exp ( - Math. pow ((progress - 0.86 ) / 0.065 , 2 ));
1327 glitchEnvelope = Math. max (glitchEnvelope, swapPulse * 0.65 , settlePulse);
1328 const tick =
1329 Math. sin (Math. floor (t * 36 ) * 17.1 + i * 4.7 ) * settlePulse * transition.glitch;
1330 for ( const [ obj , isIncoming ] of [
1331 [slot.from, false ],
1332 [slot.to, true ],
1333 ]) {
1334 if ( ! obj) continue ;
1335 const isActive = slot.sameGlyph ? ! isIncoming : incoming === isIncoming;
1336 // New glyphs fly up from deep Z throughout the move. They are not
1337 // duplicates split out of a source letter, and never disappear at the seam.
1338 const extra = ! slot.from;
1339 const entrance = extra ? smooth (motion / 0.34 ) : 1 ;
1340 const opacity = entrance;
1341 obj.visible = (extra || isActive) && opacity > 0.001 && ! passedCamera;
1342 opacityFor (obj, opacity);
1343 obj.position. set (x + tick * 0.12 , y + tick * 0.04 , z);
1344 obj.rotation. set (
1345 0 ,
1346 slot.sameGlyph
1347 ? Math. sin (Math. PI * motion) * 0.55
1348 : extra
1349 ? - Math. PI * 0.75 * ( 1 - motion)
1350 : incoming
1351 ? - Math. PI * ( 1 - motion)
1352 : Math. PI * motion,
1353 tick * 0.012 ,
1354 );
1355 const xyScale = THREE .MathUtils. lerp (start.xyScale, end.xyScale, motion);
1356 obj.scale. set (
1357 xyScale,
1358 xyScale,
1359 THREE .MathUtils. lerp ( 0.01 , end.depthScale, portal) *
1360 (extra || slot.sameGlyph ? 1 : 0.015 + 0.985 * edge),
1361 );
1362 }
1363 }
1364 return glitchEnvelope;
1365 }
1366
1367 function recedeSupportingLines ( t ) {
1368 supportingLines. forEach (( obj , i ) => {
1369 const travel = smooth ((t - 2.65 - i * 0.09 ) / 1.3 );
1370 obj.position.z = - 145 * travel;
1371 depthFade (obj, travel, 0.6 );
1372 });
1373 }
1374
1375 // ---------- post-processing chain (experiment pass order + glitch pass) ----------
1376 const renderTarget = new THREE . WebGLRenderTarget ( W , H , {
1377 format: THREE .RGBAFormat,
1378 type: THREE .HalfFloatType,
1379 depthBuffer: true ,
1380 samples: 4 ,
1381 });
1382 const composer = new EffectComposer (renderer, renderTarget);
1383 composer. setSize ( W , H );
1384
1385 const gridRenderPass = new RenderPass (gridScene, camera);
1386 gridRenderPass.clearAlpha = 0 ;
1387 composer. addPass (gridRenderPass);
1388
1389 const contentRenderPass = new RenderPass (scene, camera);
1390 contentRenderPass.clear = false ;
1391 composer. addPass (contentRenderPass);
1392
1393 const blurPassH = new ShaderPass (EdgeBlurShader);
1394 blurPassH.uniforms.uDirection.value. set ( 1 , 0 );
1395 composer. addPass (blurPassH);
1396 const blurPassV = new ShaderPass (EdgeBlurShader);
1397 blurPassV.uniforms.uDirection.value. set ( 0 , 1 );
1398 composer. addPass (blurPassV);
1399
1400 const postfxPass = new ShaderPass (PostFXShader);
1401 composer. addPass (postfxPass);
1402
1403 const glitchPass = new ShaderPass (GlitchShader);
1404 composer. addPass (glitchPass);
1405
1406 const transFXPass = new ShaderPass (TransitionFXShader);
1407 composer. addPass (transFXPass);
1408
1409 const portalPass = new ShaderPass (PortalCompositeShader);
1410 portalPass.material.transparent = true ;
1411 composer. addPass (portalPass);
1412
1413 composer. addPass ( new OutputPass ());
1414
1415 // ---------- warp dots (2D overlay canvas, pure function of state) ----------
1416 const warpCanvas = document. getElementById ( "wpt-warp" );
1417 // Keep warp-dot rasterization identical on fresh and repeated seeks.
1418 const wctx = warpCanvas. getContext ( "2d" , { willReadFrequently: true });
1419 const warpDots = [];
1420 {
1421 const count = P .warpDotCount;
1422 const depthLayers = Math. ceil (Math. cbrt (count) * 2 );
1423 const sideCount = Math. ceil (Math. sqrt (count / depthLayers));
1424 outer : for ( let iz = 0 ; iz < depthLayers; iz ++ ) {
1425 for ( let iy = 0 ; iy < sideCount; iy ++ ) {
1426 for ( let ix = 0 ; ix < sideCount; ix ++ ) {
1427 if (warpDots. length >= count) break outer ;
1428 warpDots. push ({
1429 x: (ix - (sideCount - 1 ) / 2 ) * 1.0 ,
1430 y: (iy - (sideCount - 1 ) / 2 ) * 1.0 ,
1431 z: iz * 1.0 + 0.5 ,
1432 });
1433 }
1434 }
1435 }
1436 }
1437 const WARP_MAX_Z = Math. max ( 10 , Math. ceil (Math. cbrt ( P .warpDotCount) * 2 ));
1438
1439 function drawWarpDots ( S ) {
1440 wctx. clearRect ( 0 , 0 , W , H );
1441 const vis = S .warp;
1442 if (vis < 0.01 ) return ;
1443 const cx = W / 2 ;
1444 const cy = H / 2 ;
1445 for ( let i = 0 ; i < warpDots. length ; i ++ ) {
1446 const d = warpDots[i];
1447 let z = (((d.z - S .travel) % WARP_MAX_Z ) + WARP_MAX_Z ) % WARP_MAX_Z ;
1448 if (z < 0.05 ) z += WARP_MAX_Z ;
1449
1450 const scale = 300 / z;
1451 const sx = cx + d.x * scale;
1452 const sy = cy + d.y * scale;
1453 const size = ( P .warpDotSize / z) * 3 ;
1454 if (size < 0.3 ) continue ;
1455 if (sx < - 10 || sx > W + 10 || sy < - 10 || sy > H + 10 ) continue ;
1456
1457 const depthAlpha = Math. min ( 1 , 2 / z);
1458 const alpha = depthAlpha * vis * P .warpDotOpacity;
1459
1460 wctx.globalAlpha = alpha;
1461 wctx.fillStyle = P .warpDotColor;
1462 wctx. beginPath ();
1463 wctx. arc (sx, sy, size, 0 , Math. PI * 2 );
1464 wctx. fill ();
1465
1466 if (z < 2 && size > 1 ) {
1467 const streakLen = ( 2 - z) * 20 * vis;
1468 const dx = sx - cx;
1469 const dy = sy - cy;
1470 const dist = Math. sqrt (dx * dx + dy * dy) || 1 ;
1471 wctx.globalAlpha = alpha * 0.3 ;
1472 wctx. beginPath ();
1473 wctx. moveTo (sx, sy);
1474 wctx. lineTo (sx + (dx / dist) * streakLen, sy + (dy / dist) * streakLen);
1475 wctx.strokeStyle = P .warpDotColor;
1476 wctx.lineWidth = size * 0.5 ;
1477 wctx. stroke ();
1478 }
1479 }
1480 wctx.globalAlpha = 1 ;
1481 }
1482
1483 // ---------- render: everything derived from timeline time + state proxy ----------
1484 const startCol = new THREE . Color ( P .portalStartColor);
1485 const endCol = new THREE . Color ( P .bgColor);
1486
1487 function renderScene ( t ) {
1488 const S = window.__wptState;
1489 const portal = S .portal;
1490 const push = S .push;
1491 const transAmount = 1.0 - Math. abs (portal * 2 - 1 );
1492 const effTrans = Math. min ( 1 , Math. max (transAmount, S .exitFx * 0.85 ));
1493
1494 // Camera: FOV/distance lerp (2D -> 3D) plus exit push through the letters
1495 camera.fov = THREE .MathUtils. lerp ( P .initialFOV, P .finalFOV, portal) + push * 10 ;
1496 camera.position.z =
1497 THREE .MathUtils. lerp ( P .cameraDistance, P .cameraDistance3D, portal) -
1498 push * 52 +
1499 Math. sin (t * 0.34 ) * 1.6 * portal * ( 1 - push);
1500 const driftAmp = portal * ( 1 - 0.7 * push);
1501 camera.rotation.x = Math. cos (t * 0.47 ) * 0.04 * driftAmp;
1502 camera.rotation.y = Math. sin (t * 0.62 + 1.2 ) * 0.06 * driftAmp;
1503 camera.position.x = Math. sin (t * 0.41 ) * 1.4 * driftAmp;
1504 camera.position.y = Math. cos (t * 0.29 ) * 1.0 * driftAmp;
1505 camera. updateProjectionMatrix ();
1506 camera. updateMatrixWorld ();
1507
1508 // Portal composite
1509 portalPass.uniforms.uPortalRadius.value = portal * P .portalScale;
1510 portalPass.uniforms.uPortalProgress.value = portal;
1511 portalPass.uniforms.uTime.value = t;
1512
1513 // Vignette / scanline / CA scale with portal
1514 postfxPass.uniforms.uVignetteIntensity.value = P .vignetteIntensity * portal;
1515 postfxPass.uniforms.uChromaticAberration.value = P .chromaticAberration * portal;
1516 postfxPass.uniforms.uScanlineIntensity.value = P .scanlineIntensity * portal;
1517 postfxPass.uniforms.uTime.value = t;
1518
1519 const blurAmount = P .edgeBlur * portal;
1520 blurPassH.uniforms.uBlurAmount.value = blurAmount;
1521 blurPassV.uniforms.uBlurAmount.value = blurAmount;
1522
1523 // Transition FX peak at portal 0.5 + on the exit push
1524 transFXPass.uniforms.uTransAmount.value = effTrans;
1525
1526 // Block glitch ticks (the handoff envelope below follows duration/easing).
1527 glitchPass.uniforms.uTime.value = t;
1528 glitchPass.uniforms.uShakePower.value = P .glitchPower * S .glitch;
1529 glitchPass.uniforms.uShakeRate.value = S .glitch > 0.001 ? 1.0 : 0.0 ;
1530 glitchPass.uniforms.uShakeColorRate.value = P .glitchColorRate * Math. min ( 1 , S .glitch * 1.8 );
1531
1532 // Grid + background plane
1533 const showContent = portal > 0.01 ;
1534 gridLines.visible = showContent;
1535 contentGroup.visible = showContent;
1536 bgPlane.visible = showContent;
1537 const colorT = Math. min ( 1 , Math. max ( 0 , (portal - 0.15 ) * P .portalColorSpeed));
1538 bgPlaneMat.color. copy (startCol). lerp (endCol, colorT);
1539 gridMat.uniforms.uCameraPos.value. copy (camera.position);
1540 gridMat.uniforms.uTime.value = t;
1541
1542 // Floating shapes drift
1543 for ( let i = 0 ; i < floatingShapes. length ; i ++ ) {
1544 const mesh = floatingShapes[i];
1545 const base = mesh.userData.basePos;
1546 const phase = i * 2.17 ;
1547 mesh.position.x =
1548 base.x + Math. sin (t * 0.15 + phase) * 1.2 + Math. sin (t * 0.07 + phase * 3 ) * 0.5 ;
1549 mesh.position.y =
1550 base.y + Math. sin (t * 0.12 + phase * 1.4 ) * 0.8 + Math. cos (t * 0.09 + phase * 2 ) * 0.3 ;
1551 mesh.position.z =
1552 base.z + Math. sin (t * 0.1 + phase * 0.7 ) * 0.6 + P .contentZOffset * portal;
1553 mesh.rotation.x = Math. sin (t * 0.08 + phase) * 0.4 ;
1554 mesh.rotation.y = t * 0.05 * (i % 2 === 0 ? 1 : - 1 ) + phase;
1555 mesh.rotation.z = Math. sin (t * 0.06 + phase * 1.3 ) * 0.3 ;
1556 }
1557
1558 // Content: flat page -> per-letter depth and z-spread
1559 contentGroup.position.z = P .contentZOffset * portal;
1560 for ( let i = 0 ; i < textMeshes. length ; i ++ ) {
1561 const obj = textMeshes[i];
1562 if (obj.userData.isLetter) {
1563 obj.position.z = obj.userData.targetZ * portal;
1564 const zScale = THREE .MathUtils. lerp ( 0.01 , obj.userData.depthScale, portal);
1565 obj.scale.z = zScale;
1566 }
1567 obj.position.y = obj.userData.baseY + Math. sin (t * 0.6 + i * 0.7 ) * 0.12 * portal;
1568 }
1569 const handoffGlitch = handoffLetters (t, portal);
1570 recedeSupportingLines (t);
1571 const glitch = Math. max ( S .glitch * 0.5 , handoffGlitch * 0.35 ) * transition.glitch;
1572 glitchPass.uniforms.uShakePower.value = P .glitchPower * glitch;
1573 glitchPass.uniforms.uShakeRate.value = glitch > 0.001 ? 1 : 0 ;
1574 glitchPass.uniforms.uShakeColorRate.value = P .glitchColorRate * glitch * 1.8 ;
1575
1576 if (portal < 0.001 ) {
1577 renderer. clear ();
1578 wctx. clearRect ( 0 , 0 , W , H );
1579 return ;
1580 }
1581 composer. render ();
1582 drawWarpDots ( S );
1583 }
1584
1585 window.__wptRender = renderScene;
1586 const tl = window.__timelines[ "wireframe-portal-title" ];
1587 renderScene (tl ? tl. time () : 0 );
1588 /* The portal is closed at t=0 and draws nothing, so the first real draw would
1589 link every shader mid-playback. Draw across the timeline and flush the GPU
1590 before ready instead. */
1591 window.__hf = window.__hf || {};
1592 window.__hf.buildReady = window.__hf.buildReady || {};
1593 window.__hf.buildReady[ "wireframe-portal-title" ] = ( async () => {
1594 const warmDur = tl ? tl. duration () : 0 ;
1595 for ( const f of [ 0.3 , 0.5 , 0.7 ]) {
1596 await new Promise (( resolve ) => setTimeout (resolve, 0 ));
1597 renderScene (warmDur * f);
1598 }
1599 renderScene (tl ? tl. time () : 0 );
1600 renderer. getContext (). finish ();
1601 })();
1602 </ script >
1603 </ body >
1604 </ html >