@volter/threejs-runtime 0.5.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/package.json +62 -0
  4. package/src/adapter/body-marks.ts +69 -0
  5. package/src/adapter/constraint.ts +78 -0
  6. package/src/adapter/first-party-navigation-system.ts +48 -0
  7. package/src/adapter/hierarchy-marks.ts +156 -0
  8. package/src/adapter/ingest/scene-capture.ts +865 -0
  9. package/src/adapter/ingest/structural-ids.ts +139 -0
  10. package/src/adapter/ingest/visible-capture-window.ts +302 -0
  11. package/src/adapter/object3d-authoring-subject.ts +50 -0
  12. package/src/adapter/rapier-physics-adapter.ts +226 -0
  13. package/src/adapter/reflection-probe.ts +75 -0
  14. package/src/adapter/renderer-config.ts +116 -0
  15. package/src/adapter/trigger-volume.ts +29 -0
  16. package/src/ai/navigation.ts +627 -0
  17. package/src/animation/animation-clock.ts +479 -0
  18. package/src/animation/runtime-inspection.ts +45 -0
  19. package/src/animation/xstate-animation-binding.ts +655 -0
  20. package/src/animation/xstate-animation-meta.ts +360 -0
  21. package/src/asset-formats/camera.ts +18 -0
  22. package/src/asset-formats/collider.ts +66 -0
  23. package/src/asset-formats/index.ts +45 -0
  24. package/src/asset-formats/light.ts +50 -0
  25. package/src/asset-formats/material.ts +93 -0
  26. package/src/asset-formats/mesh.ts +78 -0
  27. package/src/asset-formats/particles.ts +426 -0
  28. package/src/asset-formats/render-env.ts +318 -0
  29. package/src/asset-formats/tuples.ts +21 -0
  30. package/src/asset-loaders.ts +242 -0
  31. package/src/asset-parse-error.ts +29 -0
  32. package/src/assets.ts +68 -0
  33. package/src/behavior/xstate-inspection.ts +51 -0
  34. package/src/defaults.ts +135 -0
  35. package/src/ecs/object-marks.ts +75 -0
  36. package/src/ecs/scene-index.ts +439 -0
  37. package/src/ecs/scene-query.ts +43 -0
  38. package/src/ecs/user-data.ts +272 -0
  39. package/src/loader.ts +134 -0
  40. package/src/physics/collider-dimensions.ts +121 -0
  41. package/src/physics/collision-system.ts +76 -0
  42. package/src/physics/physics-registry.ts +83 -0
  43. package/src/physics/transform-writer.ts +41 -0
  44. package/src/render/directional-shadow-fit.ts +158 -0
  45. package/src/render/environment-capture.ts +133 -0
  46. package/src/render/ibl-override-material.ts +202 -0
  47. package/src/render/light-camera-factory.ts +103 -0
  48. package/src/render/matcap-texture.ts +92 -0
  49. package/src/render/material-factory.ts +201 -0
  50. package/src/render/mesh-shadow.ts +18 -0
  51. package/src/render/particles-factory.ts +589 -0
  52. package/src/render/render-batch-system.ts +260 -0
  53. package/src/render/render-features.ts +128 -0
  54. package/src/render/render-settings.ts +71 -0
  55. package/src/render/soft-particle-depth.ts +360 -0
  56. package/src/render/spark-renderer-lifecycle.ts +64 -0
  57. package/src/render/structural-signature.ts +231 -0
  58. package/src/render/viewport-shading.ts +163 -0
  59. package/src/render/voxel-surface.ts +358 -0
  60. package/src/setup/setup-particles.ts +32 -0
  61. package/src/setup/setup-physics.ts +67 -0
  62. package/src/setup/setup-renderer.ts +524 -0
  63. package/src/types-n8ao.d.ts +37 -0
  64. package/src/types-realism-effects.d.ts +61 -0
  65. package/vendor/realism-effects/LICENSE.md +21 -0
  66. package/vendor/realism-effects/UPSTREAM.md +19 -0
  67. package/vendor/realism-effects/dist/index.cjs +3447 -0
  68. package/vendor/realism-effects/dist/index.d.ts +59 -0
  69. package/vendor/realism-effects/dist/index.js +3434 -0
  70. package/vendor/realism-effects/package.json +23 -0
@@ -0,0 +1,589 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: quarks' API uses untyped FunctionJSON internally; our Zod types are structurally compatible but TS can't unify them
2
+ /**
3
+ * Converts ParticlesDescriptor JSON → three.quarks ParticleSystem.
4
+ *
5
+ * A published engine entry point (the export map is the wildcard `./*`): user
6
+ * games outside this repo reach it directly, and the in-repo callers are the
7
+ * editor's own selection-bounds proof and `defaultParticlesData`'s consumers.
8
+ * The schema mirrors quarks' native JSON format, so most fields pass through
9
+ * directly to quarks' fromJSON or constructor methods.
10
+ *
11
+ * It used to name `godot-compat`'s `createCpuParticles3D` as its one in-repo
12
+ * caller; that lane is ARCHIVED off main — `git fetch origin
13
+ * archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
14
+ */
15
+
16
+ import {
17
+ ApplyForce,
18
+ ChangeEmitDirection,
19
+ CircleEmitter,
20
+ type ColorGenerator,
21
+ ColorGeneratorFromJSON,
22
+ ColorOverLife,
23
+ ConeEmitter,
24
+ DonutEmitter,
25
+ type EmitterShape,
26
+ ForceOverLife,
27
+ FrameOverLife,
28
+ type FunctionColorGenerator,
29
+ GravityForce,
30
+ GridEmitter,
31
+ HemisphereEmitter,
32
+ LimitSpeedOverLife,
33
+ Noise,
34
+ OrbitOverLife,
35
+ PointEmitter,
36
+ Vector3 as QVector3,
37
+ RectangleEmitter,
38
+ RotationOverLife,
39
+ SizeOverLife,
40
+ SpeedOverLife,
41
+ SphereEmitter,
42
+ TurbulenceField,
43
+ ValueGeneratorFromJSON,
44
+ WidthOverLength,
45
+ } from 'quarks.core';
46
+ import * as THREE from 'three';
47
+ import { type BatchedRenderer, ParticleSystem, RenderMode } from 'three.quarks';
48
+ import type {
49
+ BehaviorJSON,
50
+ ColorGeneratorJSON,
51
+ EmitterShapeJSON,
52
+ ParticlesDescriptor,
53
+ ValueGeneratorJSON,
54
+ } from '../asset-formats/particles';
55
+ import { armSoftParticleDepth, disarmSoftParticleDepth } from './soft-particle-depth';
56
+
57
+ // --- Render mode mapping ---
58
+
59
+ const RENDER_MODE_MAP: Record<string, RenderMode> = {
60
+ billboard: RenderMode.BillBoard,
61
+ stretchedBillboard: RenderMode.StretchedBillBoard,
62
+ mesh: RenderMode.Mesh,
63
+ trail: RenderMode.Trail,
64
+ horizontalBillboard: RenderMode.HorizontalBillBoard,
65
+ verticalBillboard: RenderMode.VerticalBillBoard,
66
+ };
67
+
68
+ // --- Texture loader (shared cache) ---
69
+
70
+ import { textureLoader } from '../loader';
71
+
72
+ type ParticleMapSampler = NonNullable<ParticlesDescriptor['material']['mapSampler']>;
73
+
74
+ const WRAP: Record<NonNullable<ParticleMapSampler['wrap']>, THREE.Wrapping> = {
75
+ clamp: THREE.ClampToEdgeWrapping,
76
+ repeat: THREE.RepeatWrapping,
77
+ mirror: THREE.MirroredRepeatWrapping,
78
+ };
79
+
80
+ /** `minFilter` is where the two sampler axes MEET — `filter` chooses linear vs nearest and
81
+ * `mipmaps` chooses whether the mip chain is sampled at all — and three spells the four
82
+ * combinations as four constants. A table rather than nested conditionals, which read as a
83
+ * puzzle. (`magFilter` has no mip half; it is the `filter` axis alone.) */
84
+ const MIN_FILTER = {
85
+ 'linear/mips': THREE.LinearMipmapLinearFilter,
86
+ 'linear/no-mips': THREE.LinearFilter,
87
+ 'nearest/mips': THREE.NearestMipmapNearestFilter,
88
+ 'nearest/no-mips': THREE.NearestFilter,
89
+ } as const;
90
+
91
+ const particleTexCache = new Map<string, THREE.Texture>();
92
+
93
+ /**
94
+ * The sprite texture for a material's `map`, loaded once per URL+sampler.
95
+ *
96
+ * Two things are decided here rather than left to three's loader defaults:
97
+ *
98
+ * - **COLOUR SPACE.** `THREE.TextureLoader` leaves `colorSpace` at `NoColorSpace`, which tells
99
+ * three the texels are already linear — so an ordinary sRGB PNG sprite is composited
100
+ * UNDECODED and every mid-tone comes out far too bright. A `map` is a colour texture by
101
+ * definition (three's own `GLTFLoader` states `SRGBColorSpace` on `baseColorTexture` for the
102
+ * same reason), so this states sRGB unconditionally. It is not a descriptor field: there is no
103
+ * correct second value for this slot. Measured 2026-08-14 against real Godot 3.6/GLES3 — a
104
+ * 128,128,128 sprite read back 0.5015 there and 0.7373 here, and after this line 0.5019; the
105
+ * whole A/B is in `godot-compat/cpu-particles-3d.ts`'s header.
106
+ * - **SAMPLER.** `material.mapSampler`, when the descriptor carries one. The cache key includes
107
+ * it because two emitters may name ONE image and want different sampling of it — a URL-only
108
+ * key would hand the second one the first's configured texture.
109
+ *
110
+ * `flipY` is deliberately untouched: the billboard quad this shader draws is built by
111
+ * `three.quarks` with three's own V-up UVs, so three's `flipY = true` is the consistent half of
112
+ * that pair.
113
+ */
114
+ function loadParticleTexture(url: string, sampler?: ParticleMapSampler): THREE.Texture {
115
+ const key = sampler === undefined ? url : `${url}\u0000${JSON.stringify(sampler)}`;
116
+ const cached = particleTexCache.get(key);
117
+ if (cached) return cached;
118
+ const tex = textureLoader.load(url);
119
+ tex.colorSpace = THREE.SRGBColorSpace;
120
+ if (sampler !== undefined) applyMapSampler(tex, sampler);
121
+ particleTexCache.set(key, tex);
122
+ return tex;
123
+ }
124
+
125
+ /** One `mapSampler` onto three's own texture properties. An unstated field is left at three's
126
+ * default rather than restated — see {@link loadParticleTexture}. */
127
+ function applyMapSampler(tex: THREE.Texture, sampler: ParticleMapSampler): void {
128
+ if (sampler.wrap !== undefined) {
129
+ tex.wrapS = WRAP[sampler.wrap];
130
+ tex.wrapT = WRAP[sampler.wrap];
131
+ }
132
+ if (sampler.filter !== undefined || sampler.mipmaps !== undefined) {
133
+ // An unstated axis takes three's own default, which is linear and mipmapped.
134
+ const filter = sampler.filter ?? 'linear';
135
+ const mips = sampler.mipmaps === false ? 'no-mips' : 'mips';
136
+ if (sampler.filter !== undefined) {
137
+ tex.magFilter = filter === 'linear' ? THREE.LinearFilter : THREE.NearestFilter;
138
+ }
139
+ tex.minFilter = MIN_FILTER[`${filter}/${mips}`];
140
+ }
141
+ if (sampler.mipmaps !== undefined) tex.generateMipmaps = sampler.mipmaps;
142
+ // Anisotropy rides through unclamped: three's own `WebGLTextures` uploads
143
+ // `Math.min(texture.anisotropy, capabilities.getMaxAnisotropy())`, so the renderer's real
144
+ // capability is what binds and a second clamp here would have no effect.
145
+ if (sampler.anisotropy !== undefined) tex.anisotropy = sampler.anisotropy;
146
+ }
147
+
148
+ // --- Helpers ---
149
+
150
+ /** Strip keys with undefined values so exactOptionalPropertyTypes is satisfied. */
151
+ function defined(obj: Record<string, any>): any {
152
+ const result: Record<string, unknown> = {};
153
+ for (const [k, v] of Object.entries(obj)) {
154
+ if (v !== undefined) result[k] = v;
155
+ }
156
+ return result;
157
+ }
158
+
159
+ // --- Emitter shape ---
160
+
161
+ function createEmitterShape(json: EmitterShapeJSON) {
162
+ switch (json.type) {
163
+ case 'point':
164
+ return new PointEmitter();
165
+ case 'sphere':
166
+ return new SphereEmitter(
167
+ defined({ radius: json.radius, arc: json.arc, thickness: json.thickness }),
168
+ );
169
+ case 'hemisphere':
170
+ return new HemisphereEmitter(
171
+ defined({ radius: json.radius, arc: json.arc, thickness: json.thickness }),
172
+ );
173
+ case 'cone':
174
+ return new ConeEmitter(
175
+ defined({
176
+ radius: json.radius,
177
+ arc: json.arc,
178
+ thickness: json.thickness,
179
+ angle: json.angle,
180
+ }),
181
+ );
182
+ case 'circle':
183
+ return new CircleEmitter(
184
+ defined({ radius: json.radius, arc: json.arc, thickness: json.thickness }),
185
+ );
186
+ case 'donut':
187
+ return new DonutEmitter(
188
+ defined({
189
+ radius: json.radius,
190
+ arc: json.arc,
191
+ thickness: json.thickness,
192
+ donutRadius: json.donutRadius,
193
+ }),
194
+ );
195
+ case 'rectangle':
196
+ return new RectangleEmitter(
197
+ defined({ width: json.width, height: json.height, thickness: json.thickness }),
198
+ );
199
+ case 'grid':
200
+ return new GridEmitter(
201
+ defined({ width: json.width, height: json.height, column: json.column, row: json.row }),
202
+ );
203
+ }
204
+ }
205
+
206
+ // --- Value / Color generators ---
207
+ // Our Zod-inferred types are structurally identical to quarks' FunctionJSON
208
+ // but TypeScript can't unify them, so we cast through the untyped bridge.
209
+
210
+ function valueGen(json: ValueGeneratorJSON) {
211
+ return ValueGeneratorFromJSON(json as any);
212
+ }
213
+
214
+ function colorGen(json: ColorGeneratorJSON) {
215
+ return ColorGeneratorFromJSON(json as any);
216
+ }
217
+
218
+ // --- Behaviors ---
219
+
220
+ function createBehavior(json: BehaviorJSON): any {
221
+ switch (json.type) {
222
+ case 'ApplyForce':
223
+ return new ApplyForce(
224
+ new QVector3(json.direction[0], json.direction[1], json.direction[2]),
225
+ valueGen(json.magnitude) as any,
226
+ );
227
+ case 'ColorOverLife':
228
+ return new ColorOverLife(colorGen(json.color) as any);
229
+ case 'SizeOverLife':
230
+ return new SizeOverLife(valueGen(json.size) as any);
231
+ case 'SpeedOverLife':
232
+ return new SpeedOverLife(valueGen(json.speed) as any);
233
+ case 'RotationOverLife':
234
+ return new RotationOverLife(valueGen(json.angularVelocity) as any);
235
+ case 'ForceOverLife':
236
+ return new ForceOverLife(
237
+ valueGen(json.x) as any,
238
+ valueGen(json.y) as any,
239
+ valueGen(json.z) as any,
240
+ );
241
+ case 'OrbitOverLife':
242
+ return new OrbitOverLife(
243
+ valueGen(json.orbitSpeed) as any,
244
+ json.axis ? new QVector3(json.axis[0], json.axis[1], json.axis[2]) : undefined,
245
+ );
246
+ case 'Noise':
247
+ return new Noise(
248
+ valueGen(json.frequency) as any,
249
+ valueGen(json.power) as any,
250
+ json.positionAmount ? (valueGen(json.positionAmount) as any) : undefined,
251
+ json.rotationAmount ? (valueGen(json.rotationAmount) as any) : undefined,
252
+ );
253
+ case 'TurbulenceField':
254
+ return new TurbulenceField(
255
+ new QVector3(json.scale[0], json.scale[1], json.scale[2]),
256
+ json.octaves,
257
+ new QVector3(
258
+ json.velocityMultiplier[0],
259
+ json.velocityMultiplier[1],
260
+ json.velocityMultiplier[2],
261
+ ),
262
+ new QVector3(json.timeScale[0], json.timeScale[1], json.timeScale[2]),
263
+ );
264
+ case 'FrameOverLife':
265
+ return new FrameOverLife(valueGen(json.frame) as any);
266
+ case 'LimitSpeedOverLife':
267
+ return new LimitSpeedOverLife(valueGen(json.speed) as any, json.dampen);
268
+ case 'ChangeEmitDirection':
269
+ return new ChangeEmitDirection(valueGen(json.angle) as any);
270
+ case 'GravityForce':
271
+ return new GravityForce(
272
+ new QVector3(json.center[0], json.center[1], json.center[2]),
273
+ json.magnitude,
274
+ );
275
+ case 'WidthOverLength':
276
+ return new WidthOverLength(valueGen(json.width) as any);
277
+ }
278
+ }
279
+
280
+ // --- Material ---
281
+
282
+ function createParticleMaterial(mat: ParticlesDescriptor['material']): THREE.Material {
283
+ const textures: Record<string, THREE.Texture> = {};
284
+ if (mat.map) textures['map'] = loadParticleTexture(mat.map, mat.mapSampler);
285
+
286
+ const blending = mat.blending === 'normal' ? THREE.NormalBlending : THREE.AdditiveBlending;
287
+ const transparent = mat.transparent !== false;
288
+ const depthWrite = mat.depthWrite ?? false;
289
+ const side =
290
+ mat.side === 'back'
291
+ ? THREE.BackSide
292
+ : mat.side === 'double'
293
+ ? THREE.DoubleSide
294
+ : THREE.FrontSide;
295
+
296
+ if (mat.type === 'standard') {
297
+ return new THREE.MeshStandardMaterial({
298
+ color: mat.color ?? '#ffffff',
299
+ ...textures,
300
+ blending,
301
+ transparent,
302
+ depthWrite,
303
+ side,
304
+ });
305
+ }
306
+
307
+ return new THREE.MeshBasicMaterial({
308
+ color: mat.color ?? '#ffffff',
309
+ ...textures,
310
+ blending,
311
+ transparent,
312
+ depthWrite,
313
+ side,
314
+ });
315
+ }
316
+
317
+ // --- Default sprite texture (soft radial gradient) ---
318
+
319
+ let _defaultSpriteTex: THREE.Texture | null = null;
320
+
321
+ function getDefaultSpriteTex(): THREE.Texture {
322
+ if (_defaultSpriteTex) return _defaultSpriteTex;
323
+ const canvas = document.createElement('canvas');
324
+ canvas.width = 16;
325
+ canvas.height = 16;
326
+ const c2d = canvas.getContext('2d')!;
327
+ const grad = c2d.createRadialGradient(8, 8, 0, 8, 8, 8);
328
+ grad.addColorStop(0, 'rgba(255,255,255,1)');
329
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
330
+ c2d.fillStyle = grad;
331
+ c2d.fillRect(0, 0, 16, 16);
332
+ _defaultSpriteTex = new THREE.CanvasTexture(canvas);
333
+ return _defaultSpriteTex;
334
+ }
335
+
336
+ // --- Public API ---
337
+
338
+ export interface ParticleSystemResult {
339
+ /** The emitter Object3D (add to scene for transform) */
340
+ emitter: THREE.Object3D;
341
+ /** The ParticleSystem instance (register with BatchedRenderer) */
342
+ system: ParticleSystem;
343
+ }
344
+
345
+ /**
346
+ * The non-JSON companion to a {@link ParticlesDescriptor} — the runtime objects
347
+ * a JSON document cannot carry, and an authoring toggle. Every field is
348
+ * optional and the whole argument is optional, so existing single-argument
349
+ * callers are unchanged.
350
+ *
351
+ * This is how a caller with fidelity a plain descriptor cannot express reaches
352
+ * the SAME factory instead of hand-building its own `new ParticleSystem(...)`:
353
+ * a foreign-engine carry (e.g. Godot CPUParticles) supplies its own
354
+ * `EmitterShape`, start-colour generator, and instancing geometry here, and
355
+ * expresses everything else — lifecycle, scalar start values, emission,
356
+ * behaviors, material — as descriptor JSON.
357
+ */
358
+ export interface ParticleSystemObjects {
359
+ /**
360
+ * A pre-built emitter shape for a spawn distribution the JSON `shape` field
361
+ * cannot express (a `three.quarks` `EmitterShape` plugin instance). When
362
+ * present it REPLACES `data.shape`.
363
+ */
364
+ shape?: EmitterShape;
365
+ /**
366
+ * A pre-built start-colour generator for a per-particle draw the descriptor's
367
+ * JSON generator vocabulary cannot express. When present it REPLACES
368
+ * `data.startColor`.
369
+ */
370
+ startColor?: ColorGenerator | FunctionColorGenerator;
371
+ /**
372
+ * The geometry instanced per particle in `RenderMode.Mesh`. A
373
+ * `BufferGeometry` is not JSON-serializable, so `renderMode: 'mesh'` requires
374
+ * the caller to resolve and pass it here.
375
+ */
376
+ instancingGeometry?: THREE.BufferGeometry;
377
+ /**
378
+ * When the material has no texture `map`, the factory substitutes a soft
379
+ * radial default sprite so a freshly-authored billboard shows something.
380
+ * Pass `false` to keep the material's own (map-less) appearance — a faithful
381
+ * carry of a foreign engine whose particle draws a plain colored quad, and
382
+ * the way that carry avoids the default-sprite canvas entirely. Defaults to
383
+ * `true`.
384
+ */
385
+ defaultSprite?: boolean;
386
+ }
387
+
388
+ /**
389
+ * Create a three.quarks ParticleSystem from ParticlesDescriptor JSON data.
390
+ *
391
+ * `objects` carries the pieces a JSON descriptor cannot: a plugin emitter
392
+ * shape, a start-colour generator, a mesh-mode instancing geometry, and the
393
+ * default-sprite toggle. It is optional and additive — a call with only `data`
394
+ * behaves exactly as before.
395
+ */
396
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: straightforward field-by-field mapping
397
+ export function createParticleSystemFromData(
398
+ data: ParticlesDescriptor,
399
+ objects?: ParticleSystemObjects,
400
+ ): ParticleSystemResult {
401
+ const material = createParticleMaterial(data.material);
402
+
403
+ // Apply default sprite if no texture specified (unless the caller opts out to
404
+ // keep a map-less material faithful to its source).
405
+ if ((objects?.defaultSprite ?? true) && !data.material.map && 'map' in material) {
406
+ (material as THREE.MeshBasicMaterial).map = getDefaultSpriteTex();
407
+ }
408
+
409
+ // Build params, strip undefined keys for exactOptionalPropertyTypes compat
410
+ const params: Record<string, unknown> = { material };
411
+
412
+ if (data.autoDestroy != null) params['autoDestroy'] = data.autoDestroy;
413
+ if (data.looping != null) params['looping'] = data.looping;
414
+ if (data.prewarm != null) params['prewarm'] = data.prewarm;
415
+ if (data.duration != null) params['duration'] = data.duration;
416
+ // A pre-built shape (a distribution the JSON cannot express) wins over the
417
+ // descriptor's own shape; otherwise build the shape from JSON.
418
+ if (objects?.shape) params['shape'] = objects.shape;
419
+ else if (data.shape) params['shape'] = createEmitterShape(data.shape);
420
+ if (objects?.instancingGeometry) params['instancingGeometry'] = objects.instancingGeometry;
421
+
422
+ if (data.startLife) params['startLife'] = valueGen(data.startLife);
423
+ if (data.startSpeed) params['startSpeed'] = valueGen(data.startSpeed);
424
+ if (data.startSize) params['startSize'] = valueGen(data.startSize);
425
+ if (data.startRotation) params['startRotation'] = valueGen(data.startRotation);
426
+ if (objects?.startColor) params['startColor'] = objects.startColor;
427
+ else if (data.startColor) params['startColor'] = colorGen(data.startColor);
428
+ if (data.startTileIndex) params['startTileIndex'] = valueGen(data.startTileIndex);
429
+
430
+ // Trail mode requires startLength inside rendererEmitterSettings, not as a top-level param
431
+ if (data.renderMode === 'trail' && data.startLength) {
432
+ params['rendererEmitterSettings'] = { startLength: valueGen(data.startLength) };
433
+ }
434
+
435
+ if (data.emissionOverTime) params['emissionOverTime'] = valueGen(data.emissionOverTime);
436
+ if (data.emissionOverDistance)
437
+ params['emissionOverDistance'] = valueGen(data.emissionOverDistance);
438
+ if (data.emissionBursts) {
439
+ params['emissionBursts'] = data.emissionBursts.map((b) => ({
440
+ time: b.time,
441
+ count: valueGen(b.count),
442
+ cycle: b.cycle ?? 1,
443
+ interval: b.interval ?? 0,
444
+ probability: b.probability ?? 1,
445
+ }));
446
+ }
447
+
448
+ if (data.behaviors) params['behaviors'] = data.behaviors.map(createBehavior);
449
+ if (data.renderMode != null) params['renderMode'] = RENDER_MODE_MAP[data.renderMode];
450
+ if (data.worldSpace != null) params['worldSpace'] = data.worldSpace;
451
+ if (data.renderOrder != null) params['renderOrder'] = data.renderOrder;
452
+ if (data.uTileCount != null) params['uTileCount'] = data.uTileCount;
453
+ if (data.vTileCount != null) params['vTileCount'] = data.vTileCount;
454
+ if (data.blendTiles != null) params['blendTiles'] = data.blendTiles;
455
+ if (data.softParticles != null) params['softParticles'] = data.softParticles;
456
+ if (data.softFarFade != null) params['softFarFade'] = data.softFarFade;
457
+ if (data.softNearFade != null) params['softNearFade'] = data.softNearFade;
458
+
459
+ const ps = new ParticleSystem(params as any);
460
+
461
+ return { emitter: ps.emitter, system: ps };
462
+ }
463
+
464
+ /**
465
+ * Patch a RUNNING ParticleSystem in place from ParticlesDescriptor JSON — the
466
+ * editor's live-tune path (W1b). Unlike `createParticleSystemFromData`, this
467
+ * does NOT recreate the system: the particle pool, per-particle ages, and the
468
+ * emission clock all survive, so a curve-key drag reshapes the live emitter
469
+ * with no restart stutter.
470
+ *
471
+ * Covers the live-tunable subset: lifecycle scalars (duration/looping/
472
+ * prewarm), emitter shape, start-value generators, emission rates + bursts,
473
+ * and the behavior stack (behavior instances are rebuilt from JSON — quarks
474
+ * behaviors read their generators per-update, so existing particles pick the
475
+ * new curves up on the next frame).
476
+ *
477
+ * NOT covered (these change the render batch and must go through the normal
478
+ * teardown/rebuild path): material, renderMode, tiling/soft-particle render
479
+ * settings, worldSpace.
480
+ */
481
+ export function applyParticlesDataLive(system: ParticleSystem, data: ParticlesDescriptor): void {
482
+ if (data.duration != null) system.duration = data.duration;
483
+ if (data.looping != null) system.looping = data.looping;
484
+ if (data.prewarm != null) system.prewarm = data.prewarm;
485
+ if (data.shape) system.emitterShape = createEmitterShape(data.shape);
486
+ if (data.startLife) system.startLife = valueGen(data.startLife) as any;
487
+ if (data.startSpeed) system.startSpeed = valueGen(data.startSpeed) as any;
488
+ if (data.startSize) system.startSize = valueGen(data.startSize) as any;
489
+ if (data.startRotation) system.startRotation = valueGen(data.startRotation) as any;
490
+ if (data.startColor) system.startColor = colorGen(data.startColor) as any;
491
+ if (data.startTileIndex) system.startTileIndex = valueGen(data.startTileIndex) as any;
492
+ if (data.emissionOverTime) system.emissionOverTime = valueGen(data.emissionOverTime) as any;
493
+ if (data.emissionOverDistance)
494
+ system.emissionOverDistance = valueGen(data.emissionOverDistance) as any;
495
+ system.emissionBursts = (data.emissionBursts ?? []).map((b) => ({
496
+ time: b.time,
497
+ count: valueGen(b.count) as any,
498
+ cycle: b.cycle ?? 1,
499
+ interval: b.interval ?? 0,
500
+ probability: b.probability ?? 1,
501
+ }));
502
+ system.behaviors = (data.behaviors ?? []).map(createBehavior);
503
+ }
504
+
505
+ /**
506
+ * Scrub a ParticleSystem to an absolute time by deterministic re-simulation:
507
+ * restart, then advance in fixed steps to `time`, then pause. Used by the
508
+ * editor's emitter-transport time scrub. `update` is TS-private on
509
+ * ParticleSystem but is exactly what BatchedRenderer.update calls per system.
510
+ */
511
+ export function scrubParticleSystemTo(system: ParticleSystem, time: number, step = 1 / 60): void {
512
+ system.restart();
513
+ const target = Math.max(0, time);
514
+ let t = 0;
515
+ while (t < target) {
516
+ const dt = Math.min(step, target - t);
517
+ (system as any).update(dt);
518
+ t += dt;
519
+ }
520
+ system.pause();
521
+ }
522
+
523
+ /**
524
+ * Register a particle system with a BatchedRenderer.
525
+ *
526
+ * This is also the ONE seat that arms the scene-depth prepass: a system built
527
+ * with `softParticles` compiles its batch with three.quarks' `SOFT_PARTICLES`
528
+ * define, which samples a `depthTexture` uniform nothing else in this engine
529
+ * would ever fill (see `render/soft-particle-depth.ts` for what an unfilled one
530
+ * does to the frame). Arming here — rather than in
531
+ * {@link createParticleSystemFromData} — is what makes it correct: the uniform
532
+ * lives on the BATCH, and no batch exists until a renderer is handed the
533
+ * system.
534
+ */
535
+ export function registerParticleSystem(
536
+ batchedRenderer: BatchedRenderer,
537
+ system: ParticleSystem,
538
+ ): void {
539
+ batchedRenderer.addSystem(system);
540
+ if (system.softParticles) armSoftParticleDepth(batchedRenderer);
541
+ }
542
+
543
+ /**
544
+ * Unregister a particle system from a BatchedRenderer.
545
+ *
546
+ * Disarms the depth prepass — one decrement against the arming above, so a
547
+ * renderer hosting several soft systems stays armed until its last one leaves.
548
+ */
549
+ export function unregisterParticleSystem(
550
+ batchedRenderer: BatchedRenderer,
551
+ system: ParticleSystem,
552
+ ): void {
553
+ batchedRenderer.deleteSystem(system);
554
+ if (system.softParticles) disarmSoftParticleDepth(batchedRenderer);
555
+ }
556
+
557
+ /**
558
+ * Default particle data for creating a new particle emitter via AddSectionMenu.
559
+ */
560
+ export function defaultParticlesData(): ParticlesDescriptor {
561
+ return {
562
+ looping: true,
563
+ duration: 2,
564
+ shape: { type: 'cone', radius: 0.1, angle: 0.3 },
565
+ startLife: { type: 'IntervalValue', a: 0.5, b: 1.5 },
566
+ startSpeed: { type: 'IntervalValue', a: 1, b: 3 },
567
+ startSize: { type: 'IntervalValue', a: 0.05, b: 0.15 },
568
+ startColor: {
569
+ type: 'ConstantColor',
570
+ color: { r: 1, g: 0.8, b: 0.2, a: 1 },
571
+ },
572
+ emissionOverTime: { type: 'ConstantValue', value: 30 },
573
+ behaviors: [
574
+ {
575
+ type: 'SizeOverLife',
576
+ size: {
577
+ type: 'PiecewiseBezier',
578
+ functions: [{ function: { p0: 1, p1: 0.67, p2: 0.33, p3: 0 }, start: 0 }],
579
+ },
580
+ },
581
+ ],
582
+ material: {
583
+ type: 'basic',
584
+ blending: 'additive',
585
+ transparent: true,
586
+ depthWrite: false,
587
+ },
588
+ };
589
+ }