frost-sequence-camera-orbit
by HeyGen · official repository
An orbiting camera follows an ice logo as it breaks apart, reforms into two text moments, and fades. HyperFrames block, 1920×1080, 22.5s, 216 variables.
OfficialRuns commands61 scripts
Labels from its files: runs commands. 83 files, read Oct 21, 2026. Nothing was run.
npx skills add https://github.com/heygen-com/hyperframes --skill frost-sequence-camera-orbitWhat it lets an agent do
Each label, and the line in the skill's files that set it.
- Runs commands
Allows a shell or ships scripts the agent runs.
- It ships 61 scripts the agent can run.
Findings
Matches of fixed checks, with the text they matched.
No check matched anything in this skill's files.
Not a safety rating, and no finding is not a safety guarantee. These are fixed checks of the files as read on Oct 21, 2026. Nothing was run. How skills are checked.
SKILL.md
As the publisher wrote it. Shown as text, never rendered.
---
name: frost-sequence-camera-orbit
description: An orbiting camera follows an ice logo as it breaks apart, reforms into two text moments, and fades. HyperFrames block, 1920×1080, 22.5s, 216 variables.
---
# Frost Sequence Camera Orbit
A complete 22.5-second ice sequence: logo, first text, second text, breakup, and fade. The camera orbits the fixed objects with slower front-facing passes for reading. The same particle field carries each transition. Customize the two text moments and SVG logo; the source includes editable camera and assembly timing.
Composition id: `frost-sequence-rig`. Duration 22.5 s at 30 fps, 1920×1080.
## Files
- `frost-sequence-camera-orbit.html` (131 KB)
- `assets/example-logo.svg` (1 KB)
- `assets/fonts/Geist-Bold.ttf` (65 KB)
- `assets/fonts/Geist-OFL.txt` (4 KB)
- `assets/fonts/Geist-Regular.ttf` (65 KB)
- `assets/fonts/Geist-SemiBold.ttf` (65 KB)
- `assets/frost.js` (1.5 MB)
- `assets/Three-LICENSE.txt` (1 KB)
- `assets/ThreeMeshBVH-LICENSE.txt` (1 KB)
- `assets/OpentypeJS-LICENSE.txt` (1 KB)
- `assets/Clipper-LICENSE.txt` (3 KB)
- `assets/gsap-3.14.2.min.js` (128 KB)
- `assets/GSAP-NOTICE.txt` (1 KB)
- `assets/logo.svg` (1 KB)
- `assets/shards-atlas.png` (fetched from the CDN at install)
- `assets/test-mark.svg` (1 KB)
- `assets/textures/bluenoise64.png` (12 KB)
- `assets/textures/ice-inclusions-generated.png` (fetched from the CDN at install)
## Install
Install with `npx hyperframes add frost-sequence-camera-orbit`; by default the files above land under `compositions/frost-sequence-camera-orbit/`. Then mount the block from the host `index.html`:
```html
<div
data-composition-id="frost-sequence-rig"
data-composition-src="compositions/frost-sequence-camera-orbit/frost-sequence-camera-orbit.html"
data-start="0"
data-duration="22.5"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
```
Render with custom values by targeting the composition file directly:
```sh
npx --yes hyperframes@0.8.12 render 'compositions/frost-sequence-camera-orbit/frost-sequence-camera-orbit.html' --variables '{"headline1":"Hard to|break.","headline2":"Easy to|remember."}'
```
## 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 |
| --------------------------- | ------- | ------------------- | --------------------------------------------------------------------------------------------- | ------------- | -------------------------- | ------- | ------- |
| `headline1` | string | `"Hard to | break."` | First text ( | = line break) max 60 chars |
| `headline2` | string | `"Easy to | remember."` | Second text ( | = line break) max 60 chars |
| `logoUrl` | string | `"assets/logo.svg"` | Logo SVG asset path (upload in Assets, then paste path) max 200 chars |
| `assemblyMode` | enum | `"hybrid"` | Assembly response (physical | directed | hybrid) |
| `materialBaseColor` | color | `"#ffffff"` | Base color |
| `baseRoughness` | number | `0.31` | Base roughness 0–0.5 step 0.005 |
| `materialTransmission` | boolean | `true` | Transmission |
| `materialBacklight` | number | `0.27` | Backlight through ice 0–2 step 0.01 |
| `ior` | number | `1.675` | Index of refraction 1–2 step 0.005 |
| `thicknessScale` | number | `2.04` | Thickness scale 0.1–3 step 0.01 |
| `materialAbsorption` | boolean | `true` | Absorption / tint |
| `attenuationColor` | color | `"#d5f4ff"` | Attenuation colour (white = no absorption, clear glass) |
| `attenuationDistance` | number | `6.54` | Attenuation distance 0.05–12 step 0.01 |
| `materialDispersion` | boolean | `false` | Dispersion |
| `dispersion` | number | `0.12` | Dispersion (0 = off; on/off reloads) 0–0.3 step 0.005 |
| `materialReflections` | boolean | `true` | Reflections |
| `envIntensity` | number | `2.04` | Environment intensity 0–3 step 0.01 |
| `specularIntensity` | number | `1.44` | Specular intensity 0–2 step 0.01 |
| `materialFrost` | boolean | `true` | Frost |
| `materialInteriorFrost` | number | `0` | Uniform frost 0–1 step 0.01 |
| `frostScale` | number | `0.7` | Scale 0.2–6 step 0.05 |
| `frostThreshold` | number | `0.62` | Threshold (1 = no frost, clear glass) 0–1 step 0.01 |
| `frostSoftness` | number | `0.24` | Softness 0.01–1 step 0.01 |
| `frostRoughness` | number | `0.73` | Roughness 0–1 step 0.01 |
| `frostDiffuse` | number | `0.11` | Diffuse (how much light frosted areas catch) 0–1 step 0.01 |
| `materialSurfaceBumps` | boolean | `false` | Object surface bumps / crack notches |
| `materialCrystals` | boolean | `true` | Crystal bumps |
| `crystalBump` | number | `0.01` | Crystal bump 0–1 step 0.01 |
| `crystalScale` | number | `4` | Crystal scale 4–80 step 1 |
| `materialGrain` | boolean | `true` | Grain bumps |
| `materialGrainAmount` | number | `0.47` | Grain strength 0–2 step 0.01 |
| `materialGrainScale` | number | `150` | Grain scale 1–150 step 1 |
| `materialCutNormals` | boolean | `true` | Fracture normals |
| `materialMicro` | boolean | `true` | Micro bumps |
| `microBump` | number | `0.445` | Micro bump 0–1 step 0.005 |
| `microScale` | number | `10` | Micro scale 10–200 step 1 |
| `microCoverage` | number | `0.25` | Micro coverage 0–1 step 0.01 |
| `bumpMaskScale` | number | `1.45` | Mask scale 0.1–6 step 0.05 |
| `materialRipples` | boolean | `true` | Ripples |
| `rippleBump` | number | `0.22` | Ripple bump 0–1 step 0.01 |
| `rippleScale` | number | `23` | Ripple scale 2–30 step 0.5 |
| `materialSmudges` | boolean | `true` | Smudges |
| `smudgeAmount` | number | `0.78` | Amount 0–1 step 0.01 |
| `smudgeCoverage` | number | `0.58` | Coverage 0–1 step 0.01 |
| `smudgeMaskScale` | number | `1.85` | Mask scale 0.1–6 step 0.05 |
| `smudgeAnisotropy` | number | `16.5` | Anisotropy 1–20 step 0.5 |
| `smudgeRoughness` | number | `0.75` | Roughness 0–1 step 0.01 |
| `smudgeWhiteness` | number | `0.035` | Whiteness 0–0.5 step 0.005 |
| `smudgeScale` | number | `1.3` | Scale 0.5–10 step 0.1 |
| `materialCracks` | boolean | `true` | Cracks |
| `crackLargeScale` | number | `2.45` | Large scale 0.3–8 step 0.05 |
| `crackWarp` | number | `0.22` | Warp 0–1.5 step 0.01 |
| `crackCoverage` | number | `0.19` | Coverage 0–1 step 0.01 |
| `crackRegionScale` | number | `1.8` | Region scale 0.1–4 step 0.05 |
| `crackRegionCoverage` | number | `0.6` | Region coverage 0–1 step 0.01 |
| `veinScale` | number | `8` | Vein scale 1–20 step 0.25 |
| `veinContrast` | number | `0.55` | Vein contrast 0–1 step 0.01 |
| `crackWidth` | number | `0.0025` | Width 0.0005–0.02 step 0.0005 |
| `crackBrightness` | number | `0.65` | Brightness 0–3 step 0.01 |
| `crackDarkness` | number | `0.69` | Darkness 0–1 step 0.01 |
| `crackRefraction` | number | `0.076` | Refraction 0–0.1 step 0.001 |
| `crackSurfaceStrength` | number | `0.16` | Surface strength 0–1 step 0.01 |
| `fineScale` | number | `18.4` | Fine scale 2–20 step 0.1 |
| `fineAmount` | number | `0.52` | Fine amount 0–1 step 0.01 |
| `fineCoverage` | number | `1` | Fine coverage 0–1 step 0.01 |
| `materialScatter` | boolean | `true` | Internal scattering |
| `materialInclusionScale` | number | `0.05` | Inclusion scale 0.05–3 step 0.01 |
| `materialInclusionAmount` | number | `2` | Photographic inclusions 0–2 step 0.01 |
| `interiorScatter` | number | `0.09` | Interior scatter 0–1 step 0.01 |
| `materialClearcoat` | boolean | `true` | Clearcoat |
| `clearcoat` | number | `0` | Clearcoat 0–1 step 0.01 |
| `clearcoatRoughness` | number | `0` | Clearcoat roughness 0–1 step 0.005 |
| `materialShardNormals` | boolean | `true` | Shard normals |
| `spriteNormal` | number | `0.15` | Sprite normal strength 0–2.5 step 0.05 |
| `materialShardFrost` | boolean | `true` | Shard frost |
| `materialShardTransmission` | boolean | `true` | Shard transparency |
| `spriteSeeThrough` | number | `1` | See-through (0 = off; on/off reloads) 0–1 step 0.01 |
| `materialShardReflections` | boolean | `true` | Shard reflections |
| `minPixelSize` | number | `0.25` | Minimum pixel size 0–4 step 0.05 |
| `grainSizeMultiplier` | number | `1.05` | Grain size multiplier 0.2–4 step 0.05 |
| `spriteSize` | number | `1.9` | Sprite size 0.3–4 step 0.05 |
| `spriteTilt` | number | `12` | Sprite tilt 0–70 step 1 |
| `spriteAlphaCut` | number | `0.6` | Alpha cut 0.05–0.6 step 0.01 |
| `fontWeight` | enum | `"600"` | Type · Weight (Geist) (400 | 600 | 700) |
| `letterSpacing` | number | `0.01` | Type · Letter spacing -0.1–0.4 step 0.005 |
| `shardAmount` | number | `0.44` | Shards · Visible fraction of the broken volume (Powder amount) 0.02–1 step 0.01 |
| `strayDust` | number | `24` | Shards · Ambient dust motes around the object (the experiment had 40) 0–200 step 1 |
| `sliceRadius` | number | `0.6` | Break · First (diagonal) slice radius 0.05–1.5 step 0.01 |
| `sliceStrength` | number | `21.5` | Break · First slice strength 1–40 step 0.5 |
| `finalEjectBoost` | number | `2.5` | Break · Last break: eject speed and speed cap multiplier 1–6 step 0.1 |
| `shatterRadius` | number | `0.45` | Break · Follow-up slice radius (sets their spacing too) 0.1–1.5 step 0.01 |
| `shatterStrength` | number | `32.5` | Break · Follow-up slice strength 1–40 step 0.5 |
| `cutThreshold` | number | `0.3` | Tune break · Cut threshold (surface gone above this erosion) 0.3–0.98 step 0.01 |
| `cutSoftness` | number | `0.19` | Tune break · Cut softness 0.005–0.3 step 0.005 |
| `edgeWidth` | number | `0.19` | Tune break · Crumbly edge band width 0.05–0.8 step 0.01 |
| `edgeInset` | number | `0` | Tune break · Edge inset 0–0.3 step 0.005 |
| `brushSoftness` | number | `0.15` | Tune break · Brush softness (edge falloff of a slice) 0.02–1 step 0.01 |
| `brushNoise` | number | `0.4` | Tune break · Brush noise (ragged boundary) 0–1 step 0.01 |
| `crumbleRate` | number | `4.3` | Tune break · Crumble rate along cracks (high = the whole shape goes at once) 0–6 step 0.05 |
| `crumbleCrackBias` | number | `5.1` | Tune break · Crumble crack bias 0–6 step 0.05 |
| `crumbleDuration` | number | `0.35` | Tune break · Crumble duration after a stroke 0–2 step 0.01 |
| `ejectSpeed` | number | `0.91` | Flight · Eject speed 0–4 step 0.01 |
| `ejectSpread` | number | `0.48` | Flight · Eject spread along the normal 0–3 step 0.01 |
| `ejectTurbulence` | number | `4` | Flight · Eject turbulence 0–4 step 0.01 |
| `drag` | number | `0` | Flight · Drag (speed decays by this per second) 0–8 step 0.01 |
| `gravity` | number | `0` | Flight · Gravity (0 = shards never fall) 0–2 step 0.005 |
| `turbulence` | number | `4` | Flight · Turbulence strength (curl noise) 0–4 step 0.01 |
| `turbulenceScale` | number | `2.65` | Flight · Turbulence scale 0.2–8 step 0.05 |
| `turbulenceDecay` | number | `3.65` | Flight · Turbulence decay with age (low = keeps swirling) 0.05–4 step 0.01 |
| `clumpCohesion` | number | `0.9` | Flight · Clump cohesion (shards orbit a leader; 0 = none) 0–10 step 0.05 |
| `followObject` | number | `30` | Flight · Shards follow the object motion for (s; 30 = whole flight) 0–30 step 0.5 |
| `settleTime` | number | `2.2` | Flight · Settle time (velocity is killed after this; 12 = never) 0.3–12 step 0.05 |
| `settledDrift` | number | `1` | Flight · Organic drift once settled (curl noise) 0–1 step 0.005 |
| `maxSpeed` | number | `26.3` | Flight · Speed cap 1–40 step 0.1 |
| `repelStrength` | number | `30` | Flight · Push out of the solid shape while it breaks 0–30 step 0.1 |
| `repelRange` | number | `0.97` | Flight · Push range outside the surface 0.02–2 step 0.01 |
| `repelRadial` | number | `17.5` | Flight · Push away from the shape centre (clears pockets and the hole) 0–60 step 0.5 |
| `repelRadialRange` | number | `3.3` | Flight · Radial push range (object radii) 1–4 step 0.05 |
| `tumble` | number | `1.05` | Flight · Tumble rate 0–12 step 0.05 |
| `returnGroupStagger` | number | `1.35` | Assembly · Regional delay (seconds) 0–1.5 step 0.05 |
| `returnGroupScale` | number | `2.55` | Assembly · Region / noise size 0.1–3 step 0.05 |
| `returnGroupSeed` | number | `60765` | Return · Group timing seed 0–65535 step 1 |
| `formSpread` | number | `0` | Return · Wave spread (nearest shards leave first, seconds) 0–4 step 0.05 |
| `waveReach` | number | `10` | Return · Distance over which the wave spreads 0.2–10 step 0.1 |
| `formJitter` | number | `2.8` | Return · Per-shard stagger (random delay up to this) 0–3 step 0.05 |
| `formFill` | number | `3` | Return · Fill-in rate for voxels no shard returns to 0.05–3 step 0.05 |
| `returnSpring` | number | `29.9` | Return · Spring stiffness 0.5–40 step 0.1 |
| `returnDamping` | number | `1.34` | Return · Spring damping 0.2–2 step 0.01 |
| `returnRamp` | number | `0.45` | Return · Spring ramp-in (seconds until it pulls at full strength) 0–3 step 0.05 |
| `returnMaxSpeed` | number | `60` | Return · Speed cap on the way home 1–60 step 0.5 |
| `alignToSurface` | number | `1` | Return · Shards turn to lie on the surface (0 = keep tumbling) 0–1 step 0.01 |
| `alignCurve` | number | `3.75` | Return · Alignment curve over the flight home (1 linear, higher = later) 0.2–4 step 0.05 |
| `healRate` | number | `3` | Return · Neighbour heal rate 0.02–3 step 0.01 |
| `cellRestore` | number | `60` | Return · Cell restore rate 0–60 step 0.5 |
| `landedFade` | number | `1.65` | Return · Landed shard fade 0–2 step 0.01 |
| `refrostTime` | number | `10.6` | Return · Refrost time 0.2–12 step 0.1 |
| `keyColor` | color | `"#f0f7ff"` | Light · Key colour |
| `keyIntensity` | number | `5.05` | Light · Key intensity 0–12 step 0.05 |
| `keyElevation` | number | `42` | Light · Key elevation 10–89 step 0.5 |
| `keyAzimuth` | number | `-38` | Light · Key azimuth -90–90 step 0.5 |
| `keySize` | number | `1.25` | Light · Key size (softbox) 0.2–3 step 0.01 |
| `fill` | number | `0.18` | Light · Fill intensity (hemisphere, diffuse only: barely shows on clear ice) 0–1.5 step 0.005 |
| `fillColor` | color | `"#cadde9"` | Light · Fill sky colour |
| `fillGroundColor` | color | `"#172635"` | Light · Fill ground colour |
| `rim` | number | `2.5` | Light · Rim spot intensity 0–5 step 0.01 |
| `rimColor` | color | `"#d9f1ff"` | Light · Rim colour |
| `rimElevation` | number | `20` | Light · Rim elevation (0 = straight behind) 0–89 step 0.5 |
| `swayAmplitude` | number | `0` | Light · Key sway amplitude 0–0.15 step 0.001 |
| `swayPeriod` | number | `35` | Light · Key sway period 2–40 step 0.5 |
| `envSoftbox` | number | `1.1` | Light · Environment softbox (the main light on glass) 0–3 step 0.01 |
| `envRim` | number | `1.4` | Light · Environment rim strip 0–3 step 0.01 |
| `envFill` | number | `0.22` | Light · Environment front fill 0–1 step 0.005 |
| `backdropTop` | color | `"#050505"` | Backdrop · Top colour |
| `backdropMid` | color | `"#050505"` | Backdrop · Mid colour |
| `backdropBottom` | color | `"#050505"` | Backdrop · Bottom colour |
| `backdropCenterX` | number | `0.73` | Backdrop · Bloom centre X 0–1 step 0.005 |
| `backdropCenterY` | number | `0.22` | Backdrop · Bloom centre Y 0–2 step 0.005 |
| `backdropRadius` | number | `0.8` | Backdrop · Bloom radius 0.1–2 step 0.005 |
| `backdropFalloff` | number | `0.85` | Backdrop · Bloom falloff 0.5–6 step 0.01 |
| `backdropNoise` | number | `0` | Backdrop · Dither noise 0–3 step 0.05 |
| `bloomThreshold` | number | `3` | Post · Bloom threshold 0–3 step 0.01 |
| `bloomIntensity` | number | `0.04` | Post · Bloom intensity 0–1.5 step 0.005 |
| `bloomRadius` | number | `1` | Post · Bloom radius 0–1 step 0.005 |
| `monochrome` | number | `0.04` | Post · Monochrome 0–1 step 0.01 |
| `tonemap` | enum | `"aces"` | Post · Tonemap (agx | aces | neutral | linear) |
| `exposure` | number | `1` | Post · Exposure 0.1–4 step 0.01 |
| `contrast` | number | `1.03` | Post · Contrast 0.6–1.6 step 0.005 |
| `blackLift` | number | `0` | Post · Black lift 0–0.1 step 0.001 |
| `vignetteStrength` | number | `0.14` | Post · Vignette strength 0–1 step 0.005 |
| `vignetteSoftness` | number | `1.2` | Post · Vignette softness 0.1–1.5 step 0.005 |
| `vignetteRadius` | number | `1.2` | Post · Vignette radius 0.2–1.6 step 0.005 |
| `grainStrength` | number | `0` | Post · Film grain 0–0.15 step 0.001 |
| `quality` | enum | `"full"` | Performance · Quality profile (full | lite) |
| `upscaler` | enum | `"fsr1"` | Performance · Upscaler (fsr1 | taau | bilinear | native) |
| `renderScale` | number | `1` | Performance · Scene resolution scale (upscaled to 1080p) 0.35–1 step 0.05 |
| `shapeResolution` | enum | `"256"` | Shape voxel resolution (128 | 256 | 384) |
| `erosionResolution` | enum | `"96"` | Performance · Erosion field resolution (voxels per axis) (64 | 96 | 128 | 192) |
| `particleCount` | enum | `"100k"` | Performance · Powder particles (100k | 250k | 500k | 1M) |
| `logoMeshDetail` | number | `2` | Shape · Logo mesh detail (1-4; higher = finer surface) 1–4 step 1 |
| `deformStrength` | number | `0` | Geometry · Ice deformation strength (0 = original) 0–0.08 step 0.001 |
| `deformScale` | number | `0.41` | Geometry · Noise feature size (larger = broader) 0.001–0.5 step 0.001 |
| `deformSeed` | number | `7` | Geometry · Deformation seed 0–65535 step 1 |
| `rimAzimuth` | number | `-146` | Light · Rim azimuth -180–180 step 1 |
| `rimAngle` | number | `26` | Light · Rim beam angle 10–89 step 1 |
| `rimSize` | number | `0.45` | Light · Rim reflection size 0.1–3 step 0.05 |
| `fillReflectionStrength` | number | `0.3` | Light · Fill reflection strength 0–2 step 0.01 |
| `accentCoolIntensity` | number | `3.71` | Studio Cool accent · intensity 0–4 step 0.01 |
| `accentCoolReflection` | number | `2.31` | Studio Cool accent · reflection 0–3 step 0.01 |
| `accentCoolElevation` | number | `63` | Studio Cool accent · elevation -85–85 step 1 |
| `accentCoolAzimuth` | number | `22` | Studio Cool accent · azimuth -180–180 step 1 |
| `accentCoolSize` | number | `2.65` | Studio Cool accent · size 0.1–3 step 0.05 |
| `accentCoolColor` | color | `"#ff5900"` | Studio Cool accent · color |
| `accentWarmIntensity` | number | `2.99` | Studio Warm accent · intensity 0–4 step 0.01 |
| `accentWarmReflection` | number | `0.34` | Studio Warm accent · reflection 0–3 step 0.01 |
| `accentWarmElevation` | number | `30` | Studio Warm accent · elevation -85–85 step 1 |
| `accentWarmAzimuth` | number | `-30` | Studio Warm accent · azimuth -180–180 step 1 |
| `accentWarmSize` | number | `1.2` | Studio Warm accent · size 0.1–3 step 0.05 |
| `accentWarmColor` | color | `"#75aaff"` | Studio Warm accent · color |
| `returnNoiseAmount` | enum | `"2"` | Assembly · Regional pattern (2 | 1 | 0) |
| `assemblyFrontDuration` | number | `3.2` | Assembly · Growth duration (seconds) 0–6 step 0.05 |
| `assemblyOriginX` | number | `-0.65` | Assembly · Growth start X -1–1 step 0.01 |
| `assemblyOriginY` | number | `0.55` | Assembly · Growth start Y -1–1 step 0.01 |
| `assemblyAngle` | number | `-35` | Assembly · Seam direction (degrees) -180–180 step 1 |
| `assemblySpread` | number | `0.65` | Assembly · Outward spread vs seam travel 0–1 step 0.01 |
| `assemblyFrontNoise` | number | `0.35` | Assembly · Growth edge irregularity 0–1 step 0.01 |
| `assemblySpeedVariation` | number | `0.65` | Assembly · Return speed variation 0–1 step 0.01 |
| `assemblyBend` | number | `1.2` | Assembly · Approach path bend 0–3 step 0.05 |
| `assemblySwirl` | number | `1.1` | Assembly · Approach twist 0–3 step 0.05 |
| `assemblyLandingVariation` | number | `0.8` | Assembly · Landing transition variation 0–1 step 0.01 |
| `rendererProfile` | enum | `"studio"` | Renderer experiment (reload required) (original | lookup | mesh | studio | matcap) |
| `textWidth` | number | `6.5` | Type · Headline block width (the mark is 2.6 wide) 1.2–9 step 0.05 |
| `textLineHeight` | number | `0.95` | Type · Line height 0.7–1.4 step 0.01 |
| `textDepth` | number | `0.16` | Type · Extrusion depth (fraction of the font size) 0.05–0.8 step 0.01 |
| `textBevel` | number | `0.04` | Type · Bevel (fraction of the font size) 0–0.12 step 0.005 |
| `textCorner` | number | `0.008` | Type · Corner rounding (fraction of the font size) 0–0.06 step 0.002 |
| `textMeshDetail` | number | `4` | Type · Text mesh detail (1-4; higher = smoother deformation) 1–4 step 1 |
## Runtime contract
- One paused GSAP timeline registered as `window.__timelines["frost-sequence-rig"]`.
- 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: WebGPU, GSAP, Matcap.
- External runtime dependencies: none (served locally).
## 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.
Scripts (12)
Read as text. anlyon.dev never runs them.
source/build.mjsShowHide
// Bundle src/frost.ts (+ three@0.185.1 WebGPU/TSL, three-mesh-bvh) into one
// classic IIFE at assets/frost.js exposing `window.Frost`. Run `npm install && npm run build` after editing src/.
import "./tools/compile-preflight.mjs";
if (process.exitCode) process.exit(process.exitCode);
// CPU-only shader generation checks the portable interface budget, including motion vectors.
await import("./tools/run-shard-compile.mjs");
if (process.exitCode) process.exit(process.exitCode);
import { build } from "esbuild";
import { createRequire } from "node:module";
import { statSync } from "node:fs";
const require = createRequire(import.meta.url);
// The addons (SVGLoader, BufferGeometryUtils, GLTFLoader, three-mesh-bvh) import the classic `three` build; the
// app uses `three/webgpu`, which re-exports the core. Point the bare specifier at the WebGPU build so exactly one
// Three instance exists in the bundle (same alias as the experiment's vite.config.ts).
const threeWebgpu = require.resolve("three/webgpu");
const threeAlias = {
name: "three-webgpu-alias",
setup(b) {
b.onResolve({ filter: /^three$/ }, () => ({ path: threeWebgpu }));
},
};
await build({
entryPoints: ["src/frost.ts"],
bundle: true,
format: "iife",
globalName: "Frost",
minify: true,
sourcemap: false,
target: ["es2022"],
outfile: "assets/frost.js",
plugins: [threeAlias],
legalComments: "linked",
logLevel: "info",
banner: {
js: "/* Frost: three@0.185.1 (MIT) + three-mesh-bvh@0.9.14 (MIT). Generated by build.mjs; edit src/ and rebuild. */",
},
});
console.log(`assets/frost.js ${(statSync("assets/frost.js").size / 1024).toFixed(0)} KB`);
// Lightweight workspace uses classic WebGL only; math/geometry helpers share their source
// with the full WebGPU block without importing its renderer or simulation.
await build({
entryPoints: ["src/motion/editor.ts"],
bundle: true,
format: "iife",
minify: true,
target: ["es2022"],
outfile: "assets/motion.js",
legalComments: "linked",
logLevel: "info",
plugins: [
{
name: "motion-three",
setup(b) {
b.onResolve({ filter: /^three\/webgpu$/ }, () => ({
path: require.resolve("three").replace("three.cjs", "three.module.js"),
}));
},
},
],
});
source/src/World.tsShowHide
import { CrackPlanes } from "./ice/CrackPlanes";
import { RW } from "./rewrite";
import { createMeshEntry } from "./ice/MeshEntry";
import { createGlassMatcap } from "./ice/GlassMatcap";
// World: owns the scene graph, the object, the simulation modules and the render loop.
// A thin host component mounts it elsewhere. Everything tunable reads from the DialKit store `D`.
import * as THREE from "three/webgpu";
import { D } from "./dials/store";
import type { ShapeName } from "./dials/defaults";
import { clock } from "./core/clock";
import { input, sim, heroFrame } from "./core/state";
import { damp } from "./core/ease";
import { rng } from "./core/seed";
import { createBackdrop } from "./scene/Backdrop";
import { createEnvironment } from "./scene/Environment";
import { Lights } from "./scene/Lights";
import { Rig } from "./scene/Rig";
import { makeShape, type ShapeSpec, type LogoSDF } from "./shape/sdf";
import { buildGeometry } from "./shape/geometry";
import { createPost } from "./post/Post";
import { createIceMaterial, type IceMaterialBundle } from "./ice/IceMaterial";
import { ErosionField } from "./erosion/ErosionField";
import { Powder } from "./powder/Powder";
import { Interaction } from "./core/interaction";
import { DebugViews } from "./scene/DebugViews";
import { tsl } from "./tsl/t";
import { AdaptiveResolution } from "./core/AdaptiveResolution";
export interface WorldKey {
logoId: number;
shape: string;
seed: number;
particleCount: string;
resolution: string;
densityGrid: string;
size: number;
tubeRatio: number;
segments: number;
variants: number;
faceted: boolean;
sprites: boolean;
spriteBlend: boolean;
strays: number;
clumps: number;
dispersion: boolean;
voronoiCells: string;
turbulence: string;
}
export const worldKeyOf = (): WorldKey => ({
logoId: currentLogoId,
shape: heroFrame.enabled && heroFrame.shape ? heroFrame.shape : D.shape.shape,
seed: D.shape.seed,
particleCount: D.powder.particleCount,
resolution: D.erosion.resolution,
densityGrid: D.powder.densityGrid,
size: D.shape.size,
tubeRatio: D.shape.tubeRatio,
segments: D.shape.segments,
variants: D.powder.grainVariants,
faceted: D.powder.facetedGrains,
sprites: D.powder.sprites.enabled,
spriteBlend: D.powder.sprites.seeThrough > 0,
strays: D.powder.strayCount,
clumps: D.powder.clumpCount,
dispersion: true,
voronoiCells: D.performance.voronoiCells,
turbulence: D.performance.turbulence,
});
export const keyString = (k: WorldKey) => JSON.stringify(k);
export let currentLogoId = 0;
export const bumpLogoId = () => {
currentLogoId++;
};
/** Undocumented URL flags for bisecting problems: ?nobake ?nopowder ?noerosion ?noice ?nopost */
export const FLAGS = new Set(
typeof location !== "undefined" ? [...new URLSearchParams(location.search).keys()] : [],
);
/** hash buckets for break-cell restore counters (cells are identified by their baked hash) */
export const CELL_BUCKETS = 65536;
const PARTICLE_COUNTS: Record<string, number> = {
"100k": 100_000,
"250k": 250_000,
"500k": 500_000,
"1M": 1_000_000,
"2M": 2_000_000,
};
export class World {
/** FROST: the composition's continuous motion lives on this outer group; objectGroup (the experiment's) is its child
* and only carries the facing offset that makes a freshly formed shape face the camera. */
readonly motionGroup = new THREE.Group();
readonly objectGroup = new THREE.Group();
readonly backdrop = createBackdrop();
readonly env = createEnvironment();
readonly lights = new Lights();
rig = new Rig();
readonly post;
shape!: ShapeSpec;
mesh!: THREE.Mesh;
meshEntry?: ReturnType<typeof createMeshEntry>;
glassMatcap?: THREE.Texture;
materialPlanes?: CrackPlanes;
ice!: IceMaterialBundle;
erosion!: ErosionField;
powder!: Powder;
interaction!: Interaction;
debugViews!: DebugViews;
private pixelRatio = 0;
private frozenByDial = false;
private statsTimer = 0;
buildCount = 0;
private timersPending = false;
private fieldMaybeNonZero = true;
private lastActivityT = 0;
/**
* Idle-skip bookkeeping. The field only changes during a stroke, the crumble window, or while
* healing/refrost still has something to decay; the powder only needs work while grains are out.
*/
private idleState(t: number) {
const stroke = this.interaction.strokeCount > 0;
if (stroke) {
this.fieldMaybeNonZero = true;
this.lastActivityT = t;
}
const st = this.erosion.stats;
// a readback taken after the last stroke that shows an empty field proves it is static again
if (
this.fieldMaybeNonZero &&
this.erosion.statsReadT > sim.lastStrokeT + 0.6 &&
st.max < 0.002 &&
st.refrost < 0.002
)
this.fieldMaybeNonZero = false;
const healingNow = sim.healing && this.fieldMaybeNonZero;
const refrostNow = this.fieldMaybeNonZero && st.refrost > 0.002;
const fieldActive =
!D.performance.idleSkip ||
stroke ||
t < this.erosion.crumbleUntil ||
healingNow ||
refrostNow;
const powderActive =
!D.performance.idleSkip ||
fieldActive ||
sim.counts.active > 0 ||
t - this.lastActivityT < 1.5 ||
sim.resetRequestedAt >= 0;
sim.fieldActive = fieldActive;
sim.powderActive = powderActive;
return { fieldActive, powderActive };
}
private envSig = "";
private backdropSig = "";
private frameTimes = new Float64Array(30);
private frameTimeSum = 0;
private frameTimeCount = 0;
private frameTimeIndex = 0;
private lastFrameStart = 0;
private gpuPending = false;
private gpuTimerLast = -Infinity;
private readonly renderSize = new THREE.Vector2();
private readonly adaptiveResolution = new AdaptiveResolution();
/** ?offscreen=1 renders into a render target, not the canvas (presenting kills headless SwiftShader devices). */
private offscreenRT: THREE.RenderTarget | null = FLAGS.has("offscreen")
? new THREE.RenderTarget(16, 16, {
depthBuffer: false,
type: THREE.UnsignedByteType,
format: THREE.RGBAFormat,
})
: null;
constructor(
readonly renderer: THREE.WebGPURenderer,
readonly scene: THREE.Scene,
readonly camera: THREE.PerspectiveCamera,
readonly blueNoise: THREE.Texture,
public logo?: { sdf: LogoSDF; geometry: THREE.BufferGeometry },
readonly fractureDetail?: THREE.Texture,
) {
this.motionGroup.add(this.objectGroup);
scene.add(this.motionGroup, this.lights.group, camera);
scene.backgroundNode = this.backdrop.node;
scene.environment = this.env.texture;
this.build();
this.post = createPost({
scene,
camera,
renderer,
blueNoise,
haze: (depthNode, viewZ) => this.powder.hazeNode(depthNode, viewZ, camera),
initial: D.post,
initialPerformance: D.performance,
initialHaze: D.powder.hazeIntensity !== 0,
});
}
/** (Re)build the shape-dependent parts: geometry, ice material, erosion field, powder. */
build() {
this.buildCount++;
const key = worldKeyOf();
const rand = rng(key.seed + 1);
const wanted = heroFrame.enabled && heroFrame.shape ? heroFrame.shape : D.shape.shape;
const shapeName = (wanted === "logo" && !this.logo ? "torus" : wanted) as ShapeName;
this.shape = makeShape(shapeName, key.size, key.tubeRatio, this.logo?.sdf);
const geometry = buildGeometry(this.shape, key.segments, this.logo?.geometry);
// one shared atomic uint buffer: [powder density G^3 | 16 counters | erosion heal grid R^3]
const G = parseInt(key.densityGrid, 10),
R = parseInt(key.resolution, 10);
const countersOffset = G * G * G,
healOffset = countersOffset + 16,
cellOffset = healOffset + R * R * R,
flightOffset = cellOffset + CELL_BUCKETS;
const atomics = tsl.instancedArray(flightOffset + CELL_BUCKETS, "uint").toAtomic();
this.erosion = new ErosionField(
this.renderer,
this.shape,
R,
key.seed,
atomics,
healOffset,
cellOffset,
flightOffset,
);
if (RW.materials) {
this.materialPlanes = new CrackPlanes(this.renderer, this.shape.bound, key.seed);
(this.erosion as any).materialPlanes = this.materialPlanes;
}
this.erosion.skipBake = FLAGS.has("nobake");
if (RW.mesh) this.meshEntry = createMeshEntry(geometry, this.camera);
if (RW.matcap) {
this.env.update(D.lighting);
this.glassMatcap = createGlassMatcap(this.env.texture);
}
this.ice = createIceMaterial({
meshEntry: this.meshEntry,
glassMatcap: this.glassMatcap,
shape: this.shape,
erosion: this.erosion,
seed: key.seed,
blueNoise: this.blueNoise,
environment: this.env.texture,
fractureDetail: this.fractureDetail,
backdrop: this.backdrop.uniforms,
dispersion: key.dispersion,
voronoiCells: key.voronoiCells as "27" | "8",
});
if (FLAGS.has("noice")) {
const m = new THREE.MeshStandardNodeMaterial();
m.roughness = 0.3;
m.color.set("#bbbbbb");
(this.ice as any).material = m;
}
if (this.meshEntry) this.meshEntry.material.positionNode = this.ice.material.positionNode;
this.mesh = new THREE.Mesh(geometry, this.ice.material);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
this.mesh.frustumCulled = false;
this.objectGroup.add(this.mesh);
this.powder = new Powder({
enabled: !FLAGS.has("nopowder"),
renderer: this.renderer,
scene: this.scene,
shape: this.shape,
erosion: this.erosion,
seed: key.seed,
rand,
count: PARTICLE_COUNTS[key.particleCount] ?? 1_000_000,
variants: key.variants,
strays: key.strays,
densityRes: G,
clumpCount: key.clumps,
objectGroup: this.objectGroup,
turbulence: key.turbulence as "full" | "fast",
atomics,
countersOffset,
healOffset,
cellOffset,
flightOffset,
fieldRes: R,
backdrop: this.backdrop.uniforms,
fractureDetail: this.fractureDetail,
iceUniforms: this.ice.uniforms,
iceFeatures: this.ice.features,
environment: this.env.texture,
});
this.interaction = new Interaction(
this.shape,
this.objectGroup,
this.camera,
this.erosion,
this.powder,
);
this.debugViews = new DebugViews(this.camera, this.erosion, this.powder);
this.scene.add(this.debugViews.group);
}
disposeBuilt() {
this.materialPlanes?.dispose();
this.materialPlanes = undefined;
this.meshEntry?.dispose();
this.meshEntry = undefined;
this.glassMatcap?.dispose();
this.glassMatcap = undefined;
this.debugViews.dispose();
this.scene.remove(this.debugViews.group);
this.objectGroup.remove(this.mesh);
this.mesh.geometry.dispose();
this.ice.material.dispose();
this.erosion.dispose();
this.powder.dispose();
}
rebuild() {
this.disposeBuilt();
this.build();
this.post.post.needsUpdate = true;
}
private updateEnvironment() {
const L = D.lighting;
const sig = JSON.stringify([
L.envSoftbox,
L.envRim,
L.envFill,
L.key,
L.fill,
L.fillColor,
L.fillGroundColor,
L.fillReflectionStrength,
L.rimColor,
L.rimElevation,
L.rimAzimuth,
L.rimSize,
L.accentCool,
L.accentWarm,
]);
if (sig !== this.envSig) {
this.envSig = sig;
this.env.update(L);
}
this.scene.environmentIntensity = D.ice.envIntensity;
}
private updateBackdrop(t: number) {
const b = D.lighting.backdrop;
const h = heroFrame.enabled ? heroFrame.backdrop : null;
const u = this.backdrop.uniforms;
const top = h?.top ?? b.top,
mid = h?.mid ?? b.mid,
bottom = h?.bottom ?? b.bottom;
const centerX = h?.centerX ?? b.centerX,
centerY = h?.centerY ?? b.centerY,
radius = h?.radius ?? b.radius,
falloff = h?.falloff ?? b.falloff;
const sig = `${top}|${mid}|${bottom}|${centerX}|${centerY}|${radius}|${falloff}|${b.noise}`;
if (sig !== this.backdropSig) {
this.backdropSig = sig;
u.uTop.value.set(top);
u.uMid.value.set(mid);
u.uBottom.value.set(bottom);
u.uCenter.value.set(centerX, centerY);
u.uRadius.value = radius;
u.uFalloff.value = falloff;
u.uNoise.value = b.noise;
}
u.uTime.value = t;
}
/** FROST: called after the camera is final for this frame and before the post graph renders. */
onBeforeRender: ((t: number) => void) | null = null;
/** Called after the rig places the object group, before its world matrix is used (the object motion). */
onBeforeSimulation: ((t: number, dt: number) => void) | null = null;
onObjectTransform: ((t: number) => void) | null = null;
/** Authored camera evaluates after the current target matrix, before interaction and render. */
onCameraTransform: ((t: number) => void) | null = null;
/**
* One frame: input -> rig -> simulation compute -> post render. The composition supplies the clock (t, dt),
* not performance.now(), so each step is pure; `render` false advances without drawing (fast-forward after a seek).
*/
frame(t: number, dt: number, render = true) {
clock.t = t;
clock.dt = dt;
clock.frame++;
const start = t * 1000;
const elapsed = 1000 / D.performance.targetFps;
const splitOutput =
!FLAGS.has("nopost") &&
D.performance.nativePostEffects &&
D.performance.upscaler !== "native";
const resolution = this.adaptiveResolution.updateScene(
t,
elapsed,
window.devicePixelRatio || 1,
D.post.pixelRatioCap,
D.performance.adaptiveResolution && D.performance.dynamicSceneResolution,
D.performance.targetFps,
D.performance.minPixelRatio,
D.performance.sceneResolutionScale,
D.performance.minSceneResolutionScale,
splitOutput,
);
sim.pixelRatio = resolution.sourceRatio;
sim.outputPixelRatio = resolution.outputRatio;
sim.sceneResolutionScale = resolution.sceneScale;
if (
resolution.outputRatio !== this.pixelRatio ||
this.renderer.getPixelRatio() !== resolution.outputRatio
) {
this.pixelRatio = resolution.outputRatio;
this.renderer.setPixelRatio(resolution.outputRatio);
const sz = this.renderer.getSize(this.renderSize);
this.renderer.setSize(sz.x, sz.y, false);
}
if (!this.onCameraTransform) this.rig.update(this.camera, this.objectGroup, t, dt);
if (heroFrame.enabled && heroFrame.framing)
this.motionGroup.position.set(heroFrame.objectX, heroFrame.objectY, heroFrame.objectZ);
else this.motionGroup.position.set(D.shape.offsetX, D.shape.offsetY, D.shape.offsetZ);
this.objectGroup.position.set(0, 0, 0);
if (this.onObjectTransform) this.onObjectTransform(t);
this.motionGroup.updateMatrixWorld(true);
this.onCameraTransform?.(t);
this.updateEnvironment();
this.updateBackdrop(t);
sim.hoverAmount = damp(sim.hoverAmount, sim.overObject ? 1 : 0, 0.4 / 3, dt);
this.lights.update(t, sim.hoverAmount);
this.interaction.update(t, dt);
this.onBeforeSimulation?.(t, dt);
this.debugViews.update(this.interaction);
if (!FLAGS.has("noice")) {
this.ice.update(t, dt, this.lights.key, this.objectGroup);
this.ice.uniforms.hover.value = sim.hoverAmount;
}
const c0 = performance.now();
const idle = this.idleState(t);
if (dt > 0) {
if (!FLAGS.has("noerosion"))
this.erosion.step(
this.renderer,
t,
dt,
this.interaction.strokeSegments,
this.interaction.strokeCount,
idle.fieldActive,
);
}
// Uniform-only updates at dt=0 keep paused workbench edits live.
if (!FLAGS.has("nopowder"))
this.powder.step(this.renderer, t, dt, this.interaction, this.lights.key, idle);
sim.computeMs = performance.now() - c0;
const wantStats = D.debug.stats || D.performance.idleSkip;
if (wantStats) {
this.statsTimer += Math.max(dt, 1 / 120);
const interval = this.fieldMaybeNonZero ? 0.25 : 0.4;
if (this.statsTimer > interval || this.offscreenRT) {
this.statsTimer = 0;
this.erosion.readStats(this.renderer, t);
sim.erosion = this.erosion.stats;
}
}
this.camera.updateMatrixWorld();
if (this.onBeforeRender) this.onBeforeRender(t);
// Sprite size belongs to the low-resolution scene pass, not the native-resolution final output.
this.powder.updateCamera(
this.camera,
this.renderer.getSize(this.renderSize).y * resolution.sourceRatio,
);
const focus = this.camera.position.distanceTo(this.rig.lookAt);
this.post.update(
D.post,
D.performance,
focus,
sim.sceneFade,
D.powder.hazeIntensity,
t,
D.version,
);
this.materialPlanes?.update();
this.post.setSceneResolution(resolution.sceneScale);
this.meshEntry?.update(this.mesh, resolution.sceneScale);
(this.mesh.material as any).wireframe = D.debug.wireframe;
if (this.offscreenRT) {
const sz = this.renderer.getSize(this.renderSize);
const pr = this.renderer.getPixelRatio();
const w = Math.max(1, Math.floor(sz.x * pr)),
h = Math.max(1, Math.floor(sz.y * pr));
if (this.offscreenRT.width !== w || this.offscreenRT.height !== h)
this.offscreenRT.setSize(w, h);
this.renderer.setRenderTarget(this.offscreenRT);
}
this.post.setHazeResolution(
D.performance.hazeResolution === "quarter"
? 0.25
: D.performance.hazeResolution === "half"
? 0.5
: 1,
);
// HyperFrames drives explicit frames outside Three's animation loop. Advance
// the node frame so FRAME-scoped passes cannot reuse the previous image.
if (render) {
(this.renderer as any)._nodes.nodeFrame.update();
this.post.setFrame(Math.round(t * 60));
if (FLAGS.has("nopost")) this.renderer.render(this.scene, this.camera);
else this.post.post.render();
}
if (this.offscreenRT) this.renderer.setRenderTarget(null);
if (D.performance.gpuTimers && !this.timersPending && t - this.gpuTimerLast >= 0.25) {
const r: any = this.renderer;
if (r.backend?.trackTimestamp) {
this.timersPending = true;
this.gpuTimerLast = t;
Promise.all([r.resolveTimestampsAsync("render"), r.resolveTimestampsAsync("compute")])
.then(() => {
sim.gpuRenderMs = r.info.render.timestamp || 0;
sim.gpuComputeMs = r.info.compute.timestamp || 0;
this.timersPending = false;
})
.catch(() => {
this.timersPending = false;
});
}
}
if (this.offscreenRT && !this.gpuPending) {
const q = (this.renderer as any).backend?.device?.queue;
if (q?.onSubmittedWorkDone) {
this.gpuPending = true;
q.onSubmittedWorkDone()
.then(() => {
sim.gpuFrames++;
this.gpuPending = false;
})
.catch(() => {
this.gpuPending = false;
});
}
}
void input;
}
/**
* FROST: return every stateful part of the simulation to its t = 0 state so a backward seek can replay
* the timeline deterministically (field cleared, grains dormant, rig and interaction fresh).
*/
resetSim() {
this.erosion.clear(this.renderer);
this.erosion.crumbleUntil = -1;
this.erosion.u.crumbleUntil.value = -1;
this.powder.reset(this.renderer);
this.interaction = new Interaction(
this.shape,
this.objectGroup,
this.camera,
this.erosion,
this.powder,
);
this.rig = new Rig();
this.mesh.visible = true;
sim.strokeActive = false;
sim.overObject = false;
sim.lastStrokeT = -1e9;
sim.healing = false;
sim.resetRequestedAt = -1;
sim.fade = 1;
sim.sceneF… [1970 more characters not shown]source/src/assets.tsShowHide
// FROST: the experiment served its assets from the site root (/textures, /fonts, /logo.svg ...). As a // HyperFrames block they live in ./assets next to index.html, resolved relative to the composition document. export const ASSET_BASE: string = (typeof window !== "undefined" && (window as any).__frostAssetBase) || "assets/"; export const assetUrl = (name: string) => ASSET_BASE + name;
source/src/cache.tsShowHide
// FROST: an IndexedDB cache for the expensive, deterministic build products (extruded geometries, distance
// fields, grain homes), keyed by everything that shapes them. A reload with the same headlines and type
// settings skips the voxelisation; the entry is dropped automatically when the key changes.
import * as THREE from "three/webgpu";
const DB = "frost-build-cache",
STORE = "builds",
VERSION = 1;
/** bump when the build pipeline changes in a way that makes old entries wrong */
export const BUILD_VERSION = "frost-build-10-planar-cap-normals";
export interface CachedGeometry {
position: Float32Array;
normal: Float32Array;
uv: Float32Array | null;
index: Uint32Array | null;
}
export interface CachedBuild {
geometries: CachedGeometry[];
sdfs: {
data: Float32Array;
res: number;
bound: number;
thickness: number;
sampleBound: number;
}[];
rests: Float32Array[];
}
function open(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
try {
if (typeof indexedDB === "undefined") return resolve(null);
const req = indexedDB.open(DB, VERSION);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
export async function readBuild(key: string): Promise<CachedBuild | null> {
const db = await open();
if (!db) return null;
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => resolve((req.result as CachedBuild) ?? null);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
export async function writeBuild(key: string, build: CachedBuild): Promise<void> {
const db = await open();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
store.clear(); // one entry: the current build
store.put(build, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch {
resolve();
}
});
}
export function packGeometry(g: THREE.BufferGeometry): CachedGeometry {
const a = (name: string) =>
g.attributes[name] ? (g.attributes[name].array as Float32Array) : null;
return {
position: a("position")!,
normal: a("normal")!,
uv: a("uv"),
index: g.index ? new Uint32Array(g.index.array as ArrayLike<number>) : null,
};
}
export function unpackGeometry(c: CachedGeometry): THREE.BufferGeometry {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(c.position, 3));
g.setAttribute("normal", new THREE.Float32BufferAttribute(c.normal, 3));
if (c.uv) g.setAttribute("uv", new THREE.Float32BufferAttribute(c.uv, 2));
if (c.index) g.setIndex(new THREE.BufferAttribute(c.index, 1));
g.computeBoundingBox();
return g;
}
source/src/core/AdaptiveResolution.tsShowHide
/**
* Keeps the render workload inside a frame budget by changing only pixel density (effects, simulation
* rates and content untouched). Adjustments are quantised and hysteretic so targets are not resized constantly.
*/
export class AdaptiveResolution {
private ratio = 0;
private frameMs = 0;
private frames = 0;
private lastAdjust = 0;
update(
now: number,
elapsedMs: number,
deviceRatio: number,
cap: number,
enabled: boolean,
targetFps: number,
minimum: number,
) {
const maximum = Math.max(0.5, Math.min(deviceRatio || 1, cap));
const minRatio = Math.min(maximum, Math.max(0.5, minimum));
if (!this.ratio || !enabled) this.ratio = maximum;
this.ratio = Math.min(maximum, Math.max(minRatio, this.ratio));
// Clip one-off shader compilation/rebuild stalls before feeding the controller.
const sample = Math.min(50, Math.max(1, elapsedMs));
this.frameMs = this.frames === 0 ? sample : this.frameMs + (sample - this.frameMs) * 0.08;
this.frames++;
if (!enabled || this.frames < 45 || now - this.lastAdjust < 0.5) return this.ratio;
const budget = 1000 / Math.max(30, targetFps);
let next = this.ratio;
if (this.frameMs > budget * 1.08 && this.ratio > minRatio) {
const ideal = this.ratio * Math.sqrt(budget / this.frameMs) * 0.98;
next = Math.min(this.ratio - 0.05, ideal);
next = Math.floor(next * 20) / 20;
} else if (this.frameMs < budget * 0.82 && this.ratio < maximum) {
next = Math.ceil((this.ratio + 0.05) * 20) / 20;
}
next = Math.min(maximum, Math.max(minRatio, next));
if (next !== this.ratio) {
this.ratio = next;
// Give the resized render targets time to establish their new steady-state cost.
this.frameMs = budget;
this.lastAdjust = now;
}
return this.ratio;
}
/**
* Plans a split-resolution frame. `sourceRatio` is the DPR paid by the scene pass; `outputRatio` is the
* canvas/final-post DPR. With split output disabled they are identical (whole-frame scaling fallback).
*/
updateScene(
now: number,
elapsedMs: number,
deviceRatio: number,
outputCap: number,
enabled: boolean,
targetFps: number,
minPixelRatio: number,
maxSceneScale: number,
minSceneScale: number,
splitOutput: boolean,
) {
const nativeRatio = Math.max(0.5, Math.min(deviceRatio || 1, outputCap));
const maxScale = Math.max(0.25, Math.min(1, maxSceneScale));
const sourceMaximum = Math.max(0.5, nativeRatio * maxScale);
const relativeMinimum = nativeRatio * Math.max(0.25, Math.min(maxScale, minSceneScale));
const sourceMinimum = Math.min(sourceMaximum, Math.max(0.5, minPixelRatio, relativeMinimum));
const sourceRatio = this.update(
now,
elapsedMs,
sourceMaximum,
sourceMaximum,
enabled,
targetFps,
sourceMinimum,
);
const outputRatio = splitOutput ? nativeRatio : sourceRatio;
return {
sourceRatio,
outputRatio,
sceneScale: splitOutput ? sourceRatio / nativeRatio : 1,
};
}
}
source/src/core/clock.tsShowHide
/** Wall-clock with pause/step support. All systems read `clock.t` and `clock.dt`. */
export const clock = {
t: 0,
dt: 1 / 60,
frame: 0,
paused: false,
stepRequested: false,
_last: performance.now() / 1000,
tick(now = performance.now() / 1000) {
const raw = Math.min(0.05, Math.max(0, now - this._last));
this._last = now;
if (this.paused && !this.stepRequested) {
this.dt = 0;
return;
}
this.stepRequested = false;
this.dt = this.paused ? 1 / 60 : raw;
this.t += this.dt;
this.frame++;
},
};
source/src/core/ease.tsShowHide
export const expoInOut = (x: number) =>
x <= 0
? 0
: x >= 1
? 1
: x < 0.5
? Math.pow(2, 20 * x - 10) / 2
: (2 - Math.pow(2, -20 * x + 10)) / 2;
export const expoOut = (x: number) => (x >= 1 ? 1 : 1 - Math.pow(2, -10 * x));
export const smooth = (x: number) => x * x * (3 - 2 * x);
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** Frame-rate independent exponential smoothing toward `target` with time constant `tau` (seconds). */
export const damp = (cur: number, target: number, tau: number, dt: number) =>
tau <= 0 ? target : cur + (target - cur) * (1 - Math.exp(-dt / tau));
source/src/core/interaction.tsShowHide
// Cursor -> object-space SDF raycast -> stroke capsules (through the volume) + stroke velocity for the powder.
import * as THREE from "three/webgpu";
import type { ShapeSpec } from "../shape/sdf";
import { raycastSDF } from "../shape/sdf";
import { input, sim, heroFrame } from "./state";
import { clock } from "./clock";
import { D } from "../dials/store";
import { damp } from "./ease";
import type { ErosionField } from "../erosion/ErosionField";
import { MAX_SEGMENTS } from "../erosion/ErosionField";
import type { Powder } from "../powder/Powder";
const DEPTH_STEPS = 4;
/** ?stroke=1 drives a synthetic sweep (headless checks). */
const FORCE_STROKE =
typeof location !== "undefined" && new URLSearchParams(location.search).has("stroke");
/** ?strokeFrames=N stops the synthetic sweep after N frames (to watch what the field does afterwards). */
const STROKE_FRAMES =
typeof location !== "undefined"
? parseInt(new URLSearchParams(location.search).get("strokeFrames") || "1000000", 10)
: 1e6;
const STROKE_START =
typeof location !== "undefined"
? parseInt(new URLSearchParams(location.search).get("strokeStart") || "0", 10)
: 0;
export class Interaction {
readonly strokeSegments: THREE.Vector4[] = Array.from(
{ length: MAX_SEGMENTS * 2 },
() => new THREE.Vector4(),
);
strokeCount = 0;
/** Smoothed stroke velocity in world units / s and its direction (kept after the stroke ends, speed decays). */
readonly strokeVel = new THREE.Vector3();
readonly strokeDir = new THREE.Vector3(1, 0.4, 0).normalize();
strokeSpeed = 0;
/** Debug: last entry/exit hit points in world space. */
readonly hitEntry = new THREE.Vector3();
readonly hitExit = new THREE.Vector3();
hasHit = false;
private prevEntry = new THREE.Vector3();
private prevExit = new THREE.Vector3();
private prevEntryWorld = new THREE.Vector3();
private prevHit = false;
private prevX = 0;
private prevY = 0;
/** Scripted strokes (hero triggers): a sweep along an object-space path, or a burst at a point. */
/** FROST: a queue instead of one sweep, so a shatter can run several parallel slices with staggered starts. */
private scripted: {
kind: "sweep" | "front";
from: THREE.Vector3;
to: THREE.Vector3;
normal?: THREE.Vector3;
reach?: number;
t0: number;
delay: number;
duration: number;
radius: number;
strength: number;
through: boolean;
prevT: number;
}[] = [];
private scrollP = -1;
private scrollPath = { from: new THREE.Vector3(-1, -0.6, 0), to: new THREE.Vector3(1, 0.6, 0) };
private ray = new THREE.Ray();
private inv = new THREE.Matrix4();
private tmp = new THREE.Vector3();
private objectRayOrigin = new THREE.Vector3();
private objectRayDirection = new THREE.Vector3();
private workA = new THREE.Vector3();
private workB = new THREE.Vector3();
private workDirection = new THREE.Vector3();
private velocity = new THREE.Vector3();
private rayHit = { entry: new THREE.Vector3(), exit: new THREE.Vector3(), tEntry: 0, tExit: 0 };
private resetDone = true;
constructor(
readonly shape: ShapeSpec,
readonly objectGroup: THREE.Object3D,
readonly camera: THREE.PerspectiveCamera,
readonly erosion: ErosionField | null,
readonly powder: Powder | null,
/** V2: called when a reset completes (chunks snap home) */
readonly onReset?: () => void,
) {}
/** Play a sweep across the object (object-space path, fractions of the bound). */
sweep(
opts: {
from?: [number, number, number];
to?: [number, number, number];
duration?: number;
radius?: number;
strength?: number;
through?: boolean;
delay?: number;
} = {},
) {
const B = this.shape.bound;
const f = opts.from ?? [-1, -0.55, 0],
to = opts.to ?? [1, 0.55, 0];
this.scripted.push({
kind: "sweep",
from: new THREE.Vector3(f[0] * B, f[1] * B, f[2] * B),
to: new THREE.Vector3(to[0] * B, to[1] * B, to[2] * B),
t0: -1,
delay: opts.delay ?? 0,
duration: opts.duration ?? 0.7,
radius: opts.radius ?? D.erosion.brushRadius,
strength: opts.strength ?? D.erosion.brushStrength * 1.5,
through: opts.through ?? true,
prevT: 0,
});
}
/**
* Break front: a band the full slice-path length (plus half a bound each way) travels outward on both sides,
* eroding all it passes until the shape is gone. `reach` in bounds; eject direction stays the slice's.
*/
front(opts: {
from: [number, number, number];
to: [number, number, number];
delay?: number;
duration?: number;
reach?: number;
radius?: number;
strength?: number;
}) {
const B = this.shape.bound;
const [fx, fy] = opts.from,
[tx, ty] = opts.to;
const dx = tx - fx,
dy = ty - fy,
len = Math.hypot(dx, dy) || 1;
const ex = (dx / len) * 0.5,
ey = (dy / len) * 0.5;
this.scripted.push({
kind: "front",
from: new THREE.Vector3((fx - ex) * B, (fy - ey) * B, 0),
to: new THREE.Vector3((tx + ex) * B, (ty + ey) * B, 0),
normal: new THREE.Vector3(-dy / len, dx / len, 0),
reach: (opts.reach ?? 1.3) * B,
t0: -1,
delay: opts.delay ?? 0,
duration: opts.duration ?? 1.5,
radius: opts.radius ?? D.erosion.brushRadius,
strength: opts.strength ?? D.erosion.brushStrength * 1.5,
through: true,
prevT: 0,
});
}
/** FROST: drop every queued sweep (a reset). */
clearScripted() {
this.scripted.length = 0;
}
/** Burst at a world-space point (e.g. a click hit), eroding a sphere and throwing grains outward. */
burst(worldPoint: THREE.Vector3, radius: number, strength: number, duration = 0.18) {
const inv = new THREE.Matrix4().copy(this.objectGroup.matrixWorld).invert();
const p = worldPoint.clone().applyMatrix4(inv);
this.scripted.push({
from: p,
to: p.clone(),
t0: -1,
delay: 0,
duration,
radius,
strength,
through: false,
prevT: 0,
});
const centre = new THREE.Vector3().setFromMatrixPosition(this.objectGroup.matrixWorld);
const dir = worldPoint.clone().sub(centre);
if (dir.lengthSq() < 1e-6) dir.set(0, 1, 0);
this.strokeDir.copy(dir.normalize());
this.strokeSpeed = D.erosion.brushSpeedRef * 1.5;
}
/** Scroll-driven erosion: erodes along a diagonal as `p` (0..1) advances; call every frame. */
sweepTo(p: number) {
this.scrollP = Math.max(0, Math.min(1, p));
}
private pushScripted(t: number, dt: number) {
if (this.scripted.length === 0) return false;
let pushed = false;
for (const sc of this.scripted) {
if (sc.t0 < 0) sc.t0 = t + sc.delay;
if (t < sc.t0) continue;
const u = Math.min(1, (t - sc.t0) / Math.max(0.01, sc.duration));
const B = this.shape.bound;
if (sc.kind === "front") {
// the band starts fast and slows down; its radius never drops below the distance it moved this frame
const ease = (x: number) => 1 - (1 - x) * (1 - x);
const dPrev = sc.reach! * ease(sc.prevT),
dNow = sc.reach! * ease(u);
const r = Math.max(sc.radius, (dNow - dPrev) * 1.5);
// one band per depth slice: the shapes are thin, so one at z = 0 usually covers them
const th = this.shape.thickness;
const zs = th <= r * 0.9 ? [0] : [-th, 0, th];
if (this.strokeCount + zs.length * 2 > MAX_SEGMENTS) break;
for (const s of [1, -1]) {
const a = this.workA.copy(sc.from).addScaledVector(sc.normal!, s * dNow),
b = this.workB.copy(sc.to).addScaledVector(sc.normal!, s * dNow);
for (const z of zs) {
this.strokeSegments[this.strokeCount * 2].set(a.x, a.y, z, r);
this.strokeSegments[this.strokeCount * 2 + 1].set(b.x, b.y, z, sc.strength);
this.strokeCount++;
}
}
this.strokeSpeed = Math.max(this.strokeSpeed, (sc.reach! / sc.duration) * 0.6);
sc.prevT = u;
pushed = true;
continue;
}
const a = this.workA.copy(sc.from).lerp(sc.to, sc.prevT),
b = this.workB.copy(sc.from).lerp(sc.to, u);
const n = sc.through ? 3 : 1;
if (this.strokeCount + n > MAX_SEGMENTS) break;
for (let k = 0; k < n; k++) {
const z = sc.through ? -B + (2 * B * (k + 0.5)) / n : 0;
this.strokeSegments[this.strokeCount * 2].set(
a.x,
a.y,
sc.through ? z - B / n : a.z,
sc.radius,
);
this.strokeSegments[this.strokeCount * 2 + 1].set(
b.x,
b.y,
sc.through ? z + B / n : b.z,
sc.strength,
);
this.strokeCount++;
}
if (sc.from.distanceToSquared(sc.to) > 1e-6) {
const dirW = this.workDirection
.copy(sc.to)
.sub(sc.from)
.transformDirection(this.objectGroup.matrixWorld);
this.strokeDir.copy(dirW.normalize());
this.strokeSpeed = Math.max(
this.strokeSpeed,
(sc.from.distanceTo(sc.to) / sc.duration) * 0.6,
);
}
sc.prevT = u;
pushed = true;
}
this.scripted = this.scripted.filter((sc) => sc.prevT < 1);
void dt;
return pushed;
}
private pushScroll(dt: number) {
if (this.scrollP < 0) return false;
const p = this.scrollP;
if (this.prevScrollP < 0) {
this.prevScrollP = p;
return false;
}
const dp = p - this.prevScrollP;
if (Math.abs(dp) < 0.0005) return false;
const B = this.shape.bound;
const a = this.workA
.copy(this.scrollPath.from)
.lerp(this.scrollPath.to, this.prevScrollP)
.multiplyScalar(B);
const b = this.workB.copy(this.scrollPath.from).lerp(this.scrollPath.to, p).multiplyScalar(B);
if (dp > 0) {
for (let k = 0; k < 3 && this.strokeCount < MAX_SEGMENTS; k++) {
const z = -B + (2 * B * (k + 0.5)) / 3;
this.strokeSegments[this.strokeCount * 2].set(a.x, a.y, z - B / 3, D.erosion.brushRadius);
this.strokeSegments[this.strokeCount * 2 + 1].set(
b.x,
b.y,
z + B / 3,
D.erosion.brushStrength * 2.5,
);
this.strokeCount++;
}
const dirW = this.workDirection
.copy(b)
.sub(a)
.transformDirection(this.objectGroup.matrixWorld);
if (dirW.lengthSq() > 1e-8) this.strokeDir.copy(dirW.normalize());
this.strokeSpeed = Math.max(
this.strokeSpeed,
Math.min(6, (Math.abs(dp) * 2 * B) / Math.max(dt, 1 / 120)),
);
}
this.prevScrollP = p;
return dp > 0;
}
private prevScrollP = -1;
update(t: number, dt: number) {
this.strokeCount = 0;
const E = D.erosion;
// --- reset (click / R): fade the powder over `resetFade` ms, then clear everything
if (sim.resetRequestedAt >= 0) {
const u = (t - sim.resetRequestedAt) / Math.max(0.05, D.healing.resetFade / 1000);
sim.fade = Math.max(0, 1 - u);
if (u >= 1) {
if (this.erosion) this.erosion.clear(this.erosion.renderer);
if (this.powder && this.erosion) this.powder.reset(this.erosion.renderer);
this.onReset?.();
sim.resetRequestedAt = -1;
sim.lastStrokeT = -1e9;
sim.fade = 1;
}
} else sim.fade = damp(sim.fade, 1, 0.15, dt);
const moved = input.x !== this.prevX || input.y !== this.prevY;
let hit: ReturnType<typeof raycastSDF> = null;
if (input.inside) {
// world ray from the cursor, into object space
this.tmp.set(input.x, input.y, 0.5).unproject(this.camera);
this.ray.origin.copy(this.camera.position);
this.ray.direction.copy(this.tmp).sub(this.camera.position).normalize();
this.inv.copy(this.objectGroup.matrixWorld).invert();
const o = this.objectRayOrigin.copy(this.ray.origin).applyMatrix4(this.inv);
const d = this.objectRayDirection.copy(this.ray.direction).transformDirection(this.inv);
hit = raycastSDF(this.shape, o, d, 60, this.rayHit);
}
sim.overObject = !!hit;
this.hasHit = !!hit;
if (hit) {
this.hitEntry.copy(hit.entry).applyMatrix4(this.objectGroup.matrixWorld);
this.hitExit.copy(hit.exit).applyMatrix4(this.objectGroup.matrixWorld);
}
let strokeThisFrame = false;
if (dt > 0 && this.pushScripted(t, dt)) strokeThisFrame = true;
if (dt > 0 && this.pushScroll(dt)) strokeThisFrame = true;
const cursorErodes = !heroFrame.enabled || heroFrame.cursorErodes;
if (
cursorErodes &&
hit &&
this.prevHit &&
moved &&
dt > 0 &&
this.strokeCount + DEPTH_STEPS <= MAX_SEGMENTS
) {
const entryWorld = this.hitEntry;
const vel = this.velocity.copy(entryWorld).sub(this.prevEntryWorld).divideScalar(dt);
const speed = vel.length();
if (speed > 0.02) {
strokeThisFrame = true;
this.strokeVel.lerp(vel, 0.5);
this.strokeSpeed = damp(this.strokeSpeed, Math.min(speed, E.brushSpeedRef * 3), 0.08, dt);
if (this.strokeVel.lengthSq() > 1e-6) this.strokeDir.copy(this.strokeVel).normalize();
const k = THREE.MathUtils.clamp(speed / E.brushSpeedRef, 0.12, 2.5);
const strength = E.brushStrength * k;
const radius = E.brushRadius * (0.72 + 0.28 * Math.min(1, k));
for (let s = 0; s < DEPTH_STEPS; s++) {
const f = (s / (DEPTH_STEPS - 1)) * E.throughDepth;
const idx = this.strokeCount + s;
this.strokeSegments[idx * 2].set(
THREE.MathUtils.lerp(this.prevEntry.x, this.prevExit.x, f),
THREE.MathUtils.lerp(this.prevEntry.y, this.prevExit.y, f),
THREE.MathUtils.lerp(this.prevEntry.z, this.prevExit.z, f),
radius,
);
this.strokeSegments[idx * 2 + 1].set(
THREE.MathUtils.lerp(hit.entry.x, hit.exit.x, f),
THREE.MathUtils.lerp(hit.entry.y, hit.exit.y, f),
THREE.MathUtils.lerp(hit.entry.z, hit.exit.z, f),
strength,
);
}
this.strokeCount += DEPTH_STEPS;
}
}
if (
(D.debug.forceStroke ||
(FORCE_STROKE && clock.frame >= STROKE_START && clock.frame < STROKE_FRAMES)) &&
dt > 0
) {
// synthetic diagonal sweep (lower-left -> upper-right) for testing without a cursor
const ph = (t * 0.25) % 1;
const B = this.shape.bound * 0.9;
const ax = -B + ph * 2 * B,
ay = -B * 0.7 + ph * 1.4 * B;
const bx = ax + 0.15,
by = ay + 0.1;
this.strokeSegments[0].set(ax, ay, -B, E.brushRadius);
this.strokeSegments[1].set(bx, by, B, E.brushStrength);
this.strokeCount = 1;
this.strokeDir.set(1, 0.7, 0).normalize();
this.strokeSpeed = E.brushSpeedRef;
strokeThisFrame = true;
}
if (!strokeThisFrame) this.strokeSpeed = damp(this.strokeSpeed, 0, 1.2, dt);
sim.strokeActive = strokeThisFrame;
// scroll-driven pages hold their erosion: healing is suppressed while suspended
if (heroFrame.enabled && heroFrame.healSuspended) sim.lastStrokeT = t;
if (hit) {
this.prevEntry.copy(hit.entry);
this.prevExit.copy(hit.exit);
this.prevEntryWorld.copy(this.hitEntry);
}
this.prevHit = !!hit;
this.prevX = input.x;
this.prevY = input.y;
void this.resetDone;
}
}
source/src/core/motion.tsShowHide
import type { Schedule, Pose } from "../frost";
export const SETTLE = 2.8;
export const ATTACK = 0.65; // anticipation before each break
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
/** C2 turn envelopes: continuous velocity/acceleration over an ongoing drift. */
export function makePoseAt(S: Schedule, P: Pose) {
const flowEase = (u: number) => {
u = clamp01(u);
return u * u * u * (u * (u * 6 - 15) + 10);
};
/** Quintic rise and fall meet with zero velocity and acceleration at the crest. */
const bump = (u: number, a: number) =>
u <= a ? flowEase(u / Math.max(a, 1e-6)) : 1 - flowEase((u - a) / Math.max(1 - a, 1e-6));
type Turn = {
t0: number;
tBreakEnd: number;
t1: number;
yaw: number;
pitch: number;
z: number;
keep: boolean;
};
const turns: Turn[] = [
{
t0: S.logoBreak - ATTACK,
tBreakEnd: S.form1,
t1: S.form1 + SETTLE,
yaw: P.turnYaw,
pitch: P.turnPitch,
z: P.approach,
keep: false,
},
{
t0: S.break1 - ATTACK,
tBreakEnd: S.form2,
t1: S.form2 + SETTLE,
yaw: P.turnYaw2,
pitch: P.turnPitch2,
z: -P.retreat,
keep: false,
},
{
t0: S.break2 - ATTACK,
tBreakEnd: S.fadeOut + 1.5,
t1: S.fadeOut + 1.5,
yaw: P.finalYaw,
pitch: P.finalPitch,
z: P.approach + P.zoomIn,
keep: true,
},
];
/** the keyed part of the path (without drift / idle), in degrees and units */
function keyPoseAt(t: number) {
let yaw = 0,
pitch = 0,
z = 0;
for (const k of turns) {
const u = (t - k.t0) / Math.max(k.t1 - k.t0, 1e-6);
if (u <= 0) continue;
yaw += k.yaw * flowEase(u);
const a = (k.tBreakEnd - k.t0) / Math.max(k.t1 - k.t0, 1e-6);
const b = k.keep ? flowEase(u) : bump(u, a);
pitch += k.pitch * b;
z += k.z * b;
}
return { yaw, pitch, z };
}
const driftYawAt = (t: number) => P.driftYaw * t;
const idleYawAt = (t: number) => P.idleYaw * Math.sin((2 * Math.PI * t) / 9);
return function poseAt(t: number) {
const k = keyPoseAt(t);
const sway = P.driftSway * Math.sin((2 * Math.PI * t) / 11);
return {
yaw: k.yaw + driftYawAt(t) + idleYawAt(t),
pitch: k.pitch + sway,
z: k.z + 0.0875 * P.driftSway * Math.sin((2 * Math.PI * t) / 11),
};
};
}
source/src/core/schedule.tsShowHide
import type { Schedule } from "../frost";
import { ATTACK, SETTLE } from "./motion";
export const FADE_DURATION = 1.5;
export const DEFAULT_SCHEDULE: Readonly<Schedule> = Object.freeze({
logoBreak: 1.5,
form1: 3.8,
break1: 10.375,
form2: 12.675,
break2: 18.5,
fadeOut: 22,
});
export const durationOf = (s: Schedule) => s.fadeOut + FADE_DURATION;
/** Preserve entered beat times, moving only overlaps forward; never truncate the final break. */
export function resolveSchedule(raw: Schedule, breakDuration: number): Schedule {
const breakSpan = 0.3 + breakDuration + 0.6;
const logoBreak = raw.logoBreak;
const form1 = Math.max(raw.form1, logoBreak + breakSpan);
const break1 = Math.max(raw.break1, form1 + SETTLE + ATTACK);
const form2 = Math.max(raw.form2, break1 + breakSpan);
const break2 = Math.max(raw.break2, form2 + SETTLE + ATTACK);
const fadeOut = Math.max(raw.fadeOut, break2 + breakSpan);
return { logoBreak, form1, break1, form2, break2, fadeOut };
}
source/src/core/seed.tsShowHide
/** Deterministic PRNG (mulberry32) seeded from the DialKit `seed`. */
export function rng(seed: number) {
let a = (seed * 1664525 + 1013904223) >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
source/src/core/state.tsShowHide
/** Shared interaction state written by the DOM layer and read by the render loop. */
export const input = {
x: 0,
y: 0, // normalized -1..1 (y up)
px: 0,
py: 0, // pixels
inside: false, // pointer inside the viewport
lastMoveT: 0, // clock time of last movement
moved: false,
clicked: false,
uiHidden: false,
overUi: false,
};
export const sim = {
strokeActive: false, // cursor currently over the object with movement
overObject: false,
lastStrokeT: -1e9,
healing: false,
resetRequestedAt: -1, // clock time when a reset fade started, -1 when none
fade: 1, // powder fade multiplier (reset)
sceneFade: 1, // scene exposure multiplier (shape crossfade)
hoverAmount: 0, // smoothed 0..1 hover feel
fps: 0,
frameMs: 0,
pixelRatio: 1, // effective DPR of the heavy scene pass
outputPixelRatio: 1, // DPR of the final post-processing output
sceneResolutionScale: 1,
gpuFrames: 0,
gpuRenderMs: 0,
gpuComputeMs: 0,
fieldActive: true,
powderActive: true,
computeMs: 0,
counts: { active: 0, healing: 0, dormant: 0, total: 0 },
erosion: { max: 0, over50: 0, over90: 0, refrost: 0 },
};
/**
* Per-page override layer used by the hero variants. Nothing here is set on `/`, which keeps the
* experiment page exactly as tuned. Hero pages write into it every frame from their own DialKit panel.
*/
export const heroFrame = {
enabled: false,
clickResets: true,
/** false = the hero keeps the shared Camera folder + Shape offsets (the page looks exactly like `/`) */
framing: true,
/** camera framing (replaces the Camera folder values while enabled) */
distance: 13.5,
height: 1.2,
lookAtX: 0,
lookAtY: -0.15,
fov: 27,
baseYaw: 0,
basePitch: 0,
rotateYaw: 35,
rotatePitch: 10,
parallaxRange: 3,
/** world-space offset of the object (so it can sit right / left / cropped) */
objectX: 0,
objectY: 0,
objectZ: 0,
/** optional backdrop override (light variants) */
backdrop: null as null | {
top: string;
mid: string;
bottom: string;
centerX: number;
centerY: number;
radius: number;
falloff: number;
},
/** optional shape override (logo variant) */
shape: null as null | string,
/** while true the field never heals (scroll-driven erosion holds its state) */
healSuspended: false,
/** cursor erosion on/off (some variants only erode via scripted triggers) */
cursorErodes: true,
/** show the stats HUD on hero pages (off by default, the page is the point) */
hud: false,
/** Optional final camera transform used by scroll-driven camera exports after the normal object rig. */
cameraOverride: null as null | ((camera: import("three/webgpu").PerspectiveCamera) => void),
cameraProgress: 0,
/** extrusion parameters for the logo shape (hero 4); a change reloads the mark */
logoParams: null as null | Record<string, number>,
};
