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