@woosh/meep-engine 3.18.0 → 3.20.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 (228) hide show
  1. package/build/bundle-worker-image-decoder.js +1 -1
  2. package/build/bundle-worker-terrain.js +1 -1
  3. package/editor/view/particles/effect/ParticleCurveEditorView.d.ts.map +1 -1
  4. package/editor/view/particles/effect/ParticleCurveEditorView.js +303 -120
  5. package/editor/view/particles/effect/ParticleGradientEditorView.d.ts.map +1 -1
  6. package/editor/view/particles/effect/ParticleGradientEditorView.js +108 -63
  7. package/editor/view/particles/effect/ParticleGraphEditorView.js +2 -2
  8. package/editor/view/particles/effect/ParticleNodeParametersView.d.ts.map +1 -1
  9. package/editor/view/particles/effect/ParticleNodeParametersView.js +7 -46
  10. package/editor/view/particles/effect/particle-editor.css +60 -30
  11. package/package.json +1 -2
  12. package/samples/engine/README.md +1 -1
  13. package/src/core/binary/compression/decompress_bytes.d.ts +13 -0
  14. package/src/core/binary/compression/decompress_bytes.d.ts.map +1 -0
  15. package/src/core/binary/compression/decompress_bytes.js +28 -0
  16. package/src/core/collection/array/array_group_by.d.ts.map +1 -1
  17. package/src/core/collection/array/array_group_by.js +2 -11
  18. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  19. package/src/core/geom/3d/shape/HeightMapShape3D.js +4 -9
  20. package/src/core/model/node-graph/visual/layout/layout_assign_coordinates.js +67 -22
  21. package/src/core/model/object/optional_clone.d.ts +7 -0
  22. package/src/core/model/object/optional_clone.d.ts.map +1 -0
  23. package/src/core/model/object/optional_equality.d.ts +10 -0
  24. package/src/core/model/object/optional_equality.d.ts.map +1 -0
  25. package/src/{shade/descriptor/util → core/model/object}/optional_equality.js +2 -2
  26. package/src/core/model/object/optional_hash.d.ts +9 -0
  27. package/src/core/model/object/optional_hash.d.ts.map +1 -0
  28. package/src/{shade/descriptor/util → core/model/object}/optional_hash.js +3 -3
  29. package/src/engine/asset/Asset.d.ts +1 -1
  30. package/src/engine/asset/Asset.d.ts.map +1 -1
  31. package/src/engine/asset/Asset.js +3 -1
  32. package/src/engine/asset/loaders/image/ImageDecoderWorker.js +12 -27
  33. package/src/engine/asset/loaders/image/prototypePNG.js +8 -7
  34. package/src/engine/ecs/gui/hud/HeadsUpDisplaySystem.d.ts.map +1 -1
  35. package/src/engine/ecs/gui/hud/HeadsUpDisplaySystem.js +9 -10
  36. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  37. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +4 -0
  38. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts +266 -0
  39. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts.map +1 -0
  40. package/src/engine/graphics/ecs/particles/ParticleEffect.js +439 -0
  41. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts +58 -0
  42. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts.map +1 -0
  43. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.js +219 -0
  44. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +178 -25
  45. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
  46. package/src/engine/graphics3/GPUParticleEmitterSystem.js +809 -310
  47. package/src/format/image/png/PNGReader.d.ts +7 -6
  48. package/src/format/image/png/PNGReader.d.ts.map +1 -1
  49. package/src/format/image/png/PNGReader.js +13 -12
  50. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts +3 -2
  51. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts.map +1 -1
  52. package/src/format/image/png/chunk/png_chunk_decode_iTXt.js +5 -4
  53. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts +3 -2
  54. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts.map +1 -1
  55. package/src/format/image/png/chunk/png_chunk_decode_zTXt.js +5 -4
  56. package/src/format/image/png/png_inflate.d.ts +3 -3
  57. package/src/format/image/png/png_inflate.d.ts.map +1 -1
  58. package/src/format/image/png/png_inflate.js +29 -39
  59. package/src/format/texture/ktx2/ktx2_read.d.ts +4 -4
  60. package/src/format/texture/ktx2/ktx2_read.d.ts.map +1 -1
  61. package/src/format/texture/ktx2/ktx2_read.js +18 -21
  62. package/src/shade/descriptor/binding/BindGroupLayoutDescriptor.js +1 -1
  63. package/src/shade/descriptor/binding/BindGroupLayoutEntry.js +3 -3
  64. package/src/shade/descriptor/pipeline/PipelineDescriptorBase.js +1 -1
  65. package/src/shade/descriptor/pipeline/PipelineLayoutDescriptor.js +1 -1
  66. package/src/shade/descriptor/pipeline/render/ColorTargetState.js +3 -3
  67. package/src/shade/descriptor/pipeline/render/FragmentState.js +1 -1
  68. package/src/shade/descriptor/pipeline/render/RenderPipelineDescriptor.js +3 -3
  69. package/src/shade/playground/particle_ecs/README.md +203 -0
  70. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts +39 -0
  71. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts.map +1 -0
  72. package/src/shade/playground/particle_ecs/bonfire_editor.js +315 -0
  73. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts +145 -0
  74. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts.map +1 -0
  75. package/src/shade/playground/particle_ecs/bonfire_effects.js +202 -0
  76. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts +23 -0
  77. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts.map +1 -0
  78. package/src/shade/playground/particle_ecs/bonfire_sprites.js +315 -0
  79. package/src/shade/playground/particle_ecs/bonfire_world.d.ts +86 -0
  80. package/src/shade/playground/particle_ecs/bonfire_world.d.ts.map +1 -0
  81. package/src/shade/playground/particle_ecs/bonfire_world.js +303 -0
  82. package/src/shade/playground/particle_ecs/effects/embers.json +1634 -0
  83. package/src/shade/playground/particle_ecs/effects/flame.json +1882 -0
  84. package/src/shade/playground/particle_ecs/effects/smoke.json +1860 -0
  85. package/src/shade/playground/particle_ecs/effects/soot.json +1606 -0
  86. package/src/shade/playground/particle_ecs/index.html +330 -0
  87. package/src/shade/playground/particle_ecs/main.d.ts +2 -0
  88. package/src/shade/playground/particle_ecs/main.d.ts.map +1 -0
  89. package/src/shade/playground/particle_ecs/main.js +566 -0
  90. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts +16 -0
  91. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts.map +1 -0
  92. package/src/shade/playground/particle_ecs/moonlit_environment.js +144 -0
  93. package/src/shade/playground/particle_editor/README.md +5 -4
  94. package/src/shade/playground/particle_system/particle_prototype.d.ts +7 -9
  95. package/src/shade/playground/particle_system/particle_prototype.d.ts.map +1 -1
  96. package/src/shade/playground/particle_system/particle_prototype.js +7 -9
  97. package/src/shade/playground/profile_hotkey.d.ts +58 -0
  98. package/src/shade/playground/profile_hotkey.d.ts.map +1 -0
  99. package/src/shade/playground/profile_hotkey.js +325 -0
  100. package/src/shade/playground/ssr_motion/README.md +22 -0
  101. package/src/shade/playground/ssr_motion/index.html +18 -0
  102. package/src/shade/playground/ssr_motion/main.d.ts +2 -0
  103. package/src/shade/playground/ssr_motion/main.d.ts.map +1 -0
  104. package/src/shade/playground/ssr_motion/main.js +149 -0
  105. package/src/shade/playground/ssr_variance/README.md +105 -0
  106. package/src/shade/playground/ssr_variance/capture.d.ts +16 -0
  107. package/src/shade/playground/ssr_variance/capture.d.ts.map +1 -0
  108. package/src/shade/playground/ssr_variance/capture.js +96 -0
  109. package/src/shade/playground/ssr_variance/index.html +23 -0
  110. package/src/shade/playground/ssr_variance/main.d.ts +2 -0
  111. package/src/shade/playground/ssr_variance/main.d.ts.map +1 -0
  112. package/src/shade/playground/ssr_variance/main.js +246 -0
  113. package/src/shade/playground/ssr_variance/reference.d.ts +18 -0
  114. package/src/shade/playground/ssr_variance/reference.d.ts.map +1 -0
  115. package/src/shade/playground/ssr_variance/reference.js +78 -0
  116. package/src/shade/playground/ssr_variance/scene.d.ts +11 -0
  117. package/src/shade/playground/ssr_variance/scene.d.ts.map +1 -0
  118. package/src/shade/playground/ssr_variance/scene.js +61 -0
  119. package/src/shade/playground/ssr_variance/statistics.d.ts +44 -0
  120. package/src/shade/playground/ssr_variance/statistics.d.ts.map +1 -0
  121. package/src/shade/playground/ssr_variance/statistics.js +51 -0
  122. package/src/shade/renderer/geometry/Geometry.d.ts.map +1 -1
  123. package/src/shade/renderer/geometry/Geometry.js +4 -7
  124. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts.map +1 -1
  125. package/src/shade/renderer/loader/gltf/tiny-gltf.js +9 -3
  126. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts +4 -4
  127. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts.map +1 -1
  128. package/src/shade/renderer/loader/usd/usd_decode_image.js +4 -4
  129. package/src/shade/renderer/material/StandardShadeMaterial.js +2 -2
  130. package/src/shade/renderer/particles/DESIGN.md +745 -702
  131. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  132. package/src/shade/renderer/particles/GPUParticleSystem.js +3 -1
  133. package/src/shade/renderer/particles/graph/ParticleGroupBuilder.d.ts +1 -1
  134. package/src/shade/renderer/particles/graph/ParticleGroupBuilder.js +1 -1
  135. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  136. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +9 -15
  137. package/src/shade/renderer/particles/graph/particle_group_library.d.ts.map +1 -1
  138. package/src/shade/renderer/particles/graph/particle_group_library.js +8 -3
  139. package/src/shade/renderer/particles/graph/particle_node_parameters.d.ts +0 -2
  140. package/src/shade/renderer/particles/graph/particle_node_parameters.d.ts.map +1 -1
  141. package/src/shade/renderer/particles/graph/particle_node_parameters.js +0 -9
  142. package/src/shade/renderer/particles/graph/particle_node_presentation.d.ts.map +1 -1
  143. package/src/shade/renderer/particles/graph/particle_node_presentation.js +0 -10
  144. package/src/shade/renderer/particles/graph/particle_ramp.d.ts +2 -8
  145. package/src/shade/renderer/particles/graph/particle_ramp.d.ts.map +1 -1
  146. package/src/shade/renderer/particles/graph/particle_ramp.js +2 -8
  147. package/src/shade/renderer/particles/graph/validate_particle_graph.js +0 -6
  148. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  149. package/src/shade/renderer/particles/graph_particles_avboit.js +13 -4
  150. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +1 -12
  151. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -1
  152. package/src/shade/renderer/particles/isa/InstructionStream.js +1 -12
  153. package/src/shade/renderer/particles/isa/ParticleVMISA.d.ts +54 -60
  154. package/src/shade/renderer/particles/isa/ParticleVMISA.d.ts.map +1 -1
  155. package/src/shade/renderer/particles/isa/ParticleVMISA.js +9 -17
  156. package/src/shade/renderer/particles/isa/particle_assembly.d.ts +0 -1
  157. package/src/shade/renderer/particles/isa/particle_assembly.d.ts.map +1 -1
  158. package/src/shade/renderer/particles/isa/particle_assembly.js +2 -16
  159. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.d.ts +1 -1
  160. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.js +1 -1
  161. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lift.js +0 -14
  162. package/src/shade/renderer/particles/optimizer/dag/particle_vector_pack.js +3 -17
  163. package/src/shade/renderer/particles/optimizer/particle_program_analysis.d.ts.map +1 -1
  164. package/src/shade/renderer/particles/optimizer/particle_program_analysis.js +15 -36
  165. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.d.ts.map +1 -1
  166. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.js +1 -5
  167. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.d.ts.map +1 -1
  168. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.js +1 -8
  169. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.d.ts +2 -11
  170. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.d.ts.map +1 -1
  171. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.js +2 -11
  172. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +11 -4
  173. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
  174. package/src/shade/renderer/particles/runtime/ParticleEmitter.js +431 -424
  175. package/src/shade/renderer/particles/shaders/chunk_particle_avboit_contribution.d.ts.map +1 -1
  176. package/src/shade/renderer/particles/shaders/chunk_particle_avboit_contribution.js +13 -0
  177. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.d.ts +3 -6
  178. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.d.ts.map +1 -1
  179. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.js +49 -17
  180. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  181. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +0 -2
  182. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.d.ts.map +1 -1
  183. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.js +0 -2
  184. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  185. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +1 -5
  186. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.d.ts.map +1 -1
  187. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.js +0 -2
  188. package/src/shade/renderer/particles/vm/ParticleVMReference.d.ts +1 -3
  189. package/src/shade/renderer/particles/vm/ParticleVMReference.d.ts.map +1 -1
  190. package/src/shade/renderer/particles/vm/ParticleVMReference.js +4 -20
  191. package/src/shade/renderer/particles/vm/chunk_particle_vm.d.ts.map +1 -1
  192. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +2 -5
  193. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.d.ts.map +1 -1
  194. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.js +0 -2
  195. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.d.ts.map +1 -1
  196. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.js +0 -2
  197. package/src/shade/renderer/postprocess/ssr/SSR.d.ts.map +1 -1
  198. package/src/shade/renderer/postprocess/ssr/SSR.js +2 -1
  199. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts.map +1 -1
  200. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.js +20 -5
  201. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts.map +1 -1
  202. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.js +19 -5
  203. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts +4 -0
  204. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts.map +1 -0
  205. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.js +137 -0
  206. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts +0 -8
  207. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts.map +1 -1
  208. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.js +18 -114
  209. package/src/shade/renderer/scene/Mesh.js +1 -1
  210. package/src/shade/renderer/texture/ShadeTexture.js +1 -1
  211. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts +2 -2
  212. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts.map +1 -1
  213. package/src/shade/renderer/texture/source/texel_data_from_ktx2.js +3 -3
  214. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts +0 -19
  215. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +0 -1
  216. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +0 -30
  217. package/src/shade/renderer/particles/shaders/chunk_particle_curve_disabled.d.ts +0 -11
  218. package/src/shade/renderer/particles/shaders/chunk_particle_curve_disabled.d.ts.map +0 -1
  219. package/src/shade/renderer/particles/shaders/chunk_particle_curve_disabled.js +0 -13
  220. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts +0 -20
  221. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts.map +0 -1
  222. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts +0 -14
  223. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts.map +0 -1
  224. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts +0 -50
  225. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts.map +0 -1
  226. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +0 -17
  227. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +0 -1
  228. /package/src/{shade/descriptor/util → core/model/object}/optional_clone.js +0 -0
@@ -0,0 +1,203 @@
1
+ # Bonfire — GPU particles as ECS entities
2
+
3
+ A moonlit fire pit, and the demonstrator for the ECS particle API. Everything in the set is an
4
+ entity carrying a `Transform64` and one more component — the ground and the logs a `ShadedGeometry`,
5
+ the moon and the firelight a `Light`, and the flame, smoke, embers and soot a **`ParticleEffect`**.
6
+ Four systems observe those pairs and put rows into one `Scene`. The page owns no scene graph, no
7
+ frame graph, no pass and no emitter object.
8
+
9
+ Spawning a fire is therefore this, and nothing else:
10
+
11
+ ```js
12
+ new Entity()
13
+ .add(new Transform64())
14
+ .add(ParticleEffect.from({ ...create_particle_effect({ layout, init, update }), spawn_rate: 560 }))
15
+ .build(dataset);
16
+ ```
17
+
18
+ with `entityManager.addSystem(new GPUParticleEmitterSystem(graphics, scene, assetManager))` once, at
19
+ boot. Move the entity and the fire moves; destroy it and the fire goes out. That is the point of the
20
+ page: an effect is placed, moved, saved and destroyed by the same machinery as everything else, and
21
+ `../particle_system/` next door is what it used to take — emitters built as scene nodes and added to
22
+ a `Scene` by hand.
23
+
24
+ ## Running it
25
+
26
+ ```bash
27
+ npm run dev --workspace @woosh/meep-engine
28
+ ```
29
+
30
+ then open <http://localhost:5173/src/shade/playground/particle_ecs/index.html>.
31
+
32
+ It has to be `http://localhost` rather than `file://`: WebGPU needs a secure context, and module
33
+ imports from `file://` are blocked by CORS anyway.
34
+
35
+ **It needs `subgroups` and immediate data**, both of which the renderer requires of any device it
36
+ accepts. Chrome/Edge 113+ with immediates; Claude Code's in-app browser pane is **not** one — it has
37
+ reported no adapter at all since 2026-09-02.
38
+
39
+ ## What is on screen
40
+
41
+ | entity | component | what it is there to show |
42
+ |---|---|---|
43
+ | flame | `ParticleEffect` | additive billboards, short-lived, stirred by noise and pinched inward so the column narrows |
44
+ | smoke | `ParticleEffect` | alpha, soft-depth, expanding, firelit at the base and moonlit above it |
45
+ | embers | `ParticleEffect` | small additive motes, buoyant then falling, flickering on their own clock |
46
+ | soot | `ParticleEffect` | dark tumbling flakes that ride the plume and fall back through the firelight |
47
+ | ground, logs, stones | `ShadedGeometry` | something for the fire to light, and for the smoke to fade against |
48
+ | moon | `Light` (directional) | the cool half of the lighting, aimed at the moon in the environment map |
49
+ | firelight | `Light` (point) | the warm half, flickered every frame by writing the component |
50
+
51
+ The sky is a generated octahedral environment map (`moonlit_environment.js`) and the four sprites are
52
+ generated PNGs (`bonfire_sprites.js`), so a fresh checkout with no `test_assets/` looks exactly like
53
+ everybody else's. The four effects are saved documents under `effects/` — the editor opens them, and
54
+ what it writes is what the page reads.
55
+
56
+ ### The two controls, and why they are the whole API
57
+
58
+ - **The checkboxes** write `effect.emitting`. No system call, no node, and nothing to remember the
59
+ rate with — `emitting` gates `spawn_rate` without clearing it, and the system's next comparison
60
+ finds the change. Particles already alive burn out on their own, which is what putting a fire out
61
+ looks like.
62
+ - **"Stoke the fire"** calls `particles.burst(entity, count)`. The request is queued and handed over
63
+ after the next frame's membership sweep, so it works before the first frame has run, and it goes
64
+ down the same GPU spawn path the continuous rate does.
65
+
66
+ ### Editing an effect while it burns
67
+
68
+ <kbd>E</kbd> switches between looking at the fire and editing it. The editor takes the **whole
69
+ window** — the graph, the instructions it compiles to, the emitter settings and a CPU preview, over
70
+ the effect the selector names — and <kbd>E</kbd> again puts you back in the scene. Not a panel with a
71
+ share of the screen: a node graph wants every pixel it can get, and so does the fire.
72
+
73
+ Nothing is drawn while it is up. The editor covers the window, so a frame underneath it is a whole
74
+ scene — the transparency pipeline, the particle passes, the resolve — composited behind an opaque
75
+ panel and thrown away. Measured at 1075x1216, the ten-a-second trickle this replaces cost 3.5 ms of
76
+ CPU to encode and about 21 ms from submission to completion, each. The machine goes to the graph
77
+ editor, which is the thing you are actually looking at.
78
+
79
+ An edit still reaches the GPU; it waits for the way back. `GPUParticleEmitterSystem` publishes the
80
+ emitter table from `prepare` at FrameStart rather than from its tick, and that is idempotent by
81
+ design — a frame must not depend on a simulation tick having preceded it — so the first frame after
82
+ <kbd>Esc</kbd> carries everything you changed. What you give up is that the fire no longer advances
83
+ behind the editor: the simulation is GPU work, so you come back to the instant you left rather than
84
+ a few seconds on. An edit kills and restarts the effect anyway, which is where that would otherwise
85
+ have shown.
86
+
87
+ Change anything and the fire changes. What happens is worth knowing, because it is the reason a live
88
+ edit is safe: the document recompiles and the entity's `ParticleEffect` component is **removed and
89
+ added back**, not mutated. An edit can change the attribute layout, and a layout change repoints
90
+ every word of a particle record — replacing the component retires the emitter's table row and takes
91
+ its generation with it, so the particles running the old program are killed rather than reinterpreted
92
+ under the new one. The effect restarts; for a fire that is what you wanted to see anyway. An effect
93
+ that does not compile leaves the previous one burning and says so in the toolbar.
94
+
95
+ **The round trip is the point.** Each effect *is* a saved document — `effects/flame.json` and its
96
+ three siblings, which is what `bonfire_effects.js` loads. Press **Download**, drop the file over the
97
+ one it came from, reload, and the page is running what you edited. There is no porting step and no
98
+ authoring code to keep in step, because the file the editor writes is the file the page reads.
99
+ **Import…** does the same without the reload, for trying a file out.
100
+
101
+ Two things to know before editing one:
102
+
103
+ - **One store per attribute per phase.** Every store runs and nothing orders them, so a second store
104
+ does not refine the first, it replaces it. That is how the smoke's drag silently ate its own
105
+ velocity integration — the picture only looked slightly wrong — until the editor's validator was
106
+ pointed at it. The graph editor calls it an error and `bonfire.spec.js` pins that none of the four
107
+ has one; nothing on the runtime path checks.
108
+ - **The register budget is shared.** See below.
109
+
110
+ ## What to look at
111
+
112
+ - **The smoke fades where it meets the ground and the logs** rather than showing the hard line a
113
+ camera-facing quad cuts into a surface. That is `soft_depth`, sampling the same depth buffer the
114
+ AVBOIT draw depth-tests against.
115
+ - **The flame does not darken what is behind it.** Additive emitters write the emission accumulator,
116
+ which the resolve adds outside the coverage normalization — so the fire lights the frame instead of
117
+ taking coverage from it. The smoke, which is matter, does cover.
118
+ - **The soot flakes cross in front of the flame and are lit by nothing**, and still read, because
119
+ they are dark against something bright. That is the cheapest depth cue in the set.
120
+ - **Orbit around it.** The particles and the transparent surfaces composite against each other with
121
+ no order between them — both write extinction into one voxel volume and both resolve out of the
122
+ same accumulators. Switch the renderer to MBOIT and the particles vanish while everything else
123
+ stays: MBOIT is the legacy path and has no particle channel.
124
+
125
+ ### Profiling
126
+
127
+ <kbd>T</kbd> starts a GPU capture, <kbd>T</kbd> again stops it and saves an `.sgpt`. The status line
128
+ bottom-right says what it is doing and what the file was called; the console repeats the name, because
129
+ a browser that declines the save says nothing about which capture it declined.
130
+
131
+ Worth having on this page in particular: four distinct compiled programs means the simulate pass runs
132
+ four coherence buckets, and what that costs relative to the emit, the bounds measurement and the three
133
+ AVBOIT side-channel passes is not something anybody can guess. The capture's note carries which
134
+ effects were emitting and how many particles were alive when it was taken.
135
+
136
+ The wiring is `../profile_hotkey.js`, four lines at the bottom of `main.js`. The engine never
137
+ constructs a `GPUProfileSession` — `Renderer.profile_session` is a nullable field and the null checks
138
+ around it are the whole integration, which is what keeps the profiler out of a bundle that does not
139
+ ask for it.
140
+
141
+ ## What it deliberately does not do
142
+
143
+ - **No emitter opts into `EMITTER_FLAG.LIGHTING`.** The shipped hook is
144
+ `chunk_particle_lighting_unlit`, a pass-through, so the flag would cost a bit in a record and
145
+ change no pixel. The smoke and the soot are *painted* lit instead: their gradients carry the fire's
146
+ warmth low down and the moon's blue higher up, over the particle's own age. When a lit hook lands,
147
+ those gradients are the thing to reconsider.
148
+ - **No emitter opts into culling.** One fire, always on screen; the frustum test in the tick pass
149
+ would never be taken. The measured bounds are still published every frame and still read by the
150
+ occupancy pass.
151
+ - **No flipbooks.** Every sprite here is a single frame. `../particle_system/` has the flipbook path.
152
+
153
+ ## Verifying it
154
+
155
+ ```bash
156
+ npx vitest run src/shade/playground/particle_ecs
157
+ ```
158
+
159
+ `bonfire_effects.js`, `bonfire_world.js` and `moonlit_environment.js` have every reference to the
160
+ page removed, so the spec opens the real documents, compiles them, and builds the real world with no
161
+ device:
162
+
163
+ - every saved document opens, compiles, and carries no diagnostic the editor would show — which is
164
+ the duplicate-store check above, pinned against the data rather than against the detector,
165
+ - every effect's compiled program fits `PARTICLE_VM_FAST_REGISTER_SLOTS`. That is a **shared**
166
+ budget, and the reason it is a test rather than a comment: one program needing more than the
167
+ register file holds swaps emit and simulate for storage-backed variants *for the whole system*, so
168
+ an effect that grows past it does not slow itself down, it slows down every effect in the scene,
169
+ - the render channels resolve to attributes that exist, and the blend modes are what each effect is,
170
+ - every entity built carries a `Transform64` and exactly one more component,
171
+ - the sky averages a hundredth of daylight and its brightest texel is where `MOON_DIRECTION` points,
172
+ so the shadows on the ground fall away from the moon that is actually in the map.
173
+
174
+ What a particle *frame* encodes is not this page's business: that is pinned next to the code, against
175
+ `SoftwareGPUDevice`, in `renderer/particles/graph_particles.spec.js` (the simulation, pass by pass)
176
+ and `graph_particles_avboit.spec.js` (the three side-channel passes). The ECS half —
177
+ membership, placement, the field comparison, the atlas — is pinned in
178
+ `engine/graphics3/GPUParticleEmitterSystem.spec.js`.
179
+
180
+ The picture is the browser's job; no spec here can see it.
181
+
182
+ ### Stepping frames by hand
183
+
184
+ Add `?debug` to the URL. `requestAnimationFrame` is then not used at all, and `window.__step(n)` runs
185
+ exactly `n` frames and returns the frame counter — which is what makes the loop drivable in a hidden
186
+ or headless tab, where rAF is suspended. `window.__stats` carries the last counters snapshot, and
187
+ `camera_state()` / `apply_camera(...)` copy a view between sessions. `globalThis` also carries
188
+ `graphics`, `scene`, `dataset`, `entities`, `particles` and `world`, which is enough to drive the
189
+ whole ECS from a console or a CDP session.
190
+
191
+ ## Cost
192
+
193
+ About 1,600 live particles at the default rates, four distinct compiled programs, and a frame that is
194
+ GPU-driven end to end: the host walks no emitters and counts no particles. The emitter tick pass
195
+ integrates every rate on the GPU, a prefix scan of its counts sizes the emit dispatch, and
196
+ `shader_particle_build_indirect` writes the simulate dispatch args and the draw args. The one thing
197
+ the CPU asks for is the burst button, and it asks through the same path.
198
+
199
+ The four programs are around 620 VM instructions each in UPDATE, most of which is the three
200
+ `simplex3` samples the turbulence is made of. That is deliberately not the library's `curl3`, which
201
+ is twelve of them for a divergence-free field: over a metre of flame nobody can tell, and the four
202
+ effects share the register budget above — the editor's toolbar reports both numbers after every
203
+ recompile, which is the cheapest way to notice an edit that has gone past them.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @typedef {object} EffectEditorHandle
3
+ * @property {function(): boolean} is_open
4
+ * @property {function(): void} toggle
5
+ * @property {function(string): void} select which effect the editor shows
6
+ * @property {function(): void} dispose
7
+ */
8
+ /**
9
+ * @param {object} params
10
+ * @param {import("../../../engine/ecs/EntityComponentDataset.js").EntityComponentDataset} params.dataset
11
+ * @param {import("./bonfire_world.js").BonfireWorld} params.world
12
+ * @param {Object<string, string|null>} params.sprites image URL per effect name
13
+ * @param {HTMLElement} params.host the panel the editor is mounted into; shown and hidden by this
14
+ * @param {HTMLSelectElement} params.selector which effect is being edited
15
+ * @param {HTMLElement} params.status one line about the last compile
16
+ * @param {HTMLElement} params.mount where the editor view's element goes, inside `host`
17
+ * @returns {EffectEditorHandle}
18
+ */
19
+ export function install_effect_editor({ dataset, world, sprites, host, selector, status, mount }: {
20
+ dataset: import("../../../engine/ecs/EntityComponentDataset.js").EntityComponentDataset;
21
+ world: import("./bonfire_world.js").BonfireWorld;
22
+ sprites: {
23
+ [x: string]: string | null;
24
+ };
25
+ host: HTMLElement;
26
+ selector: HTMLSelectElement;
27
+ status: HTMLElement;
28
+ mount: HTMLElement;
29
+ }): EffectEditorHandle;
30
+ export type EffectEditorHandle = {
31
+ is_open: () => boolean;
32
+ toggle: () => void;
33
+ /**
34
+ * which effect the editor shows
35
+ */
36
+ select: (arg0: string) => void;
37
+ dispose: () => void;
38
+ };
39
+ //# sourceMappingURL=bonfire_editor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bonfire_editor.d.ts","sourceRoot":"","sources":["../../../../../src/shade/playground/particle_ecs/bonfire_editor.js"],"names":[],"mappings":"AA0CA;;;;;;GAMG;AAEH;;;;;;;;;;GAUG;AACH;IATkG,OAAO,EAA9F,OAAO,+CAA+C,EAAE,sBAAsB;IAC5B,KAAK,EAAvD,OAAO,oBAAoB,EAAE,YAAY;IACL,OAAO;YAApC,MAAM,GAAE,MAAM,GAAC,IAAI;;IACN,IAAI,EAAxB,WAAW;IACe,QAAQ,EAAlC,iBAAiB;IACG,MAAM,EAA1B,WAAW;IACS,KAAK,EAAzB,WAAW;IACT,kBAAkB,CA+P9B;;mBA9QyB,OAAO;kBACP,IAAI;;;;mBACP,MAAM,KAAG,IAAI;mBACV,IAAI"}
@@ -0,0 +1,315 @@
1
+ import { ActionProcessor } from "../../../core/process/undo/ActionProcessor.js";
2
+ import { ParticleEffect } from "../../../engine/graphics/ecs/particles/ParticleEffect.js";
3
+ import {
4
+ ParticleEffectEditorView
5
+ } from "../../../../editor/view/particles/effect/ParticleEffectEditorView.js";
6
+ import {
7
+ BONFIRE_EFFECT_NAMES,
8
+ bonfire_effect_component,
9
+ bonfire_effect_document,
10
+ particle_node_visuals,
11
+ } from "./bonfire_effects.js";
12
+
13
+ /**
14
+ * The particle editor, over the fire that is burning.
15
+ *
16
+ * Open it, pick an effect, change something, and the fire changes — while it burns, in the scene it
17
+ * belongs to, lit by the light it is lighting. That is the point: an effect is a thing you judge by
18
+ * looking at it in place, and an editor on a page of its own can only ever show you a preview of it
19
+ * against nothing.
20
+ *
21
+ * ## How an edit reaches the GPU
22
+ *
23
+ * The document recompiles and the entity's component is **replaced** — removed and added back — not
24
+ * mutated. That is deliberate and it is the reason a live edit is safe at all: an edit can change the
25
+ * attribute layout, and a layout change repoints every word of a particle record. Replacing the
26
+ * component retires the emitter's table row and takes its generation with it, so the particles that
27
+ * were running the old program are killed on the next simulate rather than being reinterpreted
28
+ * under the new one. Mutating `effect.program` in place would keep the row, keep the particles, and
29
+ * read their records through a layout that no longer describes them.
30
+ *
31
+ * The cost is that the effect restarts. For a fire that is what you want to see anyway.
32
+ *
33
+ * ## What it does not do
34
+ *
35
+ * It does not write files. A downloaded document is the whole effect — drop it over
36
+ * `effects/<name>.json` and the page loads it next time, with no porting step, because the file the
37
+ * editor writes is the file the page reads.
38
+ */
39
+
40
+ /** How long the editor waits after the last keystroke before recompiling. Milliseconds. */
41
+ const APPLY_DELAY_MS = 250;
42
+
43
+ /**
44
+ * @typedef {object} EffectEditorHandle
45
+ * @property {function(): boolean} is_open
46
+ * @property {function(): void} toggle
47
+ * @property {function(string): void} select which effect the editor shows
48
+ * @property {function(): void} dispose
49
+ */
50
+
51
+ /**
52
+ * @param {object} params
53
+ * @param {import("../../../engine/ecs/EntityComponentDataset.js").EntityComponentDataset} params.dataset
54
+ * @param {import("./bonfire_world.js").BonfireWorld} params.world
55
+ * @param {Object<string, string|null>} params.sprites image URL per effect name
56
+ * @param {HTMLElement} params.host the panel the editor is mounted into; shown and hidden by this
57
+ * @param {HTMLSelectElement} params.selector which effect is being edited
58
+ * @param {HTMLElement} params.status one line about the last compile
59
+ * @param {HTMLElement} params.mount where the editor view's element goes, inside `host`
60
+ * @returns {EffectEditorHandle}
61
+ */
62
+ export function install_effect_editor({ dataset, world, sprites, host, selector, status, mount }) {
63
+ /**
64
+ * The open documents, by effect name. Kept across close/open, because closing the editor to look
65
+ * at the fire and losing the edit would make the editor useless for the one thing it is for.
66
+ *
67
+ * @type {Map<string, import("../../../../editor/particles/effect/ParticleEffectDocument.js").ParticleEffectDocument>}
68
+ */
69
+ const documents = new Map();
70
+
71
+ /** @type {Map<string, function():void>} the change subscription of each open document */
72
+ const subscriptions = new Map();
73
+
74
+ const entities = {
75
+ flame: world.flame,
76
+ smoke: world.smoke,
77
+ embers: world.embers,
78
+ soot: world.soot,
79
+ };
80
+
81
+ const actionProcessor = new ActionProcessor({});
82
+
83
+ /** @type {ParticleEffectEditorView|null} */
84
+ let view = null;
85
+
86
+ /** @type {string} */
87
+ let active = BONFIRE_EFFECT_NAMES[0];
88
+
89
+ /** @type {number} */
90
+ let apply_timer = 0;
91
+
92
+ /** @type {boolean} */
93
+ let open = false;
94
+
95
+ for (const name of BONFIRE_EFFECT_NAMES) {
96
+ const option = document.createElement("option");
97
+
98
+ option.value = name;
99
+ option.textContent = name;
100
+
101
+ selector.appendChild(option);
102
+ }
103
+
104
+ /**
105
+ * @param {string} name
106
+ * @returns {import("../../../../editor/particles/effect/ParticleEffectDocument.js").ParticleEffectDocument}
107
+ */
108
+ function open_document(name) {
109
+ const existing = documents.get(name);
110
+
111
+ if (existing !== undefined) {
112
+ return existing;
113
+ }
114
+
115
+ const opened = bonfire_effect_document(name);
116
+
117
+ documents.set(name, opened);
118
+
119
+ const on_changed = () => {
120
+ clearTimeout(apply_timer);
121
+
122
+ // Debounced, because a change is announced per keystroke and per dragged node, and a
123
+ // recompile is a graph lowering plus the optimizer.
124
+ apply_timer = setTimeout(() => apply(name), APPLY_DELAY_MS);
125
+ };
126
+
127
+ opened.on.changed.add(on_changed);
128
+ subscriptions.set(name, () => opened.on.changed.remove(on_changed));
129
+
130
+ return opened;
131
+ }
132
+
133
+ /**
134
+ * Recompile one effect and put the result on its entity.
135
+ *
136
+ * @param {string} name
137
+ */
138
+ function apply(name) {
139
+ const source = documents.get(name);
140
+
141
+ if (source === undefined) {
142
+ return;
143
+ }
144
+
145
+ const component = bonfire_effect_component(source, sprites[name]);
146
+ const index = BONFIRE_EFFECT_NAMES.indexOf(name);
147
+ const previous = world.effects[index];
148
+
149
+ if (component === null) {
150
+ const errors = source.compile().diagnostics.filter(entry => entry.severity === "error");
151
+
152
+ set_status(
153
+ `${name} does not compile — ${errors.length} error${errors.length === 1 ? "" : "s"}`
154
+ + `, the previous one is still burning`,
155
+ true
156
+ );
157
+
158
+ return;
159
+ }
160
+
161
+ // Whether the effect is currently switched off is the page's state rather than the
162
+ // document's, so it survives the swap: unticking a box and then editing the graph should not
163
+ // relight it.
164
+ component.emitting = previous.emitting;
165
+
166
+ const entity = entities[name];
167
+
168
+ /*
169
+ Removed and added rather than assigned: see the note at the top. The system's `unlink` takes
170
+ the emitter node out of the scene, which retires its table row and kills the particles that
171
+ were running the old program; `link` builds a new one on the next comparison.
172
+ */
173
+ dataset.removeComponentFromEntity(entity, ParticleEffect);
174
+ dataset.addComponentToEntity(entity, component);
175
+
176
+ world.effects[index] = component;
177
+
178
+ const warnings = source.compile().diagnostics.length;
179
+
180
+ set_status(
181
+ `${name} applied — ${component.program.init.length / 4 | 0} + `
182
+ + `${component.program.update.length / 4 | 0} instructions, `
183
+ + `${component.program.register_count} registers`
184
+ + (warnings > 0 ? `, ${warnings} diagnostic${warnings === 1 ? "" : "s"}` : "")
185
+ );
186
+ }
187
+
188
+ /**
189
+ * @param {string} text
190
+ * @param {boolean} [failed]
191
+ */
192
+ function set_status(text, failed = false) {
193
+ status.textContent = text;
194
+ status.className = failed ? "failed" : "";
195
+ }
196
+
197
+ /**
198
+ * Mount the editor over the document for `name`, replacing whatever was mounted.
199
+ *
200
+ * A view per document rather than one view re-pointed: `ParticleEffectEditorView` takes its
201
+ * effect at construction and every panel inside it has subscribed to that one.
202
+ *
203
+ * @param {string} name
204
+ */
205
+ function mount_view(name) {
206
+ if (view !== null) {
207
+ view.unlink();
208
+ view.el.remove();
209
+ view = null;
210
+ }
211
+
212
+ view = new ParticleEffectEditorView({ effect: open_document(name), actionProcessor });
213
+
214
+ mount.appendChild(view.el);
215
+
216
+ view.link();
217
+
218
+ resize();
219
+ }
220
+
221
+ function resize() {
222
+ if (view === null) {
223
+ return;
224
+ }
225
+
226
+ view.size.set(mount.clientWidth, mount.clientHeight);
227
+ }
228
+
229
+ /**
230
+ * @param {string} name
231
+ */
232
+ function select(name) {
233
+ if (!BONFIRE_EFFECT_NAMES.includes(name)) {
234
+ return;
235
+ }
236
+
237
+ active = name;
238
+ selector.value = name;
239
+
240
+ if (open) {
241
+ mount_view(name);
242
+ set_status(`editing ${name}`);
243
+ }
244
+ }
245
+
246
+ function show() {
247
+ open = true;
248
+ host.hidden = false;
249
+
250
+ mount_view(active);
251
+
252
+ set_status(`editing ${active}`);
253
+ }
254
+
255
+ function hide() {
256
+ open = false;
257
+ host.hidden = true;
258
+
259
+ if (view !== null) {
260
+ view.unlink();
261
+ view.el.remove();
262
+ view = null;
263
+ }
264
+ }
265
+
266
+ function toggle() {
267
+ if (open) {
268
+ hide();
269
+ } else {
270
+ show();
271
+ }
272
+ }
273
+
274
+ selector.addEventListener("change", () => select(selector.value));
275
+
276
+ window.addEventListener("resize", resize);
277
+
278
+ return {
279
+ is_open: () => open,
280
+ toggle,
281
+ select,
282
+
283
+ /** The document currently being edited, for the page's download and import buttons. */
284
+ current: () => open_document(active),
285
+
286
+ /** @param {object} json */
287
+ import_json: json => {
288
+ const target = open_document(active);
289
+
290
+ target.fromJSON(json, particle_node_visuals());
291
+
292
+ // `fromJSON` batches, so the change lands as one — but the apply is debounced off it and
293
+ // a freshly imported effect should be on screen now rather than in a quarter second.
294
+ clearTimeout(apply_timer);
295
+ apply(active);
296
+
297
+ if (open) {
298
+ mount_view(active);
299
+ }
300
+ },
301
+
302
+ dispose: () => {
303
+ hide();
304
+
305
+ for (const unsubscribe of subscriptions.values()) {
306
+ unsubscribe();
307
+ }
308
+
309
+ subscriptions.clear();
310
+ documents.clear();
311
+
312
+ window.removeEventListener("resize", resize);
313
+ },
314
+ };
315
+ }