@woosh/meep-engine 3.18.0 → 3.19.0

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 (120) hide show
  1. package/build/bundle-worker-image-decoder.js +1 -1
  2. package/editor/view/particles/effect/ParticleCurveEditorView.d.ts.map +1 -1
  3. package/editor/view/particles/effect/ParticleCurveEditorView.js +303 -120
  4. package/editor/view/particles/effect/ParticleGradientEditorView.d.ts.map +1 -1
  5. package/editor/view/particles/effect/ParticleGradientEditorView.js +108 -63
  6. package/editor/view/particles/effect/ParticleGraphEditorView.js +1 -1
  7. package/editor/view/particles/effect/ParticleNodeParametersView.d.ts.map +1 -1
  8. package/editor/view/particles/effect/ParticleNodeParametersView.js +3 -1
  9. package/editor/view/particles/effect/particle-editor.css +60 -30
  10. package/package.json +1 -2
  11. package/samples/engine/README.md +1 -1
  12. package/src/core/binary/compression/decompress_bytes.d.ts +13 -0
  13. package/src/core/binary/compression/decompress_bytes.d.ts.map +1 -0
  14. package/src/core/binary/compression/decompress_bytes.js +28 -0
  15. package/src/core/model/node-graph/visual/layout/layout_assign_coordinates.js +67 -22
  16. package/src/engine/asset/loaders/image/ImageDecoderWorker.js +12 -27
  17. package/src/engine/asset/loaders/image/prototypePNG.js +8 -7
  18. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  19. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +4 -0
  20. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts +266 -0
  21. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts.map +1 -0
  22. package/src/engine/graphics/ecs/particles/ParticleEffect.js +455 -0
  23. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts +58 -0
  24. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts.map +1 -0
  25. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.js +219 -0
  26. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +178 -25
  27. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
  28. package/src/engine/graphics3/GPUParticleEmitterSystem.js +809 -310
  29. package/src/format/image/png/PNGReader.d.ts +7 -6
  30. package/src/format/image/png/PNGReader.d.ts.map +1 -1
  31. package/src/format/image/png/PNGReader.js +13 -12
  32. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts +3 -2
  33. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts.map +1 -1
  34. package/src/format/image/png/chunk/png_chunk_decode_iTXt.js +5 -4
  35. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts +3 -2
  36. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts.map +1 -1
  37. package/src/format/image/png/chunk/png_chunk_decode_zTXt.js +5 -4
  38. package/src/format/image/png/png_inflate.d.ts +3 -3
  39. package/src/format/image/png/png_inflate.d.ts.map +1 -1
  40. package/src/format/image/png/png_inflate.js +29 -39
  41. package/src/format/texture/ktx2/ktx2_read.d.ts +4 -4
  42. package/src/format/texture/ktx2/ktx2_read.d.ts.map +1 -1
  43. package/src/format/texture/ktx2/ktx2_read.js +18 -21
  44. package/src/shade/playground/particle_ecs/README.md +203 -0
  45. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts +39 -0
  46. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts.map +1 -0
  47. package/src/shade/playground/particle_ecs/bonfire_editor.js +315 -0
  48. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts +145 -0
  49. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts.map +1 -0
  50. package/src/shade/playground/particle_ecs/bonfire_effects.js +202 -0
  51. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts +23 -0
  52. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts.map +1 -0
  53. package/src/shade/playground/particle_ecs/bonfire_sprites.js +315 -0
  54. package/src/shade/playground/particle_ecs/bonfire_world.d.ts +86 -0
  55. package/src/shade/playground/particle_ecs/bonfire_world.d.ts.map +1 -0
  56. package/src/shade/playground/particle_ecs/bonfire_world.js +303 -0
  57. package/src/shade/playground/particle_ecs/effects/embers.json +1634 -0
  58. package/src/shade/playground/particle_ecs/effects/flame.json +1882 -0
  59. package/src/shade/playground/particle_ecs/effects/smoke.json +1860 -0
  60. package/src/shade/playground/particle_ecs/effects/soot.json +1606 -0
  61. package/src/shade/playground/particle_ecs/index.html +330 -0
  62. package/src/shade/playground/particle_ecs/main.d.ts +2 -0
  63. package/src/shade/playground/particle_ecs/main.d.ts.map +1 -0
  64. package/src/shade/playground/particle_ecs/main.js +566 -0
  65. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts +16 -0
  66. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts.map +1 -0
  67. package/src/shade/playground/particle_ecs/moonlit_environment.js +144 -0
  68. package/src/shade/playground/particle_editor/README.md +5 -4
  69. package/src/shade/playground/profile_hotkey.d.ts +58 -0
  70. package/src/shade/playground/profile_hotkey.d.ts.map +1 -0
  71. package/src/shade/playground/profile_hotkey.js +325 -0
  72. package/src/shade/playground/ssr_variance/README.md +105 -0
  73. package/src/shade/playground/ssr_variance/capture.d.ts +16 -0
  74. package/src/shade/playground/ssr_variance/capture.d.ts.map +1 -0
  75. package/src/shade/playground/ssr_variance/capture.js +96 -0
  76. package/src/shade/playground/ssr_variance/index.html +22 -0
  77. package/src/shade/playground/ssr_variance/main.d.ts +2 -0
  78. package/src/shade/playground/ssr_variance/main.d.ts.map +1 -0
  79. package/src/shade/playground/ssr_variance/main.js +246 -0
  80. package/src/shade/playground/ssr_variance/reference.d.ts +18 -0
  81. package/src/shade/playground/ssr_variance/reference.d.ts.map +1 -0
  82. package/src/shade/playground/ssr_variance/reference.js +78 -0
  83. package/src/shade/playground/ssr_variance/scene.d.ts +11 -0
  84. package/src/shade/playground/ssr_variance/scene.d.ts.map +1 -0
  85. package/src/shade/playground/ssr_variance/scene.js +61 -0
  86. package/src/shade/playground/ssr_variance/statistics.d.ts +44 -0
  87. package/src/shade/playground/ssr_variance/statistics.d.ts.map +1 -0
  88. package/src/shade/playground/ssr_variance/statistics.js +51 -0
  89. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts.map +1 -1
  90. package/src/shade/renderer/loader/gltf/tiny-gltf.js +9 -3
  91. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts +4 -4
  92. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts.map +1 -1
  93. package/src/shade/renderer/loader/usd/usd_decode_image.js +4 -4
  94. package/src/shade/renderer/particles/DESIGN.md +748 -703
  95. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +11 -4
  96. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
  97. package/src/shade/renderer/particles/runtime/ParticleEmitter.js +431 -424
  98. package/src/shade/renderer/postprocess/ssr/SSR.d.ts.map +1 -1
  99. package/src/shade/renderer/postprocess/ssr/SSR.js +2 -1
  100. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts.map +1 -1
  101. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.js +0 -6
  102. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts.map +1 -1
  103. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.js +19 -5
  104. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts +4 -0
  105. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts.map +1 -0
  106. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.js +133 -0
  107. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts +0 -8
  108. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts.map +1 -1
  109. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.js +16 -113
  110. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts +2 -2
  111. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts.map +1 -1
  112. package/src/shade/renderer/texture/source/texel_data_from_ktx2.js +3 -3
  113. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts +0 -20
  114. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts.map +0 -1
  115. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts +0 -14
  116. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts.map +0 -1
  117. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts +0 -50
  118. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts.map +0 -1
  119. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +0 -17
  120. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +0 -1
@@ -1,424 +1,431 @@
1
- import { assert } from "../../../../core/assert.js";
2
- import { is_typed_array_equals } from "../../../../core/collection/array/typed/is_typed_array_equals.js";
3
- import { float32_array_hash } from "../../../../core/collection/array/typed/float32_array_hash.js";
4
- import { hash_mix2 } from "../../../../core/math/hash/hash_mix2.js";
5
- import { hash_mix3 } from "../../../../core/math/hash/hash_mix3.js";
6
- import { computeHashFloat } from "../../../../core/primitives/numbers/computeHashFloat.js";
7
- import { computeStringHash } from "../../../../core/primitives/strings/computeStringHash.js";
8
- import { Node3D } from "../../scene/Node3D.js";
9
- import { EMITTER_ATTRIBUTE_ABSENT, make_emitter_flags } from "../data/PARTICLE_EMITTER_STRUCT.js";
10
-
11
- /**
12
- * Render channels an emitter can bind to one of its effect's per-particle attributes. The names
13
- * match the `render_*` fields of {@link ../data/PARTICLE_EMITTER_STRUCT.js}.
14
- *
15
- * @type {string[]}
16
- */
17
- export const EMITTER_RENDER_CHANNELS = Object.freeze([
18
- "position", "size", "color", "rotation", "frame", "velocity",
19
- ]);
20
-
21
- /**
22
- * How many record words the render pass reads for each channel.
23
- *
24
- * A channel is read at a fixed width — `chunk_particle_billboard_vertex.js` takes four words for
25
- * colour and three for position, whatever the attribute behind the offset happens to be. Binding one
26
- * to a narrower attribute is therefore not a smaller read but a read into whatever the layout put
27
- * next, and nothing downstream objects: the particles simply come out wrong.
28
- *
29
- * Binding is deliberately not made to enforce this — the offsets are the record's, and an author who
30
- * knows what is packed where may mean it — but an editor has this table so it can say so.
31
- *
32
- * @type {Object<string, number>}
33
- */
34
- export const EMITTER_RENDER_CHANNEL_COMPONENTS = Object.freeze({
35
- position: 3,
36
- size: 1,
37
- color: 4,
38
- rotation: 1,
39
- frame: 1,
40
- velocity: 3,
41
- });
42
-
43
- /**
44
- * One particle emitter: a scene node that runs a compiled effect.
45
- *
46
- * Being a {@link Node3D} is what places it. Add it to a scene and it gets a row in the scene
47
- * database's `transforms` table like any other node; parent it to a joint and the GPU hierarchy
48
- * pass composes its world matrix from the joint's every frame, animation included, with nothing
49
- * copied through the CPU. Remove it from the scene and the row goes with it — and so do its
50
- * particles, within a frame, because the emitter's table row is retired with it and its particles
51
- * carry that row's generation. There is no transform of its own to keep in step with anything.
52
- *
53
- * The rest is a description of an effect and nothing else: the attribute {@link layout} and the
54
- * compiled {@link program}, the {@link texture} to draw with, spawn rate, pre-warm duration, blend /
55
- * projection / feature flags, render-attribute bindings and flipbook grid. Every one of those is the author's,
56
- * means the same thing with no device present, and is what a saved effect is made of.
57
- *
58
- * There are no bounds among them, and there is nowhere here they could go. An emitter's particles
59
- * are simulated in world space by arbitrary bytecode: they go where the program sends them, at
60
- * whatever size it gives them, for as long as it keeps them alive. Nothing an author could write
61
- * down would bound that except "infinite", which culls nothing and marks every depth slice. So the
62
- * box is measured instead — from the live particles, by a pass of its own at the end of every frame
63
- * (`../bounds/`) — and it lives with the rest of the measured state in
64
- * {@link ../data/PARTICLE_EMITTER_STATE.js}. The one thing that IS the author's is whether the
65
- * emitter opts into culling at all: `EMITTER_FLAG.CULL` in {@link flags}.
66
- *
67
- * **Nothing here is the GPU's.** Which table row the emitter was given, which tenancy of it, where
68
- * its program was placed in the program heap, which `transforms` row its node ended up in, where
69
- * its texture was packed into the atlas — all of that is
70
- * {@link ./GPUParticleEmitterContext.js GPUParticleEmitterContext}, held by the
71
- * {@link ./EmitterRegistry.js} that registered the emitter and reachable through
72
- * {@link ./EmitterRegistry.js#context}. An emitter no registry holds has none, which is also how
73
- * "is this emitter live?" is asked. Nor is anything the GPU integrates per frame here — spawn
74
- * accumulator, age, sleep are {@link ../data/PARTICLE_EMITTER_STATE.js}, and they stay on the GPU.
75
- *
76
- * Fields are plain data with no setters. After changing any of them, set `needsUpdate = true` — the
77
- * node's own change counter, the same one the renderer reads to decide whether a transform needs
78
- * re-uploading — and {@link ./EmitterRegistry.js#flush} restages the row on its next flush. One
79
- * counter rather than a setter per field because a row is restaged whole either way: one changed
80
- * field and ten cost the same upload.
81
- */
82
- export class ParticleEmitter extends Node3D {
83
-
84
- /**
85
- * The per-particle attribute schema: which named attributes a particle of this emitter has, how
86
- * wide each is, and what word of the record it sits at.
87
- *
88
- * @type {import("../layout/ParticleLayout.js").ParticleLayout}
89
- */
90
- layout;
91
-
92
- /**
93
- * The compiled INIT/UPDATE bytecode run for every particle of this emitter.
94
- * Replace the immutable program to edit it; do not mutate its bytecode in place.
95
- *
96
- * @type {import("../isa/ParticleProgram.js").ParticleProgram}
97
- */
98
- program;
99
-
100
- /**
101
- * Image to draw the particles with, by URL, or `null` to draw against the default atlas — a
102
- * single white texel, so an untextured emitter is a flat quad of its colour.
103
- *
104
- * The ECS GPUParticleEmitterSystem loads this URL through AssetManager and packs the image
105
- * into a shared sheet. Standalone callers provide their own atlas and regions. Where
106
- * a given image lands in that sheet is the system's answer, not the author's — it changes on
107
- * every repack, without the emitter changing. The resolved patch is
108
- * {@link ./GPUParticleEmitterContext.js#atlas_region}.
109
- *
110
- * @type {string|null}
111
- */
112
- texture = null;
113
-
114
- /**
115
- * Base RNG seed.
116
- *
117
- * Two emitters running one program produce identical particles from identical seeds. `0` reads
118
- * as "unset", and the row written for such an emitter takes its registration's generation
119
- * instead, which no other emitter of that registry shares.
120
- *
121
- * @type {number}
122
- */
123
- seed = 0;
124
-
125
- /**
126
- * Continuous emission rate, particles per second. Integrated on the GPU by the emitter tick
127
- * pass; the CPU never counts particles.
128
- * @type {number}
129
- */
130
- spawn_rate = 0;
131
-
132
- /**
133
- * Seconds this emitter is run forward before it is first shown, `0` for none.
134
- *
135
- * A continuous effect — a smoke column, a dust field — starts empty and grows into its steady
136
- * state over one particle lifetime, in front of whoever is watching. Setting this to about the
137
- * effect's particle lifetime means the population that arrives on the first visible frame is the
138
- * one the emitter would have had by then: spawned, aged, and thinned by its own program over
139
- * that long, rather than a crowd of newborns.
140
- *
141
- * The author's number, and the only thing about a warm-up that is authored. It is not written to
142
- * the GPU record: the system queues an emitter that has one at the moment it is registered, and
143
- * the warm-up pipeline (`../warmup/`) runs its simulation forward on that frame, over a
144
- * population of its own, before moving the result into the scene. Longer costs a longer loop on
145
- * that one frame and nothing after it.
146
- *
147
- * Prewarm controls the initial population's age distribution. It is not needed to bootstrap
148
- * culling: emitters without a measured population have unknown bounds and remain eligible to spawn.
149
- *
150
- * @type {number}
151
- */
152
- prewarm = 0;
153
-
154
- /**
155
- * Packed feature / blend / projection bits — see {@link make_emitter_flags}.
156
- * @type {number}
157
- */
158
- flags = 0;
159
-
160
- /**
161
- * Flipbook grid: columns, rows, over whatever region {@link texture} was packed into. `[1, 1]`
162
- * is a single frame.
163
- * @type {Float32Array}
164
- */
165
- flipbook = new Float32Array([1, 1]);
166
-
167
- /** @type {number} record word offset of world position, or {@link EMITTER_ATTRIBUTE_ABSENT} */
168
- render_position = EMITTER_ATTRIBUTE_ABSENT;
169
- /** @type {number} record word offset of size */
170
- render_size = EMITTER_ATTRIBUTE_ABSENT;
171
- /** @type {number} record word offset of rgba colour */
172
- render_color = EMITTER_ATTRIBUTE_ABSENT;
173
- /** @type {number} record word offset of roll angle, radians */
174
- render_rotation = EMITTER_ATTRIBUTE_ABSENT;
175
- /** @type {number} record word offset of flipbook frame index */
176
- render_frame = EMITTER_ATTRIBUTE_ABSENT;
177
- /** @type {number} record word offset of velocity, read by the STRETCHED projection */
178
- render_velocity = EMITTER_ATTRIBUTE_ABSENT;
179
-
180
- get type() {
181
- return "ParticleEmitter";
182
- }
183
-
184
- /**
185
- * Bind render channels to per-particle attributes of this emitter's effect, by attribute name.
186
- * Channels left out of `channels` keep whatever they had; a channel that was never bound stays
187
- * at {@link EMITTER_ATTRIBUTE_ABSENT}, which the passes read as "this emitter has no such
188
- * attribute" — the render pass substitutes a default, the sort pass treats the particle as
189
- * being at the origin.
190
- *
191
- * The record stores word offsets rather than names because that is what the render and sort
192
- * passes index a particle record with; resolving here is the only place a name is involved.
193
- *
194
- * @param {Object<string,string>} channels channel name (see {@link EMITTER_RENDER_CHANNELS}) ->
195
- * attribute name in the effect's layout
196
- */
197
- bind_render(channels) {
198
- assert.isObject(channels, 'channels');
199
- assert.defined(this.layout, 'layout');
200
-
201
- const layout = this.layout;
202
-
203
- for (const channel of Object.keys(channels)) {
204
- const offset = layout.offsetOf(channels[channel]);
205
-
206
- switch (channel) {
207
- case "position": this.render_position = offset; break;
208
- case "size": this.render_size = offset; break;
209
- case "color": this.render_color = offset; break;
210
- case "rotation": this.render_rotation = offset; break;
211
- case "frame": this.render_frame = offset; break;
212
- case "velocity": this.render_velocity = offset; break;
213
- default:
214
- throw new Error(
215
- `Unknown render channel '${channel}', expected one of ${EMITTER_RENDER_CHANNELS.join(', ')}`
216
- );
217
- }
218
- }
219
-
220
- this.needsUpdate = true;
221
- }
222
-
223
- /**
224
- * Structural equality over everything an emitter *is*: where it sits, what it runs, what it
225
- * draws with, and how it is bound to the renderer. Two emitters that compare equal describe one
226
- * effect in one place, and one row would serve both.
227
- *
228
- * The node's identity is not part of it — `id`, parent and children say where an emitter is in
229
- * a scene, not what it is — and neither is anything the GPU assigned it, which is not here to
230
- * be compared. {@link transform_global} is left out too, being derived from
231
- * {@link transform_local} and the parent chain.
232
- *
233
- * @param {ParticleEmitter} other
234
- * @returns {boolean}
235
- */
236
- equals(other) {
237
- if (this === other) {
238
- return true;
239
- }
240
-
241
- return this.name === other.name
242
- && this.texture === other.texture
243
- && this.seed === other.seed
244
- && this.spawn_rate === other.spawn_rate
245
- && this.prewarm === other.prewarm
246
- && this.flags === other.flags
247
- && this.render_position === other.render_position
248
- && this.render_size === other.render_size
249
- && this.render_color === other.render_color
250
- && this.render_rotation === other.render_rotation
251
- && this.render_frame === other.render_frame
252
- && this.render_velocity === other.render_velocity
253
- && is_typed_array_equals(this.flipbook, other.flipbook)
254
- && this.transform_local.equals(other.transform_local)
255
- && this.layout.equals(other.layout)
256
- && this.program.equals(other.program);
257
- }
258
-
259
- /**
260
- * Hash over everything {@link equals} compares, in the same terms — the compiled program hashes
261
- * by its own bytecode rather than by object identity, so two emitters separately compiled from
262
- * one graph hash equally, as they compare equally.
263
- *
264
- * @returns {number} int32
265
- */
266
- hash() {
267
- const bindings = hash_mix3(
268
- hash_mix3(this.render_position, this.render_size, this.render_color),
269
- hash_mix3(this.render_rotation, this.render_frame, this.render_velocity),
270
- this.flags
271
- );
272
-
273
- const flipbook = float32_array_hash(this.flipbook, 0, this.flipbook.length);
274
-
275
- const appearance = hash_mix3(
276
- computeStringHash(this.name),
277
- computeStringHash(this.texture),
278
- hash_mix3(this.seed, computeHashFloat(this.spawn_rate), computeHashFloat(this.prewarm))
279
- );
280
-
281
- return hash_mix3(
282
- hash_mix2(bindings, flipbook),
283
- hash_mix2(appearance, this.transform_local.hash()),
284
- hash_mix2(this.layout.hash(), this.program.hash())
285
- );
286
- }
287
-
288
- /**
289
- * Make this emitter a copy of `other`: same effect, same settings, same placement.
290
- *
291
- * The layout and the compiled program are shared rather than duplicated — both are immutable
292
- * once compiled, and sharing them is what puts two emitters of one effect on one program id in
293
- * the program heap, hence in one simulation bucket.
294
- *
295
- * What is not copied is the node's identity and its place in a scene ({@link Node3D#id},
296
- * parent, children), and anything a registry gave the original: a copy is a second emitter, not
297
- * a second reference to the first one's table row.
298
- *
299
- * @param {ParticleEmitter} other
300
- */
301
- copy(other) {
302
- super.copy(other);
303
-
304
- this.name = other.name;
305
-
306
- this.layout = other.layout;
307
- this.program = other.program;
308
- this.texture = other.texture;
309
-
310
- this.seed = other.seed;
311
- this.spawn_rate = other.spawn_rate;
312
- this.prewarm = other.prewarm;
313
- this.flags = other.flags;
314
-
315
- this.flipbook.set(other.flipbook);
316
-
317
- this.render_position = other.render_position;
318
- this.render_size = other.render_size;
319
- this.render_color = other.render_color;
320
- this.render_rotation = other.render_rotation;
321
- this.render_frame = other.render_frame;
322
- this.render_velocity = other.render_velocity;
323
- }
324
-
325
- /**
326
- * A second emitter equal to this one, and registered with nothing. Its subtree is cloned the
327
- * way {@link Node3D#clone} clones one — overridden only because the base builds a plain
328
- * `Node3D`, which would keep the transform and drop the effect.
329
- *
330
- * @returns {ParticleEmitter}
331
- */
332
- clone() {
333
- const r = new ParticleEmitter();
334
-
335
- r.copy(this);
336
-
337
- for (const child of this.children) {
338
- const clone = child.clone();
339
-
340
- clone.parent = r;
341
-
342
- r.children.push(clone);
343
- }
344
-
345
- return r;
346
- }
347
-
348
- /**
349
- * Build an emitter from a plain description. Every parameter is optional except the compiled
350
- * effect — an emitter with no program has nothing to run.
351
- *
352
- * `layout` and `program` are the two halves of what {@link ./create_particle_effect.js} returns,
353
- * so an effect goes in by spreading it: `ParticleEmitter.from({ ...effect, spawn_rate: 100 })`.
354
- *
355
- * @param {object} config
356
- * @param {import("../layout/ParticleLayout.js").ParticleLayout} config.layout per-particle
357
- * attribute schema
358
- * @param {import("../isa/ParticleProgram.js").ParticleProgram} config.program compiled INIT /
359
- * UPDATE bytecode
360
- * @param {ArrayLike<number>} [config.position] local translation, `[x, y, z]`
361
- * @param {string|null} [config.texture] image URL to draw with
362
- * @param {number} [config.seed]
363
- * @param {number} [config.spawn_rate] particles/second
364
- * @param {number} [config.prewarm] seconds of emission to run forward before the first frame
365
- * @param {object} [config.flags] passed to {@link make_emitter_flags}
366
- * @param {Object<string,string>} [config.render] render channel -> attribute name
367
- * @param {ArrayLike<number>} [config.flipbook] [cols, rows]
368
- * @param {string} [config.name]
369
- * @returns {ParticleEmitter}
370
- */
371
- static from(config) {
372
- assert.isObject(config, 'config');
373
- assert.defined(config.layout, 'config.layout');
374
- assert.defined(config.program, 'config.program');
375
-
376
- const emitter = new ParticleEmitter();
377
-
378
- emitter.layout = config.layout;
379
- emitter.program = config.program;
380
-
381
- if (config.name !== undefined) {
382
- emitter.name = config.name;
383
- }
384
-
385
- if (config.texture !== undefined) {
386
- emitter.texture = config.texture;
387
- }
388
-
389
- if (config.position !== undefined) {
390
- emitter.transform_local.setTranslation(config.position[0], config.position[1], config.position[2]);
391
- emitter.updateMatrices();
392
- }
393
-
394
- if (config.seed !== undefined) {
395
- emitter.seed = config.seed;
396
- }
397
-
398
- if (config.spawn_rate !== undefined) {
399
- emitter.spawn_rate = config.spawn_rate;
400
- }
401
-
402
- if (config.prewarm !== undefined) {
403
- emitter.prewarm = config.prewarm;
404
- }
405
-
406
- emitter.flags = make_emitter_flags(config.flags ?? {});
407
-
408
- if (config.flipbook !== undefined) {
409
- emitter.flipbook.set(config.flipbook);
410
- }
411
-
412
- if (config.render !== undefined) {
413
- emitter.bind_render(config.render);
414
- }
415
-
416
- return emitter;
417
- }
418
- }
419
-
420
- /**
421
- * @readonly
422
- * @type {boolean}
423
- */
424
- ParticleEmitter.prototype.isParticleEmitter = true;
1
+ import { assert } from "../../../../core/assert.js";
2
+ import { is_typed_array_equals } from "../../../../core/collection/array/typed/is_typed_array_equals.js";
3
+ import { float32_array_hash } from "../../../../core/collection/array/typed/float32_array_hash.js";
4
+ import { hash_mix2 } from "../../../../core/math/hash/hash_mix2.js";
5
+ import { hash_mix3 } from "../../../../core/math/hash/hash_mix3.js";
6
+ import { computeHashFloat } from "../../../../core/primitives/numbers/computeHashFloat.js";
7
+ import { computeStringHash } from "../../../../core/primitives/strings/computeStringHash.js";
8
+ import { Node3D } from "../../scene/Node3D.js";
9
+ import { EMITTER_ATTRIBUTE_ABSENT, make_emitter_flags } from "../data/PARTICLE_EMITTER_STRUCT.js";
10
+
11
+ /**
12
+ * Render channels an emitter can bind to one of its effect's per-particle attributes. The names
13
+ * match the `render_*` fields of {@link ../data/PARTICLE_EMITTER_STRUCT.js}.
14
+ *
15
+ * @type {string[]}
16
+ */
17
+ export const EMITTER_RENDER_CHANNELS = Object.freeze([
18
+ "position", "size", "color", "rotation", "frame", "velocity",
19
+ ]);
20
+
21
+ /**
22
+ * How many record words the render pass reads for each channel.
23
+ *
24
+ * A channel is read at a fixed width — `chunk_particle_billboard_vertex.js` takes four words for
25
+ * colour and three for position, whatever the attribute behind the offset happens to be. Binding one
26
+ * to a narrower attribute is therefore not a smaller read but a read into whatever the layout put
27
+ * next, and nothing downstream objects: the particles simply come out wrong.
28
+ *
29
+ * Binding is deliberately not made to enforce this — the offsets are the record's, and an author who
30
+ * knows what is packed where may mean it — but an editor has this table so it can say so.
31
+ *
32
+ * @type {Object<string, number>}
33
+ */
34
+ export const EMITTER_RENDER_CHANNEL_COMPONENTS = Object.freeze({
35
+ position: 3,
36
+ size: 1,
37
+ color: 4,
38
+ rotation: 1,
39
+ frame: 1,
40
+ velocity: 3,
41
+ });
42
+
43
+ /**
44
+ * One particle emitter: a scene node that runs a compiled effect.
45
+ *
46
+ * **In a game, this is built for you.** An entity carrying
47
+ * {@link ../../../../engine/graphics/ecs/particles/ParticleEffect.js ParticleEffect} and a
48
+ * `Transform64` gets one of these from `engine/graphics3/GPUParticleEmitterSystem.js`, which owns it
49
+ * and keeps every field below in step with the component. Build one by hand for the cases with no
50
+ * ECS behind them — a playground, a tool, a spec — or reach for the one the system built through its
51
+ * `node_of(entity)` when an entity reference cannot say what you mean.
52
+ *
53
+ * Being a {@link Node3D} is what places it. Add it to a scene and it gets a row in the scene
54
+ * database's `transforms` table like any other node; parent it to a joint and the GPU hierarchy
55
+ * pass composes its world matrix from the joint's every frame, animation included, with nothing
56
+ * copied through the CPU. Remove it from the scene and the row goes with it — and so do its
57
+ * particles, within a frame, because the emitter's table row is retired with it and its particles
58
+ * carry that row's generation. There is no transform of its own to keep in step with anything.
59
+ *
60
+ * The rest is a description of an effect and nothing else: the attribute {@link layout} and the
61
+ * compiled {@link program}, the {@link texture} to draw with, spawn rate, pre-warm duration, blend /
62
+ * projection / feature flags, render-attribute bindings and flipbook grid. Every one of those is the author's,
63
+ * means the same thing with no device present, and is what a saved effect is made of.
64
+ *
65
+ * There are no bounds among them, and there is nowhere here they could go. An emitter's particles
66
+ * are simulated in world space by arbitrary bytecode: they go where the program sends them, at
67
+ * whatever size it gives them, for as long as it keeps them alive. Nothing an author could write
68
+ * down would bound that except "infinite", which culls nothing and marks every depth slice. So the
69
+ * box is measured instead — from the live particles, by a pass of its own at the end of every frame
70
+ * (`../bounds/`) — and it lives with the rest of the measured state in
71
+ * {@link ../data/PARTICLE_EMITTER_STATE.js}. The one thing that IS the author's is whether the
72
+ * emitter opts into culling at all: `EMITTER_FLAG.CULL` in {@link flags}.
73
+ *
74
+ * **Nothing here is the GPU's.** Which table row the emitter was given, which tenancy of it, where
75
+ * its program was placed in the program heap, which `transforms` row its node ended up in, where
76
+ * its texture was packed into the atlas — all of that is
77
+ * {@link ./GPUParticleEmitterContext.js GPUParticleEmitterContext}, held by the
78
+ * {@link ./EmitterRegistry.js} that registered the emitter and reachable through
79
+ * {@link ./EmitterRegistry.js#context}. An emitter no registry holds has none, which is also how
80
+ * "is this emitter live?" is asked. Nor is anything the GPU integrates per frame here — spawn
81
+ * accumulator, age, sleep are {@link ../data/PARTICLE_EMITTER_STATE.js}, and they stay on the GPU.
82
+ *
83
+ * Fields are plain data with no setters. After changing any of them, set `needsUpdate = true` — the
84
+ * node's own change counter, the same one the renderer reads to decide whether a transform needs
85
+ * re-uploading — and {@link ./EmitterRegistry.js#flush} restages the row on its next flush. One
86
+ * counter rather than a setter per field because a row is restaged whole either way: one changed
87
+ * field and ten cost the same upload.
88
+ */
89
+ export class ParticleEmitter extends Node3D {
90
+
91
+ /**
92
+ * The per-particle attribute schema: which named attributes a particle of this emitter has, how
93
+ * wide each is, and what word of the record it sits at.
94
+ *
95
+ * @type {import("../layout/ParticleLayout.js").ParticleLayout}
96
+ */
97
+ layout;
98
+
99
+ /**
100
+ * The compiled INIT/UPDATE bytecode run for every particle of this emitter.
101
+ * Replace the immutable program to edit it; do not mutate its bytecode in place.
102
+ *
103
+ * @type {import("../isa/ParticleProgram.js").ParticleProgram}
104
+ */
105
+ program;
106
+
107
+ /**
108
+ * Image to draw the particles with, by URL, or `null` to draw against the default atlas — a
109
+ * single white texel, so an untextured emitter is a flat quad of its colour.
110
+ *
111
+ * `engine/graphics3/GPUParticleEmitterSystem.js` loads this URL through AssetManager and packs
112
+ * the image into a sheet its scene shares. Standalone callers provide their own atlas and
113
+ * regions. Where a given image lands in that sheet is the system's answer, not the author's — it
114
+ * changes on every repack, without the emitter changing. The resolved patch is
115
+ * {@link ./GPUParticleEmitterContext.js#atlas_region}.
116
+ *
117
+ * @type {string|null}
118
+ */
119
+ texture = null;
120
+
121
+ /**
122
+ * Base RNG seed.
123
+ *
124
+ * Two emitters running one program produce identical particles from identical seeds. `0` reads
125
+ * as "unset", and the row written for such an emitter takes its registration's generation
126
+ * instead, which no other emitter of that registry shares.
127
+ *
128
+ * @type {number}
129
+ */
130
+ seed = 0;
131
+
132
+ /**
133
+ * Continuous emission rate, particles per second. Integrated on the GPU by the emitter tick
134
+ * pass; the CPU never counts particles.
135
+ * @type {number}
136
+ */
137
+ spawn_rate = 0;
138
+
139
+ /**
140
+ * Seconds this emitter is run forward before it is first shown, `0` for none.
141
+ *
142
+ * A continuous effect — a smoke column, a dust field — starts empty and grows into its steady
143
+ * state over one particle lifetime, in front of whoever is watching. Setting this to about the
144
+ * effect's particle lifetime means the population that arrives on the first visible frame is the
145
+ * one the emitter would have had by then: spawned, aged, and thinned by its own program over
146
+ * that long, rather than a crowd of newborns.
147
+ *
148
+ * The author's number, and the only thing about a warm-up that is authored. It is not written to
149
+ * the GPU record: the system queues an emitter that has one at the moment it is registered, and
150
+ * the warm-up pipeline (`../warmup/`) runs its simulation forward on that frame, over a
151
+ * population of its own, before moving the result into the scene. Longer costs a longer loop on
152
+ * that one frame and nothing after it.
153
+ *
154
+ * Prewarm controls the initial population's age distribution. It is not needed to bootstrap
155
+ * culling: emitters without a measured population have unknown bounds and remain eligible to spawn.
156
+ *
157
+ * @type {number}
158
+ */
159
+ prewarm = 0;
160
+
161
+ /**
162
+ * Packed feature / blend / projection bits — see {@link make_emitter_flags}.
163
+ * @type {number}
164
+ */
165
+ flags = 0;
166
+
167
+ /**
168
+ * Flipbook grid: columns, rows, over whatever region {@link texture} was packed into. `[1, 1]`
169
+ * is a single frame.
170
+ * @type {Float32Array}
171
+ */
172
+ flipbook = new Float32Array([1, 1]);
173
+
174
+ /** @type {number} record word offset of world position, or {@link EMITTER_ATTRIBUTE_ABSENT} */
175
+ render_position = EMITTER_ATTRIBUTE_ABSENT;
176
+ /** @type {number} record word offset of size */
177
+ render_size = EMITTER_ATTRIBUTE_ABSENT;
178
+ /** @type {number} record word offset of rgba colour */
179
+ render_color = EMITTER_ATTRIBUTE_ABSENT;
180
+ /** @type {number} record word offset of roll angle, radians */
181
+ render_rotation = EMITTER_ATTRIBUTE_ABSENT;
182
+ /** @type {number} record word offset of flipbook frame index */
183
+ render_frame = EMITTER_ATTRIBUTE_ABSENT;
184
+ /** @type {number} record word offset of velocity, read by the STRETCHED projection */
185
+ render_velocity = EMITTER_ATTRIBUTE_ABSENT;
186
+
187
+ get type() {
188
+ return "ParticleEmitter";
189
+ }
190
+
191
+ /**
192
+ * Bind render channels to per-particle attributes of this emitter's effect, by attribute name.
193
+ * Channels left out of `channels` keep whatever they had; a channel that was never bound stays
194
+ * at {@link EMITTER_ATTRIBUTE_ABSENT}, which the passes read as "this emitter has no such
195
+ * attribute" — the render pass substitutes a default, the sort pass treats the particle as
196
+ * being at the origin.
197
+ *
198
+ * The record stores word offsets rather than names because that is what the render and sort
199
+ * passes index a particle record with; resolving here is the only place a name is involved.
200
+ *
201
+ * @param {Object<string,string>} channels channel name (see {@link EMITTER_RENDER_CHANNELS}) ->
202
+ * attribute name in the effect's layout
203
+ */
204
+ bind_render(channels) {
205
+ assert.isObject(channels, 'channels');
206
+ assert.defined(this.layout, 'layout');
207
+
208
+ const layout = this.layout;
209
+
210
+ for (const channel of Object.keys(channels)) {
211
+ const offset = layout.offsetOf(channels[channel]);
212
+
213
+ switch (channel) {
214
+ case "position": this.render_position = offset; break;
215
+ case "size": this.render_size = offset; break;
216
+ case "color": this.render_color = offset; break;
217
+ case "rotation": this.render_rotation = offset; break;
218
+ case "frame": this.render_frame = offset; break;
219
+ case "velocity": this.render_velocity = offset; break;
220
+ default:
221
+ throw new Error(
222
+ `Unknown render channel '${channel}', expected one of ${EMITTER_RENDER_CHANNELS.join(', ')}`
223
+ );
224
+ }
225
+ }
226
+
227
+ this.needsUpdate = true;
228
+ }
229
+
230
+ /**
231
+ * Structural equality over everything an emitter *is*: where it sits, what it runs, what it
232
+ * draws with, and how it is bound to the renderer. Two emitters that compare equal describe one
233
+ * effect in one place, and one row would serve both.
234
+ *
235
+ * The node's identity is not part of it — `id`, parent and children say where an emitter is in
236
+ * a scene, not what it is — and neither is anything the GPU assigned it, which is not here to
237
+ * be compared. {@link transform_global} is left out too, being derived from
238
+ * {@link transform_local} and the parent chain.
239
+ *
240
+ * @param {ParticleEmitter} other
241
+ * @returns {boolean}
242
+ */
243
+ equals(other) {
244
+ if (this === other) {
245
+ return true;
246
+ }
247
+
248
+ return this.name === other.name
249
+ && this.texture === other.texture
250
+ && this.seed === other.seed
251
+ && this.spawn_rate === other.spawn_rate
252
+ && this.prewarm === other.prewarm
253
+ && this.flags === other.flags
254
+ && this.render_position === other.render_position
255
+ && this.render_size === other.render_size
256
+ && this.render_color === other.render_color
257
+ && this.render_rotation === other.render_rotation
258
+ && this.render_frame === other.render_frame
259
+ && this.render_velocity === other.render_velocity
260
+ && is_typed_array_equals(this.flipbook, other.flipbook)
261
+ && this.transform_local.equals(other.transform_local)
262
+ && this.layout.equals(other.layout)
263
+ && this.program.equals(other.program);
264
+ }
265
+
266
+ /**
267
+ * Hash over everything {@link equals} compares, in the same terms — the compiled program hashes
268
+ * by its own bytecode rather than by object identity, so two emitters separately compiled from
269
+ * one graph hash equally, as they compare equally.
270
+ *
271
+ * @returns {number} int32
272
+ */
273
+ hash() {
274
+ const bindings = hash_mix3(
275
+ hash_mix3(this.render_position, this.render_size, this.render_color),
276
+ hash_mix3(this.render_rotation, this.render_frame, this.render_velocity),
277
+ this.flags
278
+ );
279
+
280
+ const flipbook = float32_array_hash(this.flipbook, 0, this.flipbook.length);
281
+
282
+ const appearance = hash_mix3(
283
+ computeStringHash(this.name),
284
+ computeStringHash(this.texture),
285
+ hash_mix3(this.seed, computeHashFloat(this.spawn_rate), computeHashFloat(this.prewarm))
286
+ );
287
+
288
+ return hash_mix3(
289
+ hash_mix2(bindings, flipbook),
290
+ hash_mix2(appearance, this.transform_local.hash()),
291
+ hash_mix2(this.layout.hash(), this.program.hash())
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Make this emitter a copy of `other`: same effect, same settings, same placement.
297
+ *
298
+ * The layout and the compiled program are shared rather than duplicated — both are immutable
299
+ * once compiled, and sharing them is what puts two emitters of one effect on one program id in
300
+ * the program heap, hence in one simulation bucket.
301
+ *
302
+ * What is not copied is the node's identity and its place in a scene ({@link Node3D#id},
303
+ * parent, children), and anything a registry gave the original: a copy is a second emitter, not
304
+ * a second reference to the first one's table row.
305
+ *
306
+ * @param {ParticleEmitter} other
307
+ */
308
+ copy(other) {
309
+ super.copy(other);
310
+
311
+ this.name = other.name;
312
+
313
+ this.layout = other.layout;
314
+ this.program = other.program;
315
+ this.texture = other.texture;
316
+
317
+ this.seed = other.seed;
318
+ this.spawn_rate = other.spawn_rate;
319
+ this.prewarm = other.prewarm;
320
+ this.flags = other.flags;
321
+
322
+ this.flipbook.set(other.flipbook);
323
+
324
+ this.render_position = other.render_position;
325
+ this.render_size = other.render_size;
326
+ this.render_color = other.render_color;
327
+ this.render_rotation = other.render_rotation;
328
+ this.render_frame = other.render_frame;
329
+ this.render_velocity = other.render_velocity;
330
+ }
331
+
332
+ /**
333
+ * A second emitter equal to this one, and registered with nothing. Its subtree is cloned the
334
+ * way {@link Node3D#clone} clones one — overridden only because the base builds a plain
335
+ * `Node3D`, which would keep the transform and drop the effect.
336
+ *
337
+ * @returns {ParticleEmitter}
338
+ */
339
+ clone() {
340
+ const r = new ParticleEmitter();
341
+
342
+ r.copy(this);
343
+
344
+ for (const child of this.children) {
345
+ const clone = child.clone();
346
+
347
+ clone.parent = r;
348
+
349
+ r.children.push(clone);
350
+ }
351
+
352
+ return r;
353
+ }
354
+
355
+ /**
356
+ * Build an emitter from a plain description. Every parameter is optional except the compiled
357
+ * effect — an emitter with no program has nothing to run.
358
+ *
359
+ * `layout` and `program` are the two halves of what {@link ./create_particle_effect.js} returns,
360
+ * so an effect goes in by spreading it: `ParticleEmitter.from({ ...effect, spawn_rate: 100 })`.
361
+ *
362
+ * @param {object} config
363
+ * @param {import("../layout/ParticleLayout.js").ParticleLayout} config.layout per-particle
364
+ * attribute schema
365
+ * @param {import("../isa/ParticleProgram.js").ParticleProgram} config.program compiled INIT /
366
+ * UPDATE bytecode
367
+ * @param {ArrayLike<number>} [config.position] local translation, `[x, y, z]`
368
+ * @param {string|null} [config.texture] image URL to draw with
369
+ * @param {number} [config.seed]
370
+ * @param {number} [config.spawn_rate] particles/second
371
+ * @param {number} [config.prewarm] seconds of emission to run forward before the first frame
372
+ * @param {object} [config.flags] passed to {@link make_emitter_flags}
373
+ * @param {Object<string,string>} [config.render] render channel -> attribute name
374
+ * @param {ArrayLike<number>} [config.flipbook] [cols, rows]
375
+ * @param {string} [config.name]
376
+ * @returns {ParticleEmitter}
377
+ */
378
+ static from(config) {
379
+ assert.isObject(config, 'config');
380
+ assert.defined(config.layout, 'config.layout');
381
+ assert.defined(config.program, 'config.program');
382
+
383
+ const emitter = new ParticleEmitter();
384
+
385
+ emitter.layout = config.layout;
386
+ emitter.program = config.program;
387
+
388
+ if (config.name !== undefined) {
389
+ emitter.name = config.name;
390
+ }
391
+
392
+ if (config.texture !== undefined) {
393
+ emitter.texture = config.texture;
394
+ }
395
+
396
+ if (config.position !== undefined) {
397
+ emitter.transform_local.setTranslation(config.position[0], config.position[1], config.position[2]);
398
+ emitter.updateMatrices();
399
+ }
400
+
401
+ if (config.seed !== undefined) {
402
+ emitter.seed = config.seed;
403
+ }
404
+
405
+ if (config.spawn_rate !== undefined) {
406
+ emitter.spawn_rate = config.spawn_rate;
407
+ }
408
+
409
+ if (config.prewarm !== undefined) {
410
+ emitter.prewarm = config.prewarm;
411
+ }
412
+
413
+ emitter.flags = make_emitter_flags(config.flags ?? {});
414
+
415
+ if (config.flipbook !== undefined) {
416
+ emitter.flipbook.set(config.flipbook);
417
+ }
418
+
419
+ if (config.render !== undefined) {
420
+ emitter.bind_render(config.render);
421
+ }
422
+
423
+ return emitter;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * @readonly
429
+ * @type {boolean}
430
+ */
431
+ ParticleEmitter.prototype.isParticleEmitter = true;