Setting the file. One moment.
Frost · Frost Sequence Camera Orbit · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Three Mesh BVH LICENSE
431
function syncCamera
— line 431
This file
Number 10.33
Position 33 of 76
Type TypeScript
Size 47 KB
Lines 1,149 source/src/ frost.ts
TypeScript · 1,149 lines · 47 KB
9 import * as THREE from "three/webgpu" ;
10 import { World } from "./World" ;
11 import { MATERIAL_FEATURES, featureId } from "./ice/features" ;
12 import approvedPreset from "../presets/approved-material.json" ;
13 import sourceMaterial from "../presets/source-hero4-material.json" ;
14 import { parseTrack, applyCameraAt, type CameraTrack } from "./motion/track" ;
15 import { objectFrameAt } from "./motion/objectFrame" ;
16 export { parseTrack, cameraAt } from "./motion/track" ;
17 import { deformGeometry, resolveDeformation, type Deformation } from "./shape/deform" ;
18 import { resolveTextMeshDetail } from "./shape/textRefine" ;
19 import { resolveLogoMeshDetail } from "./shape/logoRefine" ;
20 export { validateDeformationScale } from "./shape/deform" ;
21 import { RETURN_GROUP_DEFAULTS } from "./powder/returnGroups" ;
22 import { makePoseAt, SETTLE } from "./core/motion" ;
23 import { DEFAULT_SCHEDULE, durationOf, FADE_DURATION } from "./core/schedule" ;
24 export { resolveSchedule, durationOf } from "./core/schedule" ;
25 import { D } from "./dials/store" ;
26 import { clock } from "./core/clock" ;
27 import { sim, heroFrame, input } from "./core/state" ;
28 import { rng } from "./core/seed" ;
29 import { makeShape, sampleInterior, type LogoSDF } from "./shape/sdf" ;
30 import {
31 loadLogoShapes,
32 extrudeShapes,
33 voxelize,
34 halfExtents,
35 makeLogoSDF,
36 type LogoParams,
37 } from "./shape/logo" ;
38 import { loadTypeface, textShapes, lineWidth } from "./shape/text" ;
39 import { setIceFrameIndex } from "./ice/IceMaterial" ;
40 import { ErosionField } from "./erosion/ErosionField" ;
41 import { assetUrl } from "./assets" ;
42 import { BUILD_VERSION, readBuild, writeBuild, packGeometry, unpackGeometry } from "./cache" ;
43
44 export const SIM_STEP = 1 / 60 ;
45 export const DURATION = durationOf ( DEFAULT_SCHEDULE );
46 export const REVIEW_BUILD = "frost-ice-textured-r1-reference-optics" ;
47 /** Frames re-rendered (not just simulated) before a jump target so TRAA history is converged there. */
48 const WARMUP_RENDERS = 12 ;
49
50 export interface Schedule {
51 logoBreak : number ;
52 form1 : number ;
53 break1 : number ;
54 form2 : number ;
55 break2 : number ;
56 fadeOut : number ;
57 }
58 export interface Pose {
59 turnYaw : number ;
60 turnPitch : number ;
61 approach : number ;
62 turnYaw2 : number ;
63 turnPitch2 : number ;
64 retreat : number ;
65 zoomIn : number ;
66 finalYaw : number ;
67 finalPitch : number ;
68 driftYaw : number ;
69 driftSway : number ;
70 idleYaw : number ;
71 }
72 export interface Shards {
73 amount : number ;
74 formSpread : number ;
75 formFill : number ;
76 sliceRadius : number ;
77 sliceStrength : number ;
78 shatterRadius : number ;
79 shatterStrength : number ;
80 breakDuration : number ;
81 strayDust : number ;
82 followObject : number ;
83 finalEjectBoost : number ;
84 }
85
86 export interface FrostOptions {
87 rig ?: LogoRigOptions ;
88 cameraMode ?: "original" | "authored" ;
89 cameraTrack ?: CameraTrack ;
90 canvas : HTMLCanvasElement ;
91 width : number ;
92 height : number ;
93 /** two headlines, each as lines */
94 headlines : [ string [], string []];
95 /** headline extrusion: block width (world units), line height (em), depth / bevel / corner as font-size fractions */
96 text : {
97 width : number ;
98 lineHeight : number ;
99 depth : number ;
100 bevel : number ;
101 corner : number ;
102 weight : 400 | 600 | 700 ;
103 letterSpacing : number ;
104 meshDetail ?: number ;
105 };
106 schedule : Schedule ;
107 deformation ?: Deformation ;
108 pose : Pose ;
109 shards : Shards ;
110 /** the experiment's own tunables (Erosion / Ice / Powder / Healing / Lighting / Post folders), id -> value */
111 tune : Record < string , number | string | boolean >;
112 quality : "full" | "lite" ;
113 upscaler : "fsr1" | "taau" | "bilinear" | "native" ;
114 renderScale : number ;
115 previewPixelRatio ?: number ;
116 shapeResolution ?: "128" | "256" | "384" ;
117 erosionResolution : "64" | "96" | "128" | "192" ;
118 particleCount : "100k" | "250k" | "500k" | "1M" ;
119 logoUrl : string ;
120 logoMeshDetail ?: number ;
121 fontUrl : string ;
122 onProgress ?: ( msg : string ) => void ;
123 }
124
125 /** Exposed tunables: the DialKit path in the store and whether a change alters the simulation's history. */
126 export const TUNABLES : Record < string , { path : string []; replay : boolean }> = {
127 // shards in flight
128 turbulenceScale: { path: [ "powder" , "turbulenceScale" ], replay: true },
129 clumpCohesion: { path: [ "powder" , "clumpCohesion" ], replay: true },
130 repelStrength: { path: [ "powder" , "repelStrength" ], replay: true },
131 repelRange: { path: [ "powder" , "repelRange" ], replay: true },
132 repelRadial: { path: [ "powder" , "repelRadial" ], replay: true },
133 repelRadialRange: { path: [ "powder" , "repelRadialRange" ], replay: true },
134 wrap: { path: [ "powder" , "wrap" ], replay: false },
135 turbulenceDecay: { path: [ "powder" , "turbulenceDecay" ], replay: true },
136 settledDrift: { path: [ "powder" , "settledDrift" ], replay: true },
137 tumble: { path: [ "powder" , "tumble" ], replay: false },
138 // return
139 assemblyFrontDuration: { path: [ "healing" , "assemblyFrontDuration" ], replay: true },
140 assemblyOriginX: { path: [ "healing" , "assemblyOriginX" ], replay: true },
141 assemblyOriginY: { path: [ "healing" , "assemblyOriginY" ], replay: true },
142 assemblyAngle: { path: [ "healing" , "assemblyAngle" ], replay: true },
143 assemblySpread: { path: [ "healing" , "assemblySpread" ], replay: true },
144 assemblyFrontNoise: { path: [ "healing" , "assemblyFrontNoise" ], replay: true },
145 assemblySpeedVariation: { path: [ "healing" , "assemblySpeedVariation" ], replay: true },
146 assemblyBend: { path: [ "healing" , "assemblyBend" ], replay: true },
147 assemblySwirl: { path: [ "healing" , "assemblySwirl" ], replay: true },
148 assemblyLandingVariation: { path: [ "healing" , "assemblyLandingVariation" ], replay: true },
149 returnNoiseAmount: { path: [ "healing" , "returnNoiseAmount" ], replay: true },
150 returnGroupStagger: { path: [ "healing" , "returnGroupStagger" ], replay: true },
151 returnGroupScale: { path: [ "healing" , "returnGroupScale" ], replay: true },
152 returnGroupSeed: { path: [ "healing" , "returnGroupSeed" ], replay: true },
153 formJitter: { path: [ "healing" , "waveJitter" ], replay: true },
154 waveReach: { path: [ "healing" , "waveReach" ], replay: true },
155 alignToSurface: { path: [ "healing" , "alignToSurface" ], replay: false },
156 alignCurve: { path: [ "healing" , "alignCurve" ], replay: false },
157 // lighting
158 rimAzimuth: { path: [ "lighting" , "rimAzimuth" ], replay: false },
159 rimAngle: { path: [ "lighting" , "rimAngle" ], replay: false },
160 rimSize: { path: [ "lighting" , "rimSize" ], replay: false },
161 fillReflectionStrength: { path: [ "lighting" , "fillReflectionStrength" ], replay: false },
162 accentCoolIntensity: { path: [ "lighting" , "accentCool" , "intensity" ], replay: false },
163 accentCoolReflection: { path: [ "lighting" , "accentCool" , "reflection" ], replay: false },
164 accentCoolElevation: { path: [ "lighting" , "accentCool" , "elevation" ], replay: false },
165 accentCoolAzimuth: { path: [ "lighting" , "accentCool" , "azimuth" ], replay: false },
166 accentCoolSize: { path: [ "lighting" , "accentCool" , "size" ], replay: false },
167 accentCoolColor: { path: [ "lighting" , "accentCool" , "color" ], replay: false },
168 accentWarmIntensity: { path: [ "lighting" , "accentWarm" , "intensity" ], replay: false },
169 accentWarmReflection: { path: [ "lighting" , "accentWarm" , "reflection" ], replay: false },
170 accentWarmElevation: { path: [ "lighting" , "accentWarm" , "elevation" ], replay: false },
171 accentWarmAzimuth: { path: [ "lighting" , "accentWarm" , "azimuth" ], replay: false },
172 accentWarmSize: { path: [ "lighting" , "accentWarm" , "size" ], replay: false },
173 accentWarmColor: { path: [ "lighting" , "accentWarm" , "color" ], replay: false },
174 keyColor: { path: [ "lighting" , "key" , "color" ], replay: false },
175 keyIntensity: { path: [ "lighting" , "key" , "intensity" ], replay: false },
176 keyElevation: { path: [ "lighting" , "key" , "elevation" ], replay: false },
177 keyAzimuth: { path: [ "lighting" , "key" , "azimuth" ], replay: false },
178 keySize: { path: [ "lighting" , "key" , "size" ], replay: false },
179 fill: { path: [ "lighting" , "fill" ], replay: false },
180 fillColor: { path: [ "lighting" , "fillColor" ], replay: false },
181 fillGroundColor: { path: [ "lighting" , "fillGroundColor" ], replay: false },
182 rim: { path: [ "lighting" , "rim" ], replay: false },
183 rimColor: { path: [ "lighting" , "rimColor" ], replay: false },
184 rimElevation: { path: [ "lighting" , "rimElevation" ], replay: false },
185 swayAmplitude: { path: [ "lighting" , "sway" , "amplitude" ], replay: false },
186 swayPeriod: { path: [ "lighting" , "sway" , "period" ], replay: false },
187 envSoftbox: { path: [ "lighting" , "envSoftbox" ], replay: false },
188 envRim: { path: [ "lighting" , "envRim" ], replay: false },
189 envFill: { path: [ "lighting" , "envFill" ], replay: false },
190 backdropTop: { path: [ "lighting" , "backdrop" , "top" ], replay: false },
191 backdropMid: { path: [ "lighting" , "backdrop" , "mid" ], replay: false },
192 backdropBottom: { path: [ "lighting" , "backdrop" , "bottom" ], replay: false },
193 backdropCenterX: { path: [ "lighting" , "backdrop" , "centerX" ], replay: false },
194 backdropCenterY: { path: [ "lighting" , "backdrop" , "centerY" ], replay: false },
195 backdropRadius: { path: [ "lighting" , "backdrop" , "radius" ], replay: false },
196 backdropFalloff: { path: [ "lighting" , "backdrop" , "falloff" ], replay: false },
197 backdropNoise: { path: [ "lighting" , "backdrop" , "noise" ], replay: false },
198 // ice material
199 ... Object. fromEntries (
200 Object. keys ( MATERIAL_FEATURES ). map (( name ) => [
201 featureId (name),
202 { path: [ "ice" , "features" , name], replay: false },
203 ]),
204 ),
205 materialBacklight: { path: [ "ice" , "backlight" ], replay: false },
206 materialInclusionAmount: { path: [ "ice" , "inclusionAmount" ], replay: false },
207 materialInclusionScale: { path: [ "ice" , "inclusionScale" ], replay: false },
208 materialBaseColor: { path: [ "ice" , "baseColor" ], replay: false },
209 materialInteriorFrost: { path: [ "erosion" , "interiorFrost" ], replay: false },
210 materialGrainAmount: { path: [ "erosion" , "edgeBump" ], replay: false },
211 materialGrainScale: { path: [ "erosion" , "edgeBumpScale" ], replay: false },
212 ior: { path: [ "ice" , "ior" ], replay: false },
213 dispersion: { path: [ "ice" , "dispersion" ], replay: false },
214 thicknessScale: { path: [ "ice" , "thicknessScale" ], replay: false },
215 attenuationDistance: { path: [ "ice" , "attenuationDistance" ], replay: false },
216 attenuationColor: { path: [ "ice" , "attenuationColor" ], replay: false },
217 baseRoughness: { path: [ "ice" , "baseRoughness" ], replay: false },
218 crumbleGlow: { path: [ "ice" , "crumbleGlow" ], replay: false },
219 edgeWhiteness: { path: [ "ice" , "edgeWhiteness" ], replay: false },
220 clearcoat: { path: [ "ice" , "clearcoat" ], replay: false },
221 clearcoatRoughness: { path: [ "ice" , "clearcoatRoughness" ], replay: false },
222 envIntensity: { path: [ "ice" , "envIntensity" ], replay: false },
223 specularIntensity: { path: [ "ice" , "specularIntensity" ], replay: false },
224 interiorScatter: { path: [ "ice" , "interiorScatter" ], replay: false },
225 frostScale: { path: [ "ice" , "frost" , "scale" ], replay: false },
226 frostThreshold: { path: [ "ice" , "frost" , "threshold" ], replay: false },
227 frostSoftness: { path: [ "ice" , "frost" , "softness" ], replay: false },
228 frostRoughness: { path: [ "ice" , "frost" , "roughness" ], replay: false },
229 frostDiffuse: { path: [ "ice" , "frost" , "diffuse" ], replay: false },
230 crystalBump: { path: [ "ice" , "frost" , "crystalBump" ], replay: false },
231 crystalScale: { path: [ "ice" , "frost" , "crystalScale" ], replay: false },
232 crackLargeScale: { path: [ "ice" , "cracks" , "largeScale" ], replay: false },
233 crackWarp: { path: [ "ice" , "cracks" , "warp" ], replay: false },
234 crackWarpScale: { path: [ "ice" , "cracks" , "warpScale" ], replay: false },
235 crackCoverage: { path: [ "ice" , "cracks" , "coverage" ], replay: false },
236 crackRegionScale: { path: [ "ice" , "cracks" , "regionScale" ], replay: false },
237 crackRegionCoverage: { path: [ "ice" , "cracks" , "regionCoverage" ], replay: false },
238 veinScale: { path: [ "ice" , "cracks" , "veinScale" ], replay: false },
239 veinContrast: { path: [ "ice" , "cracks" , "veinContrast" ], replay: false },
240 crackWidth: { path: [ "ice" , "cracks" , "width" ], replay: false },
241 crackBrightness: { path: [ "ice" , "cracks" , "brightness" ], replay: false },
242 crackDarkness: { path: [ "ice" , "cracks" , "darkness" ], replay: false },
243 crackRefraction: { path: [ "ice" , "cracks" , "refraction" ], replay: false },
244 crackSurfaceStrength: { path: [ "ice" , "cracks" , "surfaceStrength" ], replay: false },
245 fineScale: { path: [ "ice" , "cracks" , "fineScale" ], replay: false },
246 fineAmount: { path: [ "ice" , "cracks" , "fineAmount" ], replay: false },
247 fineCoverage: { path: [ "ice" , "cracks" , "fineCoverage" ], replay: false },
248 smudgeAmount: { path: [ "ice" , "smudges" , "amount" ], replay: false },
249 smudgeCoverage: { path: [ "ice" , "smudges" , "coverage" ], replay: false },
250 smudgeMaskScale: { path: [ "ice" , "smudges" , "maskScale" ], replay: false },
251 smudgeAnisotropy: { path: [ "ice" , "smudges" , "anisotropy" ], replay: false },
252 smudgeRoughness: { path: [ "ice" , "smudges" , "roughness" ], replay: false },
253 smudgeWhiteness: { path: [ "ice" , "smudges" , "whiteness" ], replay: false },
254 smudgeScale: { path: [ "ice" , "smudges" , "scale" ], replay: false },
255 microBump: { path: [ "ice" , "bumps" , "microBump" ], replay: false },
256 microScale: { path: [ "ice" , "bumps" , "microScale" ], replay: false },
257 microCoverage: { path: [ "ice" , "bumps" , "microCoverage" ], replay: false },
258 rippleBump: { path: [ "ice" , "bumps" , "rippleBump" ], replay: false },
259 rippleScale: { path: [ "ice" , "bumps" , "rippleScale" ], replay: false },
260 bumpMaskScale: { path: [ "ice" , "bumps" , "maskScale" ], replay: false },
261 // shard look
262 baseTone: { path: [ "powder" , "baseTone" ], replay: false },
263 fragTranslucency: { path: [ "powder" , "fragments" , "translucency" ], replay: false },
264 fragThroughTint: { path: [ "powder" , "fragments" , "throughTint" ], replay: false },
265 fragFresnelPower: { path: [ "powder" , "fragments" , "fresnelPower" ], replay: false },
266 fragRoughness: { path: [ "powder" , "fragments" , "roughness" ], replay: false },
267 fragClearcoat: { path: [ "powder" , "fragments" , "clearcoat" ], replay: false },
268 fragSpecular: { path: [ "powder" , "fragments" , "specular" ], replay: false },
269 sparkle: { path: [ "powder" , "fragments" , "sparkle" ], replay: false },
270 sparkleFraction: { path: [ "powder" , "fragments" , "sparkleFraction" ], replay: false },
271 spriteTilt: { path: [ "powder" , "sprites" , "tilt" ], replay: false },
272 spriteNormal: { path: [ "powder" , "sprites" , "normalStrength" ], replay: false },
273 spriteFrost: { path: [ "powder" , "sprites" , "frostFromAtlas" ], replay: false },
274 spriteFrostBoost: { path: [ "powder" , "sprites" , "frostBoost" ], replay: false },
275 spriteFrostRoughness: { path: [ "powder" , "sprites" , "frostRoughness" ], replay: false },
276 spriteEdgeLight: { path: [ "powder" , "sprites" , "edgeLight" ], replay: false },
277 spriteAlphaCut: { path: [ "powder" , "sprites" , "alphaCut" ], replay: false },
278 spriteSeeThrough: { path: [ "powder" , "sprites" , "seeThrough" ], replay: false },
279 // post
280 bloomThreshold: { path: [ "post" , "bloom" , "threshold" ], replay: false },
281 bloomIntensity: { path: [ "post" , "bloom" , "intensity" ], replay: false },
282 bloomRadius: { path: [ "post" , "bloom" , "radius" ], replay: false },
283 monochrome: { path: [ "post" , "monochrome" ], replay: false },
284 tonemap: { path: [ "post" , "tonemap" ], replay: false },
285 exposure: { path: [ "post" , "exposure" ], replay: false },
286 contrast: { path: [ "post" , "contrast" ], replay: false },
287 blackLift: { path: [ "post" , "blackLift" ], replay: false },
288 vignetteStrength: { path: [ "post" , "vignette" , "strength" ], replay: false },
289 vignetteSoftness: { path: [ "post" , "vignette" , "softness" ], replay: false },
290 vignetteRadius: { path: [ "post" , "vignette" , "radius" ], replay: false },
291 grainStrength: { path: [ "post" , "grain" , "strength" ], replay: false },
292 cutThreshold: { path: [ "erosion" , "cutThreshold" ], replay: false },
293 cutSoftness: { path: [ "erosion" , "cutSoftness" ], replay: false },
294 edgeWidth: { path: [ "erosion" , "edgeWidth" ], replay: false },
295 edgeInset: { path: [ "erosion" , "edgeInset" ], replay: false },
296 brushSoftness: { path: [ "erosion" , "brushSoftness" ], replay: true },
297 brushNoise: { path: [ "erosion" , "brushNoise" ], replay: true },
298 crumbleRate: { path: [ "erosion" , "crumbleRate" ], replay: true },
299 crumbleCrackBias: { path: [ "erosion" , "crumbleCrackBias" ], replay: true },
300 crumbleDuration: { path: [ "erosion" , "crumbleDuration" ], replay: true },
301 ejectSpeed: { path: [ "powder" , "ejectSpeed" ], replay: true },
302 ejectSpread: { path: [ "powder" , "ejectSpread" ], replay: true },
303 ejectTurbulence: { path: [ "powder" , "ejectTurbulence" ], replay: true },
304 drag: { path: [ "powder" , "drag" ], replay: true },
305 gravity: { path: [ "powder" , "gravity" ], replay: true },
306 turbulence: { path: [ "powder" , "turbulence" ], replay: true },
307 settleTime: { path: [ "powder" , "settleTime" ], replay: true },
308 maxSpeed: { path: [ "powder" , "maxSpeed" ], replay: true },
309 minPixelSize: { path: [ "powder" , "minPixelSize" ], replay: false },
310 grainSizeMultiplier: { path: [ "powder" , "grainSizeMultiplier" ], replay: false },
311 spriteSize: { path: [ "powder" , "sprites" , "sizeScale" ], replay: false },
312 returnSpring: { path: [ "healing" , "returnSpring" ], replay: true },
313 returnDamping: { path: [ "healing" , "returnDamping" ], replay: true },
314 returnRamp: { path: [ "healing" , "returnRamp" ], replay: true },
315 returnMaxSpeed: { path: [ "healing" , "returnMaxSpeed" ], replay: true },
316 healRate: { path: [ "healing" , "healRate" ], replay: true },
317 cellRestore: { path: [ "healing" , "cellRestore" ], replay: true },
318 landedFade: { path: [ "healing" , "landedFade" ], replay: true },
319 refrostTime: { path: [ "healing" , "refrostTime" ], replay: true },
320 };
321
322 export interface FrostInstance {
323 ready : Promise < void >;
324 renderAt ( t : number ) : void ;
325 redraw () : void ;
326 invalidate () : void ;
327 waitForGpu () : Promise < void >;
328 poseAt ( t : number ) : { yaw : number ; pitch : number ; z : number };
329 /**
330 * Apply new option values in place. 'live': the current frame was updated (re-simulated from 0 when the
331 * change alters history); 'rebuild': the change needs a full rebuild (shapes, quality, resolution).
332 */
333 applyOptions (
334 next : Pick <
335 FrostOptions ,
336 "schedule" | "pose" | "shards" | "tune" | "cameraMode" | "cameraTrack"
337 >,
338 structuralKey : string ,
339 ) : "live" | "rebuild" ;
340 readonly structuralKey : string ;
341 readonly world : World | null ;
342 /** index of the shape currently in the live field: 0 mark, 1 headline 1, 2 headline 2 */
343 readonly shape : number ;
344 dispose () : void ;
345 }
346
347 /** Approved authored appearance, also used when a direct runtime caller supplies partial tunables. */
348 export const APPEARANCE_DEFAULTS : Readonly < Record < string , number | string | boolean >> =
349 Object. freeze (
350 Object. fromEntries (
351 Object. entries ( TUNABLES )
352 . filter (
353 ([ id , spec ]) =>
354 id. startsWith ( "material" ) ||
355 [ "ice" , "lighting" , "post" ]. includes (spec.path[ 0 ]) ||
356 [ "baseTone" , "wrap" , "sparkle" , "sparkleFraction" ]. includes (id) ||
357 id. startsWith ( "frag" ) ||
358 id. startsWith ( "sprite" ),
359 )
360 . map (([ id ]) => [id, (approvedPreset as Record < string , number | string | boolean >)[id]]),
361 ),
362 );
363
364 /** hero 07 auto-sweep path (object space, fractions of the bound) */
365 const SLICE_FROM : [ number , number , number ] = [ - 1 , 0.3 , 0 ],
366 SLICE_TO : [ number , number , number ] = [ 1 , - 0.3 , 0 ];
367
368 /** everything that changes the built shapes, the world, or the render pipeline */
369 export function structuralKeyOf (
370 o : Pick <
371 FrostOptions ,
372 | "headlines"
373 | "text"
374 | "quality"
375 | "upscaler"
376 | "renderScale"
377 | "erosionResolution"
378 | "shapeResolution"
379 | "particleCount"
380 | "logoUrl"
381 | "deformation"
382 | "logoMeshDetail"
383 > & {
384 shards : { strayDust : number ; amount : number };
385 tune : Record < string , number | string | boolean >;
386 },
387 ) {
388 // dispersion and sprite see-through are compiled into the materials when non-zero (World key)
389 return JSON . stringify ([
390 o.headlines,
391 { ... o.text, meshDetail: resolveTextMeshDetail (o.text.meshDetail) },
392 resolveDeformation (o.deformation),
393 o.quality,
394 o.upscaler,
395 o.renderScale,
396 o.shapeResolution ?? "256" ,
397 o.erosionResolution,
398 o.particleCount,
399 o.logoUrl,
400 resolveLogoMeshDetail (o.logoMeshDetail),
401 o.shards.strayDust,
402 Number (o.tune.spriteSeeThrough ?? APPEARANCE_DEFAULTS .spriteSeeThrough) > 0 ,
403 ]);
404 }
405
406 export function create ( o : FrostOptions ) : FrostInstance {
407 o = {
408 ... o,
409 logoMeshDetail: resolveLogoMeshDetail (o.logoMeshDetail),
410 text: { ... o.text, meshDetail: resolveTextMeshDetail (o.text.meshDetail) },
411 };
412 let world : World | null = null ;
413 let renderer : THREE . WebGPURenderer | null = null ;
414 let disposed = false ;
415 let simIndex = - 1 ;
416 let renderedIndex = - 1 ;
417 let lastTime = 0 ;
418 let pendingGpu : Promise < void > | null = null ;
419 let requestedIndex = 0 ;
420 let seekError : unknown = null ;
421 const log = ( m : string ) => {
422 rwMetrics.stages. push ([m, performance. now () - rwMetrics.start]);
423 o. onProgress ?.(m);
424 };
425 let S = o.schedule,
426 P = o.pose,
427 SH = o.shards;
428 let cameraTrack = parseTrack (o.cameraTrack);
429 const authored = () => o.cameraMode === "authored" && cameraTrack.keys. length > 0 ;
430 const stationary = () => authored () && cameraTrack.objectMotion === "stationary" ;
431 function syncCamera () {
432 if ( ! world) return ;
433 world.onCameraTransform = authored ()
434 ? ( t ) => {
435 if (world)
436 applyCameraAt (
437 world.camera,
438 cameraTrack,
439 t,
440 world.objectGroup.matrixWorld,
441 world.rig.lookAt,
442 );
443 }
444 : null ;
445 if ( ! authored ()) {
446 world.camera.zoom = 1 ;
447 world.camera. updateProjectionMatrix ();
448 }
449 }
450 const structuralKey = structuralKeyOf (o);
451
452 // shapes: [logo, headline 1, headline 2] — geometries for the mesh, distance fields sharing one bound, and
453 // the live field texture the GPU reads (its data is overwritten on every retarget)
454 const geos : THREE . BufferGeometry [] = [];
455 const sdfs : LogoSDF [] = [];
456 /** every grain's home inside each shape (N x vec4: xyz + the grain's own threshold), computed once */
457 const restSets : Float32Array [] = [];
458 let live : LogoSDF | null = null ;
459 let current = - 1 ;
460 let logoRig : LogoRig | null = null ;
461
462 // ---------------------------------------------------------------------------------------------
463 const setPath = ( path : string [], value : number | string | boolean ) => {
464 let t : any = D ;
465 for ( let i = 0 ; i < path. length - 1 ; i ++ ) t = t[path[i]];
466 t[path[path. length - 1 ]] = value;
467 };
468 function applyTune ( tune : Record < string , number | string | boolean >) {
469 for ( const [ id , v ] of Object. entries ({
470 ... APPEARANCE_DEFAULTS ,
471 ... RETURN_GROUP_DEFAULTS ,
472 ... tune,
473 })) {
474 const spec = TUNABLES [id];
475 if (spec && ( typeof v === "string" || typeof v === "boolean" || Number. isFinite (v)))
476 setPath (spec.path, v);
477 }
478 }
479
480 // hero 07 / baked hero4 override layer (variants.tsx CameraJourney + baked.json hero4), minus the camera export
481 function configure () {
482 heroFrame.enabled = true ;
483 heroFrame.clickResets = true ;
484 heroFrame.shape = "logo" ;
485 heroFrame.cursorErodes = true ;
486 heroFrame.hud = false ;
487 heroFrame.framing = true ;
488 // the object sits on the look-at point (hero4 raised it above a DOM headline; there is no DOM here)
489 Object. assign (heroFrame, {
490 distance: 13.45 ,
491 height: 1.2 ,
492 lookAtX: 0 ,
493 lookAtY: 0.05 ,
494 fov: 29.5 ,
495 objectX: 0 ,
496 objectY: 0.05 ,
497 objectZ: 0 ,
498 baseYaw: 0 ,
499 basePitch: 0 ,
500 rotateYaw: 40 ,
501 rotatePitch: 45 ,
502 parallaxRange: 15 ,
503 });
504 heroFrame.cameraOverride = null ;
505 D .shape.deformAmplitude = 0 ; // geometric deformation is baked before SDF creation, never applied twice
506 D .camera.idleAmplitude = 0 ; // the composition owns the object's rotation
507 const Pf = D .performance;
508 Pf.adaptiveResolution = false ;
509 Pf.dynamicSceneResolution = false ;
510 Pf.sceneResolutionScale = Math. max ( 0.25 , Math. min ( 1 , o.renderScale));
511 Pf.minSceneResolutionScale = Pf.sceneResolutionScale;
512 Pf.upscaler = o.upscaler;
513 Pf.nativePostEffects = true ;
514 Pf.upscaleSharpness = 1 ;
515 Pf.minPixelRatio = o.previewPixelRatio ?? 2 ;
516 D .post.pixelRatioCap = o.previewPixelRatio ?? 2 ;
517 Pf.idleSkip = false ;
518 Pf.gpuTimers = false ;
519 D .debug.stats = false ;
520 D .debug.freeze = false ;
521 D .debug.forceStroke = false ;
522 D .shape.logo.sdfRes = [ 128 , 256 , 384 ]. includes ( Number (o.shapeResolution))
523 ? Number (o.shapeResolution)
524 : 256 ;
525 D .erosion.resolution = o.erosionResolution;
526 D .powder.particleCount = o.particleCount;
527 D .powder.amount = SH .amount;
528 D .powder.lostRadius = 80 ; // a shard 30 units out was snapped home mid-flight; the break throws them further than that
529 D .powder.strayCount = Math. round ( SH .strayDust); // the experiment's ambient dust ring stays where the object was; off by default
530 // shards keep following the object group's motion (rotation and approach) for this long after they leave: the
531 // experiment used 0.4 s, which made the cloud stop turning while the mark kept turning
532 D .powder.inheritRotation = true ;
533 D .powder.inheritTime = SH .followObject * 1000 ;
534 D .ice = { ... structuredClone (sourceMaterial.ice), features: { ... MATERIAL_FEATURES } };
535 D .lighting = structuredClone (sourceMaterial.lighting);
536 D .post = structuredClone (sourceMaterial.post);
537 D .post.pixelRatioCap = o.previewPixelRatio ?? 2 ;
538 D .post.grain.backgroundStrength = 0 ;
539 D .powder.hazeIntensity = 0 ;
540 D .ice.cracks.steps = 18 ;
541 D .erosion.interiorSteps = 48 ;
542 Pf.adaptiveSteps = sourceMaterial.performance.adaptiveSteps;
543 applyTune (o.tune);
544 if (o.quality === "lite" ) {
545 // Mirrors defaults.ts `if (LITE)`: headless / software-GPU checks shrink everything heavy after the tuned values
546 D .shape.segments = 96 ;
547 D .ice.cracks.steps = 6 ;
548 D .ice.cracks.fineCracks = false ; // Lite reduces cost without overriding the explicit dispersion control.
549 D .erosion.resolution = "64" ;
550 D .powder.particleCount = "100k" ;
551 D .powder.densityGrid = "32" ;
552 D .powder.hazeSteps = 8 ;
553 }
554 healOff ();
555 }
556 /** shards stay out: nothing schedules a return (baked healDelay 0 / returnAfter 3 would pull them back at once) */
557 function healOff () {
558 D .healing.healDelay = 1e9 ;
559 D .healing.returnAfter = 0 ;
560 D .healing.waveTime = 0 ;
561 D .version ++ ;
562 }
563 /** shards fly home in a wave (nearest first), the field fills in behind them */
564 function healOn () {
565 D .healing.healDelay = 0 ;
566 D .healing.returnAfter = 0 ;
567 D .healing.waveTime = SH .formSpread;
568 D .healing.fallbackHeal = SH .formFill;
569 D .version ++ ;
570 }
571
572 // ---------------------------------------------------------------------------------------------
573 const ready = ( async () => {
574 await new Promise (( resolve ) => setTimeout (resolve, 250 ));
575 if (disposed) return ;
576 const nav = navigator as Navigator & { gpu ?: { requestAdapter () : Promise < unknown > } };
577 if ( ! nav.gpu) throw new Error ( "Frost: navigator.gpu is unavailable (WebGPU required)" );
578 const adapter = await nav.gpu. requestAdapter (). catch (() => null );
579 if ( ! adapter) throw new Error ( "Frost: no WebGPU adapter" );
580 log ( "webgpu adapter" );
581 configure ();
582
583 const r = new THREE . WebGPURenderer ({
584 canvas: o.canvas,
585 antialias: false ,
586 forceWebGL: false ,
587 powerPreference: "high-performance" ,
588 trackTimestamp: false ,
589 } as any );
590 await r. init ();
591 if ((r as any ).backend?.isWebGLBackend)
592 throw new Error ( "Frost: WebGPU backend unavailable, WebGL fallback is not supported" );
593 r.toneMapping = THREE .NoToneMapping;
594 r.outputColorSpace = THREE .SRGBColorSpace;
595 r.shadowMap.enabled = D .lighting.shadow.intensity > 0 ; // baked 0: no shadow pass, no visible difference
596 r.shadowMap.type = THREE .VSMShadowMap;
597 r. setPixelRatio ( 1 );
598 r. setSize (o.width, o.height, false );
599 renderer = r;
600 log ( "renderer" );
601
602 const scene = new THREE . Scene ();
603 const camera = new THREE . PerspectiveCamera ( 30 , o.width / o.height, 0.5 , 80 );
604 camera.position. set ( 0 , 1.2 , 13.5 );
605
606 const logoParams = D .shape.logo as unknown as LogoParams ;
607 const Pd = D .powder;
608 const count =
609 { "100k" : 100_000 , "250k" : 250_000 , "500k" : 500_000 , "1M" : 1_000_000 }[
610 Pd.particleCount as string
611 ] ?? 100_000 ;
612 const deformation = resolveDeformation (o.deformation);
613 const logoOnly = !! o.rig && ! o.rig.sequence;
614 const cacheKey = JSON . stringify ([
615 BUILD_VERSION ,
616 logoOnly,
617 deformation,
618 o.headlines,
619 o.text,
620 o.logoUrl,
621 o.logoMeshDetail,
622 logoParams,
623 count,
624 Pd.nearSurfaceFraction,
625 Pd.nearSurfaceDepth,
626 D .shape.seed,
627 D .shape.size,
628 ]);
629 const blueNoise = await new THREE . TextureLoader ()
630 . loadAsync ( assetUrl ( "textures/bluenoise64.png" ))
631 . then (( tex ) => {
632 tex.wrapS = tex.wrapT = THREE .RepeatWrapping;
633 tex.minFilter = tex.magFilter = THREE .NearestFilter;
634 tex.generateMipmaps = false ;
635 tex.colorSpace = THREE .NoColorSpace;
636 return tex;
637 });
638 const fractureDetail = await new THREE . TextureLoader ()
639 . loadAsync ( assetUrl ( "textures/ice-inclusions-generated.png" ))
640 . then (( tex ) => {
641 tex.wrapS = tex.wrapT = THREE .MirroredRepeatWrapping;
642 tex.minFilter = THREE .LinearMipmapLinearFilter;
643 tex.magFilter = THREE .LinearFilter;
644 tex.generateMipmaps = true ;
645 tex.colorSpace = THREE .NoColorSpace;
646 return tex;
647 });
648 let bound = 0 ;
649 const cached = new URLSearchParams (location.search). has ( "fresh" )
650 ? null
651 : await readBuild (cacheKey). catch (() => null );
652 if (
653 cached &&
654 cached.geometries. length === (logoOnly ? 1 : 3 ) &&
655 cached.sdfs. length === (logoOnly ? 1 : 3 ) &&
656 cached.rests. length === (logoOnly ? 0 : 2 )
657 ) {
658 for ( const g of cached.geometries) geos. push ( unpackGeometry (g));
659 for ( const s of cached.sdfs)
660 sdfs. push ( makeLogoSDF (s.data, s.res, s.bound, s.thickness, s.sampleBound));
661 bound = cached.sdfs[ 0 ].bound;
662 log ( "shapes (cached)" );
663 } else {
664 const fontUrl = o.fontUrl. replace (
665 /Geist- [A-Za-z] + \. ttf $ / ,
666 `Geist-${ o . text . weight === 400 ? "Regular" : o . text . weight === 600 ? "SemiBold" : "Bold"}.ttf` ,
667 );
668 const [ logoShapes , font ] = await Promise . all ([
669 loadLogoShapes (o.logoUrl, logoParams),
670 logoOnly ? Promise . resolve ( null ) : loadTypeface (fontUrl),
671 ]);
672 if (disposed) return ;
673 if ( ! logoShapes. length ) throw new Error ( "Frost: the logo SVG produced no shapes" );
674 log ( "assets" );
675 // geometries: the mark exactly as the experiment builds it; the headlines through the same extrusion with
676 // depth / bevel / corner relative to the font size and far fewer curve samples (glyph outlines are curves)
677 geos. push ( extrudeShapes (logoShapes, logoParams, logoParams.width));
678 const textParams : LogoParams = {
679 ... logoParams,
680 depth: o.text.depth,
681 bevelThickness: o.text.bevel,
682 bevelSize: o.text.bevel * 0.8 ,
683 bevelOffset: 0 ,
684 cornerRadius: o.text.corner,
685 curveSegments: 10 ,
686 bevelSegments: 4 ,
687 };
688 for ( const lines of logoOnly ? [] : o.headlines) {
689 const clean = lines. map (( l ) => l. trim ()). filter (Boolean);
690 if ( ! clean. length ) clean. push ( " " );
691 const widest = Math. max (
692 0.001 ,
693 ... clean. map (( l ) => lineWidth (font, l, o.text.letterSpacing)),
694 );
695 const size = o.text.width / widest;
696 geos. push (
697 extrudeShapes (
698 textShapes (font, clean, size, o.text.lineHeight, o.text.letterSpacing),
699 textParams,
700 size,
701 ),
702 );
703 }
704 // Bake once before voxelization: render mesh, erosion SDF and all shard homes share this surface.
705 for ( let i = 0 ; i < geos. length ; i ++ ) {
706 const source = geos[i];
707 log (i === 0 ? "logo geometry" : `text mesh ${ i }/2 · detail ${ o . text . meshDetail }` );
708 await new Promise (( resolve ) => setTimeout (resolve, 0 ));
709 if (disposed) return ;
710 geos[i] = deformGeometry (
711 source,
712 deformation,
713 logoParams.creaseAngle,
714 i === 0 ? undefined : o.text.meshDetail,
715 i === 0 ? o.logoMeshDetail : undefined ,
716 );
717 if (i > 0 )
718 log ( `text mesh ${ i }/2 · ${ geos [ i ]. getAttribute ( "position" ). count / 3 } triangles` );
719 await new Promise (( resolve ) => setTimeout (resolve, 0 ));
720 if (geos[i] !== source) source. dispose ();
721 }
722 for ( const g of geos) {
723 const h = halfExtents (g);
724 bound = Math. max (bound, h.x, h.y, h.z);
725 }
726 bound *= 1.15 ;
727 for ( const g of geos) {
728 const h = halfExtents (g);
729 const sampleBound = Math. max (h.x, h.y, h.z) * 1.15 ;
730 sdfs. push (
731 RW .gpu
732 ? await voxelizeGPU (r, g, bound, logoParams.sdfRes, sampleBound)
733 : voxelize (g, bound, logoParams.sdfRes, sampleBound),
734 );
735 await new Promise (( res ) => setTimeout (res, 0 ));
736 }
737 if (disposed) return ;
738 log ( "shapes" );
739 }
740 // the live field is its own copy: retargeting overwrites it, never the per-shape sources
741 live = makeLogoSDF (
742 sdfs[ 0 ].data. slice (),
743 sdfs[ 0 ].res,
744 bound,
745 sdfs[ 0 ].thickness,
746 sdfs[ 0 ].sampleDomain.value,
747 );
748 // the erodable shell just outside the surface must cover the distance field's own error at any field resolution
749 ErosionField.extraShell = (( 2 * bound) / logoParams.sdfRes) * 2.0 ;
750
751 // mesh geometries the way World.build makes them (a scaled clone)
752 const size = D .shape.size;
753 const rawGeos = geos. slice ();
754 for ( let i = 0 ; i < geos. length ; i ++ ) {
755 const g = geos[i]. clone ();
756 g. scale (size, size, size);
757 geos[i] = g;
758 }
759
760 world = new World (
761 r,
762 scene,
763 camera,
764 blueNoise,
765 { sdf: live, geometry: geos[ 0 ] },
766 fractureDetail,
767 );
768 world. onObjectTransform = ( t ) => {
769 if ( ! world) return ;
770 if (logoRig) {
771 logoRig. pose (t);
772 return ;
773 }
774 const frame = objectFrameAt (t, S , P , current, stationary ());
775 world.motionGroup.quaternion. copy (frame.rotation);
776 world.motionGroup.position. copy (frame.position);
777 world.objectGroup.quaternion. copy (frame.facing);
778 };
779 current = 0 ;
780 syncCamera ();
781 // grain homes per shape: the mark keeps the ones the powder was built with; a headline is sampled inside its
782 // own box with the same class layout (thresholds and strays untouched)
783 const pw = world.powder;
784 restSets. push (pw.restInit. slice ());
785 if (cached && cached.rests. length === 2 ) {
786 for ( const cr of cached.rests) {
787 const rest = pw.restInit. slice ();
788 rest. set (cr. subarray ( 0 , pw.count * 4 ));
789 restSets. push (rest);
790 }
791 } else {
792 for ( let k = 1 ; k < geos. length ; k ++ ) {
793 const spec = makeShape ( "logo" , D .shape.size, D .shape.tubeRatio, sdfs[k]);
794 const h = halfExtents (rawGeos[k]);
795 const samples = sampleInterior (
796 spec,
797 pw.count,
798 Pd.nearSurfaceFraction,
799 Pd.nearSurfaceDepth,
800 rng ( D .shape.seed + 101 + k),
801 [(h.x + 0.05 ) * size, (h.y + 0.05 ) * size, (h.z + 0.05 ) * size],
802 );
803 const rest = pw.restInit. slice ();
804 for ( let i = 0 ; i < pw.count; i ++ ) {
805 rest[i * 4 ] = samples.positions[i * 3 ];
806 rest[i * 4 + 1 ] = samples.positions[i * 3 + 1 ];
807 rest[i * 4 + 2 ] = samples.positions[i * 3 + 2 ];
808 }
809 restSets. push (rest);
810 await new Promise (( res ) => setTimeout (res, 0 ));
811 }
812 if ( !new URLSearchParams (location.search). has ( "fresh" ))
813 writeBuild (cacheKey, {
814 geometries: rawGeos. map (packGeometry),
815 sdfs: sdfs. map (( s ) => ({
816 data: s.data,
817 res: s.res,
818 bound: s.bound,
819 thickness: s.thickness,
820 sampleBound: s.sampleDomain.value,
821 })),
822 rests: restSets. slice ( 1 ). map (( r ) => r. slice ( 0 , pw.count * 4 )),
823 }). catch (() => {});
824 }
825 if (o.rig) logoRig = new LogoRig (world, { ... o.rig, retarget : ( k ) => retarget (k, true ) });
826 (window as any ).__fb = { world, renderer: r, D, sim, clock };
827 // draw frame 0 now so every pipeline compiles inside the readiness gate, not at the first visible seek
828 for ( const f of [ 0.25 , 0.5 , 0.75 , 0 ]) {
829 renderAt (f * durationOf ( S ));
830 while (pendingGpu) await pendingGpu;
831 }
832 log ( "world" );
833 })();
834
835 // object motion: one continuous path. yaw(t) = constant drift + idle sway + a smooth turn step per break
836 // (velocity/acceleration matched at boundaries); pitch and depth rise through a break, ease back during formation.
837 // A headline faces the camera by construction: its object frame carries the inverse of this rotation when its
838 // formation settles (see retarget), so the group keeps turning and the text still lands square.
839 let poseAt = makePoseAt ( S , P );
840 function rebuildKeys () {
841 poseAt = makePoseAt ( S , P );
842 }
843 /** rotation of the motion group at time t */
844 const rotationAt = ( t : number ) => {
845 const p = poseAt (t);
846 return new THREE . Quaternion (). setFromEuler (
847 new THREE . Euler ( THREE .MathUtils. degToRad (p.pitch), THREE .MathUtils. degToRad (p.yaw), 0 , "YXZ" ),
848 );
849 };
850 /** facing offset for shape k: the inverse of the group rotation when its formation has settled */
851 const facingOffset = new THREE . Quaternion ();
852 function offsetFor ( k : number ) {
853 if (k === 0 || stationary ()) return new THREE . Quaternion ();
854 const t = k === 1 ? S .form1 + SETTLE : S .form2 + SETTLE ;
855 return rotationAt (t). invert ();
856 }
857
858 // ---------------------------------------------------------------------------------------------
859 // shape retargeting: the live field texture takes shape k's distances, the mesh takes its geometry, the field
860 // is re-baked and starts fully eroded (a headline) or solid (the mark), and every grain gets a home inside it
861 function retarget ( k : number , fill : boolean ) {
862 if ( ! world || ! renderer || ! live) return ;
863 const src = sdfs[k];
864 if (k !== current) {
865 live.data. set (src.data);
866 live.sampleDomain.value = src.sampleDomain.value;
867 if (live.sampleBoundNode) live.sampleBoundNode.value = src.sampleDomain.value;
868 live.texture.needsUpdate = true ;
869 live.thickness = src.thickness;
870 world.shape = makeShape ( "logo" , D .shape.size, D .shape.tubeRatio, live);
871 world.mesh.geometry = geos[k];
872 current = k;
873 }
874 // the new shape faces the camera once its formation settles, whatever the group is doing: apply the inverse
875 // rotation now (nothing is visible), and make sure the jump is not read as one frame of object motion
876 facingOffset. copy (o.rig ? new THREE . Quaternion () : offsetFor (k));
877 world.objectGroup.quaternion. copy (facingOffset);
878 world.motionGroup. updateMatrixWorld ( true );
879 world.powder. syncModel ();
880 world.erosion. rebake (renderer, fill);
881 world.powder. retarget (renderer, restSets[k]);
882 }
883 /** the diagonal slice through the shape (hero 07 auto sweep) */
884 function slice ( final = false ) {
885 if ( ! world || ! renderer) return ;
886 healOff ();
887 world.erosion.u.reconstruct.value = 0 ;
888 D .powder.repelFromObject = true ; // the shape is solid: shards are pushed out of it while it breaks
889 // the last break clears the frame: shards leave faster and the speed cap goes up with them
890 const boost = final ? SH .finalEjectBoost : 1 ;
891 D .powder.ejectSpeed = Number (o.tune.ejectSpeed) * boost;
892 D .powder.maxSpeed = Number (o.tune.maxSpeed) * boost;
893 D .powder.minEjectSpeed = 0.66 * boost;
894 D .version ++ ;
895 world.powder. restoreThresholds (renderer);
896 world.interaction. sweep ({
897 from: SLICE_FROM ,
898 to: SLICE_TO ,
899 duration: 0.7 ,
900 radius: SH .sliceRadius,
901 strength: SH .sliceStrength,
902 });
903 }
904 /** the break continues from the slice: parallel slices march outward on alternating sides, one after another,
905 * spaced so they overlap, spread over `breakDuration` */
906 function shatter () {
907 if ( ! world) return ;
908 const B = world.shape.bound;
909 const dx = SLICE_TO [ 0 ] - SLICE_FROM [ 0 ],
910 dy = SLICE_TO [ 1 ] - SLICE_FROM [ 1 ],
911 len = Math. hypot (dx, dy);
912 const nx = - dy / len,
913 ny = dx / len;
914 const spacing = Math. max ( 0.15 , SH .shatterRadius * 1.7 ) / B ; // in bound fractions
915 const perSide = Math. max ( 1 , Math. ceil ( 1.25 / spacing));
916 const interval = SH .breakDuration / (perSide * 2 );
917 let i = 0 ;
918 for ( let k = 1 ; k <= perSide; k ++ ) {
919 for ( const side of [ 1 , - 1 ]) {
920 const off = side * k * spacing;
921 world.interaction. sweep ({
922 from: [ SLICE_FROM [ 0 ] + nx * off, SLICE_FROM [ 1 ] + ny * off, 0 ],
923 to: [ SLICE_TO [ 0 ] + nx * off, SLICE_TO [ 1 ] + ny * off, 0 ],
924 duration: 0.5 ,
925 delay: i * interval,
926 radius: SH .shatterRadius,
927 strength: SH .shatterStrength,
928 });
929 i ++ ;
930 }
931 }
932 }
933 /** whatever the front missed: the field is set fully eroded and the last dormant grains leave too */
934 function finishShatter () {
935 if ( ! world || ! renderer) return ;
936 renderer. compute ((world.erosion as any ).fillNode);
937 // nothing is left to repel from: with it on, the invisible shape's pockets and hole trap shards
938 D .powder.repelFromObject = false ;
939 D .version ++ ;
940 }
941 function form ( k : number ) {
942 if ( ! world) return ;
943 world.powder.u.assemblyEnd.value = (k === 1 ? S .break1 : S .break2) - 0.65 ;
944 retarget (k, true );
945 sim.lastStrokeT = clock.t; // the fallback fill-in waits for the wave to land (ErosionField.step)
946 healOn ();
947 }
948 function startFade () {
949 if ( ! world) return ;
950 healOff ();
951 world.mesh.visible = false ;
952 D .healing.resetFade = FADE_DURATION * 1000 ;
953 sim.resetRequestedAt = clock.t;
954 }
955
956 function reset () {
957 if ( ! world) return ;
958 renderedIndex = - 1 ;
959 D .powder.repelFromObject = true ;
960 world. resetSim ();
961 world.interaction. clearScripted ();
962 facingOffset. identity ();
963 retarget ( 0 , false );
964 // A sequence may have left another shape's rest positions in the buffer.
965 // Reset positions after restoring the logo homes, including invisible grains.
966 if (o.rig?.sequence && renderer) world.powder. reset (renderer);
967 healOff ();
968 D .healing.resetFade = 50 ;
969 input.inside = false ;
970 input.x = 0 ;
971 input.y = 0 ;
972 input.lastMoveT = 0 ;
973 input.moved = false ;
974 logoRig?. reset ();
975 simIndex = 0 ;
976 }
977
978 /** One fixed step ending at `st`, firing every event scheduled inside (prev, st]. */
979 function step ( index : number , render : boolean ) {
980 if ( ! world) return ;
981 const st = index * SIM_STEP ;
982 if (logoRig) {
983 setIceFrameIndex (index);
984 world. frame (st, SIM_STEP , render);
985 return ;
986 }
987 clock.t = st;
988 // integer-exact: an event fires in the step whose index its time rounds to (no float-boundary misses)
989 const at = ( time : number ) => Math. round (time / SIM_STEP ) === index;
990 for ( const b of [ S .logoBreak, S .break1, S .break2]) {
991 if ( at (b)) slice (b === S .break2);
992 if ( at (b + 0.3 )) shatter ();
993 if ( at (b + 0.3 + SH .breakDuration + 0.5 )) finishShatter ();
994 }
995 if ( at ( S .form1)) form ( 1 );
996 if ( at ( S .form2)) form ( 2 );
997 if ( at ( S .fadeOut)) startFade ();
998 setIceFrameIndex (index);
999 world. frame (st, SIM_STEP , render);
1000 }
1001
1002 async function drainSeeks () {
1003 // Small batches bound main-thread work and GPU queue depth. New slider requests
1004 // replace obsolete targets at a batch boundary; fixed simulation steps stay exact.
1005 let activeTarget = - 1 ,
1006 firstRender = 0 ;
1007 while ( ! disposed && world && renderer) {
1008 const target = requestedIndex;
1009 if (simIndex < 0 || target < simIndex) reset ();
1010 if (target !== activeTarget) {
1011 firstRender = target - simIndex > 60 ? target - WARMUP_RENDERS : target;
1012 activeTarget = target;
1013 }
1014 const end = Math. min (target, simIndex + 8 );
1015 if (target === simIndex && renderedIndex !== target) {
1016 setIceFrameIndex (target);
1017 // Warm the post-processing history for a cold, paused first frame too.
1018 // This keeps direct seeks to zero consistent with playback and capture.
1019 const passes = o.rig && target === 0 && renderedIndex < 0 ? WARMUP_RENDERS : 1 ;
1020 for ( let pass = 0 ; pass < passes; pass ++ ) world. frame (target * SIM_STEP , 0 , true );
1021 renderedIndex = target;
1022 }
1023 while (simIndex < end) {
1024 simIndex ++ ;
1025 const render = simIndex === target || simIndex > firstRender;
1026 step (simIndex, render);
1027 if (render) renderedIndex = simIndex;
1028 }
1029 const queue = (renderer as any ).backend?.device?.queue;
1030 if (queue?.onSubmittedWorkDone) await queue. onSubmittedWorkDone ();
1031 if (disposed) return ;
1032 if (simIndex === requestedIndex && renderedIndex === requestedIndex) return ;
1033 await new Promise < void >(( resolve ) => setTimeout (resolve, 0 ));
1034 }
1035 }
1036
1037 function renderAt ( t : number ) {
1038 if (disposed) return ;
1039 lastTime = Number. isFinite (t) ? Math. max ( 0 , Math. min (o.rig?.duration ?? durationOf ( S ), t)) : 0 ;
1040 requestedIndex = Math. round (lastTime / SIM_STEP );
1041 if ( ! world || ! renderer || pendingGpu) return ;
1042 seekError = null ;
1043 pendingGpu = Promise . resolve ()
1044 . then (drainSeeks)
1045 . catch (( error ) => {
1046 seekError = error;
1047 console. error ( "Frost seek failed" , error);
1048 })
1049 . finally (() => {
1050 pendingGpu = null ;
1051 });
1052 }
1053
1054 /** Material-only changes redraw without advancing the simulation. */
1055 function redraw () {
1056 renderedIndex = - 1 ;
1057 renderAt (lastTime);
1058 }
1059
1060 function applyOptions (
1061 next : Pick <
1062 FrostOptions ,
1063 "schedule" | "pose" | "shards" | "tune" | "cameraMode" | "cameraTrack"
1064 >,
1065 key : string ,
1066 ) : "live" | "rebuild" {
1067 if (key !== structuralKey) return "rebuild" ;
1068 let replay = false ;
1069 const same = ( a : any , b : any ) => JSON . stringify (a) === JSON . stringify (b);
1070 if ( ! same (next.schedule, S ) || ! same (next.pose, P )) replay = true ;
1071 const wasStationary = stationary ();
1072 cameraTrack = parseTrack (next.cameraTrack);
1073 o.cameraTrack = cameraTrack;
1074 o.cameraMode = next.cameraMode;
1075 if (wasStationary !== stationary ()) replay = true ;
1076 syncCamera ();
1077 if ( ! same (next.shards, SH )) replay = true ;
1078 for ( const [ id , v ] of Object. entries (next.tune)) {
1079 if (o.tune[id] !== v) {
1080 const spec = TUNABLES [id];
1081 if (spec) {
1082 setPath (spec.path, v);
1083 if (spec.replay) replay = true ;
1084 }
1085 }
1086 }
1087 o.schedule = S = next.schedule;
1088 o.pose = P = next.pose;
1089 o.shards = SH = next.shards;
1090 o.tune = { ... next.tune };
1091 D .powder.amount = SH .amount;
1092 D .powder.inheritTime = SH .followObject * 1000 ;
1093 D .version ++ ;
1094 rebuildKeys ();
1095 if ( ! world) return "live" ;
1096 if (replay) {
1097 simIndex = - 1 ;
1098 renderedIndex = - 1 ;
1099 renderAt (lastTime);
1100 } else redraw ();
1101 return "live" ;
1102 }
1103
1104 async function waitForGpu () {
1105 await ready;
1106 while (pendingGpu) await pendingGpu;
1107 if (seekError) throw seekError;
1108 }
1109
1110 function dispose () {
1111 disposed = true ;
1112 world?. dispose ();
1113 world = null ;
1114 logoRig?. dispose ();
1115 for ( const s of sdfs) s.texture. dispose ();
1116 live?.texture. dispose ();
1117 live = null ;
1118 for ( const g of geos) g. dispose ();
1119 if (renderer) {
1120 renderer. dispose ();
1121 renderer = null ;
1122 }
1123 heroFrame.enabled = false ;
1124 heroFrame.shape = null ;
1125 heroFrame.cameraOverride = null ;
1126 delete (window as any ).__fb;
1127 }
1128
1129 return {
1130 ready,
1131 renderAt,
1132 redraw,
1133 invalidate : () => {
1134 simIndex = - 1 ;
1135 renderedIndex = - 1 ;
1136 },
1137 waitForGpu,
1138 poseAt : ( t : number ) => poseAt (t),
1139 applyOptions,
1140 structuralKey,
1141 get world () {
1142 return world;
1143 },
1144 get shape () {
1145 return current;
1146 },
1147 dispose,
1148 };
1149 }