Setting the file. One moment.
Erosion Field · Frost Sequence Camera Orbit · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Three Mesh BVH LICENSE
source/src/erosion/ErosionField.ts
source/src/erosion/ ErosionField.ts
TypeScript · 635 lines · 24 KB
,
11 float ,
12 uniform ,
13 uniformArray ,
14 instanceIndex ,
15 texture3D ,
16 textureStore ,
17 uvec3 ,
18 ivec3 ,
19 int ,
20 uint ,
21 If ,
22 Loop ,
23 max ,
24 min ,
25 length ,
26 dot ,
27 clamp ,
28 smoothstep ,
29 mix ,
30 abs ,
31 select ,
32 instancedArray ,
33 atomicAdd ,
34 atomicMax ,
35 atomicStore ,
36 atomicLoad ,
37 } = tsl;
38 import type { ShapeSpec } from "../shape/sdf" ;
39 import { hash31, voronoiEdge, voronoiCell, gnoise, saturate } from "../tsl/noise" ;
40 import { D } from "../dials/store" ;
41 import { sim } from "../core/state" ;
42
43 type N = any ;
44 export const MAX_SEGMENTS = 12 ;
45
46 export class ErosionField {
47 /** Floor (object units) for the erodable shell just outside the distance-field surface; set before construction. */
48 static extraShell = 0 ;
49 readonly res : number ;
50 readonly bound : number ;
51 readonly tex : THREE . Storage3DTexture ;
52 readonly scratch : THREE . Storage3DTexture ;
53 readonly crackTex : THREE . Storage3DTexture ;
54 /** Baked break cells: xyz = cell centre (object space), w = cell hash. The brush snaps to these. */
55 readonly cellTex : THREE . Storage3DTexture ;
56 readonly u = {
57 reconstruct: uniform ( 0 ),
58 dt: uniform ( 0 ),
59 time: uniform ( 0 ),
60 heal: uniform ( 0 ),
61 healRate: uniform ( 0.6 ),
62 healGap: uniform ( 0.12 ),
63 fallbackHeal: uniform ( 0.04 ),
64 fallbackOn: uniform ( 0 ),
65 refrostTime: uniform ( 4 ),
66 cellRestore: uniform ( 30 ),
67 growEdges: uniform ( 1 ),
68 breakup: uniform ( 0.7 ),
69 breakupScale: uniform ( 10 ),
70 crumbleRate: uniform ( 1.6 ),
71 crumbleCrackBias: uniform ( 2.5 ),
72 crumbleUntil: uniform ( - 1 ),
73 brushNoise: uniform ( 0.55 ),
74 brushNoiseScale: uniform ( 9 ),
75 brushSoftness: uniform ( 0.5 ),
76 segCount: uniform ( 0 ),
77 seed: uniform ( 0 ),
78 crackScale: uniform ( 2.2 ),
79 crackWarp: uniform ( 0.45 ),
80 crackWarpScale: uniform ( 1.4 ),
81 crackCoverage: uniform ( 0.55 ),
82 breakScale: uniform ( 3 ),
83 cellSnap: uniform ( 0.85 ),
84 };
85 readonly segments = uniformArray (
86 Array. from ({ length: MAX_SEGMENTS * 2 }, () => new THREE . Vector4 ()),
87 );
88 private stepNode : any ;
89 private copyNode : any ;
90 private cellClearNode : any ;
91 private flightClearNode : any ;
92 private clearNode : any ;
93 private fillNode : any ;
94 private healClearNode : any ;
95 private bakeNode : any ;
96 private statsNode : any ;
97 private statsClearNode : any ;
98 private statsBuf : any ;
99 private statsPending = false ;
100 /** Last readback: max erosion, voxels > 0.5, voxels > 0.9 (debug HUD). */
101 readonly stats = { max: 0 , over50: 0 , over90: 0 , refrost: 0 };
102 /** clock time of the last stats readback that completed (for the idle-skip logic). */
103 statsReadT = - 1 ;
104 private statsIssuedT = - 1 ;
105 private baked = false ;
106 skipBake = false ;
107 private texNode : any ;
108 private lastCrackSig = "" ;
109 private settingsVersion = - 1 ;
110
111 /**
112 * @param atomics shared atomic uint buffer (also holds the powder density grid + counters);
113 * the heal grid (one counter per field voxel) starts at `healOffset`.
114 */
115 readonly atomics : any ;
116 readonly healOffset : number ;
117
118 readonly cellOffset : number ;
119
120 readonly flightOffset : number ;
121 constructor (
122 readonly renderer : THREE . WebGPURenderer ,
123 readonly shape : ShapeSpec ,
124 res : number ,
125 seed : number ,
126 atomics : any ,
127 healOffset : number ,
128 cellOffset : number ,
129 flightOffset : number ,
130 ) {
131 this .cellOffset = cellOffset;
132 this .flightOffset = flightOffset;
133 // assigned explicitly (not as parameter properties): buildComputes() below reads them, and the
134 // TS->JS transform may order parameter-property assignment after field initialisation
135 this .atomics = atomics;
136 this .healOffset = healOffset;
137 if ( ! Number. isInteger (healOffset))
138 throw new Error ( "ErosionField: healOffset must be an integer" );
139 this .res = res;
140 this .bound = shape.bound;
141 this .u.seed.value = seed;
142 const make = () => {
143 const t = new THREE . Storage3DTexture (res, res, res);
144 t.type = THREE .HalfFloatType;
145 t.format = THREE .RGBAFormat;
146 t.minFilter = THREE .LinearFilter;
147 t.magFilter = THREE .LinearFilter;
148 t.wrapS = t.wrapT = t.wrapR = THREE .ClampToEdgeWrapping;
149 t.generateMipmaps = false ;
150 return t;
151 };
152 this .tex = make ();
153 this .scratch = make ();
154 this .crackTex = make ();
155 this .cellTex = make ();
156 this .texNode = texture3D ( this .tex);
157 this . buildComputes ();
158 }
159
160 /** Object-space position -> field uvw. */
161 uvw ( p : N ) {
162 return vec3 (p)
163 . div ( this .bound * 2 )
164 . add ( 0.5 );
165 }
166 /** Sample the field (trilinear) at an object-space position. Returns vec4 (r erosion, g refrost). */
167 sample ( pObject : N ) {
168 return this .texNode. sample ( this . uvw (pObject)). level ( 0 );
169 }
170 /** Baked crack helper texture: xyz = domain-warp vector (unit-less, -1..1), w = crack proximity 0..1. */
171 sampleCrack ( pObject : N ) {
172 return texture3D ( this .crackTex). sample ( this . uvw (pObject)). level ( 0 );
173 }
174 /** Warped crack-domain coordinate for an object-space point (shared by bake + material). */
175 static warpDomain ( p : N , warp : N , scale : N , warpAmount : N ) {
176 return vec3 (p). mul (scale). add ( vec3 (warp). mul (warpAmount));
177 }
178
179 private buildComputes () {
180 const res = this .res,
181 total = res * res * res,
182 bound = this .bound;
183 const u = this .u;
184 const voxel = (bound * 2 ) / res;
185 const shell = Math. max (voxel * 1.5 , ErosionField.extraShell);
186 const idx = instanceIndex;
187 const coord = () => {
188 const x = idx. mod ( uint (res));
189 const y = idx. div ( uint (res)). mod ( uint (res));
190 const z = idx. div ( uint (res * res));
191 return uvec3 (x, y, z);
192 };
193 const toObject = ( c : N ) =>
194 vec3 (c)
195 . add ( 0.5 )
196 . div (res)
197 . sub ( 0.5 )
198 . mul (bound * 2 );
199
200 const readTex = texture3D ( this .tex);
201 const readScratch = texture3D ( this .scratch);
202 const crack = texture3D ( this .crackTex);
203 const cells = texture3D ( this .cellTex);
204
205 // --- step: crumble propagation + heal + stroke splats -> scratch
206 this .stepNode = Fn (() => {
207 const c = coord ();
208 // NOTE: values used inside loops/branches AND afterwards must be pinned with toVar() up front,
209 // otherwise TSL assigns them at first use (inside the branch) and later reads are uninitialised.
210 const p = toObject (c). toVar ();
211 const sd = this .shape. sdfNode (p). toVar ();
212 const cur = readTex. load ( ivec3 (c)). level ( 0 ). toVar ();
213 const e = cur.r. toVar ();
214 const refrost = cur.g. toVar ();
215 const mark = cur.b. toVar (); // 1 = healed by a landed grain (propagation may spread from here only)
216 const hit = max ( 0.0 , cur.a. sub (u.dt. div ( 0.2 ))). toVar (); // brush-hit age: 1 the frame the brush touches this voxel, 0 after 0.2 s
217 const crackP = crack. load ( ivec3 (c)). level ( 0 ).w;
218 // breakup: every refill / cleanup / refrost timer runs at a locally varied rate, so a break cell (whose
219 // voxels all carry the same erosion value) no longer crosses the look thresholds all at once
220 const bn = gnoise (p. mul (u.breakupScale). add (u.seed. mul ( 1.7 ). add ( 11.0 )))
221 . mul ( 0.5 )
222 . add ( 0.5 );
223 const rateMul = mix ( float ( 1 ), float ( 0.15 ). add ( saturate (bn). mul ( 1.7 )), u.breakup). toVar ();
224
225 // crumble propagation: erosion spreads toward the max of the 6 neighbours, biased by crack proximity
226 If (u.time. lessThan (u.crumbleUntil), () => {
227 const nb = float ( 0 ). toVar ();
228 const offs = [
229 ivec3 ( 1 , 0 , 0 ),
230 ivec3 ( - 1 , 0 , 0 ),
231 ivec3 ( 0 , 1 , 0 ),
232 ivec3 ( 0 , - 1 , 0 ),
233 ivec3 ( 0 , 0 , 1 ),
234 ivec3 ( 0 , 0 , - 1 ),
235 ];
236 for ( const o of offs) {
237 const cc = clamp ( ivec3 (c). add (o), ivec3 ( 0 ), ivec3 (res - 1 ));
238 nb. assign ( max (nb, readTex. load (cc). level ( 0 ).r));
239 }
240 const inside = smoothstep ( 0.02 , - 0.02 , sd);
241 const rate = u.crumbleRate. mul ( float ( 1 ). add (crackP. mul (u.crumbleCrackBias))). mul (inside);
242 const target = nb. mul ( 0.92 ). sub ( 0.06 );
243 If (target. greaterThan (e), () => {
244 e. assign ( min (target, e. add (target. sub (e). mul ( saturate (rate. mul (u.dt))))));
245 });
246 });
247
248 // stroke splats: soft capsules with a ragged noise boundary
249 // break cell of this voxel: ice breaks along cell faces, so the brush is evaluated at the cell centre
250 // (every voxel of a cell gets the same erosion -> flat facets) and mixed with the smooth capsule
251 const cell = cells. load ( ivec3 (c)). level ( 0 ). toVar ();
252 Loop ({ start: int ( 0 ), end: int (u.segCount), type: "int" , condition: "<" }, ({ i } : any ) => {
253 const a4 = this .segments. element (i. mul ( 2 ));
254 const b4 = this .segments. element (i. mul ( 2 ). add ( 1 ));
255 const a = a4.xyz,
256 b = b4.xyz,
257 radius = a4.w,
258 strength = b4.w;
259 const ab = b. sub (a);
260 const capsuleDist = ( q : N ) => {
261 const tt = clamp ( dot (q. sub (a), ab). div ( max ( dot (ab, ab), 1e-6 )), 0.0 , 1.0 );
262 return length (q. sub (a. add (ab. mul (tt))));
263 };
264 const n = gnoise (p. mul (u.brushNoiseScale). add (u.seed))
265 . mul ( 0.5 )
266 . add ( gnoise (p. mul (u.brushNoiseScale. mul ( 2.7 )). add (u.seed. add ( 3.1 ))). mul ( 0.25 ));
267 const r = radius. mul ( float ( 1 ). add (n. mul (u.brushNoise)));
268 const inner = r. mul ( float ( 1 ). sub (u.brushSoftness));
269 const wSmooth = smoothstep (r, inner, capsuleDist (p));
270 const rCell = radius. mul ( float ( 1 ). add (cell.w. sub ( 0.5 ). mul (u.brushNoise). mul ( 0.6 )));
271 const wCell = smoothstep (
272 rCell,
273 rCell. mul ( float ( 1 ). sub (u.brushSoftness)),
274 capsuleDist (cell.xyz),
275 );
276 const w = mix (wSmooth, wCell, u.cellSnap);
277 e. assign ( min ( 1.0 , e. add (w. mul (strength). mul (u.dt))));
278 If (w. greaterThan ( 0.02 ), () => {
279 hit. assign ( 1.0 );
280 });
281 });
282
283 // healing is driven by the returning grains: a grain that lands deposits into the heal grid and
284 // the voxel refills (as frost first); voxels without a grain follow their healed neighbours
285 const healIdx = uint ( this .healOffset). add (idx);
286 const deposits = float ( atomicLoad ( this .atomics. element (healIdx))). toVar ();
287 // Ordinary healing waits for its cell; reconstruction deposits restore locally on arrival.
288 const cellId0 = uint ( clamp (cell.w. mul ( 65535.0 ), 0.0 , 65535.0 ));
289 const cellReady0 = atomicLoad (
290 this .atomics. element ( uint ( this .flightOffset). add (cellId0)),
291 ). equal ( uint ( 0 ));
292 If (deposits. greaterThan ( 0.5 ). and (cellReady0. or (u.reconstruct. greaterThan ( 0.5 ))), () => {
293 atomicStore ( this .atomics. element (healIdx), uint ( 0 ));
294 refrost. assign ( min ( 1.0 , refrost. add (e. mul ( 2.5 ))));
295 e. assign ( 0.0 );
296 mark. assign ( 1.0 );
297 });
298 // break-cell restore: the last grain of the cell to land triggers it (cellHits, one frame), then the
299 // whole cell refills at `cellRestore` (mark = 2 marks a restoring voxel). Nothing else may refill a
300 // voxel whose cell still has grains in flight (cellReady), so the object never rebuilds ahead of them.
301 const cellId = uint ( clamp (cell.w. mul ( 65535.0 ), 0.0 , 65535.0 ));
302 const cellHits = float ( atomicLoad ( this .atomics. element ( uint ( this .cellOffset). add (cellId))));
303 const inFlight = atomicLoad ( this .atomics. element ( uint ( this .flightOffset). add (cellId)));
304 const cellReady = inFlight. equal ( uint ( 0 ));
305 If (cellHits. greaterThan ( 0.5 ). and (e. greaterThan ( 0.0 )), () => {
306 mark. assign ( 2.0 );
307 });
308 If (
309 mark
310 . greaterThan ( 1.5 )
311 . and (e. greaterThan ( 0.0 ))
312 . and (cellReady)
313 . and (u.reconstruct. lessThan ( 0.5 )),
314 () => {
315 // grow back from what is already solid (hole floor, walls, landed grains) instead of popping in at once
316 const canGrow = float ( 1 ). toVar ();
317 If (u.growEdges. greaterThan ( 0.5 ), () => {
318 canGrow. assign ( 0.0 );
319 const offs2 = [
320 ivec3 ( 1 , 0 , 0 ),
321 ivec3 ( - 1 , 0 , 0 ),
322 ivec3 ( 0 , 1 , 0 ),
323 ivec3 ( 0 , - 1 , 0 ),
324 ivec3 ( 0 , 0 , 1 ),
325 ivec3 ( 0 , 0 , - 1 ),
326 ];
327 for ( const o of offs2) {
328 const cc = clamp ( ivec3 (c). add (o), ivec3 ( 0 ), ivec3 (res - 1 ));
329 If (readTex. load (cc). level ( 0 ).r. lessThan ( 0.5 ), () => {
330 canGrow. assign ( 1.0 );
331 });
332 }
333 });
334 If (canGrow. greaterThan ( 0.5 ), () => {
335 const before = e;
336 const after = max ( 0.0 , e. sub (u.cellRestore. mul (rateMul). mul (u.dt)));
337 refrost. assign ( min ( 1.0 , refrost. add (before. sub (after). mul ( 2.5 ))));
338 e. assign (after);
339 });
340 },
341 );
342 If (u.heal. greaterThan ( 0.5 ). and (cellReady. or (u.reconstruct. greaterThan ( 0.5 ))), () => {
343 // voxels without a grain of their own follow neighbours that a grain has already rebuilt
344 const nbMin = float ( 8 ). toVar ();
345 const nbMark = float ( 0 ). toVar ();
346 const offs = [
347 ivec3 ( 1 , 0 , 0 ),
348 ivec3 ( - 1 , 0 , 0 ),
349 ivec3 ( 0 , 1 , 0 ),
350 ivec3 ( 0 , - 1 , 0 ),
351 ivec3 ( 0 , 0 , 1 ),
352 ivec3 ( 0 , 0 , - 1 ),
353 ];
354 for ( const o of offs) {
355 const cc = clamp ( ivec3 (c). add (o), ivec3 ( 0 ), ivec3 (res - 1 ));
356 const nv = readTex. load (cc). level ( 0 );
357 If (nv.b. greaterThan ( 0.5 ), () => {
358 nbMin. assign ( min (nbMin, nv.r));
359 nbMark. assign ( 1.0 );
360 });
361 }
362 // Spatial front from deposited material; an additive gap per voxel left permanent holes.
363 const reconstruct = u.reconstruct. greaterThan ( 0.5 );
364 const target = select (
365 reconstruct,
366 select (nbMin. lessThan ( 0.05 ), float ( 0 ), float ( 1 )),
367 nbMin. add (u.healGap),
368 );
369 If (nbMark. greaterThan ( 0.5 ). and (target. lessThan (e)), () => {
370 const rate = select (
371 reconstruct,
372 max (u.cellRestore. mul (u.fallbackHeal), u.healRate),
373 u.healRate,
374 );
375 const after = max (target, e. sub (rate. mul (rateMul). mul (u.dt)));
376 refrost. assign ( min ( 1.0 , refrost. add (e. sub (after). mul ( 3.0 ))));
377 e. assign (after);
378 If (after. lessThanEqual (target. add ( 0.001 )), () => {
379 mark. assign ( 1.0 );
380 });
381 });
382 // fallback cleanup only after the whole wave has landed (never ahead of the grains)
383 If (u.fallbackOn. greaterThan ( 0.5 ). and (reconstruct. not ()), () => {
384 const after2 = max ( 0.0 , e. sub (u.fallbackHeal. mul (rateMul). mul (u.dt)));
385 refrost. assign ( min ( 1.0 , refrost. add (e. sub (after2). mul ( 3.0 ))));
386 e. assign (after2);
387 });
388 });
389 // anything that eroded this frame is no longer "rebuilt"
390 If (e. greaterThan (cur.r. add ( 0.0005 )), () => {
391 mark. assign ( 0.0 );
392 });
393 refrost. assign ( max ( 0.0 , refrost. sub (u.dt. mul (rateMul). div ( max (u.refrostTime, 0.01 )))));
394 // keep the field zero outside the shape so the surface sampling never reads garbage
395 e. assign ( select (sd. greaterThan (shell), 0.0 , e));
396 textureStore ( this .scratch, c, vec4 (e, refrost, mark, hit)). toWriteOnly ();
397 })(). compute (total, [ 64 ]);
398
399 // --- clear the break-cell restore triggers (after the step consumed them); the in-flight counters only
400 // clear on reset
401 this .cellClearNode = Fn (() => {
402 atomicStore ( this .atomics. element ( uint ( this .cellOffset). add (instanceIndex)), uint ( 0 ));
403 })(). compute ( 65536 , [ 64 ]);
404 this .flightClearNode = Fn (() => {
405 atomicStore ( this .atomics. element ( uint ( this .flightOffset). add (instanceIndex)), uint ( 0 ));
406 })(). compute ( 65536 , [ 64 ]);
407
408 // --- copy scratch -> tex
409 this .copyNode = Fn (() => {
410 const c = coord ();
411 const v = readScratch. load ( ivec3 (c)). level ( 0 );
412 textureStore ( this .tex, c, v). toWriteOnly ();
413 })(). compute (total, [ 64 ]);
414
415 // --- stats reduction (debug HUD): max erosion + voxel counts above thresholds
416 this .statsBuf = instancedArray ( 4 , "uint" ). toAtomic ();
417 this .statsClearNode = Fn (() => {
418 Loop ( 4 , ({ i : k } : any ) => {
419 atomicStore ( this .statsBuf. element (k), uint ( 0 ));
420 });
421 })(). compute ( 1 , [ 1 ]);
422 this .statsNode = Fn (() => {
423 const c = coord ();
424 const v = readTex. load ( ivec3 (c)). level ( 0 );
425 const e = v.r;
426 atomicMax ( this .statsBuf. element ( 0 ), uint (e. mul ( 65535.0 )));
427 atomicMax ( this .statsBuf. element ( 3 ), uint (v.g. mul ( 65535.0 )));
428 If (e. greaterThan ( 0.5 ), () => {
429 atomicAdd ( this .statsBuf. element ( 1 ), uint ( 1 ));
430 });
431 If (e. greaterThan ( 0.9 ), () => {
432 atomicAdd ( this .statsBuf. element ( 2 ), uint ( 1 ));
433 });
434 })(). compute (total, [ 64 ]);
435
436 // --- clear both
437 this .clearNode = Fn (() => {
438 const c = coord ();
439 textureStore ( this .tex, c, vec4 ( 0.0 )). toWriteOnly ();
440 })(). compute (total, [ 64 ]);
441
442 // --- FROST: the whole shape fully eroded (a shape that is not there yet: the grains heal it in), and the
443 // heal-grid counters cleared (both used when the distance field is retargeted to another shape)
444 this .fillNode = Fn (() => {
445 const c = coord ();
446 const sd = this .shape. sdfNode ( toObject (c));
447 textureStore (
448 this .tex,
449 c,
450 vec4 ( select (sd. greaterThan (shell), 0.0 , 1.0 ), 0.0 , 0.0 , 0.0 ),
451 ). toWriteOnly ();
452 })(). compute (total, [ 64 ]);
453 this .healClearNode = Fn (() => {
454 atomicStore ( this .atomics. element ( uint ( this .healOffset). add (instanceIndex)), uint ( 0 ));
455 })(). compute (total, [ 64 ]);
456
457 // --- bake the crack helper: a low-frequency domain-warp vector (so cell boundaries curve instead of
458 // reading as flat polygons) and the proximity to the nearest (warped, partially covered) boundary
459 this .bakeNode = Fn (() => {
460 const c = coord ();
461 const p = toObject (c). toVar ();
462 const wq = p. mul (u.crackWarpScale). add (u.seed. mul ( 0.11 ));
463 const warp = vec3 (
464 gnoise (wq)
465 . mul ( 0.6 )
466 . add ( gnoise (wq. mul ( 2.1 ). add ( vec3 ( 7.3 , 1.9 , 4.4 ))). mul ( 0.4 )),
467 gnoise (wq. add ( vec3 ( 13.7 , 5.1 , 9.9 )))
468 . mul ( 0.6 )
469 . add ( gnoise (wq. mul ( 2.1 ). add ( vec3 ( 2.2 , 8.8 , 6.1 ))). mul ( 0.4 )),
470 gnoise (wq. add ( vec3 ( 3.3 , 17.1 , 12.5 )))
471 . mul ( 0.6 )
472 . add ( gnoise (wq. mul ( 2.1 ). add ( vec3 ( 9.7 , 4.4 , 1.1 ))). mul ( 0.4 )),
473 );
474 const q = ErosionField. warpDomain (p, warp, u.crackScale, u.crackWarp);
475 const ve = voronoiEdge (q, u.seed, u.crackCoverage);
476 const dEdge = ve.w. div (u.crackScale); // approx object-space distance
477 const prox = smoothstep (voxel * 2.5 , 0.0 , dEdge);
478 const h = hash31 ( vec3 (c)). mul ( 0.15 );
479 textureStore ( this .crackTex, c, vec4 (warp, saturate (prox. add (h. mul (prox))))). toWriteOnly ();
480 // break cells (a separate, finer Voronoi in the same warped domain): centre back in object space
481 const bq = ErosionField. warpDomain (p, warp, u.breakScale, u.crackWarp);
482 const bc = voronoiCell (bq, u.seed. add ( 3.0 ));
483 const centreObj = bc.xyz. sub (warp. mul (u.crackWarp)). div (u.breakScale);
484 textureStore ( this .cellTex, c, vec4 (centreObj, hash31 (bc.xyz. add (u.seed)))). toWriteOnly ();
485 })(). compute (total, [ 64 ]);
486 void abs;
487 void mix;
488 }
489
490 clear ( renderer : THREE . WebGPURenderer ) {
491 renderer. compute ( this .clearNode);
492 renderer. compute ( this .cellClearNode);
493 renderer. compute ( this .flightClearNode);
494 }
495
496 /**
497 * The shape behind `this.shape.sdfNode` changed: re-bake the crack / break-cell helpers, reset the heal grid
498 * and cell counters, start solid (`fill` false) or fully eroded (`fill` true, healed in by returning grains).
499 */
500 rebake ( renderer : THREE . WebGPURenderer , fill : boolean ) {
501 const u = this .u,
502 E = D .erosion,
503 C = D .ice.cracks;
504 u.crackScale.value = C .largeScale;
505 u.crackWarp.value = C .warp;
506 u.crackWarpScale.value = C .warpScale;
507 u.crackCoverage.value = C .coverage;
508 u.breakScale.value = E .breakCellScale;
509 // Helpers depend on the shared bound and crack parameters, never the shape.
510 const sig = `${ C . largeScale }|${ C . warp }|${ C . warpScale }|${ C . coverage }|${ E . breakCellScale }` ;
511 if (( ! this .baked || sig !== this .lastCrackSig) && ! this .skipBake)
512 renderer. compute ( this .bakeNode);
513 this .lastCrackSig = sig;
514 this .baked = true ;
515 u.reconstruct.value = fill ? 1 : 0 ;
516 renderer. compute (fill ? this .fillNode : this .clearNode);
517 renderer. compute ( this .cellClearNode);
518 renderer. compute ( this .flightClearNode);
519 renderer. compute ( this .healClearNode);
520 this .crumbleUntil = - 1 ;
521 u.crumbleUntil.value = - 1 ;
522 }
523
524 /** Debug: reduce the field and read the result back asynchronously (throttled by the caller). */
525 readStats ( renderer : THREE . WebGPURenderer , t = 0 ) {
526 if ( this .statsPending) return ;
527 this .statsPending = true ;
528 this .statsIssuedT = t;
529 renderer. compute ( this .statsClearNode);
530 renderer. compute ( this .statsNode);
531 renderer
532 . getArrayBufferAsync ( this .statsBuf.value)
533 . then (( buf : ArrayBuffer ) => {
534 const a = new Uint32Array (buf);
535 this .stats.max = a[ 0 ] / 65535 ;
536 this .stats.over50 = a[ 1 ];
537 this .stats.over90 = a[ 2 ];
538 this .stats.refrost = a[ 3 ] / 65535 ;
539 this .statsReadT = this .statsIssuedT;
540 this .statsPending = false ;
541 })
542 . catch (() => {
543 this .statsPending = false ;
544 });
545 }
546
547 /** True while a stroke, the crumble window, or (refrost) healing can still change the field. */
548 crumbleUntil = - 1 ;
549
550 /**
551 * Advance the field. `segments` holds MAX_SEGMENTS capsules as (ax,ay,az,radius),(bx,by,bz,strength).
552 * With `run` false only the bookkeeping happens (the GPU passes are skipped: the field is static).
553 */
554 step (
555 renderer : THREE . WebGPURenderer ,
556 t : number ,
557 dt : number ,
558 segments : THREE . Vector4 [],
559 segCount : number ,
560 run = true ,
561 ) {
562 const E = D .erosion,
563 H = D .healing;
564 const u = this .u;
565 u.dt.value = Math. min (dt, 1 / 30 );
566 u.time.value = t;
567 if ( D .version !== this .settingsVersion) {
568 this .settingsVersion = D .version;
569 const C = D .ice.cracks;
570 const crackSig = `${ C . largeScale }|${ C . warp }|${ C . warpScale }|${ C . coverage }|${ E . breakCellScale }` ;
571 if ( ! this .baked || crackSig !== this .lastCrackSig) {
572 u.crackScale.value = C .largeScale;
573 u.crackWarp.value = C .warp;
574 u.crackWarpScale.value = C .warpScale;
575 u.crackCoverage.value = C .coverage;
576 u.breakScale.value = E .breakCellScale;
577 if ( ! this .skipBake) renderer. compute ( this .bakeNode);
578 if ( ! this .baked) renderer. compute ( this .clearNode);
579 this .baked = true ;
580 this .lastCrackSig = crackSig;
581 }
582 u.healRate.value = H .healRate;
583 u.healGap.value = H .healGap;
584 u.fallbackHeal.value = H .fallbackHeal;
585 u.refrostTime.value = H .refrostTime;
586 u.cellRestore.value = H .cellRestore;
587 u.growEdges.value = H .growFromEdges ? 1 : 0 ;
588 u.breakup.value = H .breakup;
589 u.breakupScale.value = H .breakupScale;
590 u.crumbleRate.value = E .crumbleRate;
591 u.crumbleCrackBias.value = E .crumbleCrackBias;
592 u.brushNoise.value = E .brushNoise;
593 u.brushNoiseScale.value = E .brushNoiseScale;
594 u.cellSnap.value = E .cellSnap;
595 // Softness 0 makes the capsule falloff smoothstep(r, r, d): undefined in WGSL, 1 everywhere here (whole
596 // shape eroded in one frame); keep a sliver of softness.
597 u.brushSoftness.value = Math. max ( E .brushSoftness, 0.02 );
598 }
599 if (segCount > 0 ) {
600 sim.lastStrokeT = t;
601 u.crumbleUntil.value = t + E .crumbleDuration;
602 this .crumbleUntil = t + E .crumbleDuration;
603 }
604 const healing = t - sim.lastStrokeT > H .healDelay;
605 sim.healing = healing;
606 u.heal.value = healing ? 1 : 0 ;
607 // the timer-based cleanup may only start once every grain of the wave has had time to land
608 u.fallbackOn.value =
609 t - sim.lastStrokeT >
610 H .healDelay + H .waveTime + H .waveJitter + Math. max ( H .returnDuration, H .returnAfter) + 1.0
611 ? 1
612 : 0 ;
613 u.segCount.value = segCount;
614 if (segCount > 0 ) {
615 const arr = this .segments.array as THREE . Vector4 [];
616 for ( let i = 0 ; i < segCount * 2 ; i ++ ) arr[i]. copy (segments[i]);
617 }
618 if ( ! run || u.reconstruct.value > 0.5 ) return ; // AssemblyField owns arrival-driven coverage.
619 renderer. compute ( this .stepNode);
620 renderer. compute ( this .cellClearNode);
621 renderer. compute ( this .copyNode);
622 }
623
624 /** Commit the assembly pass before both the ice and powder read the same field. */
625 commitAssembly ( renderer : THREE . WebGPURenderer ) {
626 renderer. compute ( this .copyNode);
627 }
628
629 dispose () {
630 this .tex. dispose ();
631 this .scratch. dispose ();
632 this .crackTex. dispose ();
633 this .cellTex. dispose ();
634 }
635 }