heygen-com/hyperframesReview behavior before running
SKILL DETAIL
glass-shard-title
heygen-com/hyperframes/glass-shard-title
Glass shards fly in through fog and tile themselves into the headline. HyperFrames block, 1920×1080, 12.16s, 19 variables.
Installs · 132View source
Installation
npx skills add https://github.com/heygen-com/hyperframes --skill glass-shard-title
Skill files
SKILL.md
Last synced · Sep 20, 2026
assets/fonts/CormorantGaramond-OFL.txt›
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
assets/fonts/Geist-OFL.txt›
Copyright (c) 2023 Vercel, in collaboration with basement.studio
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
assets/glass-main.mjs›
/* ================================================================
Three.js module: rasterise the headline, break its footprint into
seeded Voronoi glass shards, and render purely from scripted state.
================================================================ */
import * as THREE from "three";
import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
const G = window.__GST;
const st = G.st;
/* ---- One instance per document -------------------------------------
This composition is mounted in short-lived documents all over the
place: Studio's preview stage, the sidebar's hover previews, a
composition-stack push, the gallery's per-block tiles. One live
instance costs roughly 350 MB (a 1920x1080 WebGL context with
antialias + preserveDrawingBuffer, a 4096x2304 headline raster with
mipmaps, a PMREM environment, a transmission render target and 34
extruded shard geometries), so a stale one is not a rounding error.
Never build two on the same document, and always hand the host an
explicit way to release one. */
if (window.__gstInstance && window.__gstInstance.dispose) {
try {
window.__gstInstance.dispose();
} catch (e) {
/* best effort */
}
}
const canvas = document.getElementById("gst-canvas");
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
preserveDrawingBuffer: true,
});
renderer.setSize(1920, 1080, false);
renderer.setPixelRatio(1);
renderer.setClearColor(0x0a0a0f, 1); /* flat dark — no gradient wash */
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const FOV = 42;
const CAM_Z = 5.2;
const PANE_Z = 0.6;
const scene = new THREE.Scene();
/* Include the full configured flight, 15% seeded depth jitter, lateral
offsets rotated by the pane group, and shard extents. The old fixed
far=60 clipped distant starts even with fog disabled. */
const FAR = Math.max(
60,
CAM_Z + Math.max(-G.vars.zDist, -G.vars.zOut, 0) * 1.15 + G.vars.sideDist * 1.5 + 12,
);
const camera = new THREE.PerspectiveCamera(FOV, 1920 / 1080, 0.1, FAR);
camera.position.set(0, 0, CAM_Z);
/* Frustum size at the shard plane: world <-> texture mapping basis. */
const distPane = CAM_Z - PANE_Z;
const VH = 2 * distPane * Math.tan((FOV * Math.PI) / 360);
const VW = VH * (1920 / 1080);
/* ---- Lights ---- */
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const spot = new THREE.SpotLight(0xffffff, 7, 0, Math.PI / 4, 0.5, 1);
spot.position.set(0, 7.1, 2);
scene.add(spot);
const point = new THREE.PointLight(0xccddff, 4, 0, 1);
point.position.set(0, -3, 1);
scene.add(point);
/* ---- Environment + matcap (glass look) ---- */
const hdrTex = await new HDRLoader().loadAsync("assets/ferndale_studio_01_1k.hdr");
const pmrem = new THREE.PMREMGenerator(renderer);
const envRT = pmrem.fromEquirectangular(hdrTex);
scene.environment = envRT.texture;
scene.environmentRotation = new THREE.Euler(0, 0.6, 0);
hdrTex.dispose();
pmrem.dispose();
const matcapTex = await new THREE.TextureLoader().loadAsync("assets/matcap-1.png");
matcapTex.colorSpace = THREE.SRGBColorSpace;
matcapTex.wrapS = matcapTex.wrapT = THREE.RepeatWrapping;
const mk = 1 / 0.2;
matcapTex.repeat.set(mk, mk);
matcapTex.offset.set((1 - mk) * 0.5, (1 - mk) * 0.5);
/* Fonts must be resident BEFORE rasterising: shard selection samples
the text pixels. */
try {
await Promise.all([
document.fonts.load('600 100px "Geist"'),
document.fonts.load('400 100px "Geist"'),
]);
await document.fonts.ready;
} catch (e) {
/* system fallback */
}
/* ---- Headline raster (the ONLY content). Canvas spans the camera
frustum at the shard plane: world (x,y) -> px via VW/VH. ---- */
const TEXW = 4096;
const TEXH = Math.round(TEXW * (VH / VW));
const headCanvas = document.createElement("canvas");
headCanvas.width = TEXW;
headCanvas.height = TEXH;
const hctx = headCanvas.getContext("2d");
{
hctx.clearRect(0, 0, TEXW, TEXH);
hctx.textAlign = "center";
hctx.textBaseline = "middle";
/* Fit headline to 70% of frame width so the blob silhouette + padding stays inside frame. */
let fpx = 300;
hctx.font = `600 ${fpx}px "Geist", ui-sans-serif, sans-serif`;
hctx.letterSpacing = `${-0.035 * fpx}px`;
const w0 = hctx.measureText(G.vars.headline).width;
const maxW = TEXW * 0.7;
if (w0 > maxW) fpx = Math.floor((fpx * maxW) / w0);
hctx.font = `600 ${fpx}px "Geist", ui-sans-serif, sans-serif`;
hctx.letterSpacing = `${-0.035 * fpx}px`;
hctx.fillStyle = "#f4f4f8";
hctx.fillText(G.vars.headline, TEXW / 2, TEXH / 2);
G.fontPx = fpx;
}
const headTex = new THREE.CanvasTexture(headCanvas);
headTex.colorSpace = THREE.SRGBColorSpace;
headTex.anisotropy = 16;
/* ---- Fattened mask for cell selection (thick stroke = keep shards
that hug the letterforms, not just ones dead-centre on strokes) ---- */
const maskCanvas = document.createElement("canvas");
maskCanvas.width = 1280;
maskCanvas.height = Math.round(1280 * (VH / VW));
const mctx = maskCanvas.getContext("2d", { willReadFrequently: true });
{
const ms = maskCanvas.width / TEXW;
mctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
mctx.textAlign = "center";
mctx.textBaseline = "middle";
mctx.font = `600 ${G.fontPx * ms}px "Geist", ui-sans-serif, sans-serif`;
mctx.letterSpacing = `${-0.035 * G.fontPx * ms}px`;
mctx.fillStyle = "#fff";
mctx.strokeStyle = "#fff";
mctx.lineWidth = G.fontPx * ms * 0.22;
mctx.lineJoin = "round";
mctx.strokeText(G.vars.headline, maskCanvas.width / 2, maskCanvas.height / 2);
mctx.fillText(G.vars.headline, maskCanvas.width / 2, maskCanvas.height / 2);
}
/* Setup-only: released the moment the shard set is final (see below). */
let maskData = mctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height).data;
let maskW = maskCanvas.width,
maskH = maskCanvas.height;
function maskHit(wx, wy) {
if (!maskData) return false;
const px = Math.round((wx / VW + 0.5) * (maskW - 1));
const py = Math.round((0.5 - wy / VH) * (maskH - 1));
if (px < 0 || py < 0 || px >= maskW || py >= maskH) return false;
return maskData[(py * maskW + px) * 4 + 3] > 24;
}
/* ---- Headline band in world units (from the raster metrics) ---- */
{
hctx.font = `600 ${G.fontPx}px "Geist", ui-sans-serif, sans-serif`;
hctx.letterSpacing = `${-0.035 * G.fontPx}px`;
const m = hctx.measureText(G.vars.headline);
G.bandW = ((m.width + G.fontPx * 0.2) / TEXW) * VW;
G.bandH =
((m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + G.fontPx * 0.34) / TEXH) * VH;
}
/* ---- Organic silhouette: the assembled cluster reads as one shard of
a larger broken pane, not a rounded rectangle. A seeded harmonic blob
around the headline defines the region; chaos scales the noise,
padding sets how far the glass extends past the glyphs. The jagged
outer edge comes from natural Voronoi cell borders against a ring of
sacrificial outside sites (never rectangle clipping). ---- */
const SEED = 11;
const rnd = gstMulberry32(SEED);
const HW = G.bandW / 2,
HH = G.bandH / 2;
const PADW = G.bandH * (0.18 + G.vars.padding * 0.85);
const A0 = HW + PADW,
B0 = HH + PADW;
const randBlob = gstMulberry32(SEED * 511 + 3);
const HARM = [];
for (let k = 2; k <= 9; k++)
HARM.push({ k, amp: (randBlob() * 2 - 1) / Math.sqrt(k), ph: randBlob() * Math.PI * 2 });
function ellipseR(a, b, th) {
return (a * b) / Math.hypot(b * Math.cos(th), a * Math.sin(th));
}
function blobR(th) {
let n = 0;
for (const h of HARM) n += h.amp * Math.sin(h.k * th + h.ph);
/* chaos is deliberately violent at the top end — 1.0 is ~10x the old
deformation, so the outline can go properly feral. */
let r = ellipseR(A0, B0, th) * (1 + G.vars.chaos * 2.4 * n);
/* Floor on the text band RECT (not an ellipse): the whole headline plus
a padding margin is always inside the blob, so tiling the blob always
covers the text — at any tile count. */
const rc = Math.abs(Math.cos(th)),
rs = Math.abs(Math.sin(th));
const rectR = Math.min(
rc > 1e-6 ? (HW + PADW * 0.3) / rc : 1e9,
rs > 1e-6 ? (HH + PADW * 0.3) / rs : 1e9,
);
r = Math.max(r, rectR);
/* Hard frustum cap: whatever the sliders say, the silhouette never
spills past the frame and gets viewport-cut. */
const acx = Math.abs(Math.cos(th)),
asy = Math.abs(Math.sin(th));
const lim = Math.min(acx > 1e-6 ? (VW * 0.44) / acx : 1e9, asy > 1e-6 ? (VH * 0.43) / asy : 1e9);
return Math.min(r, lim);
}
function inBlob(x, y) {
return Math.hypot(x, y) <= blobR(Math.atan2(y, x));
}
/* Domain + density: keep tileCount as shard density over the blob. */
let blobArea = 0,
maxRX = 0,
maxRY = 0;
for (let i = 0; i < 96; i++) {
const th = (i / 96) * Math.PI * 2;
const r = blobR(th);
blobArea += 0.5 * r * r * ((Math.PI * 2) / 96);
maxRX = Math.max(maxRX, Math.abs(r * Math.cos(th)));
maxRY = Math.max(maxRY, Math.abs(r * Math.sin(th)));
}
/* tileCount is the exact number of pieces — no density inflation, no
minimum. tileCount 1 = the whole silhouette as one pane of glass. */
const N_INT = Math.max(1, Math.min(400, Math.round(G.vars.tileCount)));
void blobArea;
const bounds = [-maxRX * 1.3, -maxRY * 1.3, maxRX * 1.3, maxRY * 1.3];
/* Size variance: 0 = uniform cells (full Lloyd relaxation, even spread);
1 = chaotic — sites cluster around seeded attractors (dense regions =
small shards, sparse = huge ones) and relaxation is skipped. */
const VAR = G.vars.sizeVariance;
const randVar = gstMulberry32(SEED * 977 + 5);
const centres = [];
let cGuard = 0;
while (centres.length < 4 && cGuard++ < 400) {
const x = (randVar() * 2 - 1) * maxRX;
const y = (randVar() * 2 - 1) * maxRY;
if (inBlob(x, y)) centres.push([x, y]);
}
function gauss(r) {
/* Box-Muller from the seeded stream */
const u = Math.max(1e-9, r()),
v = r();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
let points = [];
let guard = 0;
const sigma = Math.max(maxRX, maxRY) * 0.14;
while (points.length < N_INT && guard++ < N_INT * 200) {
let x, y;
if (centres.length && rnd() < 0.85 * VAR) {
const c = centres[Math.floor(rnd() * centres.length) % centres.length];
x = c[0] + gauss(rnd) * sigma;
y = c[1] + gauss(rnd) * sigma;
} else {
x = (rnd() * 2 - 1) * maxRX;
y = (rnd() * 2 - 1) * maxRY;
}
if (inBlob(x, y)) points.push([x, y]);
}
const interiorCount = points.length;
/* The blob polygon itself is the outer boundary — every cell is clipped
against it, so the union of pieces IS the silhouette at any count. */
/* The silhouette polygon is COARSE and jittered on purpose: broken
glass has straight facet edges meeting at sharp vertices. A smooth
densely-sampled outline gave the boundary shards melted, scooped
edges — nothing like the experiment's fragments. */
const randEdge = gstMulberry32(SEED * 733 + 9);
const BLOB_VERTS = 22;
const blobPoly = [];
for (let j = 0; j < BLOB_VERTS; j++) {
const th = ((j + (randEdge() - 0.5) * 0.72) / BLOB_VERTS) * Math.PI * 2;
const r = blobR(th) * (1 + (randEdge() - 0.5) * 0.09);
blobPoly.push([r * Math.cos(th), r * Math.sin(th)]);
}
const RELAX = Math.round(3 * (1 - VAR));
for (let iter = 0; iter < RELAX; iter++) {
const vorR = d3.Delaunay.from(points).voronoi(bounds);
points = points.map((p, idx) => {
const cell = vorR.cellPolygon(idx);
if (!cell || cell.length < 3) return p;
let cx = 0,
cy = 0;
for (const pt of cell) {
cx += pt[0];
cy += pt[1];
}
return [cx / cell.length, cy / cell.length];
});
}
/* Polygon inset (seams between shards). */
function lineIntersect(p1, p2, p3, p4) {
const d = (p1[0] - p2[0]) * (p3[1] - p4[1]) - (p1[1] - p2[1]) * (p3[0] - p4[0]);
if (Math.abs(d) < 1e-9) return null;
const t = ((p1[0] - p3[0]) * (p3[1] - p4[1]) - (p1[1] - p3[1]) * (p3[0] - p4[0])) / d;
return [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])];
}
function insetPolygon(poly, inset) {
if (inset <= 0) return poly;
const n = poly.length;
if (n < 3) return null;
const edges = [];
for (let i = 0; i < n; i++) {
const a = poly[i],
b = poly[(i + 1) % n];
const dx = b[0] - a[0],
dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const nx = -dy / len,
ny = dx / len;
edges.push({
p1: [a[0] + nx * inset, a[1] + ny * inset],
p2: [b[0] + nx * inset, b[1] + ny * inset],
});
}
const m = edges.length;
if (m < 3) return null;
const out = [];
for (let k = 0; k < m; k++) {
const e1 = edges[(k + m - 1) % m],
e2 = edges[k];
out.push(lineIntersect(e1.p1, e1.p2, e2.p1, e2.p2) || e2.p1);
}
let area = 0;
for (let q = 0; q < out.length; q++) {
const [x1, y1] = out[q];
const [x2, y2] = out[(q + 1) % out.length];
area += x1 * y2 - x2 * y1;
}
return Math.abs(area) < 1e-6 ? null : out;
}
function pointInPoly(x, y, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i],
[xj, yj] = poly[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function cellTouchesText(poly) {
let minX = 1e9,
minY = 1e9,
maxX = -1e9,
maxY = -1e9;
for (const [x, y] of poly) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const N = 7;
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
const x = minX + ((i + 0.5) / N) * (maxX - minX);
const y = minY + ((j + 0.5) / N) * (maxY - minY);
if (pointInPoly(x, y, poly) && maskHit(x, y)) return true;
}
}
return false;
}
/* ---- Cells -> shard descriptors (seeded choreography per shard) ---- */
const GAP = Math.max(0, G.vars.gap);
const CORNER_R = 0.01 + G.vars.roundness * 0.16;
const PANE_DEPTH = 0.09;
function polyArea2(poly) {
let a = 0;
for (let i = 0; i < poly.length; i++) {
const [x1, y1] = poly[i],
[x2, y2] = poly[(i + 1) % poly.length];
a += x1 * y2 - x2 * y1;
}
return a / 2;
}
/* Sutherland-Hodgman: clip any polygon against a CONVEX Voronoi cell. */
function clipToConvex(subject, clip) {
const ccw = polyArea2(clip) > 0 ? 1 : -1;
let out = subject;
for (let i = 0; i < clip.length && out.length; i++) {
const a = clip[i],
b = clip[(i + 1) % clip.length];
const input = out;
out = [];
const inside = (pt) =>
ccw * ((b[0] - a[0]) * (pt[1] - a[1]) - (b[1] - a[1]) * (pt[0] - a[0])) >= -1e-9;
for (let j = 0; j < input.length; j++) {
const P = input[j],
Q = input[(j + 1) % input.length];
const Pin = inside(P),
Qin = inside(Q);
if (Pin) {
out.push(P);
if (!Qin) out.push(lineIntersect(P, Q, a, b) || Q);
} else if (Qin) {
out.push(lineIntersect(P, Q, a, b) || P);
}
}
}
return out;
}
/* Merge near-collinear boundary samples so corner rounding keeps scale. */
function simplifyPoly(poly, angTol) {
let out = poly;
for (let pass = 0; pass < 3 && out.length > 6; pass++) {
const next = [];
let dropped = false;
for (let i = 0; i < out.length; i++) {
const A = out[(i + out.length - 1) % out.length];
const B = out[i];
const C = out[(i + 1) % out.length];
const v1x = B[0] - A[0],
v1y = B[1] - A[1];
const v2x = C[0] - B[0],
v2y = C[1] - B[1];
const ang = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
if (ang < angTol && !dropped && out.length - 1 > 5) {
dropped = true;
continue;
}
dropped = false;
next.push(B);
}
if (next.length === out.length) break;
out = next;
}
return out;
}
const vor = d3.Delaunay.from(points).voronoi(bounds);
/* ---- polygon hygiene: the blob clip can emit degenerate results
(duplicate points, collinear runs, self-intersecting "bowties").
Those extrude into folded shards with broken reflections, so every
piece is sanitised and must be a simple polygon before it becomes
geometry. ---- */
function sanitizePoly(poly) {
if (!poly || poly.length < 3) return null;
let out = [];
for (const pt of poly) {
const last = out[out.length - 1];
if (last && Math.hypot(pt[0] - last[0], pt[1] - last[1]) < 1e-4) continue;
out.push(pt);
}
while (
out.length > 1 &&
Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < 1e-4
)
out.pop();
if (out.length < 3) return null;
/* drop collinear vertices */
const clean = [];
for (let i = 0; i < out.length; i++) {
const a = out[(i + out.length - 1) % out.length];
const b = out[i];
const c = out[(i + 1) % out.length];
const cr = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]);
const l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
if (Math.abs(cr) < 1e-6 * Math.max(1e-6, l1 * l2)) continue;
clean.push(b);
}
if (clean.length < 3) return null;
/* enforce CCW (the source inset assumes it) */
if (polyArea2(clean) < 0) clean.reverse();
return clean;
}
function segsIntersect(a, b, c, d) {
const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
const o1 = o(a, b, c),
o2 = o(a, b, d),
o3 = o(c, d, a),
o4 = o(c, d, b);
return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;
}
function isSimplePolygon(poly) {
const n = poly.length;
for (let i = 0; i < n; i++) {
const a = poly[i],
b = poly[(i + 1) % n];
for (let j = i + 1; j < n; j++) {
if (j === i || (j + 1) % n === i || j === (i + 1) % n) continue;
const c = poly[j],
d = poly[(j + 1) % n];
if (segsIntersect(a, b, c, d)) return false;
}
}
return true;
}
function goodPoly(poly, minArea) {
const sp = sanitizePoly(poly);
if (!sp) return null;
if (polyArea2(sp) < minArea) return null;
if (!isSimplePolygon(sp)) return null;
return sp;
}
const randFly = gstMulberry32(SEED * 31337 + 7);
const randTilt = gstMulberry32(SEED * 9973 + 1);
let cells = [];
let globalMinEdge = Infinity;
for (let ci = 0; ci < interiorCount; ci++) {
const cellPoly = vor.cellPolygon(ci);
if (!cellPoly) continue;
const cell = cellPoly.slice(0, -1);
/* The piece is the blob ∩ this cell: pieces always tile the whole
silhouette, so the text is always fully covered — tileCount only
decides how many pieces share the job. */
let raw = goodPoly(simplifyPoly(clipToConvex(blobPoly, cell), 0.14), 3e-4);
if (!raw) {
/* Degenerate clip. A text-bearing cell must not vanish (that is a
hole in a letter) — fall back to the unclipped Voronoi cell;
a non-text sliver is simply dropped. */
const fallback = goodPoly(cell, 3e-4);
if (!fallback || !cellTouchesText(fallback)) continue;
raw = fallback;
}
let off = insetPolygon(raw, GAP / 2);
off = (off && goodPoly(off, 2e-4)) || raw;
let cellMinEdge = Infinity;
for (let ei = 0; ei < off.length; ei++) {
const pa = off[ei],
pb = off[(ei + 1) % off.length];
const el = Math.hypot(pb[0] - pa[0], pb[1] - pa[1]);
if (el > 1e-6 && el < cellMinEdge) cellMinEdge = el;
}
let ccx = 0,
ccy = 0;
for (const pt of off) {
ccx += pt[0];
ccy += pt[1];
}
ccx /= off.length;
ccy /= off.length;
/* Per-corner clamping happens inside buildPieceGeometry (each corner
is limited by its own adjacent edges), so the radius is passed
through — a single short edge no longer disables rounding for the
whole shard. */
cells.push({
cx: ccx,
cy: ccy,
radius: CORNER_R,
local: off.map((pt) => [pt[0] - ccx, pt[1] - ccy]),
});
void cellMinEdge;
}
void globalMinEdge;
/* The fattened text mask has done its only job (cell selection). Drop
the 1280x720 RGBA readback and its backing canvas now instead of
keeping ~7 MB alive for the whole life of the instance. */
maskData = null;
maskCanvas.width = maskCanvas.height = 1;
const shards = cells.map((c) => {
const sign = c.cx >= 0 ? 1 : -1;
/* Direct fly coordinates: sideDist is how far to the side (world
units, 0 = no sideways motion at all), zDist is the z coordinate
the shards come from (negative = behind the pane). Both jittered
slightly per shard so the swarm doesn't move as one block. */
const inOff = [
sign * G.vars.sideDist * (0.7 + randFly() * 0.6),
G.vars.sideDist === 0 && G.vars.zDist === 0 ? 0 : (randFly() - 0.5) * 1.7,
G.vars.zDist * (0.85 + randFly() * 0.3),
];
const inSpin = [
(randFly() - 0.5) * Math.PI * 1.0 * G.vars.flyInRotation,
(randFly() - 0.5) * Math.PI * 1.3 * G.vars.flyInRotation,
(randFly() - 0.5) * Math.PI * 0.5 * G.vars.flyInRotation,
];
/* Fly-out: mirror of the fly-in — back out the same side and back
into the same depth, faster, spinning harder. */
const outOff = [
sign * G.vars.sideDist * (0.8 + randFly() * 0.7),
G.vars.sideDist === 0 && G.vars.zOut === 0 ? 0 : (randFly() - 0.5) * 2.2,
G.vars.zOut * (0.85 + randFly() * 0.3),
];
const outSpin = [
(randFly() - 0.5) * Math.PI * 1.6 * G.vars.flyOutRotation,
(randFly() - 0.5) * Math.PI * 2.0 * G.vars.flyOutRotation,
(randFly() - 0.5) * Math.PI * 0.8 * G.vars.flyOutRotation,
];
return {
cx: c.cx,
cy: c.cy,
local: c.local,
radius: c.radius,
baseRot: [(randTilt() - 0.5) * 0.08, (randTilt() - 0.5) * 0.08, (randTilt() - 0.5) * 0.03],
wobPhase: randTilt() * Math.PI * 2,
wobFreq: 0.45 + randTilt() * 0.5,
inOff,
inSpin,
outOff,
outSpin,
rIn: randFly(),
rOut: randFly(),
};
});
const n1 = Math.max(1, shards.length - 1);
/* IN: centre shards knit first. OUT: edges peel first. */
const inRank = shards.map((s, i) => [Math.abs(s.cx), i]).sort((a, b) => a[0] - b[0]);
const STG = G.vars.stagger;
inRank.forEach((pair, rank) => {
const s = shards[pair[1]];
s.inDelay = STG * 0.85 * (rank / n1) + s.rIn * 0.05 * STG;
/* The delay is bounded below 0.9, so at least 10% remains. A 0.12
duration floor pushed the last shards past tin=1 at high stagger,
leaving residual flight rotation throughout the hold and exit. */
s.inDur = 1 - s.inDelay;
});
const outRank = shards.map((s, i) => [Math.abs(s.cx), i]).sort((a, b) => b[0] - a[0]);
outRank.forEach((pair, rank) => {
const s = shards[pair[1]];
s.outDelay = STG * 0.85 * (rank / n1) + s.rOut * 0.05 * STG;
s.outDur = Math.max(0.12, 1 - s.outDelay);
});
/* ---- Materials: iridescent glass carrying its slice of the headline
(planar UVs + emissive map — no Chrome HTML-in-canvas API) ---- */
function makeIridescenceThicknessMap(size) {
const c = document.createElement("canvas");
c.width = size;
c.height = size;
const ctx = c.getContext("2d");
const grad = ctx.createLinearGradient(0, 0, size, size);
grad.addColorStop(0.0, "#202020");
grad.addColorStop(0.5, "#a0a0a0");
grad.addColorStop(1.0, "#f0f0f0");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
const radial = ctx.createRadialGradient(
size * 0.3,
size * 0.4,
0,
size * 0.3,
size * 0.4,
size * 0.9,
);
radial.addColorStop(0, "rgba(255,255,255,0.4)");
radial.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = radial;
ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return tex;
}
/* Flat dark transmission target, painted before all shards WITHOUT
occupying depth. Writing depth here hid every shard behind z=-3.5:
neither increasing fog distance nor disabling fog could reveal it.
Keep the original shading/color, including in the transmission pass. */
{
const d = CAM_Z + 3.5;
const bh2 = 2 * d * Math.tan((FOV * Math.PI) / 360);
const back = new THREE.Mesh(
new THREE.PlaneGeometry(bh2 * (1920 / 1080) * 1.05, bh2 * 1.05),
new THREE.MeshBasicMaterial({ color: 0x0a0a0f, depthWrite: false, depthTest: false }),
);
back.position.z = -3.5;
back.renderOrder = -1;
scene.add(back);
}
/* Manual per-FRAGMENT fog (three's scene.fog is bypassed entirely):
each fragment's own view depth drives an exp2 mix toward the
background, starting just behind the seated plane — so a tumbling
shard's near edge resolves before its far edge, like flying through
real fog. Applied to the glass shading and, as a pure fade, to the
additive matcap sheen (otherwise the sheen ghosts through the fog). */
const FOG_START = CAM_Z - PANE_Z + 0.28;
const FOG_RHO = G.vars.fog > 0 ? 4.6 / Math.max(2, G.vars.fog) : 0.0;
/* The headline belongs on the FRONT cap only — not mirrored on the back
cap, not smeared down the side walls. Masked by the object-space
normal. The same patch pushes the fog start just past the seated
plane so resting shards are fog-free. */
function addManualFog(shader, additive) {
shader.uniforms.uGstFogStart = { value: FOG_START };
shader.uniforms.uGstFogRho = { value: FOG_RHO };
shader.uniforms.uGstFogColor = { value: new THREE.Color(0x0a0a0f) };
shader.vertexShader = shader.vertexShader
.replace("#include <common>", "#include <common>\nvarying float vGstDep;")
.replace("#include <project_vertex>", "#include <project_vertex>\nvGstDep = -mvPosition.z;");
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
"#include <common>\nvarying float vGstDep;\nuniform float uGstFogStart;\nuniform float uGstFogRho;\nuniform vec3 uGstFogColor;",
)
.replace(
"#include <dithering_fragment>",
(additive
? "gl_FragColor.rgb *= exp(-pow(max(0.0, vGstDep - uGstFogStart) * uGstFogRho, 2.0));\n"
: "gl_FragColor.rgb = mix(gl_FragColor.rgb, uGstFogColor, 1.0 - exp(-pow(max(0.0, vGstDep - uGstFogStart) * uGstFogRho, 2.0)));\n") +
"#include <dithering_fragment>",
);
}
function patchGlass(shader) {
shader.vertexShader = shader.vertexShader
.replace("#include <common>", "#include <common>\nvarying float vNzObj;")
.replace("#include <beginnormal_vertex>", "#include <beginnormal_vertex>\nvNzObj = normal.z;");
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", "#include <common>\nvarying float vNzObj;")
.replace(
"#include <emissivemap_fragment>",
"#include <emissivemap_fragment>\ntotalEmissiveRadiance *= smoothstep(0.5, 0.78, vNzObj);",
);
addManualFog(shader, false);
}
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
transmission: 1,
thickness: st.thickness,
ior: st.ior,
dispersion: 0.24,
roughness: st.roughness,
metalness: 0,
clearcoat: 1,
clearcoatRoughness: 0.59,
specularIntensity: 1,
specularColor: 0xffffff,
attenuationDistance: 4.7,
attenuationColor: 0xffffff,
envMapIntensity: 1.1,
iridescence: 1,
iridescenceIOR: 2.34,
iridescenceThicknessRange: [80, 500],
iridescenceThicknessMap: makeIridescenceThicknessMap(256),
side: THREE.DoubleSide,
emissive: 0xffffff,
emissiveMap: headTex,
emissiveIntensity: 0.95,
});
glassMat.onBeforeCompile = patchGlass;
const matcapMat = new THREE.MeshMatcapMaterial({
fog: false,
matcap: matcapTex,
transparent: true,
opacity: 0.11,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
matcapMat.onBeforeCompile = (sh) => addManualFog(sh, true);
/* ---- Shard geometry (rounded Voronoi cells, extruded + beveled) ---- */
function softMin3(a, b, c, k) {
if (k <= 0) return Math.min(a, b, c);
const m = Math.min(a, b, c);
return m - k * Math.log(Math.exp(-(a - m) / k) + Math.exp(-(b - m) / k) + Math.exp(-(c - m) / k));
}
function buildPieceGeometry(
poly,
cornerRadius,
depth,
clampSmoothness,
smoothChamferDist,
smoothChamferAngle,
) {
const shape = new THREE.Shape();
const n = poly.length;
for (let i = 0; i < n; i++) {
const prev = poly[(i + n - 1) % n];
const curr = poly[i];
const next = poly[(i + 1) % n];
const v1x = curr[0] - prev[0],
v1y = curr[1] - prev[1];
const v2x = next[0] - curr[0],
v2y = next[1] - curr[1];
const len1 = Math.hypot(v1x, v1y),
len2 = Math.hypot(v2x, v2y);
if (len1 < 1e-6 || len2 < 1e-6) continue;
const u1x = v1x / len1,
u1y = v1y / len1;
const u2x = v2x / len2,
u2y = v2y / len2;
const cosI = Math.max(-1, Math.min(1, -u1x * u2x - u1y * u2y));
const sinI = Math.sqrt(Math.max(0, 1 - cosI * cosI));
const oneCosI = 1 + cosI;
const tanHalf = oneCosI > 1e-6 ? sinI / oneCosI : 0;
let d = 0;
if (tanHalf > 1e-4) {
const dRaw = cornerRadius / tanHalf;
const lim1 = len1 * 0.49,
lim2 = len2 * 0.49;
let dTarget = dRaw;
if (smoothChamferDist > 0 && smoothChamferAngle > 0) {
const interior = Math.acos(cosI);
if (interior < smoothChamferAngle) {
const tLin = Math.min(1, (smoothChamferAngle - interior) / smoothChamferAngle);
const tEased = tLin * tLin * (3 - 2 * tLin);
dTarget = Math.max(dRaw, smoothChamferDist * tEased);
}
}
d = Math.max(0, softMin3(dTarget, lim1, lim2, clampSmoothness));
}
const startX = curr[0] - u1x * d,
startY = curr[1] - u1y * d;
const endX = curr[0] + u2x * d,
endY = curr[1] + u2y * d;
if (i === 0) shape.moveTo(startX, startY);
else shape.lineTo(startX, startY);
if (d > 1e-4) {
const kappa = 0.5523;
shape.bezierCurveTo(
startX + u1x * d * kappa,
startY + u1y * d * kappa,
endX - u2x * d * kappa,
endY - u2y * d * kappa,
endX,
endY,
);
} else {
shape.lineTo(curr[0], curr[1]);
}
}
shape.closePath();
/* Source: bevelThickness = bevelSize = depth*0.25,
bevelSegments 6, curveSegments 24. The vars scale those constants:
bevel 0.35 and meshSmooth 0.6 reproduce the source exactly, and a
near-zero bevel disables bevelling outright — clean flat-sided
tiles instead of a micro-sliver bevel. */
const bev = depth * G.vars.bevel * 0.714;
const bevelOn = G.vars.bevel > 0.02;
return new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: bevelOn,
bevelThickness: bevelOn ? bev : 0,
bevelSize: bevelOn ? bev : 0,
bevelSegments: Math.max(1, Math.round((G.vars.meshSmooth / 0.6) * 6)),
curveSegments: Math.max(4, Math.round((G.vars.meshSmooth / 0.6) * 24)),
});
}
const paneGroup = new THREE.Group();
paneGroup.position.z = PANE_Z;
scene.add(paneGroup);
const chamferAngle = (28 * Math.PI) / 180;
const shardGroups = [];
for (const sh of shards) {
const chamferDist = Math.max(GAP / 2, sh.radius * 1.5);
const geom = buildPieceGeometry(sh.local, sh.radius, PANE_DEPTH, 0, chamferDist, chamferAngle);
geom.translate(0, 0, -PANE_DEPTH / 2);
/* Planar frustum-space UVs: each shard samples its slice of the
headline raster, so the assembled shards read as the text. */
const pos = geom.attributes.position;
const uvs = new Float32Array(pos.count * 2);
for (let v = 0; v < pos.count; v++) {
uvs[v * 2] = (pos.getX(v) + sh.cx) / VW + 0.5;
uvs[v * 2 + 1] = (pos.getY(v) + sh.cy) / VH + 0.5;
}
geom.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
const grp = new THREE.Group();
grp.add(new THREE.Mesh(geom, glassMat));
grp.add(new THREE.Mesh(geom, matcapMat));
grp.position.set(sh.cx, sh.cy, 0);
paneGroup.add(grp);
shardGroups.push(grp);
}
/* ---- Closed-form easings for per-shard local progress ---- */
/* Position-only power ease-out — never overshoots. Rotation has its
own gentle curve below, but shares position's local flight clock. */
function powOut(x) {
return 1 - Math.pow(1 - x, G.vars.easePow);
}
function clamp01(x) {
return x < 0 ? 0 : x > 1 ? 1 : x;
}
function renderScene() {
const t = st.time;
glassMat.thickness = st.thickness;
glassMat.roughness = st.roughness;
glassMat.ior = st.ior;
/* Whole-cluster idle drift (the "all rotate slightly" hold motion). */
paneGroup.rotation.x = 0.02 * Math.sin(t * 0.5 + 0.7);
paneGroup.rotation.y = 0.045 * Math.sin(t * 0.33 + 2.1);
paneGroup.position.z = PANE_Z + 0.02 * Math.sin(t * 0.5);
scene.environmentRotation.y = 0.6 + t * 0.04;
for (let i = 0; i < shards.length; i++) {
const sh = shards[i];
const grp = shardGroups[i];
/* Local fly-in progress from the master, staggered + eased. */
const ui = clamp01((st.tin - sh.inDelay) / sh.inDur);
const p = 1 - powOut(ui); /* 1 = out at edge, 0 = seated */
/* Depth travels near-linearly: with the full ease-out applied to z
the shard crossed the whole fog zone in the first frames and
arrived already clear — the fog had nothing to act on. */
const pz = 1 - (1 - Math.pow(1 - ui, Math.max(1.15, G.vars.easePow * 0.38)));
/* Keep tumbling through the FULL staggered flight window. Using p
here consumed 99.9% of the spin halfway through an easePow=10
entrance, long before depth arrived. A gentle, seeded ease-out
retains visible rotation late in transit and reaches exactly
zero only at this shard's position-arrival (ui=1), with zero
terminal angular velocity. Reuse rIn so the exit's seeded random
stream, all positions, and the arrival schedule stay unchanged.
flyInRotation remains a linear amplitude, including true zero. */
const pr = Math.pow(1 - ui, 1.15 + sh.rIn * 0.2);
const uo = clamp01((st.tout - sh.outDelay) / sh.outDur);
const q = uo * uo; /* power2.in departure */
const qz = Math.pow(uo, 1.35); /* gentler depth exit too */
let x = sh.cx,
y = sh.cy,
z = 0;
let rx = sh.baseRot[0],
ry = sh.baseRot[1],
rz = sh.baseRot[2];
if (p !== 0 || pz !== 0 || pr !== 0) {
x += sh.inOff[0] * p;
y += sh.inOff[1] * p;
z += sh.inOff[2] * pz;
rx += sh.inSpin[0] * pr;
ry += sh.inSpin[1] * pr;
rz += sh.inSpin[2] * pr;
}
/* Idle wobble: only when seated, fades in as p settles, out as q rises. */
const seated = Math.max(0, 1 - Math.abs(p) * 3) * Math.max(0, 1 - q * 3);
if (seated > 0.001) {
const w = sh.wobFreq;
rx += 0.03 * Math.sin(t * w + sh.wobPhase) * seated;
ry += 0.045 * Math.sin(t * w * 0.83 + sh.wobPhase * 2.1) * seated;
rz += 0.012 * Math.sin(t * w * 1.31 + sh.wobPhase * 0.7) * seated;
z += 0.018 * Math.sin(t * 0.9 + sh.wobPhase) * seated;
}
if (uo > 0) {
x += sh.outOff[0] * q;
y += sh.outOff[1] * q;
z += sh.outOff[2] * qz;
rx += sh.outSpin[0] * q;
ry += sh.outSpin[1] * q;
rz += sh.outSpin[2] * q;
}
grp.position.set(x, y, z);
grp.rotation.set(rx, ry, rz);
}
renderer.render(scene, camera);
}
window.__gstRender = renderScene;
renderScene();
/* ---- Teardown ------------------------------------------------------
Nothing above this line was ever released. Every host that mounts
this block does so in a document it later throws away (Studio detaches
its preview iframes; the gallery keeps one per tile), and until the
collector happens to run, a discarded instance still owns its WebGL
context, its render targets, its 4096x2304 headline raster and 34
extruded shard geometries. Release all of it the moment the document
goes away, and expose the same call so a host can do it on demand.
`pagehide` is the reliable frame-detach signal in current Chrome;
`unload` is kept as a fallback for older engines. Deliberately NOT
wired to `visibilitychange` — a backgrounded Studio tab must come
back with a working stage. */
let gstDisposed = false;
function gstDispose(event) {
/* A bfcache'd page is coming back with this canvas still on screen —
tearing the context down here would restore a dead stage. */
if (event && event.persisted === true) return;
if (gstDisposed) return;
gstDisposed = true;
try {
window.removeEventListener("pagehide", gstDispose);
window.removeEventListener("unload", gstDispose);
if (window.__gstRender === renderScene) window.__gstRender = null;
if (window.__gstInstance && window.__gstInstance.dispose === gstDispose)
window.__gstInstance = null;
scene.traverse(function (obj) {
if (obj.geometry) obj.geometry.dispose();
const mats = Array.isArray(obj.material) ? obj.material : obj.material ? [obj.material] : [];
for (const m of mats) {
for (const key in m) {
const v = m[key];
if (v && v.isTexture) v.dispose();
}
m.dispose();
}
});
headTex.dispose();
matcapTex.dispose();
envRT.dispose();
scene.environment = null;
scene.clear();
shardGroups.length = 0;
shards.length = 0;
/* Free the 2D backing stores as well — the headline raster alone is
4096 x 2304 x 4 B. */
headCanvas.width = headCanvas.height = 1;
maskCanvas.width = maskCanvas.height = 1;
renderer.dispose();
/* Hands the context back immediately instead of waiting for the
canvas to be collected; Chrome only allows a small number of live
WebGL contexts per renderer process. */
renderer.forceContextLoss();
} catch (e) {
/* teardown is best-effort */
}
}
window.__gstInstance = { dispose: gstDispose };
window.addEventListener("pagehide", gstDispose);
window.addEventListener("unload", gstDispose);
assets/Three-LICENSE.txt›
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
build-bundle.mjs›
/* Build assets/glass-main.js from assets/glass-main.mjs.
The source uses top-level await (HDR + matcap + font loads), which esbuild
cannot emit in the classic-IIFE format that HyperFrames sub-compositions
require, so the body is wrapped in an async IIFE with the imports hoisted
before bundling. Run `npm install esbuild three` first (three 0.185.1 or
later — HDRLoader is not in older three.js releases). */
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { execFileSync } from "node:child_process";
const SRC = "assets/glass-main.mjs";
const TMP = "assets/.glass-main.entry.mjs";
const OUT = "assets/glass-main.js";
const src = readFileSync(SRC, "utf8");
const lines = src.split("\n");
const imports = [];
const body = [];
for (const line of lines) {
if (/^\s*import\s/.test(line)) imports.push(line.trim());
else body.push(line);
}
writeFileSync(TMP, `${imports.join("\n")}\n(async () => {\n${body.join("\n")}\n})();\n`);
try {
execFileSync(
"npx",
[
"esbuild",
TMP,
"--bundle",
"--format=iife",
"--minify",
`--outfile=${OUT}`,
"--alias:three/addons=three/examples/jsm",
],
{ stdio: "inherit" },
);
} finally {
unlinkSync(TMP);
}
console.log("built", OUT);
glass-shard-title.html›
<!doctype html>
<html
lang="en"
data-composition-variables='[{"id":"headline","type":"string","label":"Headline","default":"Designed in glass","maxLength":40},{"id":"tileCount","type":"number","label":"Tiles","default":8,"min":1,"max":400,"step":1},{"id":"roundness","type":"number","label":"Roundness (0-1)","default":0.4,"min":0,"max":1,"step":0.01},{"id":"bevel","type":"number","label":"Bevel (0-1)","default":0.35,"min":0,"max":1,"step":0.01},{"id":"meshSmooth","type":"number","label":"Surface smoothness (0-1)","default":0.6,"min":0,"max":1,"step":0.01},{"id":"flyInTime","type":"number","label":"Fly-in time (s)","default":2.3,"min":0.2,"max":6,"step":0.05},{"id":"flyOutTime","type":"number","label":"Fly-out time (s)","default":2.4,"min":0.2,"max":6,"step":0.05},{"id":"stagger","type":"number","label":"Stagger (0-1)","default":0.27,"min":0,"max":1,"step":0.01},{"id":"flyInRotation","type":"number","label":"Fly-in rotation (0-60)","description":"Amount of inbound tumbling over the entire flight. 0 disables flight rotation; final orientation lands with each shard position.","default":5,"min":0,"max":60,"step":0.1},{"id":"flyOutRotation","type":"number","label":"Fly-out rotation (0-6)","default":6,"min":0,"max":6,"step":0.1},{"id":"chaos","type":"number","label":"Outline chaos (0-1)","default":0.25,"min":0,"max":1,"step":0.01},{"id":"padding","type":"number","label":"Edge padding (0-1)","default":1,"min":0,"max":1,"step":0.01},{"id":"sizeVariance","type":"number","label":"Size variance (0-1)","default":0.35,"min":0,"max":1,"step":0.01},{"id":"gap","type":"number","label":"Tile gap","default":0.03,"min":0,"max":0.3,"step":0.002},{"id":"easePow","type":"number","label":"Fly-in position ease (1-10)","description":"Shapes lateral and depth travel. Rotation uses the same staggered flight window with a gentle settle through arrival.","default":10,"min":1,"max":10,"step":0.1},{"id":"sideDist","type":"number","label":"Side fly distance","default":3,"min":0,"max":60,"step":0.5},{"id":"zDist","type":"number","label":"Z fly-from (negative)","default":-42,"min":-400,"max":0,"step":2},{"id":"zOut","type":"number","label":"Z fly-out to (+ = past camera)","default":14,"min":-400,"max":60,"step":2},{"id":"fog","type":"number","label":"Fog distance (0 = off)","default":36,"min":0,"max":400,"step":2}]'
>
<head>
<script>
/* ?play standalone mode only: this file lives in compositions/ but its
asset paths are project-root-relative (they resolve there when the
runtime inlines it into the host). Never runs for Studio/render. */
if (new URLSearchParams(location.search).has("play")) document.write('<base href="../">');
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Glass Shard Title</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-delaunay.min.js"></script>
<style>
@font-face {
font-family: "Geist";
src: url("assets/fonts/Geist-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Geist";
src: url("assets/fonts/Geist-SemiBold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Geist";
src: url("assets/fonts/Geist-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0a0a0f;
}
body {
font-family: "Geist", ui-sans-serif, system-ui, sans-serif;
}
#gst-root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0a0a0f; /* flat — no gradients, no glow */
}
.clip {
position: absolute;
inset: 0;
}
#gst-canvas {
display: block;
width: 100%;
height: 100%;
}
#gst-fontload {
position: absolute;
opacity: 0;
pointer-events: none;
}
</style>
</head>
<body>
<div
data-hf-id="hf-51pv"
id="gst-root"
data-composition-id="glass-shard-title"
data-start="0"
data-duration="12.16"
data-width="1920"
data-height="1080"
>
<div
data-hf-id="hf-lsju"
id="gst-canvas-clip"
class="clip"
data-start="0"
data-duration="12.16"
data-track-index="1"
data-media-start="0"
></div>
<div
data-hf-id="hf-uo1i"
id="gst-fontload"
aria-hidden="true"
data-start="0"
data-duration="12.16"
data-media-start="0"
>
<span data-hf-id="hf-kqtg" style="font-weight: 400">Aa</span
><span data-hf-id="hf-7bxm" style="font-weight: 600">Aa</span>
</div>
</div>
<script>
/* ================================================================
glass-shard-title — iridescent glass shards fly in from the left
and right edges, assemble into the headline, idle with a slight
rotation, then fly back out and leave the frame empty.
The ONLY content is the headline. Flat dark background.
Seek-safety: the timeline drives two master progresses (tin, tout)
plus the material resolve; every per-shard position/rotation is a
pure function of those masters and st.time. No rAF, no
integrators, no Math.random(). Shard layout is seeded and built
after fonts load (cell selection samples the rasterised text).
================================================================ */
window.__timelines = window.__timelines || {};
/* Studio's variables/SDK parser fails on any composition with a <canvas>
in its MARKUP (verified by bisection) — so the canvas is created at
runtime instead. Identical for preview/render; invisible to the parser.
Guarded: if this script is evaluated twice in one document a second
<canvas id="gst-canvas"> would be appended, getElementById would keep
returning the FIRST one, and the second three.js renderer would be
built on a canvas that already owns a live WebGL context. */
(function () {
if (document.getElementById("gst-canvas")) return;
var c = document.createElement("canvas");
c.id = "gst-canvas";
c.width = 1920;
c.height = 1080;
document.getElementById("gst-canvas-clip").appendChild(c);
})();
window.gstMulberry32 = gstMulberry32;
function gstMulberry32(seed) {
return function () {
var s = (seed = (seed + 0x6d2b79f5) | 0);
var t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
(function () {
var DUR = 9;
/* ---- Studio variables ---- */
var vars = {
headline: "Designed in glass",
tileCount: 8,
roundness: 0.4,
bevel: 0.35,
meshSmooth: 0.6,
flyInTime: 2.3,
flyOutTime: 2.4,
stagger: 0.27,
flyInRotation: 5,
flyOutRotation: 6,
chaos: 0.25,
padding: 1,
sizeVariance: 0.35,
gap: 0.03,
easePow: 10,
sideDist: 3,
zDist: -42,
zOut: 14,
fog: 36,
};
try {
if (window.__hyperframes && window.__hyperframes.getVariables) {
var v = window.__hyperframes.getVariables() || {};
if (v.headline) vars.headline = String(v.headline);
if (v.title) vars.headline = String(v.title); /* legacy id */
if (v.tileCount !== undefined && isFinite(Number(v.tileCount)))
vars.tileCount = Number(v.tileCount);
if (v.roundness !== undefined && isFinite(Number(v.roundness)))
vars.roundness = Number(v.roundness);
if (v.bevel !== undefined && isFinite(Number(v.bevel))) vars.bevel = Number(v.bevel);
if (v.meshSmooth !== undefined && isFinite(Number(v.meshSmooth)))
vars.meshSmooth = Number(v.meshSmooth);
if (v.flyInTime !== undefined && isFinite(Number(v.flyInTime)))
vars.flyInTime = Number(v.flyInTime);
if (v.flyOutTime !== undefined && isFinite(Number(v.flyOutTime)))
vars.flyOutTime = Number(v.flyOutTime);
if (v.stagger !== undefined && isFinite(Number(v.stagger)))
vars.stagger = Number(v.stagger);
/* Legacy callers may still provide spin. New controls override it
independently; the old multiplier/default keeps its meaning. */
if (v.spin !== undefined && isFinite(Number(v.spin))) {
vars.flyInRotation = vars.flyOutRotation = Math.max(0, Math.min(6, Number(v.spin)));
}
if (v.flyInRotation !== undefined && isFinite(Number(v.flyInRotation)))
vars.flyInRotation = Number(v.flyInRotation);
if (v.flyOutRotation !== undefined && isFinite(Number(v.flyOutRotation)))
vars.flyOutRotation = Number(v.flyOutRotation);
if (v.zOut !== undefined && isFinite(Number(v.zOut))) vars.zOut = Number(v.zOut);
if (v.chaos !== undefined && isFinite(Number(v.chaos))) vars.chaos = Number(v.chaos);
if (v.padding !== undefined && isFinite(Number(v.padding)))
vars.padding = Number(v.padding);
if (v.sizeVariance !== undefined && isFinite(Number(v.sizeVariance)))
vars.sizeVariance = Number(v.sizeVariance);
if (v.gap !== undefined && isFinite(Number(v.gap))) vars.gap = Number(v.gap);
if (v.easePow !== undefined && isFinite(Number(v.easePow)))
vars.easePow = Number(v.easePow);
if (v.sideDist !== undefined && isFinite(Number(v.sideDist)))
vars.sideDist = Number(v.sideDist);
if (v.zDist !== undefined && isFinite(Number(v.zDist))) vars.zDist = Number(v.zDist);
if (v.fog !== undefined && isFinite(Number(v.fog))) vars.fog = Number(v.fog);
}
} catch (e) {
/* keep defaults */
}
vars.tileCount = Math.max(1, Math.min(400, Math.round(vars.tileCount)));
vars.roundness = Math.max(0, Math.min(1, vars.roundness));
vars.bevel = Math.max(0, Math.min(1, vars.bevel));
vars.meshSmooth = Math.max(0, Math.min(1, vars.meshSmooth));
vars.flyInTime = Math.max(0.2, Math.min(6, vars.flyInTime));
vars.flyOutTime = Math.max(0.2, Math.min(6, vars.flyOutTime));
vars.stagger = Math.max(0, Math.min(1, vars.stagger));
vars.flyInRotation = Math.max(0, Math.min(60, vars.flyInRotation));
vars.flyOutRotation = Math.max(0, Math.min(6, vars.flyOutRotation));
vars.zOut = Math.max(-400, Math.min(60, vars.zOut));
vars.chaos = Math.max(0, Math.min(1, vars.chaos));
vars.padding = Math.max(0, Math.min(1, vars.padding));
vars.sizeVariance = Math.max(0, Math.min(1, vars.sizeVariance));
vars.gap = Math.max(0, Math.min(0.3, vars.gap));
vars.easePow = Math.max(1, Math.min(10, vars.easePow));
vars.sideDist = Math.max(0, Math.min(60, vars.sideDist));
/* zDist is a coordinate: accept "-200" style input, clamp behind pane. */
vars.zDist = Math.min(0, Math.max(-400, vars.zDist > 0 ? -vars.zDist : vars.zDist));
vars.fog = Math.max(0, Math.min(400, vars.fog));
/* ---- Phase windows (speed compresses fly phases, hold absorbs) ---- */
var IN_START = 0.12;
var OUT_END = DUR - 0.35;
/* Explicit flight durations; if in + out + a minimum hold exceed the
composition, both scale down proportionally. */
var MIN_HOLD = 0.5;
var avail = OUT_END - IN_START - MIN_HOLD;
var tIn = vars.flyInTime,
tOut = vars.flyOutTime;
if (tIn + tOut > avail) {
var k = avail / (tIn + tOut);
tIn *= k;
tOut *= k;
}
var IN_WINDOW = tIn;
var OUT_WINDOW = tOut;
var OUT_START = OUT_END - OUT_WINDOW;
/* Scripted state read by renderScene. */
var st = {
time: 0,
tin: 0 /* master fly-in progress 0..1 */,
tout: 0 /* master fly-out progress 0..1 */,
thickness: 0.5,
roughness: 0.07,
ior: 1.52,
};
window.__GST = { DUR: DUR, st: st, vars: vars };
/* ---- Timeline: paused, registered, hf-seek wired ---- */
var tl = gsap.timeline({ paused: true });
tl.eventCallback("onUpdate", function () {
if (window.__gstRender) window.__gstRender();
});
/* Studio's player seeks with GSAP events suppressed and dispatches an
hf-seek CustomEvent instead — re-sync and repaint so the stage follows. */
/* Exactly one listener and one live timeline per document. A second
evaluation of this script would otherwise stack another hf-seek
listener and leave the previous paused timeline registered, so every
seek would drive N timelines and repaint N times. Declared as a
named function (not an arrow) so the reference read back off window
is identical and removeEventListener actually matches. */
function gstOnSeek(event) {
var d = event && event.detail;
if (d && typeof d.time === "number")
tl.totalTime(Math.min(d.time, tl.totalDuration()), true);
var fn = tl.eventCallback("onUpdate");
if (fn) fn();
}
if (window.__gstSeekHandler) window.removeEventListener("hf-seek", window.__gstSeekHandler);
if (window.__gstTimeline && window.__gstTimeline !== tl) {
try {
window.__gstTimeline.kill();
} catch (e) {
/* best effort */
}
}
window.__gstSeekHandler = gstOnSeek;
window.__gstTimeline = tl;
window.addEventListener("hf-seek", gstOnSeek);
/* Release the listener and the timeline with the document. Skipped for
a bfcache'd page, which is coming back with this stage still live. */
window.addEventListener("pagehide", function (event) {
if (event && event.persisted === true) return;
window.removeEventListener("hf-seek", gstOnSeek);
window.__gstSeekHandler = null;
window.__gstTimeline = null;
try {
tl.kill();
} catch (e) {
/* best effort */
}
});
tl.to(st, { time: DUR, duration: DUR, ease: "none" }, 0);
tl.to(st, { tin: 1, duration: IN_WINDOW, ease: "none" }, IN_START);
tl.to(st, { tout: 1, duration: OUT_WINDOW, ease: "none" }, OUT_START);
/* Glass stays clear throughout. The old frosted->clear resolve made
still-flying shards transmit a BLURRED copy of the already-seated
text — the ghost headline — so it is gone. */
window.__timelines["glass-shard-title"] = tl;
})();
</script>
<script src="assets/glass-main.js"></script>
<script>
/* Standalone playback: open this file with ?play (add &loop to repeat).
Inert without the param — Studio and the renderer never add it, so
editing and deterministic renders are unaffected. */
(function () {
var q = new URLSearchParams(location.search);
if (!q.has("play")) return;
var tick = setInterval(function () {
var map = window.__timelines || {};
var ids = Object.keys(map);
if (!ids.length) return;
clearInterval(tick);
for (var i = 0; i < ids.length; i++) {
var tl = map[ids[i]];
if (q.has("loop")) tl.repeat(-1);
tl.play(0);
}
}, 100);
})();
</script>
</body>
</html>
registry-item.json›
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "glass-shard-title",
"type": "hyperframes:block",
"title": "Glass Shard Title",
"description": "Glass shards fly in through fog and tile themselves into the headline.",
"tags": ["3d-motion", "title-card", "glass", "voronoi", "shards", "fog", "hdr"],
"dimensions": {
"width": 1920,
"height": 1080
},
"duration": 12.16,
"files": [
{
"path": "glass-shard-title.html",
"target": "compositions/glass-shard-title/glass-shard-title.html",
"type": "hyperframes:composition"
},
{
"path": "assets/ferndale_studio_01_1k.hdr",
"target": "compositions/glass-shard-title/assets/ferndale_studio_01_1k.hdr",
"type": "hyperframes:asset"
},
{
"path": "assets/fonts/Geist-Bold.ttf",
"target": "compositions/glass-shard-title/assets/fonts/Geist-Bold.ttf",
"type": "hyperframes:asset"
},
{
"path": "assets/fonts/Geist-Regular.ttf",
"target": "compositions/glass-shard-title/assets/fonts/Geist-Regular.ttf",
"type": "hyperframes:asset"
},
{
"path": "assets/fonts/Geist-SemiBold.ttf",
"target": "compositions/glass-shard-title/assets/fonts/Geist-SemiBold.ttf",
"type": "hyperframes:asset"
},
{
"path": "assets/fonts/Geist-OFL.txt",
"target": "compositions/glass-shard-title/assets/fonts/Geist-OFL.txt",
"type": "hyperframes:asset"
},
{
"path": "assets/fonts/cormorant-garamond.woff2",
"target": "compositions/glass-shard-title/assets/fonts/cormorant-garamond.woff2",
"type": "hyperframes:asset"
},
{
"path": "assets/fonts/CormorantGaramond-OFL.txt",
"target": "compositions/glass-shard-title/assets/fonts/CormorantGaramond-OFL.txt",
"type": "hyperframes:asset"
},
{
"path": "assets/glass-main.js",
"target": "compositions/glass-shard-title/assets/glass-main.js",
"type": "hyperframes:asset"
},
{
"path": "assets/Three-LICENSE.txt",
"target": "compositions/glass-shard-title/assets/Three-LICENSE.txt",
"type": "hyperframes:asset"
},
{
"path": "assets/matcap-1.png",
"target": "compositions/glass-shard-title/assets/matcap-1.png",
"type": "hyperframes:asset",
"url": "https://static.heygen.ai/hyperframes-oss/registry-assets/8a4ba0deaf2bafb4.png"
},
{
"path": "SKILL.md",
"target": "compositions/glass-shard-title/SKILL.md",
"type": "hyperframes:asset"
}
],
"preview": {
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/glass-shard-title.mp4",
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/glass-shard-title.png"
}
}
SKILL.md›
---
name: glass-shard-title
description: Glass shards fly in through fog and tile themselves into the headline. HyperFrames block, 1920×1080, 12.16s, 19 variables.
---
# Glass Shard Title
Voronoi-cut glass panes fly in from depth, rotating, and land as a tiled silhouette of the headline, then fly out past the camera. Each shard is a Sutherland–Hodgman clip of a jittered blob against its Voronoi cell, so the pieces always cover the type. Glass and matcap surfaces get per-fragment fog; the z-flight is decoupled from the ease so shards visibly traverse it. Tile count 1 gives a single pane.
Composition id: `glass-shard-title`. Duration 12.16 s at 30 fps, 1920×1080.
## Files
- `glass-shard-title.html` (16 KB)
- `assets/ferndale_studio_01_1k.hdr` (1.6 MB)
- `assets/fonts/Geist-Bold.ttf` (65 KB)
- `assets/fonts/Geist-Regular.ttf` (65 KB)
- `assets/fonts/Geist-SemiBold.ttf` (65 KB)
- `assets/fonts/Geist-OFL.txt` (4 KB)
- `assets/fonts/cormorant-garamond.woff2` (37 KB)
- `assets/fonts/CormorantGaramond-OFL.txt` (4 KB)
- `assets/glass-main.js` (549 KB)
- `assets/Three-LICENSE.txt` (1 KB)
- `assets/matcap-1.png` (fetched from the CDN at install)
## Install
Install with `npx hyperframes add glass-shard-title`; by default the files above land under `compositions/glass-shard-title/`. Then mount the block from the host `index.html`:
```html
<div
data-composition-id="glass-shard-title"
data-composition-src="compositions/glass-shard-title/glass-shard-title.html"
data-start="0"
data-duration="12.16"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
```
Render with custom values by targeting the composition file directly:
```sh
npx --yes [email protected] render 'compositions/glass-shard-title/glass-shard-title.html' --variables '{"headline":"Designed in glass","tileCount":8}'
```
## Variables
Read at runtime via `window.__hyperframes.getVariables()`; declared on the composition root as `data-composition-variables` (single-quoted attribute, plain JSON).
| id | type | default | label / range |
| ---------------- | ------ | --------------------- | --------------------------------------------- |
| `headline` | string | `"Designed in glass"` | Headline max 40 chars |
| `tileCount` | number | `8` | Tiles 1–400 step 1 |
| `roundness` | number | `0.4` | Roundness (0-1) 0–1 step 0.01 |
| `bevel` | number | `0.35` | Bevel (0-1) 0–1 step 0.01 |
| `meshSmooth` | number | `0.6` | Surface smoothness (0-1) 0–1 step 0.01 |
| `flyInTime` | number | `2.3` | Fly-in time (s) 0.2–6 step 0.05 |
| `flyOutTime` | number | `2.4` | Fly-out time (s) 0.2–6 step 0.05 |
| `stagger` | number | `0.27` | Stagger (0-1) 0–1 step 0.01 |
| `flyInRotation` | number | `5` | Fly-in rotation (0-60) 0–60 step 0.1 |
| `flyOutRotation` | number | `6` | Fly-out rotation (0-6) 0–6 step 0.1 |
| `chaos` | number | `0.25` | Outline chaos (0-1) 0–1 step 0.01 |
| `padding` | number | `1` | Edge padding (0-1) 0–1 step 0.01 |
| `sizeVariance` | number | `0.35` | Size variance (0-1) 0–1 step 0.01 |
| `gap` | number | `0.03` | Tile gap 0–0.3 step 0.002 |
| `easePow` | number | `10` | Fly-in position ease (1-10) 1–10 step 0.1 |
| `sideDist` | number | `3` | Side fly distance 0–60 step 0.5 |
| `zDist` | number | `-42` | Z fly-from (negative) -400–0 step 2 |
| `zOut` | number | `14` | Z fly-out to (+ = past camera) -400–60 step 2 |
| `fog` | number | `36` | Fog distance (0 = off) 0–400 step 2 |
## Runtime contract
- One paused GSAP timeline registered as `window.__timelines["glass-shard-title"]`.
- Re-syncs on the `hf-seek` CustomEvent; every frame is a closed-form function of time (seeded PRNG only, no rAF loops, no Date.now).
- Renderer: three.js 0.181.2 (bundled), GSAP 3.14.2, Canvas 2D, GLSL shaders, HDR environment, Matcap, Seeded PRNG, d3-delaunay, Shadow maps. Budget roughly 350 MB per live instance; run one at a time.
- External runtime dependencies: `https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js`, `https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-delaunay.min.js`.
- Local fonts: assets/fonts/Geist-Regular.ttf, assets/fonts/Geist-SemiBold.ttf, assets/fonts/Geist-Bold.ttf.
- Bundled code is inlined into the composition (assets/glass-main.js); rebuild with esbuild and re-inline after editing the .mjs source.
## Editing rules (from the source project)
1. Keep `data-composition-variables` a single-quoted attribute with plain `"` JSON. Never save it through Studio's Design panel.
2. Do not put `<canvas>` in static markup; create it at runtime.
3. Keep every visual state a function of t; seek-safety is what makes the block renderable.