@woosh/meep-engine 3.17.2 → 3.18.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 (263) hide show
  1. package/editor/actions/concrete/ComponentAddAction.d.ts +1 -0
  2. package/editor/actions/concrete/ComponentAddAction.d.ts.map +1 -1
  3. package/editor/actions/concrete/ComponentAddAction.js +4 -0
  4. package/editor/actions/concrete/ComponentRemoveAction.d.ts +1 -0
  5. package/editor/actions/concrete/ComponentRemoveAction.d.ts.map +1 -1
  6. package/editor/actions/concrete/ComponentRemoveAction.js +4 -0
  7. package/editor/actions/concrete/EntityCreateAction.d.ts +1 -0
  8. package/editor/actions/concrete/EntityCreateAction.d.ts.map +1 -1
  9. package/editor/actions/concrete/EntityCreateAction.js +7 -0
  10. package/editor/actions/concrete/EntityRemoveAction.d.ts +1 -0
  11. package/editor/actions/concrete/EntityRemoveAction.d.ts.map +1 -1
  12. package/editor/actions/concrete/EntityRemoveAction.js +4 -0
  13. package/editor/actions/concrete/PatchTerrainHeightAction.d.ts +10 -0
  14. package/editor/actions/concrete/PatchTerrainHeightAction.d.ts.map +1 -1
  15. package/editor/actions/concrete/PatchTerrainHeightAction.js +107 -85
  16. package/editor/actions/concrete/TransformModifyAction.d.ts +1 -0
  17. package/editor/actions/concrete/TransformModifyAction.d.ts.map +1 -1
  18. package/editor/actions/concrete/TransformModifyAction.js +4 -0
  19. package/editor/particles/effect/ParticleEffectDocument.d.ts.map +1 -1
  20. package/editor/particles/effect/ParticleEffectDocument.js +10 -6
  21. package/editor/particles/effect/ParticleReferenceSimulation.d.ts.map +1 -1
  22. package/editor/particles/effect/ParticleReferenceSimulation.js +98 -4
  23. package/editor/particles/effect/make_starter_particle_effect.d.ts.map +1 -1
  24. package/editor/particles/effect/make_starter_particle_effect.js +0 -2
  25. package/editor/tools/engine/ToolEngine.d.ts +30 -0
  26. package/editor/tools/engine/ToolEngine.d.ts.map +1 -1
  27. package/editor/tools/engine/ToolEngine.js +257 -188
  28. package/editor/view/ecs/EntityList.d.ts.map +1 -1
  29. package/editor/view/ecs/EntityList.js +6 -5
  30. package/editor/view/particles/effect/ParticleEmitterInspectorView.d.ts.map +1 -1
  31. package/editor/view/particles/effect/ParticleEmitterInspectorView.js +17 -9
  32. package/package.json +1 -1
  33. package/src/CODEC_LAYOUT_VERIFICATION.md +1 -2
  34. package/src/core/binary/meshopt/MESHOPT_VERTEX_BLOCK.d.ts +56 -0
  35. package/src/core/binary/meshopt/MESHOPT_VERTEX_BLOCK.d.ts.map +1 -0
  36. package/src/core/binary/meshopt/MESHOPT_VERTEX_BLOCK.js +73 -0
  37. package/src/core/binary/meshopt/meshopt_decode_vertex_buffer.d.ts.map +1 -1
  38. package/src/core/binary/meshopt/meshopt_decode_vertex_buffer.js +18 -63
  39. package/src/core/binary/meshopt/meshopt_encode_byte_group.d.ts +53 -0
  40. package/src/core/binary/meshopt/meshopt_encode_byte_group.d.ts.map +1 -0
  41. package/src/core/binary/meshopt/meshopt_encode_byte_group.js +140 -0
  42. package/src/core/binary/meshopt/meshopt_encode_data_block.d.ts +32 -0
  43. package/src/core/binary/meshopt/meshopt_encode_data_block.d.ts.map +1 -0
  44. package/src/core/binary/meshopt/meshopt_encode_data_block.js +77 -0
  45. package/src/core/binary/meshopt/meshopt_encode_index_sequence.d.ts +46 -0
  46. package/src/core/binary/meshopt/meshopt_encode_index_sequence.d.ts.map +1 -0
  47. package/src/core/binary/meshopt/meshopt_encode_index_sequence.js +146 -0
  48. package/src/core/binary/meshopt/meshopt_encode_vertex_buffer.d.ts +40 -0
  49. package/src/core/binary/meshopt/meshopt_encode_vertex_buffer.d.ts.map +1 -0
  50. package/src/core/binary/meshopt/meshopt_encode_vertex_buffer.js +700 -0
  51. package/src/core/binary/meshopt/meshopt_write_uint_var.d.ts +24 -0
  52. package/src/core/binary/meshopt/meshopt_write_uint_var.d.ts.map +1 -0
  53. package/src/core/binary/meshopt/meshopt_write_uint_var.js +39 -0
  54. package/src/core/math/random/seededRandom_Mulberry32.d.ts.map +1 -1
  55. package/src/core/math/random/seededRandom_Mulberry32.js +4 -2
  56. package/src/core/process/undo/Action.d.ts +19 -0
  57. package/src/core/process/undo/Action.d.ts.map +1 -1
  58. package/src/core/process/undo/Action.js +17 -0
  59. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +78 -0
  60. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -0
  61. package/src/engine/graphics3/GPUParticleEmitterSystem.js +310 -0
  62. package/src/engine/graphics3/GraphicsEngine.d.ts +17 -0
  63. package/src/engine/graphics3/GraphicsEngine.d.ts.map +1 -1
  64. package/src/engine/graphics3/GraphicsEngine.js +30 -10
  65. package/src/engine/graphics3/particles/particle_gpu_records.d.ts.map +1 -1
  66. package/src/engine/graphics3/particles/particle_gpu_records.js +0 -2
  67. package/src/engine/interpolation/InterpolationSystem.d.ts +7 -0
  68. package/src/engine/interpolation/InterpolationSystem.d.ts.map +1 -1
  69. package/src/engine/interpolation/InterpolationSystem.js +10 -0
  70. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  71. package/src/engine/network/NetworkSession.js +14 -34
  72. package/src/engine/network/time/RenderPlayout.d.ts +76 -0
  73. package/src/engine/network/time/RenderPlayout.d.ts.map +1 -0
  74. package/src/engine/network/time/RenderPlayout.js +163 -0
  75. package/src/engine/network/transport/adapters/WebSocketTransport.d.ts.map +1 -1
  76. package/src/engine/network/transport/adapters/WebSocketTransport.js +11 -4
  77. package/src/engine/physics/ecs/PhysicsSystem.d.ts +4 -12
  78. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  79. package/src/engine/physics/ecs/PhysicsSystem.js +39 -13
  80. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  81. package/src/engine/sound/simulation/core/VolumeField.d.ts.map +1 -1
  82. package/src/engine/sound/simulation/core/VolumeField.js +4 -1
  83. package/src/format/scene/gltf/MESHOPT_COMPRESSION_PLAN.md +23 -2
  84. package/src/shade/playground/particle_system/README.md +139 -139
  85. package/src/shade/playground/particle_system/particle_prototype.d.ts.map +1 -1
  86. package/src/shade/playground/particle_system/particle_prototype.js +0 -2
  87. package/src/shade/playground/particle_system/particle_scene.d.ts +6 -9
  88. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -1
  89. package/src/shade/playground/particle_system/particle_scene.js +6 -13
  90. package/src/shade/playground/ssr_reprojection/README.md +103 -0
  91. package/src/shade/playground/ssr_reprojection/SpatialFilterProbe.d.ts +17 -0
  92. package/src/shade/playground/ssr_reprojection/SpatialFilterProbe.d.ts.map +1 -0
  93. package/src/shade/playground/ssr_reprojection/SpatialFilterProbe.js +68 -0
  94. package/src/shade/playground/ssr_reprojection/index.html +79 -0
  95. package/src/shade/playground/ssr_reprojection/main.d.ts +2 -0
  96. package/src/shade/playground/ssr_reprojection/main.d.ts.map +1 -0
  97. package/src/shade/playground/ssr_reprojection/main.js +245 -0
  98. package/src/shade/playground/ssr_reprojection/scene.d.ts +18 -0
  99. package/src/shade/playground/ssr_reprojection/scene.d.ts.map +1 -0
  100. package/src/shade/playground/ssr_reprojection/scene.js +51 -0
  101. package/src/shade/renderer/Renderer.d.ts +2 -23
  102. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  103. package/src/shade/renderer/Renderer.js +185 -218
  104. package/src/shade/renderer/animation/GPUAnimationManager.d.ts +5 -8
  105. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  106. package/src/shade/renderer/animation/GPUAnimationManager.js +12 -12
  107. package/src/shade/renderer/buffer/table/GPUTypedTable.d.ts.map +1 -1
  108. package/src/shade/renderer/buffer/table/GPUTypedTable.js +6 -0
  109. package/src/shade/renderer/buffer/table/single/GPUSingleTypeTable.d.ts.map +1 -1
  110. package/src/shade/renderer/buffer/table/single/GPUSingleTypeTable.js +7 -0
  111. package/src/shade/renderer/global_illumination/brick4/gpu/draw/chunk_brick4_sample_specular.d.ts +4 -0
  112. package/src/shade/renderer/global_illumination/brick4/gpu/draw/chunk_brick4_sample_specular.d.ts.map +1 -0
  113. package/src/shade/renderer/global_illumination/brick4/gpu/draw/chunk_brick4_sample_specular.js +51 -0
  114. package/src/shade/renderer/global_illumination/brick4/gpu/draw/shader_brick4_specular.d.ts.map +1 -1
  115. package/src/shade/renderer/global_illumination/brick4/gpu/draw/shader_brick4_specular.js +5 -35
  116. package/src/shade/renderer/particles/DESIGN.md +703 -514
  117. package/src/shade/renderer/particles/GPUParticleSystem.d.ts +16 -4
  118. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  119. package/src/shade/renderer/particles/GPUParticleSystem.js +1189 -991
  120. package/src/shade/renderer/particles/ParticleConstants.d.ts +64 -0
  121. package/src/shade/renderer/particles/ParticleConstants.d.ts.map +1 -1
  122. package/src/shade/renderer/particles/ParticleConstants.js +71 -0
  123. package/src/shade/renderer/particles/bounds/chunk_particle_bounds.d.ts +57 -0
  124. package/src/shade/renderer/particles/bounds/chunk_particle_bounds.d.ts.map +1 -0
  125. package/src/shade/renderer/particles/bounds/chunk_particle_bounds.js +181 -0
  126. package/src/shade/renderer/particles/bounds/chunk_particle_bounds_unknown.d.ts +7 -0
  127. package/src/shade/renderer/particles/bounds/chunk_particle_bounds_unknown.d.ts.map +1 -0
  128. package/src/shade/renderer/particles/bounds/chunk_particle_bounds_unknown.js +12 -0
  129. package/src/shade/renderer/particles/bounds/graph_particle_bounds.d.ts +33 -0
  130. package/src/shade/renderer/particles/bounds/graph_particle_bounds.d.ts.map +1 -0
  131. package/src/shade/renderer/particles/bounds/graph_particle_bounds.js +67 -0
  132. package/src/shade/renderer/particles/bounds/shader_particle_bounds.d.ts +40 -0
  133. package/src/shade/renderer/particles/bounds/shader_particle_bounds.d.ts.map +1 -0
  134. package/src/shade/renderer/particles/bounds/shader_particle_bounds.js +187 -0
  135. package/src/shade/renderer/particles/coherence/graph_particle_bucket.d.ts +15 -2
  136. package/src/shade/renderer/particles/coherence/graph_particle_bucket.d.ts.map +1 -1
  137. package/src/shade/renderer/particles/coherence/graph_particle_bucket.js +14 -8
  138. package/src/shade/renderer/particles/data/PARTICLE_COUNTERS.d.ts +58 -4
  139. package/src/shade/renderer/particles/data/PARTICLE_COUNTERS.d.ts.map +1 -1
  140. package/src/shade/renderer/particles/data/PARTICLE_COUNTERS.js +79 -4
  141. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STATE.d.ts +23 -1
  142. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STATE.d.ts.map +1 -1
  143. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STATE.js +35 -2
  144. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.d.ts +4 -2
  145. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.d.ts.map +1 -1
  146. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.js +5 -8
  147. package/src/shade/renderer/particles/data/particle_emitter_record.d.ts.map +1 -1
  148. package/src/shade/renderer/particles/data/particle_emitter_record.js +0 -6
  149. package/src/shade/renderer/particles/graph_particles.d.ts +25 -3
  150. package/src/shade/renderer/particles/graph_particles.d.ts.map +1 -1
  151. package/src/shade/renderer/particles/graph_particles.js +108 -31
  152. package/src/shade/renderer/particles/graph_particles_avboit.d.ts +6 -4
  153. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  154. package/src/shade/renderer/particles/graph_particles_avboit.js +5 -5
  155. package/src/shade/renderer/particles/runtime/EmitterRegistry.d.ts +5 -0
  156. package/src/shade/renderer/particles/runtime/EmitterRegistry.d.ts.map +1 -1
  157. package/src/shade/renderer/particles/runtime/EmitterRegistry.js +27 -6
  158. package/src/shade/renderer/particles/runtime/GPUParticleEmitterContext.d.ts +4 -0
  159. package/src/shade/renderer/particles/runtime/GPUParticleEmitterContext.d.ts.map +1 -1
  160. package/src/shade/renderer/particles/runtime/GPUParticleEmitterContext.js +6 -1
  161. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +35 -27
  162. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
  163. package/src/shade/renderer/particles/runtime/ParticleEmitter.js +424 -425
  164. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts +20 -0
  165. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts.map +1 -0
  166. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts +4 -0
  167. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  168. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +38 -2
  169. package/src/shade/renderer/particles/shaders/chunk_particle_spawn_budget.d.ts +22 -0
  170. package/src/shade/renderer/particles/shaders/chunk_particle_spawn_budget.d.ts.map +1 -0
  171. package/src/shade/renderer/particles/shaders/chunk_particle_spawn_budget.js +42 -0
  172. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.js +1 -1
  173. package/src/shade/renderer/particles/shaders/shader_particle_avboit_occupancy.d.ts.map +1 -1
  174. package/src/shade/renderer/particles/shaders/shader_particle_avboit_occupancy.js +53 -22
  175. package/src/shade/renderer/particles/shaders/shader_particle_emitter_tick.d.ts.map +1 -1
  176. package/src/shade/renderer/particles/shaders/shader_particle_emitter_tick.js +213 -216
  177. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts +50 -0
  178. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts.map +1 -0
  179. package/src/shade/renderer/particles/shaders/shader_particle_render.js +1 -1
  180. package/src/shade/renderer/particles/warmup/chunk_particle_warmup_step.d.ts +36 -0
  181. package/src/shade/renderer/particles/warmup/chunk_particle_warmup_step.d.ts.map +1 -0
  182. package/src/shade/renderer/particles/warmup/chunk_particle_warmup_step.js +102 -0
  183. package/src/shade/renderer/particles/warmup/graph_particle_warmup.d.ts +102 -0
  184. package/src/shade/renderer/particles/warmup/graph_particle_warmup.d.ts.map +1 -0
  185. package/src/shade/renderer/particles/warmup/graph_particle_warmup.js +329 -0
  186. package/src/shade/renderer/particles/warmup/shader_particle_warmup.d.ts +112 -0
  187. package/src/shade/renderer/particles/warmup/shader_particle_warmup.d.ts.map +1 -0
  188. package/src/shade/renderer/particles/warmup/shader_particle_warmup.js +318 -0
  189. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.d.ts +35 -0
  190. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.d.ts.map +1 -0
  191. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.js +108 -0
  192. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.d.ts +3 -0
  193. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.d.ts.map +1 -0
  194. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.js +70 -0
  195. package/src/shade/renderer/particles/warmup/warmup_schedule.d.ts +62 -0
  196. package/src/shade/renderer/particles/warmup/warmup_schedule.d.ts.map +1 -0
  197. package/src/shade/renderer/particles/warmup/warmup_schedule.js +55 -0
  198. package/src/shade/renderer/postprocess/PostProcess.d.ts.map +1 -1
  199. package/src/shade/renderer/postprocess/PostProcess.js +2 -1
  200. package/src/shade/renderer/postprocess/ssr/REPROJECTION_PROPOSAL.md +286 -0
  201. package/src/shade/renderer/postprocess/ssr/SSR.d.ts +57 -21
  202. package/src/shade/renderer/postprocess/ssr/SSR.d.ts.map +1 -1
  203. package/src/shade/renderer/postprocess/ssr/SSR.js +230 -129
  204. package/src/shade/renderer/postprocess/ssr/SSRReprojectionMode.d.ts +11 -0
  205. package/src/shade/renderer/postprocess/ssr/SSRReprojectionMode.d.ts.map +1 -0
  206. package/src/shade/renderer/postprocess/ssr/SSRReprojectionMode.js +6 -0
  207. package/src/shade/renderer/postprocess/ssr/chunk_ssr_metadata.d.ts +4 -0
  208. package/src/shade/renderer/postprocess/ssr/chunk_ssr_metadata.d.ts.map +1 -0
  209. package/src/shade/renderer/postprocess/ssr/chunk_ssr_metadata.js +32 -0
  210. package/src/shade/renderer/postprocess/ssr/chunk_ssr_spatial_variance.d.ts +4 -0
  211. package/src/shade/renderer/postprocess/ssr/chunk_ssr_spatial_variance.d.ts.map +1 -0
  212. package/src/shade/renderer/postprocess/ssr/chunk_ssr_spatial_variance.js +41 -0
  213. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts +3 -0
  214. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts.map +1 -0
  215. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.js +30 -0
  216. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts +4 -0
  217. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts.map +1 -0
  218. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.js +70 -0
  219. package/src/shade/renderer/postprocess/ssr/resolve/build_ssr_resolve_shader.d.ts +21 -0
  220. package/src/shade/renderer/postprocess/ssr/resolve/build_ssr_resolve_shader.d.ts.map +1 -0
  221. package/src/shade/renderer/postprocess/ssr/resolve/build_ssr_resolve_shader.js +363 -0
  222. package/src/shade/renderer/postprocess/ssr/resolve/chunk_get_neighbour_weight.js +1 -1
  223. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_Brick4.d.ts +2 -0
  224. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_Brick4.d.ts.map +1 -0
  225. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_Brick4.js +24 -0
  226. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_IBL.d.ts +1 -6
  227. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_IBL.d.ts.map +1 -1
  228. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_IBL.js +16 -332
  229. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_LPV.d.ts +1 -6
  230. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_LPV.d.ts.map +1 -1
  231. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_LPV.js +25 -358
  232. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts +0 -5
  233. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts.map +1 -1
  234. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.js +201 -276
  235. package/src/shade/renderer/postprocess/ssr/ssr_spatial_denoise_shader.d.ts +1 -0
  236. package/src/shade/renderer/postprocess/ssr/ssr_spatial_denoise_shader.d.ts.map +1 -1
  237. package/src/shade/renderer/postprocess/ssr/ssr_spatial_denoise_shader.js +167 -143
  238. package/src/shade/renderer/shader/graph/graph_create_buffer.d.ts +15 -0
  239. package/src/shade/renderer/shader/graph/graph_create_buffer.d.ts.map +1 -0
  240. package/src/shade/renderer/shader/graph/graph_create_buffer.js +20 -0
  241. package/src/shade/renderer/view/GPUViewContext.d.ts +3 -4
  242. package/src/shade/renderer/view/GPUViewContext.d.ts.map +1 -1
  243. package/src/shade/renderer/view/GPUViewContext.js +5 -5
  244. package/src/core/binary/meshopt/__meshopt_test_streams.d.ts +0 -24
  245. package/src/core/binary/meshopt/__meshopt_test_streams.d.ts.map +0 -1
  246. package/src/core/binary/meshopt/__meshopt_test_streams.js +0 -237
  247. package/src/core/geom/3d/atlas/atlas_test_fixtures.d.ts +0 -84
  248. package/src/core/geom/3d/atlas/atlas_test_fixtures.d.ts.map +0 -1
  249. package/src/core/geom/3d/atlas/atlas_test_fixtures.js +0 -266
  250. package/src/engine/asset/loaders/gltf_test_fixtures.d.ts +0 -97
  251. package/src/engine/asset/loaders/gltf_test_fixtures.d.ts.map +0 -1
  252. package/src/engine/asset/loaders/gltf_test_fixtures.js +0 -359
  253. package/src/shade/renderer/animation/skin_test_fixtures.d.ts +0 -86
  254. package/src/shade/renderer/animation/skin_test_fixtures.d.ts.map +0 -1
  255. package/src/shade/renderer/animation/skin_test_fixtures.js +0 -250
  256. package/src/shade/renderer/geometry/virtual/format/attribute/vgeo_layers_test_fixtures.d.ts +0 -16
  257. package/src/shade/renderer/geometry/virtual/format/attribute/vgeo_layers_test_fixtures.d.ts.map +0 -1
  258. package/src/shade/renderer/geometry/virtual/format/attribute/vgeo_layers_test_fixtures.js +0 -27
  259. package/src/shade/renderer/particles/particle_test_fixtures.d.ts +0 -151
  260. package/src/shade/renderer/particles/particle_test_fixtures.d.ts.map +0 -1
  261. package/src/shade/renderer/particles/particle_test_fixtures.js +0 -229
  262. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.js +0 -23
  263. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.js +0 -457
@@ -1,991 +1,1189 @@
1
- import { assert } from "../../../core/assert.js";
2
- import { align_4 } from "../../../core/binary/align_4.js";
3
- import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
4
- import { warn_limited } from "../../util/warn_limited.js";
5
- import { PREFIX_SCAN_CSDLDF_HEADER_WORDS } from "../gpu_primitive/prefix_sum/v1/shader_prefix_scan_csdldf.js";
6
- import {
7
- PARTICLE_EMITTER_TICK_WORKGROUP_SIZE,
8
- PARTICLE_RECORD_WORD_COUNT,
9
- PARTICLE_SIMULATE_WORKGROUP_SIZE,
10
- PARTICLE_SORT_BUCKET_COUNT,
11
- PARTICLE_SPAWN_COMMAND_WORDS,
12
- PARTICLE_VM_FAST_REGISTER_SLOTS,
13
- } from "./ParticleConstants.js";
14
- import { PARTICLE_COUNTER, PARTICLE_COUNTER_COUNT } from "./data/PARTICLE_COUNTERS.js";
15
- import { graph_particles_simulate } from "./graph_particles.js";
16
- import {
17
- graph_particle_avboit_draw,
18
- graph_particle_avboit_occupancy,
19
- graph_particle_avboit_splat,
20
- } from "./graph_particles_avboit.js";
21
- import { graph_particles_billboard } from "./graph_particles_billboard.js";
22
- import { ParticleRenderMode } from "./ParticleRenderMode.js";
23
- import { EmitterRegistry } from "./runtime/EmitterRegistry.js";
24
- import { create_particle_billboard_pipeline } from "./shaders/shader_particle_render.js";
25
- import { graph_particle_sort } from "./sort/graph_particle_sort.js";
26
-
27
- const WORD = Uint32Array.BYTES_PER_ELEMENT;
28
-
29
- const STORAGE = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
30
- const INDIRECT = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_SRC;
31
-
32
- /**
33
- * The GPU particle system: a feature of a scene, in the mould of `ReSTIRDI` — construct once
34
- * against a {@link GPUSceneContext}, and contribute the frame's passes through
35
- * {@link execute}.
36
- *
37
- * ## What it owns
38
- *
39
- * Every piece of persistent GPU state: the particle pool, the ping-pong alive lists, the dead
40
- * free-list, the counters, both indirect-args buffers, the simulation-order (coherence) scratch,
41
- * the emitter database (the emitter table and the GPU-owned state table, one buffer), the packed
42
- * program buffer, and the spawn-command ring. Buffers that depend on the emitter set — the
43
- * coherence scratch on the number of distinct programs, the program buffer on the heap, the
44
- * database on its own pages — are grown inside {@link execute}, the way ReSTIR's reservoirs follow
45
- * the resolution. The render-order (sort) scratch is not in that list because it is not
46
- * unconditional: see below.
47
- *
48
- * Every pass stays within the eight storage buffers a stage gets by default: the emitter record
49
- * and the GPU-owned state are two tables of one database buffer, and the VM's constants and code
50
- * are one packed buffer. The one exception is the wide-register fallback (below), whose emit and
51
- * simulate passes bind a ninth for the register file — inside the ten the engine already demands of
52
- * an adapter, but past the default.
53
- *
54
- * ## Two interpreters
55
- *
56
- * The VM's register file normally lives in shader registers, and is therefore small — see
57
- * {@link ./ParticleConstants.js PARTICLE_VM_FAST_REGISTER_SLOTS}. An effect whose compiled program
58
- * needs more than it holds does not fail: the whole system swaps emit and simulate for variants
59
- * that keep the file in a storage buffer, which is far slower and effectively unbounded. The choice
60
- * is per-frame, whole-system, and made here — the registry reports `max_register_count` over the
61
- * effects it holds and this is where that number meets the VM's; nothing about authoring a graph has
62
- * a register ceiling.
63
- *
64
- * ## Emitters are scene nodes
65
- *
66
- * There is no `addEmitter`. A {@link ParticleEmitter} is a {@link Node3D}: put it in the scene —
67
- * on its own or under a joint — and the next {@link execute} finds it in the scene's node list,
68
- * gives it a row in the emitter table pointed at the `transforms` row the scene context gave the
69
- * node, and it starts spawning. Take it out of the scene and its row is retired and its particles
70
- * freed. The sweep runs only when the scene's membership version moves, which is how per-frame
71
- * upkeep stays proportional to what changed rather than to how many emitters there are.
72
- *
73
- * ## Spawning is the GPU's decision
74
- *
75
- * The CPU never counts particles or walks emitters per frame. The emitter tick pass integrates
76
- * every emitter's rate on the GPU, and the emit dispatch is sized by a prefix scan of what it
77
- * decided. The host's one way to ask for particles is {@link spawn}: a burst queued into a small
78
- * command list that a pass adds to the emitter's pending spawns, on the same path as everything
79
- * else. See `graph_particles.js` for the frame.
80
- *
81
- * ## Drawing, and the one thing the mode changes
82
- *
83
- * {@link execute} is the whole per-frame call, once per frame. It always records everything that
84
- * moves particles and leaves the alive list and the indirect draw args as graph handles; what it
85
- * records after that is the {@link ParticleRenderMode} it was given.
86
- *
87
- * `BILLBOARD` sorts the alive list back-to-front and draws it over a colour target, because
88
- * premultiplied over-blending is order-dependent. `AVBOIT` records neither: under the renderer the
89
- * system is an `AVBOITSideChannel`, and the transparency orchestrator asks it for its occupancy, its
90
- * extinction and its draw at the three points where the transparent meshes have contributed theirs
91
- * ({@link avboit_occupancy}, {@link avboit_splat}, {@link avboit_draw}). Those read the same handles
92
- * and composite into a volume and a set of accumulators that are sums, so an ordering would buy
93
- * nothing — and the sort is four passes and a scan over every live particle, plus two lists a word
94
- * per particle each, which is why the mode also decides whether those buffers exist at all.
95
- *
96
- * ## Allocation
97
- *
98
- * The per-frame path allocates the frame graph's records and nothing else: emitter rows are
99
- * staged through one shared record, spawn commands go into a preallocated ring, and the
100
- * per-dispatch settings are reused objects. A command context is opened only on frames that have
101
- * something to upload.
102
- */
103
- export class GPUParticleSystem {
104
-
105
- /**
106
- * Engine GraphicsContext.
107
- * @type {object}
108
- */
109
- graphics;
110
-
111
- /**
112
- * The scene whose nodes the emitters are, and whose database their world matrices come from.
113
- * @type {import("../scene/GPUSceneContext.js").GPUSceneContext}
114
- */
115
- scene_context;
116
-
117
- /**
118
- * Maximum number of simultaneously live particles.
119
- * @type {number}
120
- */
121
- capacity;
122
-
123
- /**
124
- * @type {EmitterRegistry}
125
- */
126
- registry;
127
-
128
- /**
129
- * Frames elapsed. Drives the alive-list ping-pong and the VM's frame builtin.
130
- * @type {number}
131
- */
132
- frame = 0;
133
-
134
- /**
135
- * Seconds since start, the VM's `TIME` builtin. Advanced by `dt` each {@link execute}.
136
- * @type {number}
137
- */
138
- time = 0;
139
-
140
- /**
141
- * Cap on how much missed time a culled emitter makes up for when it comes back into view.
142
- * @type {number}
143
- */
144
- max_catchup_seconds = 1;
145
-
146
- /**
147
- * View-depth range the render sort quantises: particles nearer than `[0]` share the front bin,
148
- * farther than `[1]` the back one. Read only by {@link ParticleRenderMode.BILLBOARD}.
149
- * @type {Float32Array}
150
- */
151
- sort_depth_range = new Float32Array([0.1, 100]);
152
-
153
- /**
154
- * Sprite atlas the billboards sample, and the sampler they sample it with. A 1x1 white texel by
155
- * default, so an emitter with no art draws as a plain quad rather than nothing.
156
- * @type {GPUTextureView}
157
- */
158
- atlas;
159
-
160
- /** @type {GPUSampler} */
161
- atlas_sampler;
162
-
163
- /**
164
- * The persistent GPU buffers, by the names the graph functions bind them under. Exposed for
165
- * inspection — a harness copying the counters out to display them — never for the frame loop,
166
- * which reads nothing back. Entries that grow with the emitter set are replaced in place.
167
- *
168
- * @type {object}
169
- */
170
- buffers = {};
171
-
172
- /**
173
- * Distinct programs the coherence scratch is currently sized for.
174
- * @type {number}
175
- */
176
- #sim_bucket_capacity = 0;
177
-
178
- /**
179
- * The spawn-command ring: `[row, count]` pairs, filled by {@link spawn}, merged and uploaded on
180
- * the next {@link execute}.
181
- * @type {Uint32Array}
182
- */
183
- #spawn_commands;
184
-
185
- /**
186
- * The emitter each ring entry was issued for, so an entry whose emitter has since left the
187
- * scene — or whose row has gone to somebody else — is dropped at upload rather than delivered
188
- * to the row's next tenant.
189
- * @type {import("./runtime/ParticleEmitter.js").ParticleEmitter[]}
190
- */
191
- #spawn_command_emitters;
192
-
193
- /** @type {number} */
194
- #spawn_command_count = 0;
195
-
196
- /**
197
- * Scratch for merging the ring: pending count by row. Grown with the emitter table, never per
198
- * frame.
199
- * @type {Uint32Array}
200
- */
201
- #spawn_by_row = new Uint32Array(0);
202
-
203
- /**
204
- * Emitter node -> the sweep that last saw it in the scene.
205
- * @type {Map<import("./runtime/ParticleEmitter.js").ParticleEmitter, number>}
206
- */
207
- #swept = new Map();
208
-
209
- /** @type {number} */
210
- #sweep = 0;
211
-
212
- /**
213
- * The 1x1 white texture {@link atlas} points at until a caller replaces it. Held so
214
- * {@link destroy} can free it; a caller that assigns its own atlas owns that one.
215
- * @type {GPUTexture|null}
216
- */
217
- #default_atlas = null;
218
-
219
- /**
220
- * The scene membership version the emitter set was last reconciled against.
221
- * @type {number}
222
- */
223
- #instances_version = -1;
224
-
225
- /** @type {object|null} */
226
- #pipeline = null;
227
-
228
- /** @type {string} */
229
- #pipeline_key = "";
230
-
231
- /**
232
- * Per-frame scalars handed to {@link graph_particles_simulate}, reused rather than rebuilt.
233
- * @type {object}
234
- */
235
- #frame = {
236
- dt: 0, time: 0, index: 0, max_catchup: 1, spawn_command_count: 0,
237
- };
238
-
239
- /** @type {{capacity: number, group_count: number}} */
240
- #emitters = { capacity: 0, group_count: 0 };
241
-
242
- /**
243
- * This frame's simulation, for the draw that follows it: the graph it was recorded into, the
244
- * scene database handle it read, and the handles it produced. Reused, never reallocated.
245
- * @type {{graph: object|null, scene_database: number, emitter_group_count: number, handles: object|null}}
246
- */
247
- #simulated = { graph: null, scene_database: -1, emitter_group_count: 0, handles: null };
248
-
249
- /** @type {object} */
250
- #render = {
251
- atlas: null,
252
- atlas_sampler: null,
253
- obtain_pipeline: (color_format, depth_format) => this.#obtain_pipeline(color_format, depth_format),
254
- };
255
-
256
- /**
257
- * @param {object} graphics engine GraphicsContext (has `.device`)
258
- * @param {import("../scene/GPUSceneContext.js").GPUSceneContext} scene_context
259
- * @param {object} [options]
260
- * @param {number} [options.capacity] max simultaneous particles
261
- * @param {number} [options.spawn_command_capacity] most {@link spawn} calls one frame keeps
262
- */
263
- constructor(graphics, scene_context, { capacity = 1 << 16, spawn_command_capacity = 256 } = {}) {
264
- assert.defined(graphics, "graphics");
265
- assert.defined(scene_context, "scene_context");
266
- assert.equal(scene_context.isGPUSceneContext, true, "scene_context.isGPUSceneContext !== true");
267
- assert.isPositiveInteger(capacity, "capacity");
268
- assert.isPositiveInteger(spawn_command_capacity, "spawn_command_capacity");
269
-
270
- this.graphics = graphics;
271
- this.scene_context = scene_context;
272
- this.capacity = capacity;
273
-
274
- this.registry = new EmitterRegistry(graphics.device);
275
-
276
- this.#spawn_commands = new Uint32Array(spawn_command_capacity * PARTICLE_SPAWN_COMMAND_WORDS);
277
- this.#spawn_command_emitters = new Array(spawn_command_capacity).fill(null);
278
-
279
- this.#create_buffers();
280
- this.#create_default_atlas();
281
- }
282
-
283
- /**
284
- * The buffer the emitter table lives in — what every emitter-reading pass binds.
285
- * @returns {GPUBuffer}
286
- */
287
- get emitter_database() {
288
- return this.registry.database.buffer;
289
- }
290
-
291
- /**
292
- * The live emitters, in no particular order.
293
- * @returns {import("./runtime/ParticleEmitter.js").ParticleEmitter[]}
294
- */
295
- get emitters() {
296
- return this.registry.emitters;
297
- }
298
-
299
- /**
300
- * Number of simulation-order buckets — one per program id the registry can hand out, which is
301
- * its peak number of distinct compiled programs rather than its current one.
302
- *
303
- * This is the coherence pass's only host-provided number, and it is static topology rather than
304
- * per-frame state: it changes when an effect the set has never held is registered, not as
305
- * particles are born and die. Everything the pass measures per frame stays on the GPU. Sizing it
306
- * from the peak rather than the live count is what keeps it from moving every time an effect is
307
- * unregistered — the bins of released ids are empty, and an empty bin rounds up to zero
308
- * workgroups and shifts nothing after it.
309
- *
310
- * @returns {number}
311
- */
312
- bucketCount() {
313
- return Math.max(1, this.registry.program_id_capacity);
314
- }
315
-
316
- /**
317
- * Index of the alive list this frame reads from / writes to (ping-pong by frame parity).
318
- * @returns {number}
319
- */
320
- get alive_write_index() {
321
- return 1 - (this.frame % 2);
322
- }
323
-
324
- /**
325
- * Ask for `count` particles from an emitter, on top of its rate, on the next frame. Goes
326
- * through the GPU path — the tick pass folds it into the emitter's accumulator, the budget and
327
- * the culling apply — so it is a request, not a guarantee.
328
- *
329
- * @param {import("./runtime/ParticleEmitter.js").ParticleEmitter} emitter a registered emitter
330
- * @param {number} count
331
- */
332
- spawn(emitter, count) {
333
- assert.defined(emitter, "emitter");
334
- assert.isNonNegativeInteger(count, "count");
335
-
336
- const context = this.registry.context(emitter);
337
-
338
- assert.defined(context, "emitter is not in the scene");
339
-
340
- const ring = this.#spawn_commands;
341
- const base = this.#spawn_command_count * PARTICLE_SPAWN_COMMAND_WORDS;
342
-
343
- if (base >= ring.length) {
344
- // the text is the key warn_limited counts on, so it carries no burst size: one message
345
- // per ring size, not one per number a caller happens to ask for
346
- warn_limited(`GPUParticleSystem: spawn command ring is full (${ring.length / PARTICLE_SPAWN_COMMAND_WORDS} per frame); dropping this frame's remaining bursts`);
347
- return;
348
- }
349
-
350
- ring[base] = context.row;
351
- ring[base + 1] = count;
352
- this.#spawn_command_emitters[this.#spawn_command_count] = emitter;
353
- this.#spawn_command_count++;
354
- }
355
-
356
- /**
357
- * The simulation half of the frame: reconcile the emitter set with the scene, push what changed
358
- * to the GPU, and record every pass that moves particles — through to the indirect draw args.
359
- * What it leaves as graph handles is what a draw consumes, whichever draw that turns out to be.
360
- *
361
- * @param {object} params see {@link execute}
362
- * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} params.graph
363
- * @param {number} params.camera
364
- * @param {number} params.scene_database
365
- * @param {number} params.dt
366
- * @returns {import("./graph_particles.js").ParticleSimulationHandles}
367
- */
368
- #simulate({ graph, camera, scene_database, dt }) {
369
- this.#sync_membership();
370
- this.#upload();
371
-
372
- const frame = this.#frame;
373
- frame.dt = dt;
374
- frame.time = this.time;
375
- frame.index = this.frame;
376
- frame.max_catchup = this.max_catchup_seconds;
377
- frame.spawn_command_count = this.#spawn_command_count;
378
-
379
- const table = this.registry.table;
380
- const emitters = this.#emitters;
381
- emitters.capacity = table.element_capacity;
382
- emitters.group_count = table.dispatch_group_count(PARTICLE_EMITTER_TICK_WORKGROUP_SIZE);
383
-
384
- const buffers = this.buffers;
385
- buffers.alive_write = buffers.alive[this.alive_write_index];
386
- // The table's database owns and grows its own buffer; it is re-read every frame for that
387
- // reason.
388
- buffers.emitter_database = this.registry.database.buffer;
389
-
390
- const simulated = graph_particles_simulate({
391
- graph,
392
- camera,
393
- scene_database,
394
- gpu: buffers,
395
- emitters,
396
- capacity: this.capacity,
397
- bucket_count: this.bucketCount(),
398
- // Whose register file the VM runs on is decided here, once, for the whole system: the
399
- // registry reports what the hungriest registered effect needs and this compares it
400
- // against what the fast file holds. Re-read every frame because registering an emitter —
401
- // or unregistering the one hungry effect — can flip it either way.
402
- wide_registers: this.registry.max_register_count > PARTICLE_VM_FAST_REGISTER_SLOTS,
403
- frame,
404
- });
405
-
406
- // Kept for the draw that follows, whichever it is, and for nothing past this frame.
407
- const current = this.#simulated;
408
- current.graph = graph;
409
- current.scene_database = scene_database;
410
- current.emitter_group_count = emitters.group_count;
411
- current.handles = simulated;
412
-
413
- // Consumed: the count was recorded into the graph, the words were uploaded.
414
- this.#spawn_command_count = 0;
415
-
416
- this.frame++;
417
- this.time += dt;
418
-
419
- return simulated;
420
- }
421
-
422
- /**
423
- * The system's whole contribution to the frame: the simulation, then whatever `mode` says draws
424
- * it.
425
- *
426
- * {@link ParticleRenderMode.BILLBOARD} adds the depth sort and one billboard draw over `color`
427
- * with fixed-function blending, for a page with no transparency pipeline of its own.
428
- * {@link ParticleRenderMode.AVBOIT} adds nothing: the orchestrator draws the particles later in
429
- * the frame through {@link avboit_occupancy}, {@link avboit_splat} and {@link avboit_draw}, off
430
- * the handles this just produced, and its accumulators are order-independent — so the sort is
431
- * skipped and its buffers are never allocated. `color` comes straight back either way, so a
432
- * caller can assign the result unconditionally and let the mode be the only difference.
433
- *
434
- * Call after the scene context has built for the frame (`GPUSceneContext#update`), so every
435
- * emitter node has its `transforms` row, and after the hierarchy pass has run, so the rows'
436
- * `global` matrices are this frame's.
437
- *
438
- * **Once per frame, not once per view.** This advances the simulation by `dt` and hands out
439
- * handles into the graph it recorded into; a second view of the same scene records a second
440
- * graph and would step the simulation twice. The renderer has one view per `render` call today,
441
- * so this holds by construction; a second view of one scene needs the step and the handles
442
- * separated — record the passes for the first graph, import the same buffers into the rest.
443
- *
444
- * @param {object} params
445
- * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} params.graph
446
- * @param {number} params.camera graph handle of the camera uniform buffer
447
- * @param {number} params.scene_database graph handle of the scene database — the one the
448
- * hierarchy pass returned, so this reads the matrices it composed
449
- * @param {number} params.dt seconds since the previous frame
450
- * @param {ParticleRenderMode} [params.mode] who draws, and so whether there is a sort
451
- * @param {number} [params.color] graph handle of the colour target to draw over. Required by
452
- * `BILLBOARD`; returned untouched by `AVBOIT`
453
- * @param {number} [params.depth] graph handle of the scene depth (tested, never written).
454
- * Required by `BILLBOARD`
455
- * @returns {number|undefined} the colour handle after the particle draw, or `color` unchanged
456
- */
457
- execute({ graph, camera, scene_database, color, depth, dt, mode = ParticleRenderMode.BILLBOARD }) {
458
- assert.defined(graph, "graph");
459
- assert.isNumber(dt, "dt");
460
- assert.enum(mode, ParticleRenderMode, "mode");
461
-
462
- const billboard = mode === ParticleRenderMode.BILLBOARD;
463
-
464
- // Checked before the simulation rather than at the draw: the simulation advances `frame` and
465
- // `time` and writes the pool, and a throw after that leaves a frame half-stepped.
466
- if (billboard) {
467
- assert.isNonNegativeInteger(color, "color");
468
- assert.isNonNegativeInteger(depth, "depth");
469
- }
470
-
471
- const simulated = this.#simulate({ graph, camera, scene_database, dt });
472
-
473
- if (!billboard) {
474
- // AVBOIT: the side channels draw these handles later in the frame, and the colour target
475
- // is not this call's to touch.
476
- return color;
477
- }
478
-
479
- // Allocated here rather than in the constructor: they are two words per particle plus the
480
- // bins, and only this mode ever reads them. A system the renderer drives never makes them.
481
- this.#ensure_sort_buffers();
482
-
483
- // The render order: back-to-front by view depth, so premultiplied transparency composites
484
- // correctly. Its two per-particle passes take the dispatch the coherence reset wrote from
485
- // the GPU's own alive count.
486
- const sorted = graph_particle_sort({
487
- graph,
488
- gpu: this.buffers,
489
- camera,
490
- emitter_database: simulated.emitter_database,
491
- pool: simulated.pool,
492
- alive: simulated.alive,
493
- counters: simulated.counters,
494
- dispatch_args: simulated.sim_dispatch_args,
495
- bucket_count: PARTICLE_SORT_BUCKET_COUNT,
496
- near: this.sort_depth_range[0],
497
- far: this.sort_depth_range[1],
498
- });
499
-
500
- const render = this.#render;
501
- render.atlas = this.atlas;
502
- render.atlas_sampler = this.atlas_sampler;
503
-
504
- // One instanced billboard draw over the sorted list, sized by the draw args this frame's
505
- // build-indirect wrote.
506
- return graph_particles_billboard({
507
- graph,
508
- camera,
509
- color,
510
- depth,
511
- pool: simulated.pool,
512
- draw_list: sorted,
513
- emitter_database: simulated.emitter_database,
514
- draw_args: simulated.draw_args,
515
- render,
516
- });
517
- }
518
-
519
- /**
520
- * What this frame's {@link simulate} left, checked against the graph a side-channel call names:
521
- * the AVBOIT orchestrator records its passes into the same graph, later in the same frame.
522
- *
523
- * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} graph
524
- * @returns {{graph: object, scene_database: number, emitter_group_count: number, handles: import("./graph_particles.js").ParticleSimulationHandles}}
525
- */
526
- #simulated_for(graph) {
527
- const current = this.#simulated;
528
-
529
- assert.equal(current.graph, graph, "the particles were not simulated into this graph this frame — call execute() before the transparency pipeline records");
530
-
531
- return current;
532
- }
533
-
534
- /**
535
- * `AVBOITSideChannel#avboit_occupancy`: the emitters' bounds spheres, as depth slices.
536
- *
537
- * @param {object} args see `AVBOITSideChannel`
538
- * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} args.graph
539
- * @param {number} args.camera graph handle of the camera uniform buffer
540
- * @param {ArrayLike<number>} args.cluster_parameters the frame's depth curve
541
- * @param {number} args.occupancy graph handle of the occupancy bits
542
- * @returns {number} the occupancy handle after the write
543
- */
544
- avboit_occupancy({ graph, camera, cluster_parameters, occupancy }) {
545
- const current = this.#simulated_for(graph);
546
-
547
- return graph_particle_avboit_occupancy({
548
- graph,
549
- camera,
550
- cluster_parameters,
551
- occupancy,
552
- scene_database: current.scene_database,
553
- emitter_database: current.handles.emitter_database,
554
- emitter_group_count: current.emitter_group_count,
555
- });
556
- }
557
-
558
- /**
559
- * `AVBOITSideChannel#avboit_splat`: the live particles' extinction, into the volume.
560
- *
561
- * @param {object} args see `AVBOITSideChannel`
562
- * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} args.graph
563
- * @param {number} args.camera graph handle of the camera uniform buffer
564
- * @param {number} args.warp graph handle of the warp LUT
565
- * @param {number} args.depth_texture graph handle of the scene depth
566
- * @param {{extinction: number, overflow: number, dummy: number}} args.volume the voxel volume
567
- * @returns {{extinction: number, overflow: number, dummy: number}} the volume after the write
568
- */
569
- avboit_splat({ graph, camera, warp, depth_texture, volume }) {
570
- const { handles } = this.#simulated_for(graph);
571
-
572
- return graph_particle_avboit_splat({
573
- graph,
574
- camera,
575
- warp,
576
- depth_texture,
577
- volume,
578
- atlas: this.atlas,
579
- atlas_sampler: this.atlas_sampler,
580
- pool: handles.pool,
581
- alive: handles.alive,
582
- emitter_database: handles.emitter_database,
583
- draw_args: handles.draw_args,
584
- });
585
- }
586
-
587
- /**
588
- * `AVBOITSideChannel#avboit_draw`: the live particles, into the accumulators.
589
- *
590
- * @param {object} args see `AVBOITSideChannel`
591
- * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} args.graph
592
- * @param {import("../view/GPUViewContext.js").GPUViewContext} args.view_ctx
593
- * @param {number} args.camera graph handle of the camera uniform buffer
594
- * @param {number} args.depth_texture graph handle of the scene depth
595
- * @param {number} args.warp graph handle of the warp LUT
596
- * @param {number} args.integral graph handle of the integral texture
597
- * @param {number} args.volumetrics_lut graph handle
598
- * @param {number} args.volumetrics_metadata graph handle
599
- * @param {{color: number, norm: number, extinction: number, emission: number}} args.accumulators
600
- * @returns {{color: number, norm: number, extinction: number, emission: number}} after the draw
601
- */
602
- avboit_draw({ graph, view_ctx, camera, depth_texture, warp, integral, volumetrics_lut, volumetrics_metadata, accumulators }) {
603
- const { handles } = this.#simulated_for(graph);
604
-
605
- return graph_particle_avboit_draw({
606
- graph,
607
- view_ctx,
608
- camera,
609
- depth_texture,
610
- warp,
611
- integral,
612
- volumetrics_lut,
613
- volumetrics_metadata,
614
- accumulators,
615
- atlas: this.atlas,
616
- atlas_sampler: this.atlas_sampler,
617
- pool: handles.pool,
618
- alive: handles.alive,
619
- emitter_database: handles.emitter_database,
620
- draw_args: handles.draw_args,
621
- });
622
- }
623
-
624
- /**
625
- * Bring the emitter set in line with the scene: emitter nodes that arrived get rows, nodes that
626
- * left give theirs back. Runs only when the scene's membership version has moved.
627
- *
628
- * A node the scene context has not placed yet (no `transforms` row) is left for the next frame,
629
- * and the version is not recorded, so the sweep runs again until every emitter is bound.
630
- */
631
- #sync_membership() {
632
- const scene = this.scene_context.scene;
633
- const batch = scene.instances;
634
-
635
- if (batch.version === this.#instances_version) {
636
- return;
637
- }
638
-
639
- const sweep = ++this.#sweep;
640
- const nodes = batch.nodes;
641
- const node_count = nodes.length;
642
- const mapping = this.scene_context.id_mapping;
643
- const registry = this.registry;
644
- const swept = this.#swept;
645
-
646
- let complete = true;
647
-
648
- for (let i = 0; i < node_count; i++) {
649
- const node = nodes[i];
650
-
651
- if (node.isParticleEmitter !== true) {
652
- continue;
653
- }
654
-
655
- const transform_row = mapping.get(node.id);
656
-
657
- if (transform_row === undefined) {
658
- complete = false;
659
- continue;
660
- }
661
-
662
- if (registry.context(node) === undefined) {
663
- registry.add(node, transform_row);
664
- } else {
665
- registry.set_transform_row(node, transform_row);
666
- }
667
-
668
- swept.set(node, sweep);
669
- }
670
-
671
- for (const [emitter, seen] of swept) {
672
- if (seen !== sweep) {
673
- registry.remove(emitter);
674
- swept.delete(emitter);
675
- }
676
- }
677
-
678
- if (complete) {
679
- this.#instances_version = batch.version;
680
- }
681
- }
682
-
683
- /**
684
- * Push everything the CPU changed to the GPU: dirty emitter rows, the state buffer's growth,
685
- * rebuilt program buffers, the spawn-command ring, and the coherence scratch's growth. A
686
- * command context is opened only if something needs one.
687
- */
688
- #upload() {
689
- const registry = this.registry;
690
- const device = this.graphics.device;
691
-
692
- registry.flush();
693
-
694
- // Both tables stage into the one database: the emitter rows the author changed, and the
695
- // state rows claimed or released with them.
696
- const rows_staged = registry.table.element_upload_buffer.position > 0
697
- || registry.state_table.element_upload_buffer.position > 0;
698
-
699
- if (rows_staged) {
700
- const cmd = ShadeGPUCommandContext.create(this.graphics, "particles/upload");
701
-
702
- registry.database.update(cmd);
703
-
704
- cmd.finish();
705
- }
706
-
707
- if (registry.programs_dirty) {
708
- this.buffers.program = replace_data_buffer(device, this.buffers.program, "particles/program", registry.build_program());
709
- }
710
-
711
- this.#merge_spawn_commands();
712
-
713
- if (this.#spawn_command_count > 0) {
714
- device.queue.writeBuffer(
715
- this.buffers.spawn_commands, 0,
716
- this.#spawn_commands.buffer, 0,
717
- this.#spawn_command_count * PARTICLE_SPAWN_COMMAND_WORDS * WORD,
718
- );
719
- }
720
-
721
- this.#ensure_sim_buffers(this.bucketCount());
722
- }
723
-
724
- /**
725
- * Fold the ring down to one entry per row, dropping entries whose emitter no longer holds the
726
- * row it was issued against.
727
- *
728
- * The spawn-command pass adds to a state row with a plain read-modify-write, so two entries
729
- * naming one row would lose one; and a burst issued to an emitter that left the scene this
730
- * frame must not land on the row's next tenant. Both are settled here, in place, over a few
731
- * dozen entries at most.
732
- */
733
- #merge_spawn_commands() {
734
- const count = this.#spawn_command_count;
735
-
736
- if (count === 0) {
737
- return;
738
- }
739
-
740
- const ring = this.#spawn_commands;
741
- const emitters = this.#spawn_command_emitters;
742
-
743
- let rows = this.#spawn_by_row;
744
- const needed = Math.max(1, this.registry.table.element_capacity);
745
-
746
- if (rows.length < needed) {
747
- rows = new Uint32Array(needed);
748
- this.#spawn_by_row = rows;
749
- }
750
-
751
- for (let i = 0; i < count; i++) {
752
- const emitter = emitters[i];
753
- const row = ring[i * PARTICLE_SPAWN_COMMAND_WORDS];
754
- const context = this.registry.context(emitter);
755
-
756
- if (context !== undefined && context.row === row) {
757
- rows[row] += ring[i * PARTICLE_SPAWN_COMMAND_WORDS + 1];
758
- }
759
-
760
- emitters[i] = null;
761
- }
762
-
763
- let merged = 0;
764
-
765
- for (let i = 0; i < count; i++) {
766
- const row = ring[i * PARTICLE_SPAWN_COMMAND_WORDS];
767
- const pending = rows[row];
768
-
769
- if (pending === 0) {
770
- continue;
771
- }
772
-
773
- ring[merged * PARTICLE_SPAWN_COMMAND_WORDS] = row;
774
- ring[merged * PARTICLE_SPAWN_COMMAND_WORDS + 1] = pending;
775
- rows[row] = 0;
776
- merged++;
777
- }
778
-
779
- this.#spawn_command_count = merged;
780
- }
781
-
782
- /**
783
- * The simulation-order scratch is sized from the number of distinct programs, which only ever
784
- * grows (programs are never reclaimed, see {@link EmitterRegistry}). Reallocated when it does;
785
- * nothing in these buffers outlives a frame, so nothing is copied.
786
- *
787
- * @param {number} buckets
788
- */
789
- #ensure_sim_buffers(buckets) {
790
- if (buckets <= this.#sim_bucket_capacity) {
791
- return;
792
- }
793
-
794
- const device = this.graphics.device;
795
- const buffers = this.buffers;
796
- const capacity = this.capacity;
797
-
798
- for (const name of ["sim_list", "sim_keys", "sim_histogram", "sim_bucket_counts", "sim_cursor"]) {
799
- buffers[name]?.destroy();
800
- }
801
-
802
- // Padding rounds each bucket up to a whole workgroup, so it can add at most `workgroup - 1`
803
- // entries per bucket; one whole workgroup per bucket is the bound.
804
- buffers.sim_list = device.createBuffer({ label: "particles/sim_list", size: (capacity + buckets * PARTICLE_SIMULATE_WORKGROUP_SIZE) * WORD, usage: STORAGE });
805
- buffers.sim_keys = device.createBuffer({ label: "particles/sim_keys", size: capacity * WORD, usage: STORAGE });
806
- // `{count, elements}` for the CSDLDF scan; the scan reads whole vec4s, so the bins are
807
- // rounded up to a multiple of four.
808
- buffers.sim_histogram = device.createBuffer({ label: "particles/sim_histogram", size: (PREFIX_SCAN_CSDLDF_HEADER_WORDS + align_4(buckets)) * WORD, usage: STORAGE });
809
- buffers.sim_bucket_counts = device.createBuffer({ label: "particles/sim_bucket_counts", size: buckets * WORD, usage: STORAGE });
810
- buffers.sim_cursor = device.createBuffer({ label: "particles/sim_cursor", size: buckets * WORD, usage: STORAGE });
811
-
812
- this.#sim_bucket_capacity = buckets;
813
- }
814
-
815
- /**
816
- * The render-order scratch, made on the first frame that actually sorts.
817
- *
818
- * Only {@link ParticleRenderMode.BILLBOARD} reads any of it, and the two per-particle lists are
819
- * a word per particle each — at the renderer's default capacity, half a megabyte that AVBOIT
820
- * would never touch. Nothing in them outlives a frame, so there is nothing to carry over and the
821
- * first sorted frame is as correct as any later one.
822
- */
823
- #ensure_sort_buffers() {
824
- const buffers = this.buffers;
825
-
826
- if (buffers.sorted !== undefined) {
827
- return;
828
- }
829
-
830
- const device = this.graphics.device;
831
- const capacity = this.capacity;
832
-
833
- buffers.sorted = device.createBuffer({ label: "particles/sorted", size: capacity * WORD, usage: STORAGE });
834
- buffers.sort_keys = device.createBuffer({ label: "particles/sort_keys", size: capacity * WORD, usage: STORAGE });
835
- buffers.sort_histogram = device.createBuffer({ label: "particles/sort_histogram", size: (PREFIX_SCAN_CSDLDF_HEADER_WORDS + align_4(PARTICLE_SORT_BUCKET_COUNT)) * WORD, usage: STORAGE });
836
- buffers.sort_cursor = device.createBuffer({ label: "particles/sort_cursor", size: PARTICLE_SORT_BUCKET_COUNT * WORD, usage: STORAGE });
837
- }
838
-
839
- /**
840
- * The buffers whose size is fixed by the pool capacity, and the free list's initial contents.
841
- */
842
- #create_buffers() {
843
- const device = this.graphics.device;
844
- const capacity = this.capacity;
845
- const buffers = this.buffers;
846
-
847
- buffers.pool = device.createBuffer({ label: "particles/pool", size: capacity * PARTICLE_RECORD_WORD_COUNT * WORD, usage: STORAGE });
848
- buffers.alive = [
849
- device.createBuffer({ label: "particles/alive_a", size: capacity * WORD, usage: STORAGE }),
850
- device.createBuffer({ label: "particles/alive_b", size: capacity * WORD, usage: STORAGE }),
851
- ];
852
- buffers.dead = device.createBuffer({ label: "particles/dead", size: capacity * WORD, usage: STORAGE });
853
- buffers.counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * WORD, usage: STORAGE });
854
-
855
- // Indirect args, written on-GPU. Zero-initialised (WebGPU spec), so the first frame's
856
- // indirect dispatch/draw are no-ops until the first build-indirect has run.
857
- buffers.dispatch_args = device.createBuffer({ label: "particles/dispatch_args", size: 3 * WORD, usage: INDIRECT });
858
- buffers.draw_args = device.createBuffer({ label: "particles/draw_args", size: 4 * WORD, usage: INDIRECT });
859
- buffers.sim_dispatch_args = device.createBuffer({ label: "particles/sim_dispatch_args", size: 3 * WORD, usage: INDIRECT });
860
-
861
- buffers.spawn_commands = device.createBuffer({ label: "particles/spawn_commands", size: this.#spawn_commands.byteLength, usage: STORAGE });
862
-
863
- // Every slot free, and the counters saying so.
864
- const dead = new Uint32Array(capacity);
865
- for (let i = 0; i < capacity; i++) {
866
- dead[i] = i;
867
- }
868
- const counters = new Uint32Array(PARTICLE_COUNTER_COUNT);
869
- counters[PARTICLE_COUNTER.DEAD] = capacity;
870
-
871
- device.queue.writeBuffer(buffers.dead, 0, dead);
872
- device.queue.writeBuffer(buffers.counters, 0, counters);
873
- }
874
-
875
- /**
876
- * A 1x1 white atlas and a linear sampler, so the system draws before anyone hands it art.
877
- */
878
- #create_default_atlas() {
879
- const device = this.graphics.device;
880
-
881
- const texture = device.createTexture({
882
- label: "particles/default_atlas",
883
- size: [1, 1],
884
- format: "rgba8unorm",
885
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
886
- });
887
-
888
- device.queue.writeTexture({ texture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
889
-
890
- // kept, not just viewed: a texture nothing holds is a texture nothing can free
891
- this.#default_atlas = texture;
892
- this.atlas = texture.createView();
893
- this.atlas_sampler = device.createSampler({
894
- label: "particles/atlas_sampler",
895
- magFilter: "linear",
896
- minFilter: "linear",
897
- addressModeU: "clamp-to-edge",
898
- addressModeV: "clamp-to-edge",
899
- });
900
- }
901
-
902
- /**
903
- * @param {string} color_format
904
- * @param {string} depth_format
905
- * @returns {object} the billboard pipeline descriptor for these attachment formats
906
- */
907
- #obtain_pipeline(color_format, depth_format) {
908
- const key = `${color_format}/${depth_format}`;
909
-
910
- if (this.#pipeline === null || this.#pipeline_key !== key) {
911
- this.#pipeline = create_particle_billboard_pipeline({
912
- color_format,
913
- depth_format,
914
- // reverse-Z, the engine convention
915
- depth_compare: "greater",
916
- });
917
- this.#pipeline_key = key;
918
- }
919
-
920
- return this.#pipeline;
921
- }
922
-
923
- /**
924
- * Sizes of the persistent buffers, in bytes, as they stand. Exposed for tests.
925
- * @returns {Object<string, number>}
926
- */
927
- bufferSizes() {
928
- const sizes = {};
929
-
930
- for (const [name, buffer] of Object.entries(this.buffers)) {
931
- if (Array.isArray(buffer)) {
932
- sizes[name] = buffer[0].size;
933
- } else if (buffer !== undefined && buffer !== null) {
934
- sizes[name] = buffer.size;
935
- }
936
- }
937
-
938
- return sizes;
939
- }
940
-
941
- destroy() {
942
- /*
943
- The emitters go first, and they matter more than the buffers: they are the scene's nodes,
944
- they outlive this system, and each is holding a row of the table about to be freed. A node
945
- left holding one is stranded — the next system's membership sweep reads `row !== -1` as
946
- "already mine", takes the moved branch rather than the arrived one, and never registers it.
947
- Retiring them here puts every emitter back where it was before this system saw it.
948
- */
949
- for (const emitter of this.registry.emitters.slice()) {
950
- this.registry.remove(emitter);
951
- }
952
- this.#swept.clear();
953
- this.#instances_version = -1;
954
-
955
- for (const buffer of Object.values(this.buffers)) {
956
- if (Array.isArray(buffer)) {
957
- buffer.forEach((b) => b.destroy());
958
- } else if (buffer !== undefined && buffer !== null) {
959
- buffer.destroy();
960
- }
961
- }
962
-
963
- this.buffers = {};
964
- this.registry.database.destroy();
965
-
966
- // the default atlas is this system's own; a page that replaced it owns what it put there
967
- this.#default_atlas?.destroy();
968
- this.#default_atlas = null;
969
- }
970
- }
971
-
972
- /**
973
- * A storage buffer holding `data`, replacing `previous` (destroyed) if it exists. The program
974
- * buffer is monolithic — a program is variable-length, so there is no table to page it into — and
975
- * is rebuilt whole on the rare frame the program heap changes.
976
- *
977
- * @param {GPUDevice} device
978
- * @param {GPUBuffer|undefined} previous
979
- * @param {string} label
980
- * @param {Uint32Array} data
981
- * @returns {GPUBuffer}
982
- */
983
- function replace_data_buffer(device, previous, label, data) {
984
- previous?.destroy();
985
-
986
- const buffer = device.createBuffer({ label, size: Math.max(WORD, data.byteLength), usage: STORAGE });
987
-
988
- device.queue.writeBuffer(buffer, 0, data);
989
-
990
- return buffer;
991
- }
1
+ import { assert } from "../../../core/assert.js";
2
+ import { align_4 } from "../../../core/binary/align_4.js";
3
+ import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
4
+ import { warn_limited } from "../../util/warn_limited.js";
5
+ import { PREFIX_SCAN_CSDLDF_HEADER_WORDS } from "../gpu_primitive/prefix_sum/v1/shader_prefix_scan_csdldf.js";
6
+ import { hash_mix2 } from "../../../core/math/hash/hash_mix2.js";
7
+ import {
8
+ PARTICLE_EMITTER_TICK_WORKGROUP_SIZE,
9
+ PARTICLE_RECORD_WORD_COUNT,
10
+ PARTICLE_SIMULATE_WORKGROUP_SIZE,
11
+ PARTICLE_SORT_BUCKET_COUNT,
12
+ PARTICLE_SPAWN_COMMAND_WORDS,
13
+ PARTICLE_VM_FAST_REGISTER_SLOTS,
14
+ PARTICLE_WARMUP_EMITTER_JITTER,
15
+ PARTICLE_WARMUP_QUEUE_WORDS,
16
+ } from "./ParticleConstants.js";
17
+ import {
18
+ PARTICLE_BOUNDS_WORDS,
19
+ PARTICLE_COUNTER,
20
+ PARTICLE_COUNTER_COUNT,
21
+ particle_bounds_empty_words,
22
+ particle_counters_word_count,
23
+ } from "./data/PARTICLE_COUNTERS.js";
24
+ import { graph_particles_simulate } from "./graph_particles.js";
25
+ import {
26
+ graph_particle_avboit_draw,
27
+ graph_particle_avboit_occupancy,
28
+ graph_particle_avboit_splat,
29
+ } from "./graph_particles_avboit.js";
30
+ import { graph_particles_billboard } from "./graph_particles_billboard.js";
31
+ import { ParticleRenderMode } from "./ParticleRenderMode.js";
32
+ import { EmitterRegistry } from "./runtime/EmitterRegistry.js";
33
+ import { create_particle_billboard_pipeline } from "./shaders/shader_particle_render.js";
34
+ import { graph_particle_sort } from "./sort/graph_particle_sort.js";
35
+ import { WARMUP_QUEUE } from "./warmup/shader_particle_warmup.js";
36
+ import { warmup_schedule } from "./warmup/warmup_schedule.js";
37
+ import { GPUTextureContext } from "../texture/GPUTextureContext.js";
38
+
39
+ const WORD = Uint32Array.BYTES_PER_ELEMENT;
40
+
41
+ const STORAGE = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
42
+ const INDIRECT = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_SRC;
43
+
44
+ /**
45
+ * The GPU particle system: a feature of a scene, in the mould of `ReSTIRDI` — construct once
46
+ * against a {@link GPUSceneContext}, and contribute the frame's passes through
47
+ * {@link execute}.
48
+ *
49
+ * ## What it owns
50
+ *
51
+ * Every piece of persistent GPU state: the particle pool, the ping-pong alive lists, the dead
52
+ * free-list, the counters, both indirect-args buffers, the simulation-order (coherence) scratch,
53
+ * the emitter database (the emitter table and the GPU-owned state table, one buffer), the packed
54
+ * program buffer, and the spawn-command ring. Buffers that depend on the emitter set — the
55
+ * coherence scratch on the number of distinct programs, the program buffer on the heap, the
56
+ * database on its own pages, the counters on the emitter table's element capacity (they carry the
57
+ * per-emitter bounds accumulator, see `bounds/`) — are grown inside
58
+ * {@link execute}, the way ReSTIR's reservoirs follow the resolution. The render-order (sort)
59
+ * scratch is not in that list because it is not unconditional: see below.
60
+ *
61
+ * Every pass stays within the eight storage buffers a stage gets by default: the emitter record
62
+ * and the GPU-owned state are two tables of one database buffer, and the VM's constants and code
63
+ * are one packed buffer. The one exception is the wide-register fallback (below), whose emit and
64
+ * simulate passes bind a ninth for the register file — inside the ten the engine already demands of
65
+ * an adapter, but past the default.
66
+ *
67
+ * ## Two interpreters
68
+ *
69
+ * The VM's register file normally lives in shader registers, and is therefore small — see
70
+ * {@link ./ParticleConstants.js PARTICLE_VM_FAST_REGISTER_SLOTS}. An effect whose compiled program
71
+ * needs more than it holds does not fail: the whole system swaps emit and simulate for variants
72
+ * that keep the file in a storage buffer, which is far slower and effectively unbounded. The choice
73
+ * is per-frame, whole-system, and made here — the registry reports `max_register_count` over the
74
+ * effects it holds and this is where that number meets the VM's; nothing about authoring a graph has
75
+ * a register ceiling.
76
+ *
77
+ * ## Emitters are scene nodes
78
+ *
79
+ * There is no `addEmitter`. A {@link ParticleEmitter} is a {@link Node3D}: put it in the scene —
80
+ * on its own or under a joint — and the next {@link execute} finds it in the scene's node list,
81
+ * gives it a row in the emitter table pointed at the `transforms` row the scene context gave the
82
+ * node, and it starts spawning. Take it out of the scene and its row is retired and its particles
83
+ * freed. The sweep runs only when the scene's membership version moves, which is how per-frame
84
+ * upkeep stays proportional to what changed rather than to how many emitters there are.
85
+ *
86
+ * ## Spawning is the GPU's decision
87
+ *
88
+ * The CPU never counts particles or walks emitters per frame. The emitter tick pass integrates
89
+ * every emitter's rate on the GPU, and the emit dispatch is sized by a prefix scan of what it
90
+ * decided. The host's one way to ask for particles is {@link spawn}: a burst queued into a small
91
+ * command list that a pass adds to the emitter's pending spawns, on the same path as everything
92
+ * else. See `graph_particles.js` for the frame.
93
+ *
94
+ * ## Drawing, and the one thing the mode changes
95
+ *
96
+ * {@link execute} is the whole per-frame call, once per frame. It always records everything that
97
+ * moves particles and leaves the alive list and the indirect draw args as graph handles; what it
98
+ * records after that is the {@link ParticleRenderMode} it was given.
99
+ *
100
+ * `BILLBOARD` sorts the alive list back-to-front and draws it over a colour target, because
101
+ * premultiplied over-blending is order-dependent. `AVBOIT` records neither: under the renderer the
102
+ * system is an `AVBOITSideChannel`, and the transparency orchestrator asks it for its occupancy, its
103
+ * extinction and its draw at the three points where the transparent meshes have contributed theirs
104
+ * ({@link avboit_occupancy}, {@link avboit_splat}, {@link avboit_draw}). Those read the same handles
105
+ * and composite into a volume and a set of accumulators that are sums, so an ordering would buy
106
+ * nothing — and the sort is four passes and a scan over every live particle, plus two lists a word
107
+ * per particle each, which is why the mode also decides whether those buffers exist at all.
108
+ *
109
+ * ## Allocation
110
+ *
111
+ * The per-frame path allocates the frame graph's records and nothing else: emitter rows are
112
+ * staged through one shared record, spawn commands go into a preallocated ring, and the
113
+ * per-dispatch settings are reused objects. A command context is opened only on frames that have
114
+ * something to upload.
115
+ */
116
+ export class GPUParticleSystem {
117
+
118
+ /**
119
+ * Engine GraphicsContext.
120
+ * @type {object}
121
+ */
122
+ graphics;
123
+
124
+ /**
125
+ * The scene whose nodes the emitters are, and whose database their world matrices come from.
126
+ * @type {import("../scene/GPUSceneContext.js").GPUSceneContext}
127
+ */
128
+ scene_context;
129
+
130
+ /**
131
+ * Maximum number of simultaneously live particles.
132
+ * @type {number}
133
+ */
134
+ capacity;
135
+
136
+ /**
137
+ * @type {EmitterRegistry}
138
+ */
139
+ registry;
140
+
141
+ /**
142
+ * Frames elapsed. Drives the alive-list ping-pong and the VM's frame builtin.
143
+ * @type {number}
144
+ */
145
+ frame = 0;
146
+
147
+ /**
148
+ * Seconds since start, the VM's `TIME` builtin. Advanced by `dt` each {@link execute}.
149
+ * @type {number}
150
+ */
151
+ time = 0;
152
+
153
+ /**
154
+ * Cap on how much missed time a culled emitter makes up for when it comes back into view.
155
+ * @type {number}
156
+ */
157
+ max_catchup_seconds = 1;
158
+
159
+ /**
160
+ * View-depth range the render sort quantises: particles nearer than `[0]` share the front bin,
161
+ * farther than `[1]` the back one. Read only by {@link ParticleRenderMode.BILLBOARD}.
162
+ * @type {Float32Array}
163
+ */
164
+ sort_depth_range = new Float32Array([0.1, 100]);
165
+
166
+ /**
167
+ * Sprite atlas the billboards sample, and the sampler they sample it with. A 1x1 white texel by
168
+ * default, so an emitter with no art draws as a plain quad rather than nothing.
169
+ * @type {GPUTextureView}
170
+ */
171
+ atlas;
172
+
173
+ /** @type {GPUSampler} */
174
+ atlas_sampler;
175
+
176
+ /**
177
+ * The persistent GPU buffers, by the names the graph functions bind them under. Exposed for
178
+ * inspection — a harness copying the counters out to display them — never for the frame loop,
179
+ * which reads nothing back. Entries that grow with the emitter set are replaced in place.
180
+ *
181
+ * @type {object}
182
+ */
183
+ buffers = {};
184
+
185
+ /**
186
+ * Distinct programs the coherence scratch is currently sized for.
187
+ * @type {number}
188
+ */
189
+ #sim_bucket_capacity = 0;
190
+
191
+ /**
192
+ * The spawn-command ring: `[row, count]` pairs, filled by {@link spawn}, merged and uploaded on
193
+ * the next {@link execute}.
194
+ * @type {Uint32Array}
195
+ */
196
+ #spawn_commands;
197
+
198
+ /**
199
+ * The emitter each ring entry was issued for, so an entry whose emitter has since left the
200
+ * scene — or whose row has gone to somebody else — is dropped at upload rather than delivered
201
+ * to the row's next tenant.
202
+ * @type {import("./runtime/ParticleEmitter.js").ParticleEmitter[]}
203
+ */
204
+ #spawn_command_emitters;
205
+
206
+ /** @type {number} */
207
+ #spawn_command_count = 0;
208
+
209
+ /**
210
+ * Scratch for merging the ring: pending count by row. Grown with the emitter table, never per
211
+ * frame.
212
+ * @type {Uint32Array}
213
+ */
214
+ #spawn_by_row = new Uint32Array(0);
215
+
216
+ /**
217
+ * Emitters registered since the last frame with a pre-warm to run — the input of the warm-up
218
+ * pipeline (`warmup/`), consumed by the frame that records it. Filled where the emitter is
219
+ * registered ({@link sync_membership}), which is the one moment the host knows an emitter is
220
+ * new: the GPU decides everything else about spawning, but "this one has just arrived and asks
221
+ * to arrive warm" is a host-side fact.
222
+ *
223
+ * @type {import("./runtime/ParticleEmitter.js").ParticleEmitter[]}
224
+ */
225
+ #warmup_queue = [];
226
+
227
+ /**
228
+ * The queue as the GPU reads it, `PARTICLE_WARMUP_QUEUE_WORDS` per entry — see
229
+ * `warmup/shader_particle_warmup.js`. Grown to fit, never per frame.
230
+ * @type {Uint32Array}
231
+ */
232
+ #warmup_words = new Uint32Array(0);
233
+
234
+ /**
235
+ * Emitter node -> the sweep that last saw it in the scene.
236
+ * @type {Map<import("./runtime/ParticleEmitter.js").ParticleEmitter, number>}
237
+ */
238
+ #swept = new Map();
239
+
240
+ /** @type {number} */
241
+ #sweep = 0;
242
+
243
+ /**
244
+ * The 1x1 white texture {@link atlas} points at until a caller replaces it. Held so
245
+ * {@link destroy} can free it; a caller that assigns its own atlas owns that one.
246
+ * @type {GPUTextureContext|null}
247
+ */
248
+ #default_atlas = null;
249
+
250
+ /**
251
+ * The scene membership version the emitter set was last reconciled against.
252
+ * @type {number}
253
+ */
254
+ #instances_version = -1;
255
+
256
+ /**
257
+ * Program assignments used to build the previous frame's coherence list.
258
+ * @type {number}
259
+ */
260
+ #program_assignment_version = 0;
261
+
262
+ /** @type {object|null} */
263
+ #pipeline = null;
264
+
265
+ /** @type {string} */
266
+ #pipeline_key = "";
267
+
268
+ /**
269
+ * Per-frame scalars handed to {@link graph_particles_simulate}, reused rather than rebuilt.
270
+ * @type {object}
271
+ */
272
+ #frame = {
273
+ dt: 0, time: 0, index: 0, max_catchup: 1, spawn_command_count: 0,
274
+ warmup_count: 0, warmup_capacity: 0, warmup_schedule: null,
275
+ rebucket: false,
276
+ };
277
+
278
+ /** @type {{capacity: number, group_count: number}} */
279
+ #emitters = { capacity: 0, group_count: 0 };
280
+
281
+ /**
282
+ * This frame's simulation, for the draw that follows it: the graph it was recorded into, the
283
+ * scene database handle it read, and the handles it produced. Reused, never reallocated.
284
+ * @type {{graph: object|null, scene_database: number, emitter_group_count: number, handles: object|null}}
285
+ */
286
+ #simulated = { graph: null, scene_database: -1, emitter_group_count: 0, handles: null };
287
+
288
+ /** @type {object} */
289
+ #render = {
290
+ atlas: null,
291
+ atlas_sampler: null,
292
+ obtain_pipeline: (color_format, depth_format) => this.#obtain_pipeline(color_format, depth_format),
293
+ };
294
+
295
+ /**
296
+ * @param {object} graphics engine GraphicsContext (has `.device`)
297
+ * @param {import("../scene/GPUSceneContext.js").GPUSceneContext} scene_context
298
+ * @param {object} [options]
299
+ * @param {number} [options.capacity] max simultaneous particles
300
+ * @param {number} [options.spawn_command_capacity] most {@link spawn} calls one frame keeps
301
+ */
302
+ constructor(graphics, scene_context, { capacity = 1 << 16, spawn_command_capacity = 256 } = {}) {
303
+ assert.defined(graphics, "graphics");
304
+ assert.defined(scene_context, "scene_context");
305
+ assert.equal(scene_context.isGPUSceneContext, true, "scene_context.isGPUSceneContext !== true");
306
+ assert.isPositiveInteger(capacity, "capacity");
307
+ assert.isPositiveInteger(spawn_command_capacity, "spawn_command_capacity");
308
+
309
+ this.graphics = graphics;
310
+ this.scene_context = scene_context;
311
+ this.capacity = capacity;
312
+
313
+ this.registry = new EmitterRegistry(graphics.device);
314
+
315
+ this.#spawn_commands = new Uint32Array(spawn_command_capacity * PARTICLE_SPAWN_COMMAND_WORDS);
316
+ this.#spawn_command_emitters = new Array(spawn_command_capacity).fill(null);
317
+
318
+ this.#create_buffers();
319
+ this.#create_default_atlas();
320
+ }
321
+
322
+ /**
323
+ * The buffer the emitter table lives in — what every emitter-reading pass binds.
324
+ * @returns {GPUBuffer}
325
+ */
326
+ get emitter_database() {
327
+ return this.registry.database.buffer;
328
+ }
329
+
330
+ /**
331
+ * The live emitters, in no particular order.
332
+ * @returns {import("./runtime/ParticleEmitter.js").ParticleEmitter[]}
333
+ */
334
+ get emitters() {
335
+ return this.registry.emitters;
336
+ }
337
+
338
+ /**
339
+ * Number of simulation-order buckets — one per program id the registry can hand out, which is
340
+ * its peak number of distinct compiled programs rather than its current one.
341
+ *
342
+ * This is the coherence pass's only host-provided number, and it is static topology rather than
343
+ * per-frame state: it changes when an effect the set has never held is registered, not as
344
+ * particles are born and die. Everything the pass measures per frame stays on the GPU. Sizing it
345
+ * from the peak rather than the live count is what keeps it from moving every time an effect is
346
+ * unregistered — the bins of released ids are empty, and an empty bin rounds up to zero
347
+ * workgroups and shifts nothing after it.
348
+ *
349
+ * @returns {number}
350
+ */
351
+ bucketCount() {
352
+ return Math.max(1, this.registry.program_id_capacity);
353
+ }
354
+
355
+ /**
356
+ * Index of the alive list this frame reads from / writes to (ping-pong by frame parity).
357
+ * @returns {number}
358
+ */
359
+ get alive_write_index() {
360
+ return 1 - (this.frame % 2);
361
+ }
362
+
363
+ /**
364
+ * Ask for `count` particles from an emitter, on top of its rate, on the next frame. Goes
365
+ * through the GPU path — the tick pass folds it into the emitter's accumulator, the budget and
366
+ * the culling apply — so it is a request, not a guarantee.
367
+ *
368
+ * @param {import("./runtime/ParticleEmitter.js").ParticleEmitter} emitter a registered emitter
369
+ * @param {number} count
370
+ */
371
+ spawn(emitter, count) {
372
+ assert.defined(emitter, "emitter");
373
+ assert.isNonNegativeInteger(count, "count");
374
+
375
+ const context = this.registry.context(emitter);
376
+
377
+ assert.defined(context, "emitter is not in the scene");
378
+
379
+ const ring = this.#spawn_commands;
380
+ const base = this.#spawn_command_count * PARTICLE_SPAWN_COMMAND_WORDS;
381
+
382
+ if (base >= ring.length) {
383
+ // the text is the key warn_limited counts on, so it carries no burst size: one message
384
+ // per ring size, not one per number a caller happens to ask for
385
+ warn_limited(`GPUParticleSystem: spawn command ring is full (${ring.length / PARTICLE_SPAWN_COMMAND_WORDS} per frame); dropping this frame's remaining bursts`);
386
+ return;
387
+ }
388
+
389
+ ring[base] = context.row;
390
+ ring[base + 1] = count;
391
+ this.#spawn_command_emitters[this.#spawn_command_count] = emitter;
392
+ this.#spawn_command_count++;
393
+ }
394
+
395
+ /**
396
+ * The simulation half of the frame: reconcile the emitter set with the scene, push what changed
397
+ * to the GPU, and record every pass that moves particles — through to the indirect draw args.
398
+ * What it leaves as graph handles is what a draw consumes, whichever draw that turns out to be.
399
+ *
400
+ * @param {object} params see {@link execute}
401
+ * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} params.graph
402
+ * @param {number} params.camera
403
+ * @param {number} params.scene_database
404
+ * @param {number} params.dt
405
+ * @returns {import("./graph_particles.js").ParticleSimulationHandles}
406
+ */
407
+ #simulate({ graph, camera, scene_database, dt }) {
408
+ this.sync_membership();
409
+ this.#upload();
410
+
411
+ const frame = this.#frame;
412
+ frame.dt = dt;
413
+ frame.time = this.time;
414
+ frame.index = this.frame;
415
+ frame.max_catchup = this.max_catchup_seconds;
416
+ frame.spawn_command_count = this.#spawn_command_count;
417
+ frame.rebucket = this.#program_assignment_version !== this.registry.program_assignment_version;
418
+ this.#program_assignment_version = this.registry.program_assignment_version;
419
+
420
+ const table = this.registry.table;
421
+ const emitters = this.#emitters;
422
+ emitters.capacity = table.element_capacity;
423
+ emitters.group_count = table.dispatch_group_count(PARTICLE_EMITTER_TICK_WORKGROUP_SIZE);
424
+
425
+ const buffers = this.buffers;
426
+ buffers.alive_write = buffers.alive[this.alive_write_index];
427
+ buffers.alive_read = buffers.alive[1 - this.alive_write_index];
428
+ // The table's database owns and grows its own buffer; it is re-read every frame for that
429
+ // reason.
430
+ buffers.emitter_database = this.registry.database.buffer;
431
+
432
+ const simulated = graph_particles_simulate({
433
+ graph,
434
+ camera,
435
+ scene_database,
436
+ gpu: buffers,
437
+ emitters,
438
+ capacity: this.capacity,
439
+ bucket_count: this.bucketCount(),
440
+ // Whose register file the VM runs on is decided here, once, for the whole system: the
441
+ // registry reports what the hungriest registered effect needs and this compares it
442
+ // against what the fast file holds. Re-read every frame because registering an emitter —
443
+ // or unregistering the one hungry effect — can flip it either way.
444
+ wide_registers: this.registry.max_register_count > PARTICLE_VM_FAST_REGISTER_SLOTS,
445
+ frame,
446
+ });
447
+
448
+ // Kept for the draw that follows, whichever it is, and for nothing past this frame.
449
+ const current = this.#simulated;
450
+ current.graph = graph;
451
+ current.scene_database = scene_database;
452
+ current.emitter_group_count = emitters.group_count;
453
+ current.handles = simulated;
454
+
455
+ // Consumed: the counts were recorded into the graph, the words were uploaded.
456
+ this.#spawn_command_count = 0;
457
+ this.#warmup_queue.length = 0;
458
+
459
+ this.frame++;
460
+ this.time += dt;
461
+
462
+ return simulated;
463
+ }
464
+
465
+ /**
466
+ * The system's whole contribution to the frame: the simulation, then whatever `mode` says draws
467
+ * it.
468
+ *
469
+ * {@link ParticleRenderMode.BILLBOARD} adds the depth sort and one billboard draw over `color`
470
+ * with fixed-function blending, for a page with no transparency pipeline of its own.
471
+ * {@link ParticleRenderMode.AVBOIT} adds nothing: the orchestrator draws the particles later in
472
+ * the frame through {@link avboit_occupancy}, {@link avboit_splat} and {@link avboit_draw}, off
473
+ * the handles this just produced, and its accumulators are order-independent — so the sort is
474
+ * skipped and its buffers are never allocated. `color` comes straight back either way, so a
475
+ * caller can assign the result unconditionally and let the mode be the only difference.
476
+ *
477
+ * Call after the scene context has built for the frame (`GPUSceneContext#update`), so every
478
+ * emitter node has its `transforms` row, and after the hierarchy pass has run, so the rows'
479
+ * `global` matrices are this frame's.
480
+ *
481
+ * **Once per frame, not once per view.** This advances the simulation by `dt` and hands out
482
+ * handles into the graph it recorded into; a second view of the same scene records a second
483
+ * graph and would step the simulation twice. The renderer has one view per `render` call today,
484
+ * so this holds by construction; a second view of one scene needs the step and the handles
485
+ * separated — record the passes for the first graph, import the same buffers into the rest.
486
+ *
487
+ * @param {object} params
488
+ * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} params.graph
489
+ * @param {number} params.camera graph handle of the camera uniform buffer
490
+ * @param {number} params.scene_database graph handle of the scene database — the one the
491
+ * hierarchy pass returned, so this reads the matrices it composed
492
+ * @param {number} params.dt seconds since the previous frame
493
+ * @param {ParticleRenderMode} [params.mode] who draws, and so whether there is a sort
494
+ * @param {number} [params.color] graph handle of the colour target to draw over. Required by
495
+ * `BILLBOARD`; returned untouched by `AVBOIT`
496
+ * @param {number} [params.depth] graph handle of the scene depth (tested, never written).
497
+ * Required by `BILLBOARD`
498
+ * @returns {number|undefined} the colour handle after the particle draw, or `color` unchanged
499
+ */
500
+ execute({ graph, camera, scene_database, color, depth, dt, mode = ParticleRenderMode.BILLBOARD }) {
501
+ assert.defined(graph, "graph");
502
+ assert.isNumber(dt, "dt");
503
+ assert.enum(mode, ParticleRenderMode, "mode");
504
+
505
+ const billboard = mode === ParticleRenderMode.BILLBOARD;
506
+
507
+ // Checked before the simulation rather than at the draw: the simulation advances `frame` and
508
+ // `time` and writes the pool, and a throw after that leaves a frame half-stepped.
509
+ if (billboard) {
510
+ assert.isNonNegativeInteger(color, "color");
511
+ assert.isNonNegativeInteger(depth, "depth");
512
+ }
513
+
514
+ const simulated = this.#simulate({ graph, camera, scene_database, dt });
515
+
516
+ if (!billboard) {
517
+ // AVBOIT: the side channels draw these handles later in the frame, and the colour target
518
+ // is not this call's to touch.
519
+ return color;
520
+ }
521
+
522
+ // Allocated here rather than in the constructor: they are two words per particle plus the
523
+ // bins, and only this mode ever reads them. A system the renderer drives never makes them.
524
+ this.#ensure_sort_buffers();
525
+
526
+ // The render order: back-to-front by view depth, so premultiplied transparency composites
527
+ // correctly. Its two per-particle passes take the dispatch the coherence reset wrote from
528
+ // the GPU's own alive count.
529
+ const sorted = graph_particle_sort({
530
+ graph,
531
+ gpu: this.buffers,
532
+ camera,
533
+ emitter_database: simulated.emitter_database,
534
+ pool: simulated.pool,
535
+ alive: simulated.alive,
536
+ counters: simulated.counters,
537
+ dispatch_args: simulated.sim_dispatch_args,
538
+ bucket_count: PARTICLE_SORT_BUCKET_COUNT,
539
+ near: this.sort_depth_range[0],
540
+ far: this.sort_depth_range[1],
541
+ });
542
+
543
+ const render = this.#render;
544
+ render.atlas = this.atlas;
545
+ render.atlas_sampler = this.atlas_sampler;
546
+
547
+ // One instanced billboard draw over the sorted list, sized by the draw args this frame's
548
+ // build-indirect wrote.
549
+ return graph_particles_billboard({
550
+ graph,
551
+ camera,
552
+ color,
553
+ depth,
554
+ pool: simulated.pool,
555
+ draw_list: sorted,
556
+ emitter_database: simulated.emitter_database,
557
+ draw_args: simulated.draw_args,
558
+ render,
559
+ });
560
+ }
561
+
562
+ /**
563
+ * What this frame's {@link simulate} left, checked against the graph a side-channel call names:
564
+ * the AVBOIT orchestrator records its passes into the same graph, later in the same frame.
565
+ *
566
+ * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} graph
567
+ * @returns {{graph: object, scene_database: number, emitter_group_count: number, handles: import("./graph_particles.js").ParticleSimulationHandles}}
568
+ */
569
+ #simulated_for(graph) {
570
+ const current = this.#simulated;
571
+
572
+ assert.equal(current.graph, graph, "the particles were not simulated into this graph this frame — call execute() before the transparency pipeline records");
573
+
574
+ return current;
575
+ }
576
+
577
+ /**
578
+ * `AVBOITSideChannel#avboit_occupancy`: the emitters' measured bounding boxes, as depth slices.
579
+ *
580
+ * @param {object} args see `AVBOITSideChannel`
581
+ * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} args.graph
582
+ * @param {number} args.camera graph handle of the camera uniform buffer
583
+ * @param {ArrayLike<number>} args.cluster_parameters the frame's depth curve
584
+ * @param {number} args.occupancy graph handle of the occupancy bits
585
+ * @returns {number} the occupancy handle after the write
586
+ */
587
+ avboit_occupancy({ graph, camera, cluster_parameters, occupancy }) {
588
+ const current = this.#simulated_for(graph);
589
+
590
+ return graph_particle_avboit_occupancy({
591
+ graph,
592
+ camera,
593
+ cluster_parameters,
594
+ occupancy,
595
+ emitter_database: current.handles.emitter_database,
596
+ emitter_group_count: current.emitter_group_count,
597
+ });
598
+ }
599
+
600
+ /**
601
+ * `AVBOITSideChannel#avboit_splat`: the live particles' extinction, into the volume.
602
+ *
603
+ * @param {object} args see `AVBOITSideChannel`
604
+ * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} args.graph
605
+ * @param {number} args.camera graph handle of the camera uniform buffer
606
+ * @param {number} args.warp graph handle of the warp LUT
607
+ * @param {number} args.depth_texture graph handle of the scene depth
608
+ * @param {{extinction: number, overflow: number, dummy: number}} args.volume the voxel volume
609
+ * @returns {{extinction: number, overflow: number, dummy: number}} the volume after the write
610
+ */
611
+ avboit_splat({ graph, camera, warp, depth_texture, volume }) {
612
+ const { handles } = this.#simulated_for(graph);
613
+
614
+ return graph_particle_avboit_splat({
615
+ graph,
616
+ camera,
617
+ warp,
618
+ depth_texture,
619
+ volume,
620
+ atlas: this.atlas,
621
+ atlas_sampler: this.atlas_sampler,
622
+ pool: handles.pool,
623
+ alive: handles.alive,
624
+ emitter_database: handles.emitter_database,
625
+ draw_args: handles.draw_args,
626
+ });
627
+ }
628
+
629
+ /**
630
+ * `AVBOITSideChannel#avboit_draw`: the live particles, into the accumulators.
631
+ *
632
+ * @param {object} args see `AVBOITSideChannel`
633
+ * @param {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} args.graph
634
+ * @param {import("../view/GPUViewContext.js").GPUViewContext} args.view_ctx
635
+ * @param {number} args.camera graph handle of the camera uniform buffer
636
+ * @param {number} args.depth_texture graph handle of the scene depth
637
+ * @param {number} args.warp graph handle of the warp LUT
638
+ * @param {number} args.integral graph handle of the integral texture
639
+ * @param {number} args.volumetrics_lut graph handle
640
+ * @param {number} args.volumetrics_metadata graph handle
641
+ * @param {{color: number, norm: number, extinction: number, emission: number}} args.accumulators
642
+ * @returns {{color: number, norm: number, extinction: number, emission: number}} after the draw
643
+ */
644
+ avboit_draw({ graph, view_ctx, camera, depth_texture, warp, integral, volumetrics_lut, volumetrics_metadata, accumulators }) {
645
+ const { handles } = this.#simulated_for(graph);
646
+
647
+ return graph_particle_avboit_draw({
648
+ graph,
649
+ view_ctx,
650
+ camera,
651
+ depth_texture,
652
+ warp,
653
+ integral,
654
+ volumetrics_lut,
655
+ volumetrics_metadata,
656
+ accumulators,
657
+ atlas: this.atlas,
658
+ atlas_sampler: this.atlas_sampler,
659
+ pool: handles.pool,
660
+ alive: handles.alive,
661
+ emitter_database: handles.emitter_database,
662
+ draw_args: handles.draw_args,
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Bring the emitter set in line with the scene: emitter nodes that arrived get rows, nodes that
668
+ * left give theirs back. Runs only when the scene's membership version has moved.
669
+ *
670
+ * A node the scene context has not placed yet (no `transforms` row) is left for the next frame,
671
+ * and the version is not recorded, so the sweep runs again until every emitter is bound.
672
+ * An atlas owner may call this after the scene context builds, before setting row regions.
673
+ * execute() calls it as well; unchanged membership costs one version comparison.
674
+ * @returns {void}
675
+ */
676
+ sync_membership() {
677
+ const scene = this.scene_context.scene;
678
+ const batch = scene.instances;
679
+
680
+ if (batch.version === this.#instances_version) {
681
+ return;
682
+ }
683
+
684
+ const sweep = ++this.#sweep;
685
+ const nodes = batch.nodes;
686
+ const node_count = nodes.length;
687
+ const mapping = this.scene_context.id_mapping;
688
+ const registry = this.registry;
689
+ const swept = this.#swept;
690
+
691
+ let complete = true;
692
+
693
+ for (let i = 0; i < node_count; i++) {
694
+ const node = nodes[i];
695
+
696
+ if (node.isParticleEmitter !== true) {
697
+ continue;
698
+ }
699
+
700
+ const transform_row = mapping.get(node.id);
701
+
702
+ if (transform_row === undefined) {
703
+ complete = false;
704
+ continue;
705
+ }
706
+
707
+ if (registry.context(node) === undefined) {
708
+ registry.add(node, transform_row);
709
+
710
+ // Just arrived, and asked to arrive warm: the warm-up pipeline runs its simulation
711
+ // forward this frame, before it is first drawn. A reused row is a new emitter as far
712
+ // as this is concerned — it is the emitter that asks, not the row.
713
+ if (node.prewarm > 0) {
714
+ this.#warmup_queue.push(node);
715
+ }
716
+ } else {
717
+ registry.set_transform_row(node, transform_row);
718
+ }
719
+
720
+ swept.set(node, sweep);
721
+ }
722
+
723
+ for (const [emitter, seen] of swept) {
724
+ if (seen !== sweep) {
725
+ registry.remove(emitter);
726
+ swept.delete(emitter);
727
+ }
728
+ }
729
+
730
+ if (complete) {
731
+ this.#instances_version = batch.version;
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Push everything the CPU changed to the GPU: dirty emitter rows, the state buffer's growth,
737
+ * rebuilt program buffers, the spawn-command ring, and the coherence scratch's growth. A
738
+ * command context is opened only if something needs one.
739
+ */
740
+ #upload() {
741
+ const registry = this.registry;
742
+ const device = this.graphics.device;
743
+
744
+ registry.flush();
745
+
746
+ // Both tables stage into the one database: the emitter rows the author changed, and the
747
+ // state rows claimed or released with them.
748
+ const rows_staged = registry.table.element_upload_buffer.position > 0
749
+ || registry.state_table.element_upload_buffer.position > 0;
750
+
751
+ if (rows_staged) {
752
+ const cmd = ShadeGPUCommandContext.create(this.graphics, "particles/upload");
753
+
754
+ registry.database.update(cmd);
755
+
756
+ cmd.finish();
757
+ }
758
+
759
+ if (registry.programs_dirty) {
760
+ this.buffers.program = replace_data_buffer(device, this.buffers.program, "particles/program", registry.build_program());
761
+ }
762
+
763
+ this.#merge_spawn_commands();
764
+
765
+ if (this.#spawn_command_count > 0) {
766
+ device.queue.writeBuffer(
767
+ this.buffers.spawn_commands, 0,
768
+ this.#spawn_commands.buffer, 0,
769
+ this.#spawn_command_count * PARTICLE_SPAWN_COMMAND_WORDS * WORD,
770
+ );
771
+ }
772
+
773
+ this.#upload_warmup_queue();
774
+
775
+ this.#ensure_sim_buffers(this.bucketCount());
776
+ this.#ensure_counters(registry.table.element_capacity);
777
+ }
778
+
779
+ /**
780
+ * Turn the emitters queued for a warm-up into the words the warm-up tick reads, and the numbers
781
+ * the frame graph sizes the loop by; upload the words. Nothing when nothing is queued, which is
782
+ * nearly every frame.
783
+ *
784
+ * Two per-emitter numbers are hashed here rather than left at zero, both to keep emitters warmed
785
+ * in one batch off each other's beat: the accumulator's starting fraction, so their first whole
786
+ * particles fall on different ticks, and the clock each one's tick runs at. See
787
+ * `warmup/shader_particle_warmup.js`.
788
+ *
789
+ * The transient pool is sized by what the batch can spawn in all — every emitter's rate over its
790
+ * own pre-warm, plus one each for the rounding — and no larger than the main pool it is bound
791
+ * for: what would not fit there is dropped at migration anyway.
792
+ */
793
+ #upload_warmup_queue() {
794
+ const queue = this.#warmup_queue;
795
+ const frame = this.#frame;
796
+
797
+ // A queued emitter that has since left the scene has no row to warm; drop it here.
798
+ let count = 0;
799
+
800
+ for (let i = 0; i < queue.length; i++) {
801
+ const emitter = queue[i];
802
+
803
+ if (this.registry.context(emitter) !== undefined) {
804
+ queue[count++] = emitter;
805
+ }
806
+ }
807
+
808
+ queue.length = count;
809
+
810
+ if (count === 0) {
811
+ frame.warmup_count = 0;
812
+ frame.warmup_capacity = 0;
813
+ frame.warmup_schedule = null;
814
+ return;
815
+ }
816
+
817
+ let words = this.#warmup_words;
818
+ const needed = count * PARTICLE_WARMUP_QUEUE_WORDS;
819
+
820
+ if (words.length < needed) {
821
+ words = new Uint32Array(Math.max(needed, words.length * 2));
822
+ this.#warmup_words = words;
823
+ }
824
+
825
+ const floats = new Float32Array(words.buffer, words.byteOffset, words.length);
826
+
827
+ let max_prewarm = 0;
828
+ let spawned = 0;
829
+
830
+ for (let i = 0; i < count; i++) {
831
+ const emitter = queue[i];
832
+ const context = this.registry.context(emitter);
833
+ const base = i * PARTICLE_WARMUP_QUEUE_WORDS;
834
+
835
+ // Two unit fractions from one hash of the row and the emitter's seed: the same emitter
836
+ // in the same row warms the same way twice, and two emitters do not.
837
+ const hash = hash_mix2(context.row, context.generation) >>> 0;
838
+ const phase = (hash >>> 8) / 16777216;
839
+ const beat = ((hash & 0xff) / 255) - 0.5;
840
+
841
+ words[base + WARMUP_QUEUE.ROW] = context.row;
842
+ floats[base + WARMUP_QUEUE.PREWARM] = emitter.prewarm;
843
+ floats[base + WARMUP_QUEUE.ACCUMULATOR] = phase;
844
+ floats[base + WARMUP_QUEUE.CLOCK] = 1 + PARTICLE_WARMUP_EMITTER_JITTER * beat;
845
+
846
+ max_prewarm = Math.max(max_prewarm, emitter.prewarm);
847
+ spawned += Math.ceil(emitter.spawn_rate * emitter.prewarm) + 1;
848
+ }
849
+
850
+ const device = this.graphics.device;
851
+ const bytes = needed * WORD;
852
+
853
+ if (this.buffers.warmup_queue.size < bytes) {
854
+ this.buffers.warmup_queue.destroy();
855
+ this.buffers.warmup_queue = device.createBuffer({ label: "particles/warmup_queue", size: bytes, usage: STORAGE });
856
+ }
857
+
858
+ device.queue.writeBuffer(this.buffers.warmup_queue, 0, words.buffer, words.byteOffset, bytes);
859
+
860
+ frame.warmup_count = count;
861
+ frame.warmup_capacity = Math.max(1, Math.min(this.capacity, spawned));
862
+ frame.warmup_schedule = warmup_schedule(max_prewarm);
863
+ }
864
+
865
+ /**
866
+ * Fold the ring down to one entry per row, dropping entries whose emitter no longer holds the
867
+ * row it was issued against.
868
+ *
869
+ * The spawn-command pass adds to a state row with a plain read-modify-write, so two entries
870
+ * naming one row would lose one; and a burst issued to an emitter that left the scene this
871
+ * frame must not land on the row's next tenant. Both are settled here, in place, over a few
872
+ * dozen entries at most.
873
+ */
874
+ #merge_spawn_commands() {
875
+ const count = this.#spawn_command_count;
876
+
877
+ if (count === 0) {
878
+ return;
879
+ }
880
+
881
+ const ring = this.#spawn_commands;
882
+ const emitters = this.#spawn_command_emitters;
883
+
884
+ let rows = this.#spawn_by_row;
885
+ const needed = Math.max(1, this.registry.table.element_capacity);
886
+
887
+ if (rows.length < needed) {
888
+ rows = new Uint32Array(needed);
889
+ this.#spawn_by_row = rows;
890
+ }
891
+
892
+ for (let i = 0; i < count; i++) {
893
+ const emitter = emitters[i];
894
+ const row = ring[i * PARTICLE_SPAWN_COMMAND_WORDS];
895
+ const context = this.registry.context(emitter);
896
+
897
+ if (context !== undefined && context.row === row) {
898
+ rows[row] += ring[i * PARTICLE_SPAWN_COMMAND_WORDS + 1];
899
+ }
900
+
901
+ emitters[i] = null;
902
+ }
903
+
904
+ let merged = 0;
905
+
906
+ for (let i = 0; i < count; i++) {
907
+ const row = ring[i * PARTICLE_SPAWN_COMMAND_WORDS];
908
+ const pending = rows[row];
909
+
910
+ if (pending === 0) {
911
+ continue;
912
+ }
913
+
914
+ ring[merged * PARTICLE_SPAWN_COMMAND_WORDS] = row;
915
+ ring[merged * PARTICLE_SPAWN_COMMAND_WORDS + 1] = pending;
916
+ rows[row] = 0;
917
+ merged++;
918
+ }
919
+
920
+ this.#spawn_command_count = merged;
921
+ }
922
+
923
+ /**
924
+ * The counters buffer holds the per-emitter bounds accumulators after the counters themselves
925
+ * (`data/PARTICLE_COUNTERS.js`), so it follows the emitter table's element capacity.
926
+ *
927
+ * Grown by copy, not by reallocation: the counters at the head of it are live GPU state — the
928
+ * free-list length above all, which no CPU value can reconstruct — and the accumulators are this
929
+ * frame's measurements of emitters that have not moved. A fresh buffer would zero the free list
930
+ * and strand the whole pool.
931
+ *
932
+ * The rows past the old end are the only ones the copy does not answer for, and zero is the
933
+ * wrong answer for them: a zero accumulator reads back as a degenerate box at the world origin
934
+ * rather than as an absence, and the first box measured for such a row would be stretched to
935
+ * include the origin. They get the empty interval written over them instead — the same two words
936
+ * the publish pass resets a row to.
937
+ *
938
+ * @param {number} emitter_capacity element capacity of the emitter table
939
+ */
940
+ #ensure_counters(emitter_capacity) {
941
+ const size = particle_counters_word_count(emitter_capacity) * WORD;
942
+ const existing = this.buffers.counters;
943
+
944
+ if (existing.size >= size) {
945
+ return;
946
+ }
947
+
948
+ const device = this.graphics.device;
949
+ const grown = device.createBuffer({ label: "particles/counters", size, usage: STORAGE });
950
+
951
+ const cmd = ShadeGPUCommandContext.create(this.graphics, "particles/counters grow");
952
+
953
+ cmd.gpu_encoder.copyBufferToBuffer(existing, 0, grown, 0, existing.size);
954
+
955
+ cmd.finish();
956
+
957
+ // The new region is a whole number of accumulators: the counters are the head of the buffer
958
+ // and every growth adds rows behind them.
959
+ const added_rows = (size - existing.size) / (PARTICLE_BOUNDS_WORDS * WORD);
960
+
961
+ device.queue.writeBuffer(grown, existing.size, particle_bounds_empty_words(added_rows));
962
+
963
+ existing.destroy();
964
+
965
+ this.buffers.counters = grown;
966
+ }
967
+
968
+ /**
969
+ * Scratch follows the peak number of program ids. The coherence list itself is persistent:
970
+ * the next simulate consumes its entries and padding with the previous frame's saved count.
971
+ *
972
+ * @param {number} buckets
973
+ */
974
+ #ensure_sim_buffers(buckets) {
975
+ if (buckets <= this.#sim_bucket_capacity) {
976
+ return;
977
+ }
978
+
979
+ const device = this.graphics.device;
980
+ const buffers = this.buffers;
981
+ const capacity = this.capacity;
982
+
983
+ for (const name of ["sim_keys", "sim_histogram", "sim_bucket_counts", "sim_cursor"]) {
984
+ buffers[name]?.destroy();
985
+ }
986
+
987
+ // Padding rounds each bucket up to a whole workgroup, so it can add at most `workgroup - 1`
988
+ // entries per bucket; one whole workgroup per bucket is the bound.
989
+ const previous = buffers.sim_list;
990
+ buffers.sim_list = device.createBuffer({ label: "particles/sim_list", size: (capacity + buckets * PARTICLE_SIMULATE_WORKGROUP_SIZE) * WORD, usage: STORAGE });
991
+ if (previous !== undefined) {
992
+ const cmd = ShadeGPUCommandContext.create(this.graphics, "particles/coherence grow");
993
+ cmd.gpu_encoder.copyBufferToBuffer(previous, 0, buffers.sim_list, 0, previous.size);
994
+ cmd.finish();
995
+ previous.destroy();
996
+ }
997
+ buffers.sim_keys = device.createBuffer({ label: "particles/sim_keys", size: capacity * WORD, usage: STORAGE });
998
+ // `{count, elements}` for the CSDLDF scan; the scan reads whole vec4s, so the bins are
999
+ // rounded up to a multiple of four.
1000
+ buffers.sim_histogram = device.createBuffer({ label: "particles/sim_histogram", size: (PREFIX_SCAN_CSDLDF_HEADER_WORDS + align_4(buckets)) * WORD, usage: STORAGE });
1001
+ buffers.sim_bucket_counts = device.createBuffer({ label: "particles/sim_bucket_counts", size: buckets * WORD, usage: STORAGE });
1002
+ buffers.sim_cursor = device.createBuffer({ label: "particles/sim_cursor", size: buckets * WORD, usage: STORAGE });
1003
+
1004
+ this.#sim_bucket_capacity = buckets;
1005
+ }
1006
+
1007
+ /**
1008
+ * The render-order scratch, made on the first frame that actually sorts.
1009
+ *
1010
+ * Only {@link ParticleRenderMode.BILLBOARD} reads any of it, and the two per-particle lists are
1011
+ * a word per particle each — at the renderer's default capacity, half a megabyte that AVBOIT
1012
+ * would never touch. Nothing in them outlives a frame, so there is nothing to carry over and the
1013
+ * first sorted frame is as correct as any later one.
1014
+ */
1015
+ #ensure_sort_buffers() {
1016
+ const buffers = this.buffers;
1017
+
1018
+ if (buffers.sorted !== undefined) {
1019
+ return;
1020
+ }
1021
+
1022
+ const device = this.graphics.device;
1023
+ const capacity = this.capacity;
1024
+
1025
+ buffers.sorted = device.createBuffer({ label: "particles/sorted", size: capacity * WORD, usage: STORAGE });
1026
+ buffers.sort_keys = device.createBuffer({ label: "particles/sort_keys", size: capacity * WORD, usage: STORAGE });
1027
+ buffers.sort_histogram = device.createBuffer({ label: "particles/sort_histogram", size: (PREFIX_SCAN_CSDLDF_HEADER_WORDS + align_4(PARTICLE_SORT_BUCKET_COUNT)) * WORD, usage: STORAGE });
1028
+ buffers.sort_cursor = device.createBuffer({ label: "particles/sort_cursor", size: PARTICLE_SORT_BUCKET_COUNT * WORD, usage: STORAGE });
1029
+ }
1030
+
1031
+ /**
1032
+ * The buffers whose size is fixed by the pool capacity, and the free list's initial contents.
1033
+ */
1034
+ #create_buffers() {
1035
+ const device = this.graphics.device;
1036
+ const capacity = this.capacity;
1037
+ const buffers = this.buffers;
1038
+
1039
+ buffers.pool = device.createBuffer({ label: "particles/pool", size: capacity * PARTICLE_RECORD_WORD_COUNT * WORD, usage: STORAGE });
1040
+ buffers.alive = [
1041
+ device.createBuffer({ label: "particles/alive_a", size: capacity * WORD, usage: STORAGE }),
1042
+ device.createBuffer({ label: "particles/alive_b", size: capacity * WORD, usage: STORAGE }),
1043
+ ];
1044
+ buffers.dead = device.createBuffer({ label: "particles/dead", size: capacity * WORD, usage: STORAGE });
1045
+ // Just the counters to begin with. The per-emitter bounds accumulator that shares this
1046
+ // buffer follows the emitter table's capacity, and there is no table yet.
1047
+ buffers.counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * WORD, usage: STORAGE });
1048
+
1049
+ // Indirect args, written on-GPU. Zero-initialised (WebGPU spec), so the first frame's
1050
+ // indirect dispatch/draw are no-ops until the first build-indirect has run.
1051
+ buffers.dispatch_args = device.createBuffer({ label: "particles/dispatch_args", size: 3 * WORD, usage: INDIRECT });
1052
+ buffers.draw_args = device.createBuffer({ label: "particles/draw_args", size: 4 * WORD, usage: INDIRECT });
1053
+ buffers.sim_dispatch_args = device.createBuffer({ label: "particles/sim_dispatch_args", size: 3 * WORD, usage: INDIRECT });
1054
+
1055
+ buffers.spawn_commands = device.createBuffer({ label: "particles/spawn_commands", size: this.#spawn_commands.byteLength, usage: STORAGE });
1056
+
1057
+ // The warm-up queue: one entry to start with, grown on upload to what a frame registers.
1058
+ buffers.warmup_queue = device.createBuffer({ label: "particles/warmup_queue", size: PARTICLE_WARMUP_QUEUE_WORDS * WORD, usage: STORAGE });
1059
+
1060
+ // Every slot free, and the counters saying so.
1061
+ const dead = new Uint32Array(capacity);
1062
+ for (let i = 0; i < capacity; i++) {
1063
+ dead[i] = i;
1064
+ }
1065
+ const counters = new Uint32Array(PARTICLE_COUNTER_COUNT);
1066
+ counters[PARTICLE_COUNTER.DEAD] = capacity;
1067
+
1068
+ device.queue.writeBuffer(buffers.dead, 0, dead);
1069
+ device.queue.writeBuffer(buffers.counters, 0, counters);
1070
+ }
1071
+
1072
+ /**
1073
+ * A 1x1 white atlas and a linear sampler, so the system draws before anyone hands it art.
1074
+ * @returns {void}
1075
+ */
1076
+ #create_default_atlas() {
1077
+ const device = this.graphics.device;
1078
+
1079
+ const texture = new GPUTextureContext(device);
1080
+ texture.descriptor.label = "particles/default_atlas";
1081
+ texture.descriptor.size = [1, 1];
1082
+ texture.descriptor.format = "rgba8unorm";
1083
+ texture.descriptor.usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST;
1084
+
1085
+ device.queue.writeTexture({ texture: texture.gpu_texture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
1086
+ texture.incrementVersion();
1087
+
1088
+ // kept, not just viewed: a texture nothing holds is a texture nothing can free
1089
+ this.#default_atlas = texture;
1090
+ this.atlas = texture.obtainView();
1091
+ this.atlas_sampler = device.createSampler({
1092
+ label: "particles/atlas_sampler",
1093
+ magFilter: "linear",
1094
+ minFilter: "linear",
1095
+ addressModeU: "clamp-to-edge",
1096
+ addressModeV: "clamp-to-edge",
1097
+ });
1098
+ }
1099
+
1100
+ /**
1101
+ * @param {string} color_format
1102
+ * @param {string} depth_format
1103
+ * @returns {object} the billboard pipeline descriptor for these attachment formats
1104
+ */
1105
+ #obtain_pipeline(color_format, depth_format) {
1106
+ const key = `${color_format}/${depth_format}`;
1107
+
1108
+ if (this.#pipeline === null || this.#pipeline_key !== key) {
1109
+ this.#pipeline = create_particle_billboard_pipeline({
1110
+ color_format,
1111
+ depth_format,
1112
+ // reverse-Z, the engine convention
1113
+ depth_compare: "greater",
1114
+ });
1115
+ this.#pipeline_key = key;
1116
+ }
1117
+
1118
+ return this.#pipeline;
1119
+ }
1120
+
1121
+ /**
1122
+ * Sizes of the persistent buffers, in bytes, as they stand. Exposed for tests.
1123
+ * @returns {Object<string, number>}
1124
+ */
1125
+ bufferSizes() {
1126
+ const sizes = {};
1127
+
1128
+ for (const [name, buffer] of Object.entries(this.buffers)) {
1129
+ if (Array.isArray(buffer)) {
1130
+ sizes[name] = buffer[0].size;
1131
+ } else if (buffer !== undefined && buffer !== null) {
1132
+ sizes[name] = buffer.size;
1133
+ }
1134
+ }
1135
+
1136
+ return sizes;
1137
+ }
1138
+
1139
+ destroy() {
1140
+ /*
1141
+ The emitters go first, and they matter more than the buffers: they are the scene's nodes,
1142
+ they outlive this system, and each is holding a row of the table about to be freed. A node
1143
+ left holding one is stranded — the next system's membership sweep reads `row !== -1` as
1144
+ "already mine", takes the moved branch rather than the arrived one, and never registers it.
1145
+ Retiring them here puts every emitter back where it was before this system saw it.
1146
+ */
1147
+ for (const emitter of this.registry.emitters.slice()) {
1148
+ this.registry.remove(emitter);
1149
+ }
1150
+ this.#swept.clear();
1151
+ this.#instances_version = -1;
1152
+
1153
+ for (const buffer of Object.values(this.buffers)) {
1154
+ if (Array.isArray(buffer)) {
1155
+ buffer.forEach((b) => b.destroy());
1156
+ } else if (buffer !== undefined && buffer !== null) {
1157
+ buffer.destroy();
1158
+ }
1159
+ }
1160
+
1161
+ this.buffers = {};
1162
+ this.registry.database.destroy();
1163
+
1164
+ // the default atlas is this system's own; a page that replaced it owns what it put there
1165
+ this.#default_atlas?.destroy();
1166
+ this.#default_atlas = null;
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * A storage buffer holding `data`, replacing `previous` (destroyed) if it exists. The program
1172
+ * buffer is monolithic — a program is variable-length, so there is no table to page it into — and
1173
+ * is rebuilt whole on the rare frame the program heap changes.
1174
+ *
1175
+ * @param {GPUDevice} device
1176
+ * @param {GPUBuffer|undefined} previous
1177
+ * @param {string} label
1178
+ * @param {Uint32Array} data
1179
+ * @returns {GPUBuffer}
1180
+ */
1181
+ function replace_data_buffer(device, previous, label, data) {
1182
+ previous?.destroy();
1183
+
1184
+ const buffer = device.createBuffer({ label, size: Math.max(WORD, data.byteLength), usage: STORAGE });
1185
+
1186
+ device.queue.writeBuffer(buffer, 0, data);
1187
+
1188
+ return buffer;
1189
+ }