Setting the file. One moment. Canopy Part Title · Canopy Part Title · heygen-com/hyperframes · Skills DocsRaw file
canopy-part-title.html
HTML·1,389 lines·57 KB
1<!doctype html>
2<html
3 lang="en"
4 data-composition-variables='[{"id":"headline1","type":"string","label":"Headline 1","default":"Understory"},{"id":"headline2","type":"string","label":"Headline 2","default":"Move slowly"},{"id":"font","type":"string","label":"Headline font","default":"Helvetica"},{"id":"fontWeight","type":"number","label":"Font weight","default":900,"min":100,"max":900,"step":100},{"id":"fontSize","type":"number","label":"Font size (1 = auto-fit)","default":1,"min":0.3,"max":2,"step":0.01},{"id":"letterSpacing","type":"number","label":"Letter spacing (em)","default":0.01,"min":-0.2,"max":1,"step":0.01},{"id":"background","type":"color","label":"Background","default":"#020805"},{"id":"leafCount","type":"number","label":"Leaves per sweep","default":170,"min":40,"max":320,"step":1},{"id":"stayCount","type":"number","label":"Leaves left behind","default":5,"min":0,"max":20,"step":1},{"id":"sweepSpeed","type":"number","label":"Sweep speed","default":1,"min":0.3,"max":3,"step":0.05},{"id":"retainedBreeze","type":"number","label":"Retained leaf breeze","default":1,"min":0,"max":2,"step":0.05}]'
5>
6 <head>
7 <meta charset
=
"UTF-8"
/>
8 <meta name="viewport" content="width=1920, height=1080" />
9 <title>canopy-part-title</title>
10 <link rel="preconnect" href="https://fonts.googleapis.com" />
11 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
12 <link
13 href="https://fonts.googleapis.com/css2?family=Gelasio:wght@500;600&display=swap"
14 rel="stylesheet"
15 />
16 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
17 <style>
18 * {
19 margin: 0;
20 padding: 0;
21 box-sizing: border-box;
22 }
23 html,
24 body {
25 margin: 0;
26 width: 1920px;
27 height: 1080px;
28 overflow: hidden;
29 background: #020805;
30 }
31 #cpt-root {
32 position: relative;
33 width: 1920px;
34 height: 1080px;
35 overflow: hidden;
36 }
37 #cpt-scene {
38 position: absolute;
39 inset: 0;
40 }
41 /* Full-frame background paint on a child, never on the composition root.
42 Plain flat colour — driven by the `background` variable, no gradients. */
43 #cpt-bg {
44 position: absolute;
45 inset: 0;
46 background: #020805;
47 }
48 #cpt-canvas {
49 position: absolute;
50 inset: 0;
51 width: 100%;
52 height: 100%;
53 display: block;
54 }
55 #cpt-fontload {
56 position: absolute;
57 opacity: 0;
58 pointer-events: none;
59 font-family: Gelasio, Georgia, serif;
60 }
61 </style>
62 </head>
63 <body>
64 <div
65 data-hf-id="hf-idkp"
66 id="cpt-root"
67 data-composition-id="canopy-part-title"
68 data-start="0"
69 data-duration="12"
70 data-width="1920"
71 data-height="1080"
72 >
73 <section
74 data-hf-id="hf-wrn4"
75 id="cpt-scene"
76 class="clip"
77 data-start="0"
78 data-duration="12"
79 data-track-index="1"
80 >
81 <div
82 style="font-family: "Alan Sans", ui-sans-serif, system-ui, sans-serif"
83 data-hf-id="hf-bank"
84 id="cpt-bg"
85 ></div>
86 </section>
87 <div data-hf-id="hf-p1fl" id="cpt-fontload" aria-hidden="true">
88 <span data-hf-id="hf-grhl" style="font-weight: 500">Aa</span
89 ><span data-hf-id="hf-7bxm" style="font-weight: 600">Aa</span>
90 </div>
91 </div>
92
93 <!-- Seeded layout + master timeline. Runs synchronously before the Three module so
94 the timeline exists the moment the page is scriptable. Zero Math.random. -->
95 <script>
96 (function () {
97 "use strict";
98 window.__timelines = window.__timelines || {};
99 /* Studio's variables parser fails on any composition with a <canvas>
100 in its MARKUP — created at runtime instead (identical for render). */
101 (function () {
102 var c = document.createElement("canvas");
103 c.id = "cpt-canvas";
104 c.width = 1920;
105 c.height = 1080;
106 document.getElementById("cpt-scene").appendChild(c);
107 })();
108
109 // getVariables carries the declared defaults, standalone and mounted.
110 var V = {};
111 try {
112 if (window.__hyperframes && window.__hyperframes.getVariables)
113 V = window.__hyperframes.getVariables() || {};
114 } catch (e) {
115 /* corrupt declarations must not blank the composition */
116 }
117 var LEAF_COUNT = Math.max(40, Math.min(320, Number(V.leafCount) || 170));
118 var STAY_COUNT = Math.max(
119 0,
120 Math.min(
121 20,
122 Math.round(
123 V.stayCount === undefined || !isFinite(Number(V.stayCount)) ? 5 : Number(V.stayCount),
124 ),
125 ),
126 );
127 var SPEED = Math.max(0.3, Math.min(3, Number(V.sweepSpeed) || 1));
128 var DUR = 12;
129
130 function mulberry32(seed) {
131 return function () {
132 seed |= 0;
133 seed = (seed + 0x6d2b79f5) | 0;
134 var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
135 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
136 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
137 };
138 }
139
140 /* V1's opening sweep plus a separate depth-lift batch. Each batch has
141 exactly stayCount leaves reserved for edge rest slots. The lift
142 carries the opening's remaining edge leaves past the camera. */
143 var rand = mulberry32(0x6a756e67);
144 var TEXT_HALF_W = 8.2,
145 TEXT_HALF_H = 2.0;
146
147 function buildBatch(batchIndex) {
148 var out = [];
149 for (var i = 0; i < LEAF_COUNT; i++) {
150 var kindRoll = rand();
151 var kind =
152 kindRoll < 0.22
153 ? "monstera"
154 : kindRoll < 0.42
155 ? "alocasia"
156 : kindRoll < 0.68
157 ? "lance"
158 : kindRoll < 0.89
159 ? "calathea"
160 : "fern";
161 /* Stayers are chosen AFTER the batch is built (exactly stayCount of
162 them, scattered) — here every leaf just gets wind-seed rest values
163 and a LOW flight band: in front of the text plane but pushed away
164 from the camera so the depth of field leaves most leaves sharp. */
165 var stayer = false;
166 var y0 = (rand() * 2 - 1) * 3.6 - 0.5;
167 var restX = (rand() * 2 - 1) * 9.2;
168 var restY = y0;
169 var restRZ = rand() * Math.PI * 2;
170 out.push({
171 batch: batchIndex,
172 kind: kind,
173 stayer: stayer,
174 z: -4.6 + rand() * 3.0,
175 xStart: -12.5 - rand() * 5.5,
176 xEnd: 12.5 + rand() * 5.0,
177 restX: restX,
178 restY: restY,
179 restRZ: restRZ,
180 y0: y0,
181 delay: rand() * 0.5,
182 delay2: 0, // filled for batch-1 stayers below
183 bobAmp: 0.3 + rand() * 0.65,
184 bobN: 1.1 + rand() * 1.6,
185 spin: (rand() < 0.5 ? -1 : 1) * (1.6 + rand() * 3.0),
186 phase: rand() * Math.PI * 2,
187 rotation: rand() * Math.PI * 2,
188 tiltX: (rand() - 0.5) * 0.42,
189 tiltY: (rand() - 0.5) * 0.58,
190 widthMul: 0.83 + rand() * 0.34,
191 lengthMul: 0.86 + rand() * 0.25,
192 scale: (0.62 + rand() * 0.85) * 0.56 * 1.9,
193 });
194 }
195 return out;
196 }
197
198 /* Exactly STAY_COUNT leaves per batch stay behind, scattered at seeded
199 random positions anywhere in frame EXCEPT the headline band — never
200 lined up along the top and bottom. */
201 function pickStayers(batch) {
202 var placed = 0,
203 guard = 0;
204 while (placed < STAY_COUNT && guard++ < 600) {
205 var idx = Math.floor(rand() * batch.length) % batch.length;
206 var sp = batch[idx];
207 if (sp.stayer) continue;
208 var rx,
209 ry,
210 tries = 0;
211 do {
212 rx = (rand() * 2 - 1) * 8.6;
213 ry = (rand() * 2 - 1) * 4.3;
214 tries++;
215 } while (
216 tries < 30 &&
217 Math.abs(ry) < TEXT_HALF_H + 0.7 &&
218 Math.abs(rx) < TEXT_HALF_W + 0.8
219 );
220 sp.stayer = true;
221 sp.restX = rx;
222 sp.restY = ry;
223 if (Math.abs(rx) > TEXT_HALF_W + 0.8 && Math.abs(ry) < TEXT_HALF_H + 1.4) {
224 sp.restRZ = ((rx > 0 ? -1 : 1) * Math.PI) / 2 + (rand() - 0.5) * 0.7;
225 } else if (ry >= 0) {
226 sp.restRZ = (rand() - 0.5) * 0.8;
227 } else {
228 sp.restRZ = Math.PI + (rand() - 0.5) * 0.8;
229 }
230 placed++;
231 }
232 }
233
234 var batch1 = buildBatch(1);
235 var batch2 = buildBatch(2);
236 pickStayers(batch1);
237 pickStayers(batch2);
238 // Sweep-away order for batch-1 stayers: left-most leave first.
239 batch1.forEach(function (s) {
240 s.delay2 = ((s.restX + 16) / 32) * 0.5 + rand() * 0.14;
241 });
242 // Independent seed: V1's opening geometry/seed sequence is unchanged.
243 var liftRand = mulberry32(0x6c696674);
244 batch2.forEach(function (s, i) {
245 s.liftX = (liftRand() * 2 - 1) * 7.8;
246 s.liftY = -5.2 + liftRand() * 5.8;
247 s.liftZ = -7 + liftRand() * 4.9;
248 s.liftDelay = liftRand() * 0.28;
249 s.depthResponse = Math.max(0.25, Math.min(1, (s.liftZ + 7) / 14 + 0.55));
250 // Keep final foliage outside the headline, including its full leaf extent.
251 if (s.stayer) {
252 var a = i * 2.399963;
253 s.restX = Math.sin(a) * 8.5;
254 s.restY = (i % 2 ? 1 : -1) * 5.5;
255 s.restRZ = i % 2 ? 0 : Math.PI;
256 }
257 });
258 window.__cptBatches = [batch1, batch2];
259
260 var DUST_COUNT = 800;
261 var dustHome = new Array(DUST_COUNT * 3);
262 var dustSeeds = new Array(DUST_COUNT);
263 for (var d = 0; d < DUST_COUNT; d++) {
264 dustHome[d * 3] = (rand() * 2 - 1) * 10;
265 dustHome[d * 3 + 1] = (rand() * 2 - 1) * 6;
266 dustHome[d * 3 + 2] = rand() * 12 - 5;
267 dustSeeds[d] = rand();
268 }
269 window.__cptDust = { home: dustHome, seeds: dustSeeds };
270
271 // Separate per-leaf seeds leave the approved sweep/lift layout untouched.
272 // Different periods and phases avoid a shared rocking or looping beat.
273 [batch1, batch2].forEach(function (batch, batchIndex) {
274 batch.forEach(function (s, i) {
275 if (!s.stayer) return;
276 var breezeRand = mulberry32(0x62726565 ^ ((batchIndex + 1) * 104729 + i * 7919));
277 s.breeze = {
278 phase: breezeRand() * Math.PI * 2,
279 phase2: breezeRand() * Math.PI * 2,
280 phase3: breezeRand() * Math.PI * 2,
281 rate: 0.55 + breezeRand() * 0.45,
282 driftX: 0.22 + breezeRand() * 0.24,
283 driftY: 0.28 + breezeRand() * 0.28,
284 driftZ: 0.35 + breezeRand() * 0.45,
285 depth: (breezeRand() - 0.5) * 1.3,
286 tiltX: 0.07 + breezeRand() * 0.1,
287 tiltY: 0.1 + breezeRand() * 0.14,
288 roll: 0.12 + breezeRand() * 0.14,
289 turn: (breezeRand() < 0.5 ? -1 : 1) * (0.012 + breezeRand() * 0.022),
290 arrival: breezeRand() * 0.16,
291 };
292 });
293 });
294
295 /* Masters tweened by the timeline; all per-leaf motion is a closed-form
296 function of these plus each leaf's seeded constants. */
297 var state = { sw1: 0, sw2: 0 };
298 window.__cptState = state;
299
300 // Keep the existing speed parameter; finite holds absorb the slack.
301 var d1 = Math.max(1.2, Math.min(6.0, 3.2 / SPEED));
302 var s2 = Math.max(0.4 + d1 + 0.9, 5.2);
303 var d2 = Math.max(1.2, Math.min(3.2 / SPEED, DUR - s2 - 1.8));
304 window.__cptWindows = { d1: d1, s2: s2, d2: d2 };
305
306 var tl = gsap.timeline({ paused: true });
307 tl.addLabel("opening", 0)
308 .addLabel("headline-1", 0.4 + d1)
309 .addLabel("canopy-lift", s2)
310 .addLabel("headline-2", s2 + d2)
311 .addLabel("final-hold", 10.5);
312 tl.fromTo(
313 "#cpt-scene",
314 { opacity: 0 },
315 { opacity: 1, duration: 0.35, ease: "power1.out" },
316 0.05,
317 );
318 // Imperceptible 1.5px drift on the bg plate (same colour as the body
319 // behind it) so the layout sweep sees DOM geometry advance under seek.
320 tl.fromTo("#cpt-bg", { y: 0 }, { y: 1.5, duration: 12, ease: "none" }, 0);
321 tl.to(state, { sw1: 1, duration: d1, ease: "none" }, 0.4);
322 tl.to(state, { sw2: 1, duration: d2, ease: "none" }, s2);
323 tl.to(state, { sw2: 1, duration: 0.01 }, DUR - 0.01); // pin timeline length to DUR
324
325 tl.eventCallback("onUpdate", function () {
326 if (window.__cptRender) window.__cptRender(tl.time());
327 });
328
329 window.__timelines["canopy-part-title"] = tl;
330 })();
331 </script>
332
333 <script type="importmap">
334 { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js" } }
335 </script>
336 <script type="module">
337 import * as THREE from "three";
338 import { EffectComposer } from "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/postprocessing/EffectComposer.js";
339 import { RenderPass } from "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/postprocessing/RenderPass.js";
340 import { BokehPass } from "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/postprocessing/BokehPass.js";
341
342 const W = 1920,
343 H = 1080,
344 DUR = 12;
345 // Bare __hyperframes is this composition's own in a module; getVariables carries the defaults.
346 let V = {};
347 try {
348 if (typeof __hyperframes !== "undefined" && __hyperframes.getVariables)
349 V = __hyperframes.getVariables() || {};
350 } catch (e) {
351 /* corrupt declarations must not blank the composition */
352 }
353 const HEADLINE1 = String(V.headline1 ?? "Understory");
354 const HEADLINE2 = String(V.headline2 ?? "Move slowly");
355 /* Headline typography is a PARAMETER — never a Design-panel edit. Any
356 Google Fonts family name works; falls back through Gelasio/serif
357 while (or if never) loading. */
358 const FONT = (String(V.font || "Helvetica").trim() || "Helvetica").replace(/["']/g, "");
359 const FONT_WEIGHT = Math.max(
360 100,
361 Math.min(
362 900,
363 Math.round(
364 isFinite(Number(V.fontWeight)) && Number(V.fontWeight) ? Number(V.fontWeight) : 900,
365 ),
366 ),
367 );
368 /* fontSize is a multiplier on the auto-fitted size (0.3-2); letterSpacing
369 is in em (fraction of the font size, may be negative). */
370 const FONT_SIZE = Math.max(
371 0.3,
372 Math.min(
373 2,
374 isFinite(Number(V.fontSize)) && Number(V.fontSize) > 0 ? Number(V.fontSize) : 1,
375 ),
376 );
377 const LETTER_SP = Math.max(
378 -0.2,
379 Math.min(1, isFinite(Number(V.letterSpacing)) ? Number(V.letterSpacing) : 0.01),
380 );
381 const FONT_STACK = '"' + FONT + '", Gelasio, Georgia, serif';
382 if (!/^(Helvetica|Arial|Gelasio|Georgia|serif|sans-serif|monospace)$/i.test(FONT)) {
383 const fl = document.createElement("link");
384 fl.rel = "stylesheet";
385 fl.href =
386 "https://fonts.googleapis.com/css2?family=" +
387 encodeURIComponent(FONT).replace(/%20/g, "+") +
388 ":wght@" +
389 FONT_WEIGHT +
390 "&display=swap";
391 document.head.appendChild(fl);
392 }
393 const BG = String(V.background || "#020805");
394 const RETAINED_BREEZE = Math.max(
395 0,
396 Math.min(2, Number.isFinite(Number(V.retainedBreeze)) ? Number(V.retainedBreeze) : 1),
397 );
398 const state = window.__cptState;
399
400 // Fixed experiment parameters (the source's tuned defaults), baked in.
401 const WIND_SPEED = 1.06;
402 const WIND_STRENGTH = 2.5;
403 const TURBULENCE = 0.8;
404 const TEXT_Z = -5.0;
405 const BACKDROP_Z = -7.5;
406 const CAM_DIST = 13.6;
407
408 const clamp = THREE.MathUtils.clamp;
409 const smoothstep = (a, b, x) => {
410 const t = clamp((x - a) / (b - a), 0, 1);
411 return t * t * (3 - 2 * t);
412 };
413 function hash2(x, y, seed) {
414 let n = Math.imul(x + seed * 1013, 374761393) + Math.imul(y - seed * 79, 668265263);
415 n = n ^ (n >>> 13);
416 n = Math.imul(n, 1274126177);
417 return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
418 }
419
420 document.getElementById("cpt-bg").style.background = BG;
421 document.body.style.background = BG;
422
423 const renderer = new THREE.WebGLRenderer({
424 canvas: document.getElementById("cpt-canvas"),
425 antialias: true,
426 powerPreference: "high-performance",
427 alpha: false,
428 });
429 renderer.setSize(W, H, false);
430 renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
431 renderer.outputColorSpace = THREE.SRGBColorSpace;
432 renderer.toneMapping = THREE.ACESFilmicToneMapping;
433 renderer.toneMappingExposure = 1.8;
434 renderer.shadowMap.enabled = true;
435 renderer.shadowMap.type = THREE.PCFShadowMap;
436
437 const scene = new THREE.Scene();
438 const bgColor = new THREE.Color(BG);
439 scene.background = bgColor.clone();
440 scene.fog = new THREE.FogExp2(bgColor.clone(), 0.008);
441
442 const camera = new THREE.PerspectiveCamera(41, W / H, 0.1, 40);
443 camera.position.set(0, 0.15, 12.1);
444 camera.lookAt(0, 0, -1.5);
445
446 const composer = new EffectComposer(renderer);
447 composer.setSize(W, H);
448 // Copy only colour; retain the scene depth for the sharp headline pass.
449 composer.copyPass.material.depthTest = false;
450 composer.copyPass.material.depthWrite = false;
451 composer.addPass(new RenderPass(scene, camera));
452 const bokehPass = new BokehPass(scene, camera, {
453 focus: 16.4,
454 aperture: 0.0016,
455 maxblur: 0.0055,
456 });
457 composer.addPass(bokehPass);
458
459 const hemi = new THREE.HemisphereLight(0x9fc09b, 0x061009, 2);
460 scene.add(hemi);
461 const sun = new THREE.DirectionalLight(new THREE.Color("#ece5ff"), 8.5);
462 sun.position.set(-13.3, 8.5, 9);
463 sun.target.position.set(0, -1, -3);
464 sun.castShadow = true;
465 sun.shadow.mapSize.set(2048, 2048);
466 sun.shadow.camera.left = -20;
467 sun.shadow.camera.right = 20;
468 sun.shadow.camera.top = 20 * 0.73;
469 sun.shadow.camera.bottom = -20 * 0.73;
470 sun.shadow.camera.near = 0.5;
471 sun.shadow.camera.far = 28;
472 sun.shadow.bias = 0.00105;
473 sun.shadow.normalBias = 0.0525;
474 sun.shadow.radius = 2.5;
475 scene.add(sun, sun.target);
476 const fill = new THREE.DirectionalLight(new THREE.Color("#00bd03"), 9.55);
477 fill.position.set(-11.8, 4.2, 5.2);
478 fill.target.position.set(0.7, 1, -2);
479 scene.add(fill, fill.target);
480
481 /* Plain flat backdrop in the variable colour (unlit so it stays flat), with a
482 shadow-only catcher just in front for leaf contact shadows. */
483 const wall = new THREE.Mesh(
484 new THREE.PlaneGeometry(68, 44),
485 new THREE.MeshBasicMaterial({ color: bgColor.clone(), fog: false }),
486 );
487 wall.position.z = BACKDROP_Z;
488 scene.add(wall);
489 const catcher = new THREE.Mesh(
490 new THREE.PlaneGeometry(68, 44),
491 new THREE.ShadowMaterial({ opacity: 0.28 }),
492 );
493 catcher.position.z = BACKDROP_Z + 0.05;
494 catcher.receiveShadow = true;
495 scene.add(catcher);
496
497 // ---- Procedural leaf species textures (ported verbatim from the experiment).
498 const palettes = {
499 monstera: [
500 [22, 77, 46],
501 [79, 128, 70],
502 [170, 184, 104],
503 ],
504 alocasia: [
505 [18, 55, 43],
506 [57, 104, 73],
507 [190, 206, 149],
508 ],
509 lance: [
510 [15, 74, 42],
511 [53, 126, 65],
512 [145, 172, 84],
513 ],
514 calathea: [
515 [32, 58, 39],
516 [82, 117, 72],
517 [195, 178, 126],
518 ],
519 fern: [
520 [20, 73, 39],
521 [64, 126, 65],
522 [158, 176, 88],
523 ],
524 };
525
526 function leafWidth(kind, y) {
527 const s = Math.max(0, Math.sin(Math.PI * y));
528 if (kind === "lance" || kind === "fern") return Math.pow(s, 1.28) * (0.66 + y * 0.12);
529 if (kind === "alocasia") return Math.pow(s, 0.62) * (0.88 + 0.16 * (1 - y));
530 if (kind === "calathea") return Math.pow(s, 0.82) * 0.78;
531 return Math.pow(s, 0.55) * (0.82 + 0.15 * y);
532 }
533
534 function makeLeafTexture(kind, seed) {
535 const w = 384,
536 h = 768;
537 const colorCanvas = document.createElement("canvas");
538 const bumpCanvas = document.createElement("canvas");
539 colorCanvas.width = bumpCanvas.width = w;
540 colorCanvas.height = bumpCanvas.height = h;
541 const colorCtx = colorCanvas.getContext("2d");
542 const bumpCtx = bumpCanvas.getContext("2d");
543 const colorImage = colorCtx.createImageData(w, h);
544 const bumpImage = bumpCtx.createImageData(w, h);
545 const p = palettes[kind];
546
547 for (let py = 0; py < h; py++) {
548 const y = py / (h - 1);
549 const width = leafWidth(kind, y);
550 for (let px = 0; px < w; px++) {
551 const x = (px / (w - 1) - 0.5) * 2;
552 const nx = Math.abs(x) / Math.max(0.001, width);
553 let signed = 1 - nx;
554 let inside = signed > -0.015 && y > 0.008 && y < 0.996;
555
556 if (inside && kind === "monstera") {
557 const ax = Math.abs(x);
558 const side = x < 0 ? -1 : 1;
559 for (let k = 0; k < 6; k++) {
560 const jitter = (hash2(k, seed, 44) - 0.5) * 0.034;
561 const root = 0.205 + k * 0.083 + jitter;
562 const inner = 0.23 + k * 0.022 + (hash2(k, seed, 51) - 0.5) * 0.026;
563 const curve =
564 root + (ax - inner) * (0.14 + k * 0.012) + Math.sin((ax + k) * 13.0) * 0.008;
565 const slotWidth =
566 (0.009 + k * 0.0018) * smoothstep(inner, Math.max(inner + 0.11, width), ax);
567 if (ax > inner && Math.abs(y - curve) < slotWidth && ax < width * 1.025)
568 inside = false;
569 if (k < 4) {
570 const cx = side * (0.225 + k * 0.052 + (hash2(k, seed, 61) - 0.5) * 0.025);
571 const cy = 0.31 + k * 0.105 + (hash2(k, seed, 71) - 0.5) * 0.025;
572 const rx = 0.026 + k * 0.003;
573 const ry = 0.038 + k * 0.005;
574 const skewY = y - cy - (x - cx) * side * 0.26;
575 const hole = (x - cx) ** 2 / (rx * rx) + skewY ** 2 / (ry * ry) < 1;
576 if (hole) inside = false;
577 }
578 }
579 }
580 if (inside && kind === "alocasia" && y < 0.105) {
581 const notch = Math.abs(x) < 0.15 * (1 - y / 0.105);
582 if (notch) inside = false;
583 }
584
585 const i = (py * w + px) * 4;
586 if (!inside) {
587 colorImage.data[i + 3] = 0;
588 bumpImage.data[i] = bumpImage.data[i + 1] = bumpImage.data[i + 2] = 112;
589 bumpImage.data[i + 3] = 255;
590 continue;
591 }
592
593 const n0 = hash2(px >> 2, py >> 2, seed);
594 const n1 = hash2(px >> 4, py >> 4, seed + 17);
595 const middle = Math.exp(-Math.abs(x) * (kind === "lance" ? 28 : 35));
596 const veinFreq = kind === "lance" ? 12 : 8.5;
597 const ribWave = Math.abs(((y * veinFreq + Math.abs(x) * 1.85) % 1) - 0.5);
598 const sideVein = Math.exp(-ribWave * 46) * smoothstep(0.04, 0.82, Math.abs(x));
599 const edge = smoothstep(0, 0.16, signed);
600 const stripe =
601 kind === "calathea" ? 0.5 + 0.5 * Math.cos(y * 58 + Math.abs(x) * 10) : 0;
602 const mottling = (n0 - 0.5) * 0.16 + (n1 - 0.5) * 0.12 + stripe * 0.08;
603 const light = clamp(
604 0.42 + y * 0.12 + mottling + middle * 0.34 + sideVein * 0.16 - (1 - edge) * 0.27,
605 0,
606 1,
607 );
608 const dry = hash2(px >> 3, py >> 3, seed + 90) > 0.978 ? 0.16 : 0;
609
610 const c0 = p[0],
611 c1 = p[1],
612 c2 = p[2];
613 const t = smoothstep(0.08, 0.9, light);
614 const hi = smoothstep(0.56, 1, t);
615 for (let ch = 0; ch < 3; ch++) {
616 const low = c0[ch] + (c1[ch] - c0[ch]) * t;
617 const val =
618 low + (c2[ch] - low) * hi * 0.58 + dry * (ch === 0 ? 56 : ch === 1 ? 35 : 10);
619 colorImage.data[i + ch] = clamp(val, 0, 255);
620 }
621 colorImage.data[i + 3] = clamp(edge * 390, 0, 255);
622
623 const bump = clamp(
624 112 + (n0 - 0.5) * 22 + middle * 100 + sideVein * 52 - (1 - edge) * 18,
625 0,
626 255,
627 );
628 bumpImage.data[i] = bumpImage.data[i + 1] = bumpImage.data[i + 2] = bump;
629 bumpImage.data[i + 3] = 255;
630 }
631 }
632
633 colorCtx.putImageData(colorImage, 0, 0);
634 bumpCtx.putImageData(bumpImage, 0, 0);
635 const map = new THREE.CanvasTexture(colorCanvas);
636 map.colorSpace = THREE.SRGBColorSpace;
637 map.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
638 const bump = new THREE.CanvasTexture(bumpCanvas);
639 bump.anisotropy = map.anisotropy;
640 return { map, bump };
641 }
642
643 // Beside this file when mounted; a runtime without assetUrl keeps the plain path.
644 const assetUrl = (path) =>
645 typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
646 ? __hyperframes.assetUrl(path)
647 : path;
648 const textureLoader = new THREE.TextureLoader();
649 const [photoSurface, photoNormal] = await Promise.all([
650 textureLoader.loadAsync(assetUrl("assets/leaf-surface-color.webp")),
651 textureLoader.loadAsync(assetUrl("assets/leaf-surface-normal.webp")),
652 ]);
653 photoSurface.colorSpace = THREE.SRGBColorSpace;
654 photoSurface.wrapS = photoSurface.wrapT = THREE.RepeatWrapping;
655 photoSurface.repeat.set(2.4, 4.8);
656 photoNormal.wrapS = photoNormal.wrapT = THREE.RepeatWrapping;
657 photoNormal.repeat.set(2.4, 4.8);
658
659 const kinds = ["monstera", "alocasia", "lance", "calathea", "fern"];
660 const textureSets = Object.fromEntries(
661 kinds.map((kind, i) => [kind, makeLeafTexture(kind, i * 31 + 7)]),
662 );
663 const materials = {};
664 const shaderMaterials = [];
665
666 for (const [index, kind] of kinds.entries()) {
667 const tex = textureSets[kind];
668 const material = new THREE.MeshPhysicalMaterial({
669 map: tex.map,
670 bumpMap: tex.bump,
671 bumpScale: (kind === "monstera" ? 0.065 : 0.045) * 2.5,
672 normalMap: photoNormal,
673 normalScale: new THREE.Vector2(0.35, 0.35),
674 alphaTest: 0.42,
675 side: THREE.DoubleSide,
676 roughness: 0.5626,
677 metalness: 0,
678 clearcoat: 0.5256,
679 clearcoatRoughness: 0.4274,
680 thickness: 0,
681 transmission: 0.015,
682 sheen: 0.24,
683 sheenColor: new THREE.Color(kind === "calathea" ? 0xc6a68d : 0x7da06a),
684 sheenRoughness: 0.8,
685 emissive: new THREE.Color(0x06140a),
686 emissiveIntensity: 0.18,
687 shadowSide: THREE.DoubleSide,
688 });
689 // Transmission and ordinary passes can compile separate shader variants.
690 // All variants share one time uniform, including a late first compilation.
691 material.userData.timeUniform = { value: 0 };
692 material.onBeforeCompile = (shader) => {
693 shader.uniforms.uTime = material.userData.timeUniform;
694 shader.uniforms.uWindStrength = { value: WIND_STRENGTH };
695 shader.uniforms.uTurbulence = { value: TURBULENCE };
696 shader.uniforms.uShapeNoise = { value: 2.5 };
697 shader.uniforms.uTextureNoise = { value: 2 };
698 shader.uniforms.uPhotoTexture = { value: 1.5 };
699 shader.uniforms.uPhotoSurface = { value: photoSurface };
700 shader.vertexShader = shader.vertexShader
701 .replace(
702 "#include <common>",
703 `#include <common>
704 uniform float uTime;
705 uniform float uWindStrength;
706 uniform float uTurbulence;
707 uniform float uShapeNoise;
708 `,
709 )
710 .replace(
711 "#include <begin_vertex>",
712 `
713 vec3 transformed = vec3(position);
714 float root = pow(uv.y, 1.28);
715 float worldPhase = dot(modelMatrix[3].xyz, vec3(0.71, 1.13, 0.43)) + ${index.toFixed(1)};
716 vec2 worldXY = modelMatrix[3].xy;
717 float gustCell = sin(uTime * 0.23 + worldXY.x * 0.42 + sin(worldXY.y * 0.37 + uTime * 0.11));
718 float localGust = pow(max(0.0, gustCell), 3.6);
719 float breath = sin(uTime * 0.46 + worldPhase) * 0.20 + sin(uTime * 1.17 + worldPhase * 1.7) * 0.07 + localGust * 0.62;
720 float flutter = (sin(uTime * 2.7 + uv.y * 8.0 + worldPhase * 2.2) * 0.012 + localGust * sin(uv.y * 11.0 + worldPhase) * 0.038) * uTurbulence;
721 float edgeNoise = sin(uv.y * 31.0 + worldPhase * 4.1) * pow(abs(uv.x - 0.5) * 2.0, 2.4) * 0.022 * uShapeNoise;
722 transformed.x += (breath * 0.075 * uWindStrength + flutter + edgeNoise) * root;
723 transformed.z += breath * 0.12 * root * uWindStrength + flutter * root * 0.8 + edgeNoise;
724 transformed.z += (1.0 - pow(abs(uv.x - 0.5) * 2.0, 1.65)) * 0.075;
725 `,
726 );
727 shader.fragmentShader = shader.fragmentShader
728 .replace(
729 "#include <common>",
730 `#include <common>
731 uniform float uTextureNoise;
732 uniform float uPhotoTexture;
733 uniform sampler2D uPhotoSurface;`,
734 )
735 .replace(
736 "#include <map_fragment>",
737 `#include <map_fragment>
738 float leafNoise = fract(sin(dot(vMapUv * vec2(311.7, 917.3), vec2(12.9898, 78.233))) * 43758.5453);
739 diffuseColor.rgb *= 1.0 + (leafNoise - 0.5) * 0.18 * uTextureNoise;
740 vec3 photographedSurface = texture2D(uPhotoSurface, vMapUv * vec2(2.4, 4.8)).rgb;
741 diffuseColor.rgb *= mix(vec3(1.0), photographedSurface * 1.24, clamp(uPhotoTexture, 0.0, 1.0) * 0.26);
742 `,
743 );
744 material.userData.shader = shader;
745 };
746 material.customProgramCacheKey = () => `cpt-leaf-wind-photo-v6-${index}`;
747 materials[kind] = material;
748 shaderMaterials.push(material);
749 }
750
751 const geometryCache = new Map();
752 function leafGeometry(width, length) {
753 const key = `${width.toFixed(2)}-${length.toFixed(2)}`;
754 if (geometryCache.has(key)) return geometryCache.get(key);
755 const geo = new THREE.PlaneGeometry(width, length, 12, 22);
756 geo.translate(0, length * 0.5, 0);
757 const position = geo.attributes.position;
758 const uv = geo.attributes.uv;
759 for (let i = 0; i < position.count; i++) {
760 const across = position.getX(i) / (width * 0.5);
761 const along = uv.getY(i);
762 const arch = (1 - Math.pow(Math.abs(across), 1.62)) * Math.sin(Math.PI * along);
763 const twist = across * (along - 0.22);
764 const edgeRipple =
765 Math.sin(along * Math.PI * 5.4 + across * 2.1) * Math.pow(Math.abs(across), 2.2);
766 position.setZ(
767 i,
768 arch * length * 0.052 + twist * length * 0.038 + edgeRipple * length * 0.012,
769 );
770 }
771 position.needsUpdate = true;
772 geo.computeVertexNormals();
773 geometryCache.set(key, geo);
774 return geo;
775 }
776
777 const DIMS = {
778 monstera: [2.05, 2.7],
779 alocasia: [1.92, 2.85],
780 lance: [0.68, 3.45],
781 calathea: [1.36, 3.05],
782 fern: [0.46, 1.5],
783 };
784 const leaves = [];
785 for (const batch of window.__cptBatches) {
786 for (const spec of batch) {
787 const dims = DIMS[spec.kind];
788 if (spec.batch === 2) {
789 // Start the entire leaf below the camera frustum, including its
790 // tip, wind deformation and the small camera orbit. Its identity
791 // persists: the lift carries it into view rather than spawning it.
792 const reach =
793 Math.hypot(dims[0] * spec.widthMul * 0.5, dims[1] * spec.lengthMul) * spec.scale;
794 spec.liftStartY =
795 -Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)) * (CAM_DIST - spec.liftZ) -
796 reach -
797 2;
798 }
799 const group = new THREE.Group();
800 const mesh = new THREE.Mesh(
801 leafGeometry(dims[0] * spec.widthMul, dims[1] * spec.lengthMul),
802 materials[spec.kind],
803 );
804 mesh.castShadow = true;
805 mesh.receiveShadow = spec.z < 4.8;
806 group.add(mesh);
807 group.visible = false;
808 group.position.set(spec.xStart, spec.y0, spec.z);
809 group.scale.setScalar(spec.scale);
810 group.rotation.set(spec.tiltX, spec.tiltY, spec.rotation);
811 scene.add(group);
812 leaves.push({ group, spec });
813 }
814 }
815
816 /* ---- The headlines live IN the scene, behind the leaf band, so foliage
817 genuinely occludes them. Reveal is a wipe uniform that tracks the sweep's
818 trailing edge — the visible edge always sits under the dense part of the
819 band, so the type reads as uncovered by the passing leaves, never faded. */
820 function headlineTexture(text) {
821 // Enough source detail for the headline at both 1x and 2x output density.
822 const c = document.createElement("canvas");
823 c.width = 4096;
824 c.height = 1024;
825 const g = c.getContext("2d");
826 g.clearRect(0, 0, c.width, c.height);
827 g.textAlign = "center";
828 g.textBaseline = "middle";
829 let size = 380;
830 const applyFont = () => {
831 g.font = `${FONT_WEIGHT} ${size}px ${FONT_STACK}`;
832 g.letterSpacing = `${Math.round(size * LETTER_SP)}px`;
833 };
834 applyFont();
835 let tw = g.measureText(text).width;
836 if (tw > 3760) {
837 size = Math.floor((size * 3760) / tw);
838 applyFont();
839 }
840 /* User size multiplier rides on top of the auto-fit. */
841 size = Math.round(size * FONT_SIZE);
842 applyFont();
843 g.shadowColor = "rgba(0,0,0,0.5)";
844 g.shadowBlur = 30;
845 g.shadowOffsetY = 8;
846 g.fillStyle = "#e4eedb";
847 g.fillText(text, c.width / 2 + Math.round(size * LETTER_SP * 0.5), c.height / 2 + 16);
848 const t = new THREE.CanvasTexture(c);
849 t.colorSpace = THREE.SRGBColorSpace;
850 t.anisotropy = 16;
851 return t;
852 }
853
854 function headlinePlane(text, second = false) {
855 const mat = new THREE.MeshBasicMaterial({
856 map: headlineTexture(text),
857 transparent: true,
858 toneMapped: false,
859 fog: false,
860 depthWrite: false,
861 });
862 const uniforms = { uIn: { value: -0.05 }, uOut: { value: -0.05 }, uLift: { value: -0.05 } };
863 mat.onBeforeCompile = (shader) => {
864 shader.uniforms.uIn = uniforms.uIn;
865 shader.uniforms.uOut = uniforms.uOut;
866 shader.uniforms.uLift = uniforms.uLift;
867 shader.fragmentShader = shader.fragmentShader
868 .replace(
869 "#include <common>",
870 "#include <common>\nuniform float uIn;\nuniform float uOut;\nuniform float uLift;",
871 )
872 .replace(
873 "#include <map_fragment>",
874 `#include <map_fragment>
875 float eIn = uIn + sin(vMapUv.y * 43.0) * 0.012;
876 float eOut = uOut + sin(vMapUv.y * 37.0 + 2.0) * 0.012;
877 float vis = smoothstep(eIn + 0.02, eIn - 0.02, vMapUv.x) * smoothstep(eOut - 0.02, eOut + 0.02, vMapUv.x);
878 float lifted = smoothstep(uLift - 0.08, uLift + 0.08, vMapUv.y);
879 diffuseColor.a *= vis * ${second ? "(1.0 - lifted)" : "lifted"};`,
880 );
881 };
882 mat.customProgramCacheKey = () => "cpt-headline-lift-" + second;
883 const mesh = new THREE.Mesh(new THREE.PlaneGeometry(16.4, 4.1), mat);
884 mesh.position.set(0, 0.15, TEXT_Z);
885 mesh.renderOrder = 2;
886 // Exclude type from the bokeh colour/depth passes. Its alpha edges must
887 // not be blurred using the discontinuous text/backdrop depth samples.
888 mesh.layers.set(1);
889 scene.add(mesh);
890 return {
891 mesh,
892 mat,
893 uniforms,
894 retex: (t2) => {
895 const previous = mat.map;
896 mat.map = headlineTexture(t2);
897 previous.dispose();
898 mat.needsUpdate = true;
899 },
900 };
901 }
902
903 const plane1 = headlinePlane(HEADLINE1);
904 const plane2 = headlinePlane(HEADLINE2, true);
905
906 // ---- Glow motes (drift is a pure function of seed and t).
907 const DUST_COUNT = 800;
908 const dustHome = Float32Array.from(window.__cptDust.home);
909 const dustSeeds = Float32Array.from(window.__cptDust.seeds);
910 const dustPositions = dustHome.slice();
911 const dustGeo = new THREE.BufferGeometry();
912 dustGeo.setAttribute("position", new THREE.BufferAttribute(dustPositions, 3));
913 dustGeo.setAttribute("seed", new THREE.BufferAttribute(dustSeeds, 1));
914 const moteCanvas = document.createElement("canvas");
915 moteCanvas.width = moteCanvas.height = 64;
916 const moteCtx = moteCanvas.getContext("2d");
917 const moteGradient = moteCtx.createRadialGradient(32, 32, 0, 32, 32, 31);
918 moteGradient.addColorStop(0, "rgba(255,255,232,1)");
919 moteGradient.addColorStop(0.16, "rgba(224,255,202,.92)");
920 moteGradient.addColorStop(0.5, "rgba(180,228,160,.28)");
921 moteGradient.addColorStop(1, "rgba(140,205,125,0)");
922 moteCtx.fillStyle = moteGradient;
923 moteCtx.fillRect(0, 0, 64, 64);
924 const moteSprite = new THREE.CanvasTexture(moteCanvas);
925 moteSprite.colorSpace = THREE.SRGBColorSpace;
926 const dustMat = new THREE.PointsMaterial({
927 color: new THREE.Color("#daf9b4"),
928 map: moteSprite,
929 size: 0.1,
930 transparent: true,
931 opacity: 0.55,
932 depthWrite: false,
933 blending: THREE.AdditiveBlending,
934 });
935 const dust = new THREE.Points(dustGeo, dustMat);
936 scene.add(dust);
937
938 // BokehPass depth prepass with alpha-aware materials (ported from the experiment).
939 const dofDepthMaterials = new Map();
940 function depthMaterialFor(source) {
941 const key = source.uuid;
942 if (dofDepthMaterials.has(key)) return dofDepthMaterials.get(key);
943 const depth = new THREE.MeshDepthMaterial({
944 depthPacking: THREE.RGBADepthPacking,
945 map: source.map || null,
946 alphaTest: source.alphaTest || 0.5,
947 side: source.side,
948 depthTest: true,
949 depthWrite: true,
950 });
951 dofDepthMaterials.set(key, depth);
952 return depth;
953 }
954 bokehPass.render = function (activeRenderer, writeBuffer, readBuffer) {
955 const swapped = [];
956 const dustVisible = dust.visible;
957 dust.visible = false;
958 this.scene.traverse((object) => {
959 if (!object.isMesh) return;
960 swapped.push([object, object.material]);
961 object.material = depthMaterialFor(object.material);
962 });
963 try {
964 activeRenderer.setRenderTarget(this.renderTargetDepth);
965 activeRenderer.clear();
966 activeRenderer.render(this.scene, this.camera);
967 } finally {
968 for (const [object, material] of swapped) object.material = material;
969 dust.visible = dustVisible;
970 }
971 this.uniforms.tColor.value = readBuffer.texture;
972 this.uniforms.tDepth.value = this.renderTargetDepth.texture;
973 activeRenderer.setRenderTarget(writeBuffer);
974 if (this.clear) activeRenderer.clear();
975 this.fsQuad.render(activeRenderer);
976
977 // Bring the blurred foliage back without clearing the ORIGINAL colour
978 // pass's depth. That depth includes the leaves' alpha cutouts and wind
979 // deformation, so the unblurred headline still sits behind the canopy.
980 const copy = composer.copyPass;
981 copy.renderToScreen = false;
982 const background = scene.background;
983 const autoClear = activeRenderer.autoClear;
984 const layers = camera.layers.mask;
985 const shadowAutoUpdate = activeRenderer.shadowMap.autoUpdate;
986 try {
987 scene.background = null;
988 activeRenderer.autoClear = false;
989 activeRenderer.shadowMap.autoUpdate = false;
990 copy.render(activeRenderer, readBuffer, writeBuffer);
991 camera.layers.set(1);
992 activeRenderer.render(scene, camera);
993 } finally {
994 scene.background = background;
995 activeRenderer.autoClear = autoClear;
996 activeRenderer.shadowMap.autoUpdate = shadowAutoUpdate;
997 camera.layers.mask = layers;
998 }
999 // Keep the existing colour pipeline for both foliage and typography.
1000 copy.renderToScreen = this.renderToScreen;
1001 copy.render(activeRenderer, writeBuffer, readBuffer);
1002 };
1003
1004 const cameraTarget = new THREE.Vector3(0, 0, -1.5);
1005 const cameraOffset = new THREE.Vector3();
1006 const cameraOrbit = new THREE.Euler(0, 0, 0, "YXZ");
1007 const degToRad = THREE.MathUtils.degToRad;
1008 const DJ = 0.5;
1009
1010 function motionClock(t) {
1011 const end = window.__cptWindows.s2 + window.__cptWindows.d2;
1012 const duration = Math.max(0.1, 10.5 - end);
1013 const p = clamp((t - end) / duration, 0, 1);
1014 return t <= end ? t : end + duration * (p - (p * p) / 2);
1015 }
1016
1017 function openingWind(spec, t) {
1018 const wt = motionClock(t) * WIND_SPEED;
1019 const gust = Math.sin(wt * 0.23 + spec.restX * 0.42 + Math.sin(spec.y0 * 0.37 + wt * 0.11));
1020 const sway =
1021 (Math.sin(wt * 0.46 + spec.phase) * 0.011 +
1022 Math.sin(wt * 0.58 + spec.y0 * 0.74) * 0.007 +
1023 Math.max(0, gust) ** 3.6 * 0.03) *
1024 WIND_STRENGTH;
1025 return [sway, Math.sin(wt * 0.31 + spec.phase) * 0.02];
1026 }
1027
1028 // Sample the authored incoming path, including its actual rotation/wind.
1029 // Only retained leaves use this path; the passing leaf wall stays intact.
1030 function retainedSweepPose(s, t) {
1031 const w = window.__cptWindows;
1032 if (s.batch === 2) {
1033 const p = clamp(clamp((t - w.s2) / w.d2, 0, 1) * 1.28 - s.liftDelay, 0, 1);
1034 const lift = smoothstep(0, 0.58, p),
1035 seat = smoothstep(0.38, 1, p);
1036 return [
1037 THREE.MathUtils.lerp(s.liftX, s.restX, seat),
1038 THREE.MathUtils.lerp(s.liftY, s.restY, seat) -
1039 (s.liftY - s.liftStartY) * (1 - smoothstep(0, 0.42, p)),
1040 THREE.MathUtils.lerp(s.liftZ, s.z, lift),
1041 s.tiltX + seat * 3.6 * Math.sin(s.phase),
1042 s.tiltY + seat * 3.6 * Math.cos(s.phase * 1.7),
1043 THREE.MathUtils.lerp(s.rotation, s.restRZ, seat),
1044 ];
1045 }
1046 const p = clamp(clamp((t - 0.4) / w.d1, 0, 1) * 1.5 - s.delay, 0, 1);
1047 const pe = 1 - Math.pow(1 - p, 3);
1048 const rz = s.rotation + pe * s.spin * 0.55;
1049 const turn =
1050 ((((s.restRZ - rz + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
1051 const [wind, roll] = openingWind(s, t);
1052 return [
1053 s.xStart + (s.restX - s.xStart) * pe,
1054 s.y0 +
1055 (s.restY - s.y0) * pe +
1056 Math.sin(pe * Math.PI * 1.4 + s.phase) * s.bobAmp * (1 - pe) * 0.8 +
1057 wind * 0.14,
1058 s.z,
1059 s.tiltX + wind,
1060 s.tiltY + wind * 0.65,
1061 rz + turn * smoothstep(0.55, 1, pe) + roll,
1062 ];
1063 }
1064
1065 function retainedIdlePose(spec, t, handoff) {
1066 const b = spec.breeze,
1067 age = t - handoff.start,
1068 a = age * b.rate;
1069 const pose = handoff.center.slice();
1070 if (spec.batch === 1) {
1071 const [wind, roll] = openingWind(spec, t);
1072 pose[1] += wind * 0.14;
1073 pose[3] += wind;
1074 pose[4] += wind * 0.65;
1075 pose[5] += roll;
1076 }
1077 const floatX = Math.sin(a + b.phase) * 0.72 + Math.sin(a * 0.613 + b.phase2) * 0.28;
1078 const floatY = Math.sin(a * 0.79 + b.phase2) * 0.76 + Math.sin(a * 1.137 + b.phase3) * 0.24;
1079 const floatZ = Math.sin(a * 0.53 + b.phase3);
1080 const offsets = [
1081 b.driftX * floatX,
1082 b.driftY * floatY,
1083 b.depth + b.driftZ * floatZ,
1084 b.tiltX * Math.sin(a * 0.67 + b.phase2),
1085 b.tiltY * Math.sin(a * 0.47 + b.phase3),
1086 b.roll * floatX + b.turn * age,
1087 ];
1088 return pose.map((v, i) => v + RETAINED_BREEZE * offsets[i]);
1089 }
1090
1091 // Join while each leaf still has momentum, before its old stop point.
1092 // Quintic Hermite correction matches position, velocity AND acceleration
1093 // at both ends. The moving breeze target is present throughout settling;
1094 // there is no idle timer, zero-velocity rest, or later amplitude fade-in.
1095 for (const { spec: s } of leaves) {
1096 if (!s.stayer) continue;
1097 const w = window.__cptWindows,
1098 first = s.batch === 1;
1099 const rate = first ? 1.5 / w.d1 : 1.28 / w.d2;
1100 const progress = (first ? 0.64 : 0.78) + s.breeze.arrival * 0.12;
1101 const start = (first ? 0.4 : w.s2) + (progress + (first ? s.delay : s.liftDelay)) / rate;
1102 const end = first ? 0.4 + w.d1 : w.s2 + w.d2;
1103 const center = retainedSweepPose(s, end);
1104 const incoming = retainedSweepPose(s, start);
1105 if (first) {
1106 const [wind, roll] = openingWind(s, end);
1107 center[1] -= wind * 0.14;
1108 center[3] -= wind;
1109 center[4] -= wind * 0.65;
1110 center[5] -= roll;
1111 }
1112 for (let i = 3; i < 6; i++)
1113 center[i] += Math.round((incoming[i] - center[i]) / (2 * Math.PI)) * 2 * Math.PI;
1114 const h = (s.handoff = { start, duration: (first ? 0.52 : 0.48) / rate, center });
1115 const dt = 0.001;
1116 const delta = (time) =>
1117 retainedSweepPose(s, time).map((v, i) => v - retainedIdlePose(s, time, h)[i]);
1118 const a = delta(start - dt),
1119 b = delta(start),
1120 c = delta(start + dt);
1121 h.correction = b.map((v, i) => [
1122 v,
1123 (c[i] - a[i]) / (2 * dt),
1124 (c[i] - 2 * v + a[i]) / (dt * dt),
1125 ]);
1126 }
1127
1128 function applyRetainedMotion(group, spec, t) {
1129 const h = spec.handoff;
1130 if (!h || !group.visible) return;
1131 if (t < h.start) {
1132 // Use the same unrounded clock on both sides of the handoff; GSAP's
1133 // rounded scalar writes must not introduce a tiny boundary jump.
1134 const incoming = retainedSweepPose(spec, t);
1135 group.position.set(incoming[0], incoming[1], incoming[2]);
1136 group.rotation.set(incoming[3], incoming[4], incoming[5]);
1137 return;
1138 }
1139 const pose = retainedIdlePose(spec, t, h);
1140 const u = clamp((t - h.start) / h.duration, 0, 1);
1141 const u2 = u * u,
1142 u3 = u2 * u,
1143 u4 = u3 * u,
1144 u5 = u4 * u;
1145 const p = 1 - 10 * u3 + 15 * u4 - 6 * u5;
1146 const v = u - 6 * u3 + 8 * u4 - 3 * u5;
1147 const a = (u2 - 3 * u3 + 3 * u4 - u5) / 2;
1148 for (let i = 0; i < 6; i++) {
1149 const c = h.correction[i];
1150 pose[i] += c[0] * p + c[1] * h.duration * v + c[2] * h.duration * h.duration * a;
1151 }
1152 if (spec.batch === 1) {
1153 // Carry the current floating pose into takeoff, never pull it back
1154 // to a rest slot. Quintic lift starts with zero added speed/acceleration.
1155 const m = clamp((t - window.__cptWindows.s2) / window.__cptWindows.d2, 0, 1);
1156 const q = clamp(m * 1.5 - spec.delay2 * 0.4, 0, 1);
1157 const lift = q * q * q * (10 + q * (-15 + 6 * q));
1158 pose[1] += lift * 6;
1159 pose[2] += lift * 34;
1160 pose[5] += lift * spec.spin * 0.55;
1161 }
1162 group.position.set(pose[0], pose[1], pose[2]);
1163 group.rotation.set(pose[3], pose[4], pose[5]);
1164 }
1165
1166 let disposed = false;
1167 function renderAt(t) {
1168 if (disposed) return;
1169 t = clamp(t, 0, DUR);
1170 const settleTime = window.__cptWindows.s2 + window.__cptWindows.d2;
1171 const settleDuration = Math.max(0.1, 10.5 - settleTime);
1172 const settleProgress = clamp((t - settleTime) / settleDuration, 0, 1);
1173 const motionTime =
1174 t <= settleTime
1175 ? t
1176 : settleTime +
1177 settleDuration * (settleProgress - (settleProgress * settleProgress) / 2);
1178 const wt = motionTime * WIND_SPEED;
1179 const m1 = state.sw1;
1180 const m2 = state.sw2;
1181
1182 for (const { group, spec } of leaves) {
1183 // Depth lift: emerge from the backdrop, cross the type plane,
1184 // rise through the focal range, then pass the camera (source exitDistance=34).
1185 // No lateral sweep, frame history, pointer force, or accumulated physics.
1186 if (spec.batch === 2) {
1187 const p = clamp(m2 * 1.28 - spec.liftDelay, 0, 1);
1188 // At p=0 it is already below frame; at p=1 passers are behind
1189 // the camera. No visibility/opacity switch at either boundary.
1190 group.visible = true;
1191 const entry = smoothstep(0, 0.42, p);
1192 const lift = smoothstep(0, 0.58, p);
1193 const exit = smoothstep(0.38, 1, p);
1194 const seat = smoothstep(0.38, 1, p);
1195 const z = spec.stayer
1196 ? THREE.MathUtils.lerp(spec.liftZ, spec.z, lift)
1197 : spec.liftZ + lift * 8 * spec.depthResponse + exit * 34;
1198 const orbit = exit * 1.15;
1199 const x = spec.stayer
1200 ? THREE.MathUtils.lerp(spec.liftX, spec.restX, seat)
1201 : spec.liftX + Math.cos(spec.phase + p * 1.3) * orbit;
1202 const liftedY = spec.stayer
1203 ? THREE.MathUtils.lerp(spec.liftY, spec.restY, seat)
1204 : spec.liftY + lift * 2.2 + exit * 3.8;
1205 const y = liftedY - (spec.liftY - spec.liftStartY) * (1 - entry);
1206 group.position.set(x, y, z);
1207 group.rotation.set(
1208 spec.tiltX + exit * 3.6 * Math.sin(spec.phase),
1209 spec.tiltY + exit * 3.6 * Math.cos(spec.phase * 1.7),
1210 spec.stayer
1211 ? THREE.MathUtils.lerp(spec.rotation, spec.restRZ, seat)
1212 : spec.rotation + exit * 3.6 * 0.55,
1213 );
1214 continue;
1215 }
1216 const m = spec.batch === 1 ? m1 : m2;
1217 const p = clamp(m * (1 + DJ) - spec.delay, 0, 1);
1218 if (p <= 0.0005) {
1219 group.visible = false;
1220 continue;
1221 }
1222 group.visible = true;
1223
1224 // Analytic wind sway, always applied (scaled up once settled).
1225 const gustCell = Math.sin(
1226 wt * 0.23 + spec.restX * 0.42 + Math.sin(spec.y0 * 0.37 + wt * 0.11),
1227 );
1228 const localGust = Math.max(0, gustCell) ** 3.6;
1229 const slowWind =
1230 (Math.sin(wt * 0.46 + spec.phase) * 0.011 +
1231 Math.sin(wt * 0.58 + spec.y0 * 0.74) * 0.007 +
1232 localGust * 0.03) *
1233 WIND_STRENGTH;
1234
1235 let x,
1236 y,
1237 rz,
1238 z = spec.z;
1239 if (!spec.stayer) {
1240 const pe = p * p * (3 - 2 * p);
1241 x = spec.xStart + (spec.xEnd - spec.xStart) * pe;
1242 y = spec.y0 + Math.sin(pe * Math.PI * spec.bobN + spec.phase) * spec.bobAmp;
1243 rz = spec.rotation + pe * spec.spin;
1244 } else {
1245 const pe = 1 - Math.pow(1 - p, 3);
1246 x = spec.xStart + (spec.restX - spec.xStart) * pe;
1247 y =
1248 spec.y0 +
1249 (spec.restY - spec.y0) * pe +
1250 Math.sin(pe * Math.PI * 1.4 + spec.phase) * spec.bobAmp * (1 - pe) * 0.8;
1251 const travelRz = spec.rotation + pe * spec.spin * 0.55;
1252 const seat = smoothstep(0.55, 1, pe);
1253 const dRz =
1254 ((((spec.restRZ - travelRz + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) %
1255 (2 * Math.PI)) -
1256 Math.PI;
1257 rz = travelRz + dRz * seat;
1258 if (spec.batch === 1 && m2 > 0) {
1259 // Existing edge leaves join the same upward depth current.
1260 const q = clamp(m2 * 1.5 - spec.delay2 * 0.4, 0, 1);
1261 const qe = smoothstep(0, 1, q);
1262 z += qe * 34;
1263 y += qe * 6;
1264 rz += qe * spec.spin * 0.55;
1265 }
1266 }
1267
1268 group.position.set(x, y + slowWind * 0.14, z);
1269 group.rotation.set(
1270 spec.tiltX + slowWind,
1271 spec.tiltY + slowWind * 0.65,
1272 rz + Math.sin(wt * 0.31 + spec.phase) * 0.02,
1273 );
1274 }
1275
1276 // The background/camera can settle while retained leaves keep breathing
1277 // through the final hold. Use absolute t, never accumulated frame deltas.
1278 for (const { group, spec } of leaves) applyRetainedMotion(group, spec, t);
1279
1280 /* Reveal wipes, derived from the sweep masters. Tuned so the visible wipe
1281 edge stays inside the dense part of the leaf band (verified visually). */
1282 plane1.uniforms.uIn.value = clamp((m1 - 0.3) / 0.52, 0, 1) * 1.1 - 0.05;
1283 plane1.uniforms.uOut.value = -0.05;
1284 plane2.uniforms.uIn.value = 1.05;
1285 plane1.uniforms.uLift.value = smoothstep(0.25, 0.43, m2) * 1.3 - 0.15;
1286 plane2.uniforms.uLift.value = smoothstep(0.4, 0.62, m2) * 1.3 - 0.15;
1287 // Source experiment DOF at lift peak; return to V1's crisp hold.
1288 const lens = smoothstep(0.05, 0.28, m2) * (1 - smoothstep(0.72, 1, m2));
1289 bokehPass.uniforms.aperture.value = 0.0016 + lens * 0.0005;
1290 bokehPass.uniforms.maxblur.value = 0.0055 + lens * 0.0035;
1291 plane2.uniforms.uOut.value = -0.05;
1292
1293 for (const material of shaderMaterials) {
1294 material.userData.timeUniform.value = wt;
1295 }
1296
1297 for (let i = 0; i < DUST_COUNT; i++) {
1298 const o = i * 3;
1299 const s = dustSeeds[i];
1300 dustPositions[o] = dustHome[o] + Math.sin(wt * 0.19 + s * 18) * 0.36;
1301 dustPositions[o + 1] = dustHome[o + 1] + Math.cos(wt * 0.14 + s * 27) * 0.38;
1302 dustPositions[o + 2] = dustHome[o + 2] + Math.sin(wt * 0.11 + s * 41) * 0.4;
1303 }
1304 dustGeo.attributes.position.needsUpdate = true;
1305
1306 const swayX = Math.sin(motionTime * 0.31) * 0.6 + Math.sin(motionTime * 0.127 + 2.1) * 0.5;
1307 const swayY = Math.sin(motionTime * 0.083 + 1.7) * 0.7;
1308 cameraOrbit.set(degToRad(-swayY), degToRad(swayX), 0, "YXZ");
1309 cameraOffset.set(0, 0.15, CAM_DIST).applyEuler(cameraOrbit);
1310 camera.position.copy(cameraTarget).add(cameraOffset);
1311 camera.lookAt(cameraTarget);
1312 dust.rotation.x = degToRad(-swayY) * 0.6;
1313 dust.rotation.y = degToRad(swayX) * 0.6;
1314
1315 composer.render();
1316 }
1317
1318 function onSeek(event) {
1319 const time = clamp(Number(event.detail?.time) || 0, 0, DUR);
1320 const timeline = window.__timelines["canopy-part-title"];
1321 timeline.totalTime(time, true);
1322 renderAt(time);
1323 }
1324 window.addEventListener("hf-seek", onSeek);
1325 window.__cptInstance = {
1326 dispose() {
1327 if (disposed) return;
1328 disposed = true;
1329 window.removeEventListener("hf-seek", onSeek);
1330 const resources = new Set();
1331 scene.traverse((object) => {
1332 if (object.geometry) resources.add(object.geometry);
1333 for (const mat of [object.material].flat().filter(Boolean)) {
1334 resources.add(mat);
1335 for (const value of Object.values(mat)) if (value?.isTexture) resources.add(value);
1336 }
1337 });
1338 for (const mat of dofDepthMaterials.values()) resources.add(mat);
1339 for (const resource of resources) resource.dispose();
1340 bokehPass.dispose();
1341 composer.dispose();
1342 renderer.dispose();
1343 renderer.forceContextLoss();
1344 window.__cptRender = null;
1345 },
1346 };
1347 window.__cptRender = renderAt;
1348
1349 const tl = window.__timelines["canopy-part-title"];
1350 // Compile every leaf material before the first time sample. Hidden leaves
1351 // otherwise compile on their first visible seek with uTime=0, making that
1352 // frame differ from a later seek to the same time.
1353 for (const { group } of leaves) group.visible = true;
1354 renderer.compile(scene, camera);
1355 renderAt(0);
1356 renderAt(window.__hfThreeTime ?? (tl ? tl.time() : 0));
1357 /* Draw across the timeline and flush the GPU before ready, so shader links
1358 and texture uploads land in the readiness gate, not the first visible frames. */
1359 async function warmGpu() {
1360 const dur = tl ? tl.duration() : 0;
1361 for (const f of [0.25, 0.5, 0.75]) {
1362 await new Promise((resolve) => setTimeout(resolve, 0));
1363 renderAt(dur * f);
1364 }
1365 renderAt(tl ? tl.time() : 0);
1366 renderer.getContext().finish();
1367 }
1368 window.__hf = window.__hf || {};
1369 window.__hf.buildReady = window.__hf.buildReady || {};
1370 if (document.fonts && document.fonts.ready) {
1371 window.__cptReady = Promise.all([
1372 document.fonts.load(`${FONT_WEIGHT} 100px "${FONT}"`).catch(() => {}),
1373 document.fonts.ready,
1374 ])
1375 .then(() => {
1376 if (disposed) return;
1377 plane1.retex(HEADLINE1);
1378 plane2.retex(HEADLINE2);
1379 renderAt(tl ? tl.time() : 0);
1380 return warmGpu();
1381 })
1382 .catch(() => {});
1383 window.__hf.buildReady["canopy-part-title"] = window.__cptReady;
1384 } else {
1385 window.__hf.buildReady["canopy-part-title"] = warmGpu();
1386 }
1387 </script>
1388 </body>
1389</html>