Setting the file. One moment.
World · Frost Sequence Camera Orbit · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Three Mesh BVH LICENSE
source/src/World.ts
source/src/ World.ts
TypeScript · 630 lines · 21 KB
8
import
{ D }
from
"./dials/store"
;
9 import type { ShapeName } from "./dials/defaults" ;
10 import { clock } from "./core/clock" ;
11 import { input, sim, heroFrame } from "./core/state" ;
12 import { damp } from "./core/ease" ;
13 import { rng } from "./core/seed" ;
14 import { createBackdrop } from "./scene/Backdrop" ;
15 import { createEnvironment } from "./scene/Environment" ;
16 import { Lights } from "./scene/Lights" ;
17 import { Rig } from "./scene/Rig" ;
18 import { makeShape, type ShapeSpec, type LogoSDF } from "./shape/sdf" ;
19 import { buildGeometry } from "./shape/geometry" ;
20 import { createPost } from "./post/Post" ;
21 import { createIceMaterial, type IceMaterialBundle } from "./ice/IceMaterial" ;
22 import { ErosionField } from "./erosion/ErosionField" ;
23 import { Powder } from "./powder/Powder" ;
24 import { Interaction } from "./core/interaction" ;
25 import { DebugViews } from "./scene/DebugViews" ;
26 import { tsl } from "./tsl/t" ;
27 import { AdaptiveResolution } from "./core/AdaptiveResolution" ;
28
29 export interface WorldKey {
30 logoId : number ;
31 shape : string ;
32 seed : number ;
33 particleCount : string ;
34 resolution : string ;
35 densityGrid : string ;
36 size : number ;
37 tubeRatio : number ;
38 segments : number ;
39 variants : number ;
40 faceted : boolean ;
41 sprites : boolean ;
42 spriteBlend : boolean ;
43 strays : number ;
44 clumps : number ;
45 dispersion : boolean ;
46 voronoiCells : string ;
47 turbulence : string ;
48 }
49 export const worldKeyOf = () : WorldKey => ({
50 logoId: currentLogoId,
51 shape: heroFrame.enabled && heroFrame.shape ? heroFrame.shape : D .shape.shape,
52 seed: D .shape.seed,
53 particleCount: D .powder.particleCount,
54 resolution: D .erosion.resolution,
55 densityGrid: D .powder.densityGrid,
56 size: D .shape.size,
57 tubeRatio: D .shape.tubeRatio,
58 segments: D .shape.segments,
59 variants: D .powder.grainVariants,
60 faceted: D .powder.facetedGrains,
61 sprites: D .powder.sprites.enabled,
62 spriteBlend: D .powder.sprites.seeThrough > 0 ,
63 strays: D .powder.strayCount,
64 clumps: D .powder.clumpCount,
65 dispersion: true ,
66 voronoiCells: D .performance.voronoiCells,
67 turbulence: D .performance.turbulence,
68 });
69 export const keyString = ( k : WorldKey ) => JSON . stringify (k);
70 export let currentLogoId = 0 ;
71 export const bumpLogoId = () => {
72 currentLogoId ++ ;
73 };
74
75 /** Undocumented URL flags for bisecting problems: ?nobake ?nopowder ?noerosion ?noice ?nopost */
76 export const FLAGS = new Set (
77 typeof location !== "undefined" ? [ ...new URLSearchParams (location.search). keys ()] : [],
78 );
79 /** hash buckets for break-cell restore counters (cells are identified by their baked hash) */
80 export const CELL_BUCKETS = 65536 ;
81 const PARTICLE_COUNTS : Record < string , number > = {
82 "100k" : 100_000 ,
83 "250k" : 250_000 ,
84 "500k" : 500_000 ,
85 "1M" : 1_000_000 ,
86 "2M" : 2_000_000 ,
87 };
88
89 export class World {
90 /** FROST: the composition's continuous motion lives on this outer group; objectGroup (the experiment's) is its child
91 * and only carries the facing offset that makes a freshly formed shape face the camera. */
92 readonly motionGroup = new THREE . Group ();
93 readonly objectGroup = new THREE . Group ();
94 readonly backdrop = createBackdrop ();
95 readonly env = createEnvironment ();
96 readonly lights = new Lights ();
97 rig = new Rig ();
98 readonly post ;
99 shape !: ShapeSpec ;
100 mesh !: THREE . Mesh ;
101 meshEntry ?: ReturnType < typeof createMeshEntry>;
102 glassMatcap ?: THREE . Texture ;
103 materialPlanes ?: CrackPlanes ;
104 ice !: IceMaterialBundle ;
105 erosion !: ErosionField ;
106 powder !: Powder ;
107 interaction !: Interaction ;
108 debugViews !: DebugViews ;
109 private pixelRatio = 0 ;
110 private frozenByDial = false ;
111 private statsTimer = 0 ;
112 buildCount = 0 ;
113 private timersPending = false ;
114 private fieldMaybeNonZero = true ;
115 private lastActivityT = 0 ;
116
117 /**
118 * Idle-skip bookkeeping. The field only changes during a stroke, the crumble window, or while
119 * healing/refrost still has something to decay; the powder only needs work while grains are out.
120 */
121 private idleState ( t : number ) {
122 const stroke = this .interaction.strokeCount > 0 ;
123 if (stroke) {
124 this .fieldMaybeNonZero = true ;
125 this .lastActivityT = t;
126 }
127 const st = this .erosion.stats;
128 // a readback taken after the last stroke that shows an empty field proves it is static again
129 if (
130 this .fieldMaybeNonZero &&
131 this .erosion.statsReadT > sim.lastStrokeT + 0.6 &&
132 st.max < 0.002 &&
133 st.refrost < 0.002
134 )
135 this .fieldMaybeNonZero = false ;
136 const healingNow = sim.healing && this .fieldMaybeNonZero;
137 const refrostNow = this .fieldMaybeNonZero && st.refrost > 0.002 ;
138 const fieldActive =
139 ! D .performance.idleSkip ||
140 stroke ||
141 t < this .erosion.crumbleUntil ||
142 healingNow ||
143 refrostNow;
144 const powderActive =
145 ! D .performance.idleSkip ||
146 fieldActive ||
147 sim.counts.active > 0 ||
148 t - this .lastActivityT < 1.5 ||
149 sim.resetRequestedAt >= 0 ;
150 sim.fieldActive = fieldActive;
151 sim.powderActive = powderActive;
152 return { fieldActive, powderActive };
153 }
154 private envSig = "" ;
155 private backdropSig = "" ;
156 private frameTimes = new Float64Array ( 30 );
157 private frameTimeSum = 0 ;
158 private frameTimeCount = 0 ;
159 private frameTimeIndex = 0 ;
160 private lastFrameStart = 0 ;
161 private gpuPending = false ;
162 private gpuTimerLast = - Infinity ;
163 private readonly renderSize = new THREE . Vector2 ();
164 private readonly adaptiveResolution = new AdaptiveResolution ();
165 /** ?offscreen=1 renders into a render target, not the canvas (presenting kills headless SwiftShader devices). */
166 private offscreenRT : THREE . RenderTarget | null = FLAGS . has ( "offscreen" )
167 ? new THREE . RenderTarget ( 16 , 16 , {
168 depthBuffer: false ,
169 type: THREE .UnsignedByteType,
170 format: THREE .RGBAFormat,
171 })
172 : null ;
173
174 constructor (
175 readonly renderer : THREE . WebGPURenderer ,
176 readonly scene : THREE . Scene ,
177 readonly camera : THREE . PerspectiveCamera ,
178 readonly blueNoise : THREE . Texture ,
179 public logo ?: { sdf : LogoSDF ; geometry : THREE . BufferGeometry },
180 readonly fractureDetail ?: THREE . Texture ,
181 ) {
182 this .motionGroup. add ( this .objectGroup);
183 scene. add ( this .motionGroup, this .lights.group, camera);
184 scene.backgroundNode = this .backdrop.node;
185 scene.environment = this .env.texture;
186 this . build ();
187 this .post = createPost ({
188 scene,
189 camera,
190 renderer,
191 blueNoise,
192 haze : ( depthNode , viewZ ) => this .powder. hazeNode (depthNode, viewZ, camera),
193 initial: D .post,
194 initialPerformance: D .performance,
195 initialHaze: D .powder.hazeIntensity !== 0 ,
196 });
197 }
198
199 /** (Re)build the shape-dependent parts: geometry, ice material, erosion field, powder. */
200 build () {
201 this .buildCount ++ ;
202 const key = worldKeyOf ();
203 const rand = rng (key.seed + 1 );
204 const wanted = heroFrame.enabled && heroFrame.shape ? heroFrame.shape : D .shape.shape;
205 const shapeName = (wanted === "logo" && ! this .logo ? "torus" : wanted) as ShapeName ;
206 this .shape = makeShape (shapeName, key.size, key.tubeRatio, this .logo?.sdf);
207 const geometry = buildGeometry ( this .shape, key.segments, this .logo?.geometry);
208 // one shared atomic uint buffer: [powder density G^3 | 16 counters | erosion heal grid R^3]
209 const G = parseInt (key.densityGrid, 10 ),
210 R = parseInt (key.resolution, 10 );
211 const countersOffset = G * G * G ,
212 healOffset = countersOffset + 16 ,
213 cellOffset = healOffset + R * R * R ,
214 flightOffset = cellOffset + CELL_BUCKETS ;
215 const atomics = tsl. instancedArray (flightOffset + CELL_BUCKETS , "uint" ). toAtomic ();
216 this .erosion = new ErosionField (
217 this .renderer,
218 this .shape,
219 R ,
220 key.seed,
221 atomics,
222 healOffset,
223 cellOffset,
224 flightOffset,
225 );
226 if ( RW .materials) {
227 this .materialPlanes = new CrackPlanes ( this .renderer, this .shape.bound, key.seed);
228 ( this .erosion as any ).materialPlanes = this .materialPlanes;
229 }
230 this .erosion.skipBake = FLAGS . has ( "nobake" );
231 if ( RW .mesh) this .meshEntry = createMeshEntry (geometry, this .camera);
232 if ( RW .matcap) {
233 this .env. update ( D .lighting);
234 this .glassMatcap = createGlassMatcap ( this .env.texture);
235 }
236 this .ice = createIceMaterial ({
237 meshEntry: this .meshEntry,
238 glassMatcap: this .glassMatcap,
239 shape: this .shape,
240 erosion: this .erosion,
241 seed: key.seed,
242 blueNoise: this .blueNoise,
243 environment: this .env.texture,
244 fractureDetail: this .fractureDetail,
245 backdrop: this .backdrop.uniforms,
246 dispersion: key.dispersion,
247 voronoiCells: key.voronoiCells as "27" | "8" ,
248 });
249 if ( FLAGS . has ( "noice" )) {
250 const m = new THREE . MeshStandardNodeMaterial ();
251 m.roughness = 0.3 ;
252 m.color. set ( "#bbbbbb" );
253 ( this .ice as any ).material = m;
254 }
255 if ( this .meshEntry) this .meshEntry.material.positionNode = this .ice.material.positionNode;
256 this .mesh = new THREE . Mesh (geometry, this .ice.material);
257 this .mesh.castShadow = true ;
258 this .mesh.receiveShadow = true ;
259 this .mesh.frustumCulled = false ;
260 this .objectGroup. add ( this .mesh);
261 this .powder = new Powder ({
262 enabled: ! FLAGS . has ( "nopowder" ),
263 renderer: this .renderer,
264 scene: this .scene,
265 shape: this .shape,
266 erosion: this .erosion,
267 seed: key.seed,
268 rand,
269 count: PARTICLE_COUNTS [key.particleCount] ?? 1_000_000 ,
270 variants: key.variants,
271 strays: key.strays,
272 densityRes: G ,
273 clumpCount: key.clumps,
274 objectGroup: this .objectGroup,
275 turbulence: key.turbulence as "full" | "fast" ,
276 atomics,
277 countersOffset,
278 healOffset,
279 cellOffset,
280 flightOffset,
281 fieldRes: R ,
282 backdrop: this .backdrop.uniforms,
283 fractureDetail: this .fractureDetail,
284 iceUniforms: this .ice.uniforms,
285 iceFeatures: this .ice.features,
286 environment: this .env.texture,
287 });
288 this .interaction = new Interaction (
289 this .shape,
290 this .objectGroup,
291 this .camera,
292 this .erosion,
293 this .powder,
294 );
295 this .debugViews = new DebugViews ( this .camera, this .erosion, this .powder);
296 this .scene. add ( this .debugViews.group);
297 }
298
299 disposeBuilt () {
300 this .materialPlanes?. dispose ();
301 this .materialPlanes = undefined ;
302 this .meshEntry?. dispose ();
303 this .meshEntry = undefined ;
304 this .glassMatcap?. dispose ();
305 this .glassMatcap = undefined ;
306 this .debugViews. dispose ();
307 this .scene. remove ( this .debugViews.group);
308 this .objectGroup. remove ( this .mesh);
309 this .mesh.geometry. dispose ();
310 this .ice.material. dispose ();
311 this .erosion. dispose ();
312 this .powder. dispose ();
313 }
314
315 rebuild () {
316 this . disposeBuilt ();
317 this . build ();
318 this .post.post.needsUpdate = true ;
319 }
320
321 private updateEnvironment () {
322 const L = D .lighting;
323 const sig = JSON . stringify ([
324 L .envSoftbox,
325 L .envRim,
326 L .envFill,
327 L .key,
328 L .fill,
329 L .fillColor,
330 L .fillGroundColor,
331 L .fillReflectionStrength,
332 L .rimColor,
333 L .rimElevation,
334 L .rimAzimuth,
335 L .rimSize,
336 L .accentCool,
337 L .accentWarm,
338 ]);
339 if (sig !== this .envSig) {
340 this .envSig = sig;
341 this .env. update ( L );
342 }
343 this .scene.environmentIntensity = D .ice.envIntensity;
344 }
345
346 private updateBackdrop ( t : number ) {
347 const b = D .lighting.backdrop;
348 const h = heroFrame.enabled ? heroFrame.backdrop : null ;
349 const u = this .backdrop.uniforms;
350 const top = h?.top ?? b.top,
351 mid = h?.mid ?? b.mid,
352 bottom = h?.bottom ?? b.bottom;
353 const centerX = h?.centerX ?? b.centerX,
354 centerY = h?.centerY ?? b.centerY,
355 radius = h?.radius ?? b.radius,
356 falloff = h?.falloff ?? b.falloff;
357 const sig = `${ top }|${ mid }|${ bottom }|${ centerX }|${ centerY }|${ radius }|${ falloff }|${ b . noise }` ;
358 if (sig !== this .backdropSig) {
359 this .backdropSig = sig;
360 u.uTop.value. set (top);
361 u.uMid.value. set (mid);
362 u.uBottom.value. set (bottom);
363 u.uCenter.value. set (centerX, centerY);
364 u.uRadius.value = radius;
365 u.uFalloff.value = falloff;
366 u.uNoise.value = b.noise;
367 }
368 u.uTime.value = t;
369 }
370
371 /** FROST: called after the camera is final for this frame and before the post graph renders. */
372 onBeforeRender : (( t : number ) => void ) | null = null ;
373 /** Called after the rig places the object group, before its world matrix is used (the object motion). */
374 onBeforeSimulation : (( t : number , dt : number ) => void ) | null = null ;
375 onObjectTransform : (( t : number ) => void ) | null = null ;
376 /** Authored camera evaluates after the current target matrix, before interaction and render. */
377 onCameraTransform : (( t : number ) => void ) | null = null ;
378
379 /**
380 * One frame: input -> rig -> simulation compute -> post render. The composition supplies the clock (t, dt),
381 * not performance.now(), so each step is pure; `render` false advances without drawing (fast-forward after a seek).
382 */
383 frame ( t : number , dt : number , render = true ) {
384 clock.t = t;
385 clock.dt = dt;
386 clock.frame ++ ;
387 const start = t * 1000 ;
388 const elapsed = 1000 / D .performance.targetFps;
389 const splitOutput =
390 ! FLAGS . has ( "nopost" ) &&
391 D .performance.nativePostEffects &&
392 D .performance.upscaler !== "native" ;
393 const resolution = this .adaptiveResolution. updateScene (
394 t,
395 elapsed,
396 window.devicePixelRatio || 1 ,
397 D .post.pixelRatioCap,
398 D .performance.adaptiveResolution && D .performance.dynamicSceneResolution,
399 D .performance.targetFps,
400 D .performance.minPixelRatio,
401 D .performance.sceneResolutionScale,
402 D .performance.minSceneResolutionScale,
403 splitOutput,
404 );
405 sim.pixelRatio = resolution.sourceRatio;
406 sim.outputPixelRatio = resolution.outputRatio;
407 sim.sceneResolutionScale = resolution.sceneScale;
408 if (
409 resolution.outputRatio !== this .pixelRatio ||
410 this .renderer. getPixelRatio () !== resolution.outputRatio
411 ) {
412 this .pixelRatio = resolution.outputRatio;
413 this .renderer. setPixelRatio (resolution.outputRatio);
414 const sz = this .renderer. getSize ( this .renderSize);
415 this .renderer. setSize (sz.x, sz.y, false );
416 }
417
418 if ( ! this .onCameraTransform) this .rig. update ( this .camera, this .objectGroup, t, dt);
419 if (heroFrame.enabled && heroFrame.framing)
420 this .motionGroup.position. set (heroFrame.objectX, heroFrame.objectY, heroFrame.objectZ);
421 else this .motionGroup.position. set ( D .shape.offsetX, D .shape.offsetY, D .shape.offsetZ);
422 this .objectGroup.position. set ( 0 , 0 , 0 );
423 if ( this .onObjectTransform) this . onObjectTransform (t);
424 this .motionGroup. updateMatrixWorld ( true );
425 this . onCameraTransform ?.(t);
426 this . updateEnvironment ();
427 this . updateBackdrop (t);
428 sim.hoverAmount = damp (sim.hoverAmount, sim.overObject ? 1 : 0 , 0.4 / 3 , dt);
429 this .lights. update (t, sim.hoverAmount);
430
431 this .interaction. update (t, dt);
432 this . onBeforeSimulation ?.(t, dt);
433 this .debugViews. update ( this .interaction);
434 if ( ! FLAGS . has ( "noice" )) {
435 this .ice. update (t, dt, this .lights.key, this .objectGroup);
436 this .ice.uniforms.hover.value = sim.hoverAmount;
437 }
438
439 const c0 = performance. now ();
440 const idle = this . idleState (t);
441 if (dt > 0 ) {
442 if ( ! FLAGS . has ( "noerosion" ))
443 this .erosion. step (
444 this .renderer,
445 t,
446 dt,
447 this .interaction.strokeSegments,
448 this .interaction.strokeCount,
449 idle.fieldActive,
450 );
451 }
452 // Uniform-only updates at dt=0 keep paused workbench edits live.
453 if ( ! FLAGS . has ( "nopowder" ))
454 this .powder. step ( this .renderer, t, dt, this .interaction, this .lights.key, idle);
455 sim.computeMs = performance. now () - c0;
456 const wantStats = D .debug.stats || D .performance.idleSkip;
457 if (wantStats) {
458 this .statsTimer += Math. max (dt, 1 / 120 );
459 const interval = this .fieldMaybeNonZero ? 0.25 : 0.4 ;
460 if ( this .statsTimer > interval || this .offscreenRT) {
461 this .statsTimer = 0 ;
462 this .erosion. readStats ( this .renderer, t);
463 sim.erosion = this .erosion.stats;
464 }
465 }
466
467 this .camera. updateMatrixWorld ();
468 if ( this .onBeforeRender) this . onBeforeRender (t);
469 // Sprite size belongs to the low-resolution scene pass, not the native-resolution final output.
470 this .powder. updateCamera (
471 this .camera,
472 this .renderer. getSize ( this .renderSize).y * resolution.sourceRatio,
473 );
474 const focus = this .camera.position. distanceTo ( this .rig.lookAt);
475 this .post. update (
476 D .post,
477 D .performance,
478 focus,
479 sim.sceneFade,
480 D .powder.hazeIntensity,
481 t,
482 D .version,
483 );
484 this .materialPlanes?. update ();
485 this .post. setSceneResolution (resolution.sceneScale);
486 this .meshEntry?. update ( this .mesh, resolution.sceneScale);
487 ( this .mesh.material as any ).wireframe = D .debug.wireframe;
488 if ( this .offscreenRT) {
489 const sz = this .renderer. getSize ( this .renderSize);
490 const pr = this .renderer. getPixelRatio ();
491 const w = Math. max ( 1 , Math. floor (sz.x * pr)),
492 h = Math. max ( 1 , Math. floor (sz.y * pr));
493 if ( this .offscreenRT.width !== w || this .offscreenRT.height !== h)
494 this .offscreenRT. setSize (w, h);
495 this .renderer. setRenderTarget ( this .offscreenRT);
496 }
497 this .post. setHazeResolution (
498 D .performance.hazeResolution === "quarter"
499 ? 0.25
500 : D .performance.hazeResolution === "half"
501 ? 0.5
502 : 1 ,
503 );
504 // HyperFrames drives explicit frames outside Three's animation loop. Advance
505 // the node frame so FRAME-scoped passes cannot reuse the previous image.
506 if (render) {
507 ( this .renderer as any )._nodes.nodeFrame. update ();
508 this .post. setFrame (Math. round (t * 60 ));
509 if ( FLAGS . has ( "nopost" )) this .renderer. render ( this .scene, this .camera);
510 else this .post.post. render ();
511 }
512 if ( this .offscreenRT) this .renderer. setRenderTarget ( null );
513 if ( D .performance.gpuTimers && ! this .timersPending && t - this .gpuTimerLast >= 0.25 ) {
514 const r : any = this .renderer;
515 if (r.backend?.trackTimestamp) {
516 this .timersPending = true ;
517 this .gpuTimerLast = t;
518 Promise . all ([r. resolveTimestampsAsync ( "render" ), r. resolveTimestampsAsync ( "compute" )])
519 . then (() => {
520 sim.gpuRenderMs = r.info.render.timestamp || 0 ;
521 sim.gpuComputeMs = r.info.compute.timestamp || 0 ;
522 this .timersPending = false ;
523 })
524 . catch (() => {
525 this .timersPending = false ;
526 });
527 }
528 }
529 if ( this .offscreenRT && ! this .gpuPending) {
530 const q = ( this .renderer as any ).backend?.device?.queue;
531 if (q?.onSubmittedWorkDone) {
532 this .gpuPending = true ;
533 q. onSubmittedWorkDone ()
534 . then (() => {
535 sim.gpuFrames ++ ;
536 this .gpuPending = false ;
537 })
538 . catch (() => {
539 this .gpuPending = false ;
540 });
541 }
542 }
543 void input;
544 }
545
546 /**
547 * FROST: return every stateful part of the simulation to its t = 0 state so a backward seek can replay
548 * the timeline deterministically (field cleared, grains dormant, rig and interaction fresh).
549 */
550 resetSim () {
551 this .erosion. clear ( this .renderer);
552 this .erosion.crumbleUntil = - 1 ;
553 this .erosion.u.crumbleUntil.value = - 1 ;
554 this .powder. reset ( this .renderer);
555 this .interaction = new Interaction (
556 this .shape,
557 this .objectGroup,
558 this .camera,
559 this .erosion,
560 this .powder,
561 );
562 this .rig = new Rig ();
563 this .mesh.visible = true ;
564 sim.strokeActive = false ;
565 sim.overObject = false ;
566 sim.lastStrokeT = - 1e9 ;
567 sim.healing = false ;
568 sim.resetRequestedAt = - 1 ;
569 sim.fade = 1 ;
570 sim.sceneFade = 1 ;
571 sim.hoverAmount = 0 ;
572 sim.fieldActive = true ;
573 sim.powderActive = true ;
574 this .fieldMaybeNonZero = true ;
575 this .lastActivityT = 0 ;
576 this .statsTimer = 0 ;
577 clock.t = 0 ;
578 clock.dt = 1 / 60 ;
579 clock.frame = 0 ;
580 clock.paused = false ;
581 }
582
583 /** Debug helper: generated WGSL for the object's material. */
584 async dumpShader () {
585 const r : any = this .renderer;
586 return r.debug. getShaderAsync ( this .scene, this .camera, this .mesh);
587 }
588
589 /** Headless check helper: read the offscreen frame back, return luma stats + a PNG data URL. */
590 async readback () {
591 if ( ! this .offscreenRT) return null ;
592 const rt = this .offscreenRT,
593 w = rt.width,
594 h = rt.height;
595 const data = ( await this .renderer. readRenderTargetPixelsAsync (rt, 0 , 0 , w, h)) as Uint8Array ;
596 const stride =
597 data. length >= Math. ceil ((w * 4 ) / 256 ) * 256 * h ? Math. ceil ((w * 4 ) / 256 ) * 256 : w * 4 ;
598 let sum = 0 ,
599 mx = 0 ;
600 const cv = document. createElement ( "canvas" );
601 cv.width = w;
602 cv.height = h;
603 const ctx = cv. getContext ( "2d" ) ! ;
604 const img = ctx. createImageData (w, h);
605 for ( let y = 0 ; y < h; y ++ )
606 for ( let x = 0 ; x < w; x ++ ) {
607 const si = y * stride + x * 4 ,
608 di = (y * w + x) * 4 ; // WebGPU readback rows are top-down
609 img.data[di] = data[si];
610 img.data[di + 1 ] = data[si + 1 ];
611 img.data[di + 2 ] = data[si + 2 ];
612 img.data[di + 3 ] = 255 ;
613 const l = 0.2126 * data[si] + 0.7152 * data[si + 1 ] + 0.0722 * data[si + 2 ];
614 sum += l;
615 if (l > mx) mx = l;
616 }
617 ctx. putImageData (img, 0 , 0 );
618 return { mean: sum / (w * h), max: mx, png: cv. toDataURL ( "image/png" ), w, h };
619 }
620
621 dispose () {
622 this . disposeBuilt ();
623 this .post. dispose ();
624 this .scene. remove ( this .motionGroup, this .lights.group);
625 this .scene.backgroundNode = null ;
626 this .scene.environment = null ;
627 this .env.texture. dispose ();
628 this .fractureDetail?. dispose ();
629 }
630 }