@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
@@ -1,310 +1,809 @@
1
- import { assert } from "../../core/assert.js";
2
- import { FramePhase } from "../../shade/renderer/extension/FramePhase.js";
3
- import { RenderExtension } from "../../shade/renderer/extension/RenderExtension.js";
4
- import { GPUTextureContext } from "../../shade/renderer/texture/GPUTextureContext.js";
5
- import { System } from "../ecs/System.js";
6
- import { GameAssetType } from "../asset/GameAssetType.js";
7
- import { ImageRGBADataLoader } from "../asset/loaders/image/ImageRGBADataLoader.js";
8
- import { TextureAtlas } from "../graphics/texture/atlas/TextureAtlas.js";
9
- import { Sampler2D } from "../graphics/texture/sampler/Sampler2D.js";
10
- import { sampler2d_ensure_uint8_RGBA } from "../graphics/texture/sampler/sampler2d_ensure_uint8_RGBA.js";
11
-
12
- /**
13
- * @typedef {object} ParticleAtlasTexture
14
- * @property {number} references Number of emitters using this URL.
15
- * @property {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch|null} patch Null while loading or after failure.
16
- */
17
-
18
- /**
19
- * @typedef {object} ParticleAtlasEntry
20
- * @property {string|null} url Last observed emitter texture URL.
21
- * @property {ParticleAtlasTexture|null} texture Shared image record, or null for an untextured emitter.
22
- * @property {number} seen Last membership sweep that visited this emitter.
23
- */
24
-
25
- /**
26
- * Asset and atlas ownership for Shade's GPU particle emitters in one scene.
27
- *
28
- * This ECS system observes the scene's ParticleEmitter nodes, so it needs no additional component
29
- * or duplicate transform. Add/remove nodes through the scene as usual. One system owns the sprite
30
- * atlas for that scene; the legacy ParticleEmitterSystem continues to serve CPU particle effects.
31
- *
32
- * Images load through the supplied AssetManager and share a patch by URL. TextureAtlas already
33
- * handles packing, growth and repacking with MaxRectanglesPacker. A FrameStart extension publishes
34
- * the pixels and every current patch before the renderer records particle simulation and drawing.
35
- * Pending/failed images sample transparent; emitters without an image sample white.
36
- */
37
- export class GPUParticleEmitterSystem extends System {
38
- /**
39
- * CPU atlas, exposed for inspection. Owned by this system.
40
- * @type {TextureAtlas}
41
- */
42
- atlas = new TextureAtlas(64);
43
-
44
- /** @type {import("./GraphicsEngine.js").GraphicsEngine} */
45
- #graphics;
46
- /** @type {import("../../shade/renderer/scene/Scene.js").Scene} */
47
- #scene;
48
- /** @type {import("../asset/AssetManager.js").AssetManager} */
49
- #assets;
50
- /** @type {Map<import("../../shade/renderer/particles/runtime/ParticleEmitter.js").ParticleEmitter, ParticleAtlasEntry>} */
51
- #entries = new Map();
52
- /** @type {Map<string, ParticleAtlasTexture>} */
53
- #textures = new Map();
54
- /** @type {number} */
55
- #sweep = 0;
56
- /** @type {number} */
57
- #membership_version = -1;
58
- /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
59
- #white;
60
- /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
61
- #transparent;
62
- /** @type {GPUParticleAtlasExtension|null} */
63
- #extension = null;
64
- /** @type {GPUTextureContext|null} */
65
- #texture = null;
66
- /** @type {GPUTextureView|null} */
67
- #view = null;
68
- /** @type {GPUDevice|null} */
69
- #device = null;
70
- /** @type {number} */
71
- #uploaded_version = -1;
72
- /** @type {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem|null} */
73
- #particles = null;
74
- /** @type {GPUTextureView|null} */
75
- #previous_atlas = null;
76
- /** @type {Float32Array} */
77
- #region = new Float32Array(4);
78
-
79
- /**
80
- * @param {import("./GraphicsEngine.js").GraphicsEngine} graphics
81
- * @param {import("../../shade/renderer/scene/Scene.js").Scene} scene scene whose particle atlas this system owns
82
- * @param {import("../asset/AssetManager.js").AssetManager} assets
83
- */
84
- constructor(graphics, scene, assets) {
85
- super();
86
- assert.defined(graphics, "graphics");
87
- assert.equal(scene.isScene, true, "scene.isScene !== true");
88
- assert.defined(assets, "assets");
89
- this.#graphics = graphics;
90
- this.#scene = scene;
91
- this.#assets = assets;
92
- this.#create_defaults();
93
- }
94
-
95
- /**
96
- * @param {import("../ecs/EntityManager.js").EntityManager} entityManager
97
- * @returns {Promise<void>}
98
- */
99
- async startup(entityManager) {
100
- this.entityManager = entityManager;
101
- if (!this.#assets.hasLoaderForType(GameAssetType.Image)) {
102
- await this.#assets.registerLoader(GameAssetType.Image, new ImageRGBADataLoader());
103
- }
104
- this.#extension = new GPUParticleAtlasExtension(this);
105
- this.#graphics.add_extension(this.#extension);
106
- }
107
-
108
- /** @returns {Promise<void>} */
109
- async shutdown() {
110
- if (this.#extension !== null) {
111
- this.#graphics.remove_extension(this.#extension);
112
- this.#extension = null;
113
- }
114
- this.#detach();
115
- this.#texture?.destroy();
116
- this.#texture = null;
117
- this.#view = null;
118
- this.#device = null;
119
- this.#uploaded_version = -1;
120
- this.#entries.clear();
121
- this.#membership_version = -1;
122
- // An in-flight load checks its record's identity here before adding a patch.
123
- this.#textures.clear();
124
- this.atlas.reset();
125
- this.#create_defaults();
126
- }
127
-
128
- /** @returns {void} */
129
- #create_defaults() {
130
- const white = Sampler2D.uint8(4, 1, 1);
131
- white.data.fill(255);
132
- this.#white = this.atlas.add(white);
133
- this.#transparent = this.atlas.add(Sampler2D.uint8(4, 1, 1));
134
- }
135
-
136
- /** @returns {void} */
137
- update() {
138
- this.#sync_membership();
139
- for (const [emitter, entry] of this.#entries) {
140
- if (entry.url !== emitter.texture) {
141
- this.#release(entry.url);
142
- entry.url = emitter.texture;
143
- entry.texture = entry.url === null ? null : this.#acquire(entry.url);
144
- }
145
- }
146
- this.atlas.update();
147
- }
148
-
149
- /** @returns {void} */
150
- #sync_membership() {
151
- const instances = this.#scene.instances;
152
- if (instances.version === this.#membership_version) { return; }
153
- const sweep = ++this.#sweep;
154
- for (const emitter of instances.nodes) {
155
- if (emitter.isParticleEmitter !== true) { continue; }
156
- let entry = this.#entries.get(emitter);
157
- if (entry === undefined) {
158
- entry = { url: null, texture: null, seen: sweep };
159
- this.#entries.set(emitter, entry);
160
- }
161
- entry.seen = sweep;
162
- }
163
- for (const [emitter, entry] of this.#entries) {
164
- if (entry.seen !== sweep) {
165
- this.#release(entry.url);
166
- this.#entries.delete(emitter);
167
- }
168
- }
169
- this.#membership_version = instances.version;
170
- }
171
-
172
- /**
173
- * @param {string} url
174
- * @returns {ParticleAtlasTexture}
175
- */
176
- #acquire(url) {
177
- let texture = this.#textures.get(url);
178
- if (texture !== undefined) {
179
- texture.references++;
180
- return texture;
181
- }
182
- texture = { references: 1, patch: null };
183
- this.#textures.set(url, texture);
184
- this.#assets.promise(url, GameAssetType.Image).then(asset => {
185
- // Removed, changed URL, or shut down while loading: the completion owns nothing now.
186
- if (this.#textures.get(url) !== texture) { return; }
187
- texture.patch = this.atlas.add(sampler2d_ensure_uint8_RGBA(asset.create()));
188
- }).catch(error => {
189
- if (this.#textures.get(url) === texture) {
190
- console.warn(`GPU particle texture '${url}' could not be loaded`, error);
191
- }
192
- });
193
- return texture;
194
- }
195
-
196
- /**
197
- * @param {string|null} url
198
- * @returns {void}
199
- */
200
- #release(url) {
201
- if (url === null) { return; }
202
- const texture = this.#textures.get(url);
203
- if (--texture.references !== 0) { return; }
204
- this.#textures.delete(url);
205
- if (texture.patch !== null) { this.atlas.remove(texture.patch); }
206
- }
207
-
208
- /** Publish atlas data at FrameStart, after scene rows exist and before particle passes record.
209
- * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
210
- * @returns {void}
211
- */
212
- prepare(frame) {
213
- if (frame.view.scene.scene !== this.#scene) { return; }
214
- this.update();
215
- const renderer = this.#graphics.renderer;
216
- renderer.feature_particles_enabled = true;
217
- const particles = renderer.particles(frame.view.scene);
218
- if (particles !== this.#particles) {
219
- this.#detach();
220
- this.#particles = particles;
221
- this.#previous_atlas = particles.atlas;
222
- }
223
- particles.sync_membership();
224
- this.#upload(renderer.graphics.device);
225
- particles.atlas = this.#view;
226
-
227
- for (const [emitter, entry] of this.#entries) {
228
- if (particles.registry.context(emitter) === undefined) { continue; }
229
- const patch = entry.texture === null ? this.#white : entry.texture.patch ?? this.#transparent;
230
- const uv = patch.uv;
231
- const region = this.#region;
232
- if (patch === this.#white || patch === this.#transparent) {
233
- // Sample the texel centre everywhere, avoiding interpolation into the gutter.
234
- region[0] = uv.position.x + uv.size.x * 0.5;
235
- region[1] = uv.position.y + uv.size.y * 0.5;
236
- region[2] = 0;
237
- region[3] = 0;
238
- } else {
239
- region[0] = uv.position.x;
240
- region[1] = uv.position.y;
241
- region[2] = uv.size.x;
242
- region[3] = uv.size.y;
243
- }
244
- // Re-read every patch after packing: growth also changes UVs of unchanged images.
245
- particles.registry.set_atlas_region(emitter, region);
246
- }
247
- }
248
-
249
- /**
250
- * @param {GPUDevice} device
251
- * @returns {void}
252
- */
253
- #upload(device) {
254
- const source = this.atlas.sampler;
255
- if (this.#device !== device || this.#texture === null) {
256
- this.#texture?.destroy();
257
- this.#device = device;
258
- this.#texture = new GPUTextureContext(device);
259
- const descriptor = this.#texture.descriptor;
260
- descriptor.label = "particles/sprite atlas";
261
- descriptor.format = "rgba8unorm";
262
- descriptor.usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST;
263
- this.#uploaded_version = -1;
264
- }
265
- const texture = this.#texture;
266
- if (texture.width !== source.width || texture.height !== source.height) {
267
- texture.resize(source.width, source.height);
268
- this.#uploaded_version = -1;
269
- }
270
- if (this.#uploaded_version !== source.version) {
271
- device.queue.writeTexture({ texture: texture.gpu_texture }, source.data,
272
- { bytesPerRow: source.width * 4 }, [source.width, source.height]);
273
- texture.incrementVersion();
274
- this.#uploaded_version = source.version;
275
- }
276
- this.#view = texture.obtainView();
277
- }
278
-
279
- /** @returns {void} */
280
- #detach() {
281
- if (this.#particles !== null && this.#particles.atlas === this.#view) {
282
- this.#particles.atlas = this.#previous_atlas;
283
- for (const emitter of this.#particles.emitters) {
284
- this.#particles.registry.set_atlas_region(emitter, [0, 0, 1, 1]);
285
- }
286
- }
287
- this.#particles = null;
288
- this.#previous_atlas = null;
289
- }
290
- }
291
-
292
- class GPUParticleAtlasExtension extends RenderExtension {
293
- /** @type {string} */
294
- name = "gpu particle atlas";
295
- /** @type {FramePhase} */
296
- phase = FramePhase.FrameStart;
297
- /** @type {GPUParticleEmitterSystem} */
298
- #system;
299
-
300
- /** @param {GPUParticleEmitterSystem} system */
301
- constructor(system) {
302
- super();
303
- this.#system = system;
304
- }
305
- /**
306
- * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
307
- * @returns {void}
308
- */
309
- record(frame) { this.#system.prepare(frame); }
310
- }
1
+ import { assert } from "../../core/assert.js";
2
+ import { warn_limited } from "../../shade/util/warn_limited.js";
3
+ import { FramePhase } from "../../shade/renderer/extension/FramePhase.js";
4
+ import { RenderExtension } from "../../shade/renderer/extension/RenderExtension.js";
5
+ import { ParticleEmitter } from "../../shade/renderer/particles/runtime/ParticleEmitter.js";
6
+ import { GPUTextureContext } from "../../shade/renderer/texture/GPUTextureContext.js";
7
+ import { System } from "../ecs/System.js";
8
+ import Name from "../ecs/name/Name.js";
9
+ import { Transform64 } from "../ecs/transform/Transform64.js";
10
+ import { TRANSFORM64_EVENT_CHANGE } from "../ecs/transform/TRANSFORM64_EVENT_CHANGE.js";
11
+ import { GameAssetType } from "../asset/GameAssetType.js";
12
+ import { ImageRGBADataLoader } from "../asset/loaders/image/ImageRGBADataLoader.js";
13
+ import { ParticleEffect } from "../graphics/ecs/particles/ParticleEffect.js";
14
+ import { TextureAtlas } from "../graphics/texture/atlas/TextureAtlas.js";
15
+ import { Sampler2D } from "../graphics/texture/sampler/Sampler2D.js";
16
+ import { sampler2d_ensure_uint8_RGBA } from "../graphics/texture/sampler/sampler2d_ensure_uint8_RGBA.js";
17
+
18
+ /**
19
+ * Bursts {@link GPUParticleEmitterSystem#burst} will hold before it starts dropping them. Reached
20
+ * only when frames have stopped arriving — see the call site.
21
+ *
22
+ * @type {number}
23
+ */
24
+ const BURST_QUEUE_LIMIT = 256;
25
+
26
+ /**
27
+ * @typedef {object} ParticleAtlasTexture
28
+ * @property {number} references Number of entities using this URL.
29
+ * @property {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch|null} patch Null while loading or after failure.
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} ParticleEmitterEntry
34
+ * @property {number} entity
35
+ * @property {ParticleEffect} effect the entity's component — what to emit
36
+ * @property {Transform64} transform the entity's transform — where to emit it
37
+ * @property {ParticleEmitter|null} node the scene node, once the effect has a program to run
38
+ * @property {function():void} on_transform_changed
39
+ * @property {string|null} url last observed {@link ParticleEffect#texture}
40
+ * @property {ParticleAtlasTexture|null} texture shared image record, or null for an untextured effect
41
+ */
42
+
43
+ /**
44
+ * Draws the game's GPU particle effects: one emitter per entity carrying
45
+ * {@link ../graphics/ecs/particles/ParticleEffect.js ParticleEffect} and
46
+ * {@link ../ecs/transform/Transform64.js Transform64}.
47
+ *
48
+ * ```js
49
+ * entityManager.addSystem(new GPUParticleEmitterSystem(graphics, scene, assetManager));
50
+ *
51
+ * new Entity()
52
+ * .add(new Transform64())
53
+ * .add(ParticleEffect.from({ ...create_particle_effect({ layout, init, update }), spawn_rate: 400 }))
54
+ * .build(dataset);
55
+ * ```
56
+ *
57
+ * **The entity is the emitter.** The component says what it emits and the transform says where, the
58
+ * same division every other placed thing in this engine uses — a `Decal`, an `SGMesh`, a `Light`.
59
+ * Which means an emitter is spawned, moved, parented, attached to a bone, saved and destroyed by
60
+ * exactly the machinery that does those things for everything else, and a particle effect stops
61
+ * being a special case that needs its own scene-graph call.
62
+ *
63
+ * ## What this system does with the pair
64
+ *
65
+ * It builds a {@link ParticleEmitter} scene node per entity and owns it: the node is added to
66
+ * {@link scene} when the component has a compiled effect, follows the entity's transform, carries
67
+ * whatever the component currently says, and leaves the scene when the entity does. The node is not
68
+ * hidden — {@link node_of} hands it out — but it is not the interface either: write to the
69
+ * component and the next frame carries it.
70
+ *
71
+ * Changes are found by **comparison** rather than announced. Once a frame each entity's
72
+ * fields are compared against what was last written to its node, and the node is marked dirty only
73
+ * where they differ; `EmitterRegistry` then restages exactly the rows that moved. A component nobody
74
+ * touched costs a run of integer comparisons and no upload, so there is no change signal to fire and
75
+ * none to forget. What that buys is that gameplay can write `effect.spawn_rate = 0` anywhere,
76
+ * including from code that has never heard of this system.
77
+ *
78
+ * ## It owns the scene's sprite atlas
79
+ *
80
+ * Every {@link ParticleEffect#texture} is loaded through the supplied `AssetManager` and packed into
81
+ * one {@link TextureAtlas} shared by the scene, one patch per distinct URL however many entities
82
+ * name it, reference-counted so the last entity to let go is what unpacks it. A `FrameStart`
83
+ * extension publishes the pixels and every current patch region before the renderer records the
84
+ * particle passes — every repack moves UVs that nothing else changed, so the regions are rewritten
85
+ * whole rather than when a URL changes. Pending and failed images sample a transparent texel;
86
+ * effects with no image sample a white one.
87
+ *
88
+ * An emitter node in this scene that this system did not build — one added to the `Scene` directly —
89
+ * gets the white texel. The atlas is the scene's and that emitter has nothing in it, so the
90
+ * alternative is not "its own texture" but a UV rectangle from somebody else's image.
91
+ *
92
+ * ## What it is not
93
+ *
94
+ * It does not simulate anything and does not record a particle pass. `GPUParticleSystem` does both,
95
+ * inside the renderer, driven from the emitter table these rows are in — see
96
+ * `shade/renderer/particles/DESIGN.md`. This system's whole contribution is membership, placement,
97
+ * the fields, and the sprite sheet.
98
+ *
99
+ * It also does not retire the CPU `ParticleEmitterSystem`, which serves the `particular` effects the
100
+ * game still ships. The two coexist and share nothing.
101
+ */
102
+ export class GPUParticleEmitterSystem extends System {
103
+ dependencies = [ParticleEffect, Transform64];
104
+
105
+ /**
106
+ * CPU atlas, exposed for inspection. Owned by this system.
107
+ * @type {TextureAtlas}
108
+ */
109
+ atlas = new TextureAtlas(64);
110
+
111
+ /** @type {import("./GraphicsEngine.js").GraphicsEngine} */
112
+ #graphics;
113
+ /** @type {import("../../shade/renderer/scene/Scene.js").Scene} */
114
+ #scene;
115
+ /** @type {import("../asset/AssetManager.js").AssetManager} */
116
+ #assets;
117
+ /** @type {Map<number, ParticleEmitterEntry>} */
118
+ #entries = new Map();
119
+ /**
120
+ * The same entries by the node they own, so that the emitters this system did not build can be
121
+ * told apart from the ones it did without a linear search per emitter per frame.
122
+ * @type {Map<ParticleEmitter, ParticleEmitterEntry>}
123
+ */
124
+ #by_node = new Map();
125
+ /** @type {Map<string, ParticleAtlasTexture>} */
126
+ #textures = new Map();
127
+ /**
128
+ * Bursts asked for through {@link burst} and not yet handed to the particle system. Flat pairs
129
+ * of `entity, count`, drained every frame.
130
+ * @type {number[]}
131
+ */
132
+ #bursts = [];
133
+ /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
134
+ #white;
135
+ /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
136
+ #transparent;
137
+ /** @type {GPUParticleAtlasExtension|null} */
138
+ #extension = null;
139
+ /** @type {GPUTextureContext|null} */
140
+ #texture = null;
141
+ /** @type {GPUTextureView|null} */
142
+ #view = null;
143
+ /** @type {GPUDevice|null} */
144
+ #device = null;
145
+ /** @type {number} */
146
+ #uploaded_version = -1;
147
+ /** @type {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem|null} */
148
+ #particles = null;
149
+ /** @type {GPUTextureView|null} */
150
+ #previous_atlas = null;
151
+ /** @type {Float32Array} */
152
+ #region = new Float32Array(4);
153
+
154
+ /**
155
+ * @param {import("./GraphicsEngine.js").GraphicsEngine} graphics
156
+ * @param {import("../../shade/renderer/scene/Scene.js").Scene} scene scene the emitters are put
157
+ * in, and whose particle atlas this system owns
158
+ * @param {import("../asset/AssetManager.js").AssetManager} assets
159
+ */
160
+ constructor(graphics, scene, assets) {
161
+ super();
162
+
163
+ assert.defined(graphics, "graphics");
164
+ assert.equal(scene.isScene, true, "scene.isScene !== true");
165
+ assert.defined(assets, "assets");
166
+
167
+ this.#graphics = graphics;
168
+ this.#scene = scene;
169
+ this.#assets = assets;
170
+
171
+ this.#create_defaults();
172
+ }
173
+
174
+ /**
175
+ * The scene the emitters are put in. Received rather than created: meshes and lights come from
176
+ * systems of their own, and all of them have to be writing into one scene for any of it to be
177
+ * visible together.
178
+ *
179
+ * @returns {import("../../shade/renderer/scene/Scene.js").Scene}
180
+ */
181
+ get scene() {
182
+ return this.#scene;
183
+ }
184
+
185
+ /**
186
+ * The emitter nodes this system currently has in the scene, in no particular order. Read-only —
187
+ * they follow the entities.
188
+ *
189
+ * @returns {ParticleEmitter[]}
190
+ */
191
+ get emitters() {
192
+ return Array.from(this.#by_node.keys());
193
+ }
194
+
195
+ /**
196
+ * The scene node built for an entity.
197
+ *
198
+ * Handed out for the things an entity reference cannot express — parenting something to the
199
+ * emitter, reading the world matrix the hierarchy pass composed — and for nothing routine: the
200
+ * component is how an effect is changed, and a node written to behind the component's back is
201
+ * overwritten by the next comparison.
202
+ *
203
+ * @param {number} entity
204
+ * @returns {ParticleEmitter|null} `null` for an entity this system has not linked, or one whose
205
+ * component has no compiled effect yet
206
+ */
207
+ node_of(entity) {
208
+ const entry = this.#entries.get(entity);
209
+
210
+ if (entry === undefined) {
211
+ return null;
212
+ }
213
+
214
+ return entry.node;
215
+ }
216
+
217
+ /**
218
+ * Ask an entity's emitter for `count` particles on top of its rate, on the next frame.
219
+ *
220
+ * The one-shot half of the API, and the only thing here the CPU asks for rather than describes:
221
+ * an impact, a footfall, a muzzle flash. It goes through the same GPU path as continuous
222
+ * emission — the tick pass folds the burst into the emitter's accumulator, and the pool budget
223
+ * and the culling apply — so it is a request rather than a guarantee, and an entity whose
224
+ * emitter is not live by the time the frame starts is silently skipped.
225
+ *
226
+ * @param {number} entity
227
+ * @param {number} count
228
+ */
229
+ burst(entity, count) {
230
+ assert.isNonNegativeInteger(count, "count");
231
+
232
+ if (count === 0) {
233
+ return;
234
+ }
235
+
236
+ if (this.#bursts.length >= BURST_QUEUE_LIMIT * 2) {
237
+ /*
238
+ The queue is drained by a frame, so it only grows without bound when frames have stopped
239
+ arriving — a scene nobody draws, or a device that never came up — and gameplay carries on
240
+ asking. Dropping is right either way: nothing is going to spawn these, and a burst is a
241
+ request about the moment it was made.
242
+
243
+ The text is the key `warn_limited` counts on, so it carries no entity: one message per
244
+ limit, not one per caller that happens to be unlucky.
245
+ */
246
+ warn_limited(
247
+ `GPUParticleEmitterSystem: ${BURST_QUEUE_LIMIT} bursts are queued and no frame has `
248
+ + "drained them; dropping the rest"
249
+ );
250
+
251
+ return;
252
+ }
253
+
254
+ this.#bursts.push(entity, count);
255
+ }
256
+
257
+ /**
258
+ * @param {import("../ecs/EntityManager.js").EntityManager} entityManager
259
+ * @returns {Promise<void>}
260
+ */
261
+ async startup(entityManager) {
262
+ this.entityManager = entityManager;
263
+
264
+ if (!this.#assets.hasLoaderForType(GameAssetType.Image)) {
265
+ await this.#assets.registerLoader(GameAssetType.Image, new ImageRGBADataLoader());
266
+ }
267
+
268
+ this.#extension = new GPUParticleAtlasExtension(this);
269
+
270
+ this.#graphics.add_extension(this.#extension);
271
+ }
272
+
273
+ /** @returns {Promise<void>} */
274
+ async shutdown() {
275
+ if (this.#extension !== null) {
276
+ this.#graphics.remove_extension(this.#extension);
277
+ this.#extension = null;
278
+ }
279
+
280
+ for (const entity of Array.from(this.#entries.keys())) {
281
+ this.#drop(entity);
282
+ }
283
+
284
+ this.#detach();
285
+
286
+ this.#texture?.destroy();
287
+ this.#texture = null;
288
+ this.#view = null;
289
+ this.#device = null;
290
+ this.#uploaded_version = -1;
291
+ this.#bursts.length = 0;
292
+
293
+ // An in-flight load checks its record's identity here before adding a patch.
294
+ this.#textures.clear();
295
+
296
+ this.atlas.reset();
297
+ this.#create_defaults();
298
+ }
299
+
300
+ /**
301
+ * @param {ParticleEffect} effect
302
+ * @param {Transform64} transform
303
+ * @param {number} entity
304
+ */
305
+ link(effect, transform, entity) {
306
+ /** @type {ParticleEmitterEntry} */
307
+ const entry = {
308
+ entity,
309
+ effect,
310
+ transform,
311
+ node: null,
312
+ on_transform_changed: null,
313
+ url: null,
314
+ texture: null
315
+ };
316
+
317
+ this.#entries.set(entity, entry);
318
+
319
+ /*
320
+ The emitter is placed the moment its transform moves rather than on a poll. A Transform64
321
+ has no signals of its own, so the announcement comes from the dataset: whoever wrote the
322
+ transform sends TRANSFORM64_EVENT_CHANGE and this wakes on it. Coarser than watching
323
+ position, rotation and scale separately — a scale write wakes a placement that reads the
324
+ whole transform anyway — and that is the trade the type makes everywhere.
325
+ */
326
+ entry.on_transform_changed = () => this.#place(entry);
327
+
328
+ this.entityManager.dataset.addEntityEventListener(
329
+ entity, TRANSFORM64_EVENT_CHANGE, entry.on_transform_changed
330
+ );
331
+
332
+ // Everything else — building the node, acquiring the texture — is what a frame does anyway,
333
+ // and doing it here as well would be a second copy of it that has to stay in step.
334
+ this.#sync(entry);
335
+ }
336
+
337
+ /**
338
+ * @param {ParticleEffect} effect
339
+ * @param {Transform64} transform
340
+ * @param {number} entity
341
+ */
342
+ unlink(effect, transform, entity) {
343
+ this.#drop(entity);
344
+ }
345
+
346
+ /**
347
+ * Bring every emitter in line with its entity: build nodes for effects that have arrived, retire
348
+ * nodes for effects that have gone, copy changed fields onto the ones that stay, and follow the
349
+ * texture URLs.
350
+ *
351
+ * @param {number} [time_delta_seconds]
352
+ * @returns {void}
353
+ */
354
+ update(time_delta_seconds) {
355
+ for (const entry of this.#entries.values()) {
356
+ this.#sync(entry);
357
+ }
358
+
359
+ this.atlas.update();
360
+ }
361
+
362
+ /**
363
+ * Publish the atlas at FrameStart — after the scene context has established transform rows, and
364
+ * before the renderer records the particle passes that read the emitter table.
365
+ *
366
+ * {@link update} is called from here as well as by the entity manager, because a frame must not
367
+ * depend on a simulation tick having preceded it: an editor draws on demand, and a paused game
368
+ * still renders. It is idempotent and costs a comparison per emitter when nothing has changed,
369
+ * which is what makes running it twice a frame the cheap way to be right rather than a saving
370
+ * worth chasing.
371
+ *
372
+ * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
373
+ * @returns {void}
374
+ */
375
+ prepare(frame) {
376
+ if (frame.view.scene.scene !== this.#scene) {
377
+ return;
378
+ }
379
+
380
+ this.update();
381
+
382
+ const renderer = this.#graphics.renderer;
383
+
384
+ /*
385
+ The renderer's GPU particle feature is this system's to turn on, and it is turned on every
386
+ frame rather than once, because a device restart builds a renderer that has never heard of
387
+ it. The consequence is worth stating: while this system is in the profile there is no way to
388
+ switch GPU particles off through the renderer, because this would switch them back on at the
389
+ next FrameStart. Switching them off means taking the system out — or, for one effect,
390
+ `ParticleEffect#emitting`.
391
+ */
392
+ renderer.feature_particles_enabled = true;
393
+
394
+ const particles = renderer.particles(frame.view.scene);
395
+
396
+ if (particles !== this.#particles) {
397
+ this.#detach();
398
+ this.#particles = particles;
399
+ this.#previous_atlas = particles.atlas;
400
+ }
401
+
402
+ particles.sync_membership();
403
+
404
+ this.#upload(renderer.graphics.device);
405
+
406
+ particles.atlas = this.#view;
407
+
408
+ this.#publish_regions(particles);
409
+ this.#drain_bursts(particles);
410
+ }
411
+
412
+ /**
413
+ * Point every emitter of this scene at its patch in the atlas.
414
+ *
415
+ * Every region, every frame: growth also moves the UVs of images that did not change, and there
416
+ * is no signal for that which an emitter could subscribe to.
417
+ *
418
+ * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
419
+ */
420
+ #publish_regions(particles) {
421
+ const registry = particles.registry;
422
+ const emitters = registry.emitters;
423
+ const region = this.#region;
424
+
425
+ for (let i = 0; i < emitters.length; i++) {
426
+ const emitter = emitters[i];
427
+ const entry = this.#by_node.get(emitter);
428
+
429
+ // Not ours: it has nothing in this atlas, so it gets the one patch that is true for
430
+ // everybody — a white texel, which draws it as a flat quad of its own colour.
431
+ const patch = entry === undefined || entry.texture === null
432
+ ? this.#white
433
+ : entry.texture.patch ?? this.#transparent;
434
+
435
+ const uv = patch.uv;
436
+
437
+ if (patch === this.#white || patch === this.#transparent) {
438
+ // Sample the texel centre everywhere, avoiding interpolation into the gutter.
439
+ region[0] = uv.position.x + uv.size.x * 0.5;
440
+ region[1] = uv.position.y + uv.size.y * 0.5;
441
+ region[2] = 0;
442
+ region[3] = 0;
443
+ } else {
444
+ region[0] = uv.position.x;
445
+ region[1] = uv.position.y;
446
+ region[2] = uv.size.x;
447
+ region[3] = uv.size.y;
448
+ }
449
+
450
+ registry.set_atlas_region(emitter, region);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Hand this frame's bursts to the particle system, now that membership has been swept and every
456
+ * emitter that can have a row has one.
457
+ *
458
+ * The queue is emptied whether or not each burst could be delivered. A burst is a request about
459
+ * *this* moment — a footfall, an impact — and holding one for an emitter that is not live yet
460
+ * would deliver it seconds late, at whatever unrelated moment the emitter finally arrived.
461
+ *
462
+ * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
463
+ */
464
+ #drain_bursts(particles) {
465
+ const bursts = this.#bursts;
466
+
467
+ for (let i = 0; i < bursts.length; i += 2) {
468
+ const entry = this.#entries.get(bursts[i]);
469
+
470
+ if (entry === undefined || entry.node === null) {
471
+ continue;
472
+ }
473
+
474
+ if (particles.registry.context(entry.node) === undefined) {
475
+ continue;
476
+ }
477
+
478
+ particles.spawn(entry.node, bursts[i + 1]);
479
+ }
480
+
481
+ bursts.length = 0;
482
+ }
483
+
484
+ /**
485
+ * One entity's emitter, brought in line with its component.
486
+ *
487
+ * @param {ParticleEmitterEntry} entry
488
+ */
489
+ #sync(entry) {
490
+ const effect = entry.effect;
491
+
492
+ /*
493
+ Ahead of everything the effect gates, and deliberately: an image takes a fetch and a decode
494
+ to arrive, and an entity that names one has said what it wants to draw with whether or not
495
+ its program has finished compiling. Waiting would mean a first frame of transparent
496
+ particles for every effect that arrives with its texture.
497
+ */
498
+ if (entry.url !== effect.texture) {
499
+ this.#release(entry.url);
500
+
501
+ entry.url = effect.texture;
502
+ entry.texture = entry.url === null ? null : this.#acquire(entry.url);
503
+ }
504
+
505
+ if (effect.layout === null || effect.program === null) {
506
+ // No effect (yet, or any more). An entity whose effect is still compiling is ordinary,
507
+ // and so is one whose effect was cleared — both mean "emits nothing", and the emitter
508
+ // leaves the scene rather than keeping the last program it happened to have.
509
+ this.#detach_node(entry);
510
+
511
+ return;
512
+ }
513
+
514
+ if (entry.node === null) {
515
+ const node = new ParticleEmitter();
516
+
517
+ /*
518
+ The row takes the entity's name when it has one, exactly as `ShadedGeometrySystem` does
519
+ for a primitive. Nothing draws differently for it: what it buys is that a walk over what
520
+ is in the scene is readable in a debugger.
521
+ */
522
+ const name = this.entityManager.dataset.getComponent(entry.entity, Name);
523
+
524
+ node.name = name === undefined || name === null
525
+ ? `particles/entity ${entry.entity}`
526
+ : name.getValue();
527
+
528
+ entry.node = node;
529
+
530
+ apply_particle_effect(node, effect);
531
+
532
+ this.#place(entry);
533
+
534
+ this.#by_node.set(node, entry);
535
+ this.#scene.add(node);
536
+ } else if (apply_particle_effect(entry.node, effect)) {
537
+ // The node's own change counter, which is what EmitterRegistry restages a row from.
538
+ entry.node.needsUpdate = true;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * @param {ParticleEmitterEntry} entry
544
+ */
545
+ #place(entry) {
546
+ const node = entry.node;
547
+
548
+ if (node === null) {
549
+ return;
550
+ }
551
+
552
+ node.transform_local.copy(entry.transform);
553
+
554
+ // recomputes transform_global down the subtree, and bumps the version the scene database
555
+ // reads to decide what to upload
556
+ node.updateMatrices();
557
+ }
558
+
559
+ /**
560
+ * Take an entity's emitter back out of the scene, leaving the entry linked.
561
+ *
562
+ * @param {ParticleEmitterEntry} entry
563
+ */
564
+ #detach_node(entry) {
565
+ const node = entry.node;
566
+
567
+ if (node === null) {
568
+ return;
569
+ }
570
+
571
+ this.#scene.remove(node);
572
+ this.#by_node.delete(node);
573
+
574
+ entry.node = null;
575
+ }
576
+
577
+ /**
578
+ * @param {number} entity
579
+ */
580
+ #drop(entity) {
581
+ const entry = this.#entries.get(entity);
582
+
583
+ if (entry === undefined) {
584
+ return;
585
+ }
586
+
587
+ if (entry.on_transform_changed !== null) {
588
+ this.entityManager.dataset.removeEntityEventListener(
589
+ entity, TRANSFORM64_EVENT_CHANGE, entry.on_transform_changed
590
+ );
591
+
592
+ entry.on_transform_changed = null;
593
+ }
594
+
595
+ this.#detach_node(entry);
596
+ this.#release(entry.url);
597
+
598
+ entry.url = null;
599
+ entry.texture = null;
600
+
601
+ this.#entries.delete(entity);
602
+ }
603
+
604
+ /** @returns {void} */
605
+ #create_defaults() {
606
+ const white = Sampler2D.uint8(4, 1, 1);
607
+
608
+ white.data.fill(255);
609
+
610
+ this.#white = this.atlas.add(white);
611
+ this.#transparent = this.atlas.add(Sampler2D.uint8(4, 1, 1));
612
+ }
613
+
614
+ /**
615
+ * @param {string} url
616
+ * @returns {ParticleAtlasTexture}
617
+ */
618
+ #acquire(url) {
619
+ let texture = this.#textures.get(url);
620
+
621
+ if (texture !== undefined) {
622
+ texture.references++;
623
+
624
+ return texture;
625
+ }
626
+
627
+ texture = { references: 1, patch: null };
628
+
629
+ this.#textures.set(url, texture);
630
+
631
+ this.#assets.promise(url, GameAssetType.Image).then(asset => {
632
+ // Removed, changed URL, or shut down while loading: the completion owns nothing now.
633
+ if (this.#textures.get(url) !== texture) {
634
+ return;
635
+ }
636
+
637
+ texture.patch = this.atlas.add(sampler2d_ensure_uint8_RGBA(asset.create()));
638
+ }).catch(error => {
639
+ if (this.#textures.get(url) === texture) {
640
+ console.warn(`GPU particle texture '${url}' could not be loaded`, error);
641
+ }
642
+ });
643
+
644
+ return texture;
645
+ }
646
+
647
+ /**
648
+ * @param {string|null} url
649
+ * @returns {void}
650
+ */
651
+ #release(url) {
652
+ if (url === null) {
653
+ return;
654
+ }
655
+
656
+ const texture = this.#textures.get(url);
657
+
658
+ if (texture === undefined || --texture.references !== 0) {
659
+ return;
660
+ }
661
+
662
+ this.#textures.delete(url);
663
+
664
+ if (texture.patch !== null) {
665
+ this.atlas.remove(texture.patch);
666
+ }
667
+ }
668
+
669
+ /**
670
+ * @param {GPUDevice} device
671
+ * @returns {void}
672
+ */
673
+ #upload(device) {
674
+ const source = this.atlas.sampler;
675
+
676
+ if (this.#device !== device || this.#texture === null) {
677
+ this.#texture?.destroy();
678
+
679
+ this.#device = device;
680
+ this.#texture = new GPUTextureContext(device);
681
+
682
+ const descriptor = this.#texture.descriptor;
683
+
684
+ descriptor.label = "particles/sprite atlas";
685
+ descriptor.format = "rgba8unorm";
686
+ descriptor.usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST;
687
+
688
+ this.#uploaded_version = -1;
689
+ }
690
+
691
+ const texture = this.#texture;
692
+
693
+ if (texture.width !== source.width || texture.height !== source.height) {
694
+ texture.resize(source.width, source.height);
695
+
696
+ this.#uploaded_version = -1;
697
+ }
698
+
699
+ if (this.#uploaded_version !== source.version) {
700
+ device.queue.writeTexture({ texture: texture.gpu_texture }, source.data,
701
+ { bytesPerRow: source.width * 4 }, [source.width, source.height]);
702
+
703
+ texture.incrementVersion();
704
+
705
+ this.#uploaded_version = source.version;
706
+ }
707
+
708
+ this.#view = texture.obtainView();
709
+ }
710
+
711
+ /** @returns {void} */
712
+ #detach() {
713
+ if (this.#particles !== null && this.#particles.atlas === this.#view) {
714
+ this.#particles.atlas = this.#previous_atlas;
715
+
716
+ for (const emitter of this.#particles.emitters) {
717
+ this.#particles.registry.set_atlas_region(emitter, [0, 0, 1, 1]);
718
+ }
719
+ }
720
+
721
+ this.#particles = null;
722
+ this.#previous_atlas = null;
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Copy a component onto the scene node built for it, reporting whether anything actually moved.
728
+ *
729
+ * Comparing while assigning rather than in a pass of its own is what makes change detection cost
730
+ * nothing extra: the fields have to be read to be written, and the only addition is an `||` per
731
+ * field. What it answers is whether the emitter's GPU row has to be restaged, and restaging one that
732
+ * has not changed is an upload per emitter per frame.
733
+ *
734
+ * `spawn_rate` comes from {@link ParticleEffect#effective_spawn_rate}, so an effect switched off
735
+ * with `emitting = false` reaches the GPU as a rate of zero and keeps its authored rate to come back
736
+ * to. Layout and program are shared by reference, never copied — two entities of one effect belong
737
+ * on one program id in the heap.
738
+ *
739
+ * @param {ParticleEmitter} node
740
+ * @param {ParticleEffect} effect
741
+ * @returns {boolean} whether the node differs from what it held before
742
+ */
743
+ export function apply_particle_effect(node, effect) {
744
+ let changed = false;
745
+
746
+ changed = assign(node, "layout", effect.layout) || changed;
747
+ changed = assign(node, "program", effect.program) || changed;
748
+ changed = assign(node, "texture", effect.texture) || changed;
749
+ changed = assign(node, "seed", effect.seed) || changed;
750
+ changed = assign(node, "spawn_rate", effect.effective_spawn_rate) || changed;
751
+ changed = assign(node, "prewarm", effect.prewarm) || changed;
752
+ changed = assign(node, "flags", effect.flags) || changed;
753
+ changed = assign(node, "render_position", effect.render_position) || changed;
754
+ changed = assign(node, "render_size", effect.render_size) || changed;
755
+ changed = assign(node, "render_color", effect.render_color) || changed;
756
+ changed = assign(node, "render_rotation", effect.render_rotation) || changed;
757
+ changed = assign(node, "render_frame", effect.render_frame) || changed;
758
+ changed = assign(node, "render_velocity", effect.render_velocity) || changed;
759
+
760
+ const flipbook = node.flipbook;
761
+
762
+ if (flipbook[0] !== effect.flipbook[0] || flipbook[1] !== effect.flipbook[1]) {
763
+ flipbook.set(effect.flipbook);
764
+
765
+ changed = true;
766
+ }
767
+
768
+ return changed;
769
+ }
770
+
771
+ /**
772
+ * @param {object} target
773
+ * @param {string} field
774
+ * @param {*} value
775
+ * @returns {boolean} whether the field held something else
776
+ */
777
+ function assign(target, field, value) {
778
+ if (target[field] === value) {
779
+ return false;
780
+ }
781
+
782
+ target[field] = value;
783
+
784
+ return true;
785
+ }
786
+
787
+ class GPUParticleAtlasExtension extends RenderExtension {
788
+ /** @type {string} */
789
+ name = "gpu particle atlas";
790
+ /** @type {FramePhase} */
791
+ phase = FramePhase.FrameStart;
792
+ /** @type {GPUParticleEmitterSystem} */
793
+ #system;
794
+
795
+ /** @param {GPUParticleEmitterSystem} system */
796
+ constructor(system) {
797
+ super();
798
+
799
+ this.#system = system;
800
+ }
801
+
802
+ /**
803
+ * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
804
+ * @returns {void}
805
+ */
806
+ record(frame) {
807
+ this.#system.prepare(frame);
808
+ }
809
+ }