@woosh/meep-engine 3.60.4 → 3.60.5

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 (379) hide show
  1. package/CHANGELOG.md +2602 -0
  2. package/CONTRIBUTING.md +7 -0
  3. package/MIGRATION_2_TO_3.md +136 -0
  4. package/SECURITY.md +4 -0
  5. package/editor/EditorCameraNavigation.d.ts +2 -1
  6. package/editor/EditorCameraNavigation.d.ts.map +1 -1
  7. package/editor/EditorCameraNavigation.js +51 -57
  8. package/package.json +6 -2
  9. package/src/engine/EngineHarness.d.ts +3 -9
  10. package/src/engine/EngineHarness.d.ts.map +1 -1
  11. package/src/engine/EngineHarness.js +6 -8
  12. package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts +9 -6
  13. package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts.map +1 -1
  14. package/src/engine/graphics/camera/makeOrbitalCameraController.js +44 -71
  15. package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts +64 -0
  16. package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts.map +1 -0
  17. package/src/engine/graphics/camera/orbital/MomentumAxis.js +187 -0
  18. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts +86 -0
  19. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts.map +1 -0
  20. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.js +319 -0
  21. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts +34 -10
  22. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts.map +1 -1
  23. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js +116 -30
  24. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts +1 -7
  25. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts.map +1 -1
  26. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.js +1 -37
  27. package/src/engine/graphics/ecs/water/Water.d.ts +8 -0
  28. package/src/engine/graphics/ecs/water/Water.d.ts.map +1 -1
  29. package/src/engine/graphics/ecs/water/Water.js +8 -0
  30. package/src/engine/graphics3/WaterSystem.d.ts.map +1 -1
  31. package/src/engine/graphics3/WaterSystem.js +731 -734
  32. package/src/engine/graphics3/decal/DecalTileShader.d.ts +61 -0
  33. package/src/engine/graphics3/decal/DecalTileShader.d.ts.map +1 -0
  34. package/src/engine/graphics3/decal/DecalTileShader.js +138 -0
  35. package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts +10 -4
  36. package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts.map +1 -1
  37. package/src/engine/graphics3/decal/GPUDecalRenderer.js +149 -44
  38. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts +25 -0
  39. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts.map +1 -0
  40. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.js +55 -0
  41. package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts +22 -0
  42. package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts.map +1 -0
  43. package/src/engine/graphics3/decal/graph_gather_decal_tiles.js +70 -0
  44. package/src/engine/graphics3/decal/shader_decal_composite.d.ts +7 -5
  45. package/src/engine/graphics3/decal/shader_decal_composite.d.ts.map +1 -1
  46. package/src/engine/graphics3/decal/shader_decal_composite.js +48 -44
  47. package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts +12 -0
  48. package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts.map +1 -0
  49. package/src/engine/graphics3/decal/shader_decal_tile_copy.js +78 -0
  50. package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts +11 -0
  51. package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts.map +1 -0
  52. package/src/engine/graphics3/decal/shader_gather_decal_tiles.js +80 -0
  53. package/src/engine/input/devices/mouse/MouseEventButtons.d.ts +13 -0
  54. package/src/engine/input/devices/mouse/MouseEventButtons.d.ts.map +1 -0
  55. package/src/engine/input/devices/mouse/MouseEventButtons.js +23 -0
  56. package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts +4 -0
  57. package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts.map +1 -1
  58. package/src/engine/input/ecs/controllers/KeyboardCameraController.js +9 -45
  59. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  60. package/src/shade/playground/water_refraction/README.md +5 -6
  61. package/src/shade/playground/water_refraction/index.html +1 -1
  62. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  63. package/src/shade/renderer/Renderer.js +7 -5
  64. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts +40 -80
  65. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts.map +1 -1
  66. package/src/shade/renderer/camera/orbital/OrbitalCameraController.js +197 -147
  67. package/src/shade/renderer/fluid/GPUFluidCascade.d.ts +3 -3
  68. package/src/shade/renderer/fluid/GPUFluidCascadeStack.d.ts +1 -1
  69. package/src/shade/renderer/fluid/forcing/GPUFluidForcing.d.ts +6 -6
  70. package/src/shade/renderer/fluid/pressure/GPUFluidPressureSolve.d.ts +1 -1
  71. package/src/shade/renderer/fluid/query/GPUFluidQueries.d.ts +1 -1
  72. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts +2 -2
  73. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +2 -2
  74. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts +2 -1
  75. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts.map +1 -1
  76. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.js +18 -1
  77. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts +31 -0
  78. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts.map +1 -0
  79. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.js +343 -0
  80. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts +17 -0
  81. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts.map +1 -0
  82. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.js +29 -0
  83. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.d.ts.map +1 -1
  84. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.js +7 -13
  85. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.d.ts.map +1 -1
  86. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.js +1 -8
  87. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts +5 -5
  88. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts.map +1 -1
  89. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.js +10 -10
  90. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts +1 -4
  91. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts.map +1 -1
  92. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.js +1 -10
  93. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts +5 -15
  94. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts.map +1 -1
  95. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.js +6 -34
  96. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts.map +1 -1
  97. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.js +1 -7
  98. package/src/shade/renderer/hiz/HZBFootprint.d.ts +14 -0
  99. package/src/shade/renderer/hiz/HZBFootprint.d.ts.map +1 -0
  100. package/src/shade/renderer/hiz/HZBFootprint.js +16 -0
  101. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts +10 -0
  102. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts.map +1 -0
  103. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.js +11 -0
  104. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts +10 -0
  105. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts.map +1 -0
  106. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.js +11 -0
  107. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts +0 -7
  108. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  109. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +405 -350
  110. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts +12 -0
  111. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts.map +1 -0
  112. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.js +27 -0
  113. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts +12 -0
  114. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts.map +1 -0
  115. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.js +27 -0
  116. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts +14 -0
  117. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts.map +1 -0
  118. package/src/shade/renderer/hiz/hzb_footprint_texels.js +34 -0
  119. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts +0 -5
  120. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
  121. package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +37 -57
  122. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts +11 -0
  123. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts.map +1 -0
  124. package/src/shade/renderer/hiz/{shader_hzb_reduce_mip0_clip.js → shader_hzb_reduce_mip0.js} +120 -111
  125. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  126. package/src/shade/renderer/material/GPUMaterialManager.d.ts +7 -0
  127. package/src/shade/renderer/material/GPUMaterialManager.d.ts.map +1 -1
  128. package/src/shade/renderer/material/GPUMaterialManager.js +11 -0
  129. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts +12 -12
  130. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
  131. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +17 -118
  132. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts.map +1 -1
  133. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.js +7 -2
  134. package/src/shade/renderer/water/GPUWaterRenderer.d.ts +0 -6
  135. package/src/shade/renderer/water/GPUWaterRenderer.d.ts.map +1 -1
  136. package/src/shade/renderer/water/GPUWaterRenderer.js +12 -17
  137. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts +35 -0
  138. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts.map +1 -0
  139. package/src/shade/renderer/water/medium/graph_water_meshlets.js +89 -0
  140. package/src/shade/renderer/water/query/GPUWaterQueries.d.ts +1 -1
  141. package/src/shade/renderer/water/shader_water.d.ts.map +1 -1
  142. package/src/shade/renderer/water/shader_water.js +22 -13
  143. package/src/shade/renderer/water/shader_water_avboit.d.ts.map +1 -1
  144. package/src/shade/renderer/water/shader_water_avboit.js +7 -7
  145. package/src/shade/renderer/water/surface/graph_water_tessellation.d.ts +1 -1
  146. package/src/shade/renderer/water/surface/graph_water_tessellation.js +1 -1
  147. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +9 -1
  148. package/editor/REWORK_PLAN_2026_08.md +0 -692
  149. package/src/CODEC_LAYOUT_VERIFICATION.md +0 -419
  150. package/src/CORRECTNESS_HUNT_2026_07_24.md +0 -142
  151. package/src/DUPLICATION_AUDIT_2026_08_07/00_core_reuse_surface.md +0 -738
  152. package/src/DUPLICATION_AUDIT_2026_08_07/A_core_math_geom.md +0 -79
  153. package/src/DUPLICATION_AUDIT_2026_08_07/B_core_data.md +0 -93
  154. package/src/DUPLICATION_AUDIT_2026_08_07/C_gfx_render.md +0 -80
  155. package/src/DUPLICATION_AUDIT_2026_08_07/D_gfx_features.md +0 -164
  156. package/src/DUPLICATION_AUDIT_2026_08_07/E_physics.md +0 -69
  157. package/src/DUPLICATION_AUDIT_2026_08_07/F_sound.md +0 -110
  158. package/src/DUPLICATION_AUDIT_2026_08_07/G_net_save.md +0 -129
  159. package/src/DUPLICATION_AUDIT_2026_08_07/H_engine_misc.md +0 -72
  160. package/src/DUPLICATION_AUDIT_2026_08_07/I_generation_view.md +0 -74
  161. package/src/DUPLICATION_AUDIT_2026_08_07/K_crosscutting.md +0 -169
  162. package/src/DUPLICATION_AUDIT_2026_08_07.md +0 -254
  163. package/src/DUPLICATION_AUDIT_REMEDIATION.md +0 -250
  164. package/src/DUPLICATION_REVIEW_2026_10_24.md +0 -1072
  165. package/src/REVIEW_2026_08_06/adv-core.md +0 -511
  166. package/src/REVIEW_2026_08_06/adv-gfx.md +0 -480
  167. package/src/REVIEW_2026_08_06/adv-view.md +0 -446
  168. package/src/REVIEW_2026_08_06/api-core.md +0 -296
  169. package/src/REVIEW_2026_08_06/api-engine.md +0 -195
  170. package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +0 -507
  171. package/src/REVIEW_2026_08_06/core-binary.md +0 -348
  172. package/src/REVIEW_2026_08_06/core-collection.md +0 -376
  173. package/src/REVIEW_2026_08_06/core-math.md +0 -221
  174. package/src/REVIEW_2026_08_06/core-process.md +0 -266
  175. package/src/REVIEW_2026_08_06/core-science.md +0 -223
  176. package/src/REVIEW_2026_08_06/engine-ai-nav.md +0 -279
  177. package/src/REVIEW_2026_08_06/engine-animation.md +0 -167
  178. package/src/REVIEW_2026_08_06/engine-asset.md +0 -166
  179. package/src/REVIEW_2026_08_06/engine-ecs.md +0 -920
  180. package/src/REVIEW_2026_08_06/engine-input.md +0 -197
  181. package/src/REVIEW_2026_08_06/engine-misc.md +0 -248
  182. package/src/REVIEW_2026_08_06/engine-terrain.md +0 -52
  183. package/src/REVIEW_2026_08_06/generation.md +0 -176
  184. package/src/REVIEW_2026_08_06/geom-2d.md +0 -434
  185. package/src/REVIEW_2026_08_06/geom-3d.md +0 -117
  186. package/src/REVIEW_2026_08_06/gfx-gi.md +0 -143
  187. package/src/REVIEW_2026_08_06/gfx-render.md +0 -740
  188. package/src/REVIEW_2026_08_06/gfx-texture.md +0 -690
  189. package/src/REVIEW_2026_08_06/prior-criticals.md +0 -49
  190. package/src/REVIEW_2026_08_06/prior-highs.md +0 -138
  191. package/src/REVIEW_2026_08_06/test-gaps.md +0 -364
  192. package/src/REVIEW_2026_08_06/view.md +0 -667
  193. package/src/REVIEW_2026_08_06.md +0 -610
  194. package/src/core/binary/lz4/LZ4_HIGH_COMPRESSION_PLAN_2026_09_14.md +0 -542
  195. package/src/core/bvh2/bvh3/NOTES.md +0 -2
  196. package/src/core/bvh8/NOTES.md +0 -63
  197. package/src/core/cache/wtinylfu/benchmark/CacheWTinylfu.bench.md +0 -243
  198. package/src/core/color/COLOR_FIX_PLAN_2026_08_28.md +0 -417
  199. package/src/core/color/COLOR_REVIEW_2026_08_28.md +0 -779
  200. package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +0 -433
  201. package/src/core/geom/3d/atlas/ATLAS_PORT_PLAN.md +0 -486
  202. package/src/core/geom/3d/atlas/BFF_PLAN.md +0 -174
  203. package/src/core/geom/3d/atlas/REVIEW_02.md +0 -392
  204. package/src/core/geom/3d/gjk/GJK_REVIEW_NOTES.md +0 -146
  205. package/src/core/geom/3d/gjk/NOTES.md +0 -8
  206. package/src/core/geom/3d/topology/TOPO_MESH_RETIREMENT_PLAN.md +0 -135
  207. package/src/core/geom/GEOMETRY_REVIEW_002_API_ISSUES.md +0 -381
  208. package/src/core/geom/GEOMETRY_REVIEW_002_NAMING.md +0 -684
  209. package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +0 -554
  210. package/src/core/model/reactive/evaluation/PREDICATE_APPLICABILITY_PROPOSAL.md +0 -446
  211. package/src/engine/REVIEW_2026_07_03.md +0 -211
  212. package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +0 -815
  213. package/src/engine/control/first-person/DESIGN.md +0 -658
  214. package/src/engine/control/first-person/DESIGN_COLLISION.md +0 -382
  215. package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +0 -563
  216. package/src/engine/control/first-person/TODO.md +0 -160
  217. package/src/engine/ecs/ASYNC_SYSTEMS_PROPOSAL.md +0 -531
  218. package/src/engine/ecs/HIERARCHY_REVIEW.md +0 -717
  219. package/src/engine/ecs/async/WORKER_SYSTEM_PLAN.md +0 -262
  220. package/src/engine/ecs/ik/PLAN.md +0 -179
  221. package/src/engine/ecs/storage/binary/SERIALIZATION_COVERAGE.md +0 -272
  222. package/src/engine/ecs/storage/binary/object-v2/DESIGN.md +0 -199
  223. package/src/engine/ecs/transform/TRANSFORM64_MIGRATION_PLAN_2026_09_04.md +0 -521
  224. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +0 -463
  225. package/src/engine/graphics/ecs/camera/pp/NOTES.md +0 -48
  226. package/src/engine/graphics/render/frame_graph/CORRECTNESS_FIXES_2026_09_04.md +0 -659
  227. package/src/engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md +0 -235
  228. package/src/engine/graphics/sh3/path_tracer/REVIEW_001_ACTION_PLAN.md +0 -490
  229. package/src/engine/graphics/texture/sampler/compare/FLIP_IMPLEMENTATION_PLAN.md +0 -466
  230. package/src/engine/input/GAMEPAD_API_PROPOSAL.md +0 -717
  231. package/src/engine/input/ecs/ism/ISM_DESIGN.md +0 -1523
  232. package/src/engine/input/ecs/ism/ISM_IMPLEMENTATION_PLAN.md +0 -627
  233. package/src/engine/navigation/mesh/OVERLAPPING_SURFACES_2026_08_25.md +0 -661
  234. package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +0 -229
  235. package/src/engine/network/CONGESTION_CONTROL.md +0 -87
  236. package/src/engine/network/MESSAGE_TRANSPORT.md +0 -364
  237. package/src/engine/network/SMOOTHNESS_DESIGN.md +0 -400
  238. package/src/engine/physics/cloth/MEASUREMENTS.md +0 -5304
  239. package/src/engine/physics/cloth/PLAN.md +0 -3056
  240. package/src/engine/physics/fluid/GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md +0 -1382
  241. package/src/engine/physics/fluid/REVIEW_02_PLAN.md +0 -243
  242. package/src/engine/physics/fluid/solver/NOTES.md +0 -46
  243. package/src/engine/sound/SOUND_EMITTER_RETIREMENT_PLAN.md +0 -536
  244. package/src/engine/sound/simulation/IMPLEMENTATION_PLAN.md +0 -432
  245. package/src/engine/sound/simulation/PROBE_PLACEMENT.md +0 -250
  246. package/src/engine/sound/simulation/PROBE_TRANSFER_RESEARCH.md +0 -322
  247. package/src/engine/sound/simulation/REFLECTION_IR_PLAN.md +0 -261
  248. package/src/engine/sound/simulation/VISIBILITY_GRAPH_PLAN.md +0 -121
  249. package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +0 -999
  250. package/src/format/image/avif/DECISIONS.md +0 -545
  251. package/src/format/image/jpeg/NOTES.md +0 -3
  252. package/src/format/scene/gltf/MESHOPT_COMPRESSION_PLAN.md +0 -814
  253. package/src/generation/COORDINATES.md +0 -54
  254. package/src/generation/REVIEW_01_ACTION_PLAN.md +0 -628
  255. package/src/generation/foliage/ANNUAL_SHOOTS_BRIEF_2026_10_25.md +0 -220
  256. package/src/generation/foliage/CROWN_FILLING_BRIEF_2026_10_26.md +0 -172
  257. package/src/generation/foliage/CROWN_FULLNESS_BRIEF_2026_10_26.md +0 -86
  258. package/src/generation/foliage/FOLIAGE_DESIGN.md +0 -547
  259. package/src/generation/foliage/FOLIAGE_PLAN.md +0 -198
  260. package/src/generation/foliage/LEAF_ARCHITECTURE_BRIEF_2026_10_26.md +0 -158
  261. package/src/generation/foliage/LOD_REWORK_BRIEF_2026_10_27.md +0 -521
  262. package/src/generation/foliage/REAL_TIME_TREES_BRIEF_2026_10_27.md +0 -86
  263. package/src/generation/foliage/SELF_ORGANIZING_FRAMEWORK_BRIEF_2026_10_26.md +0 -347
  264. package/src/generation/foliage/SHOOT_SYSTEMS_ALL_SPECIES_BRIEF_2026_10_26.md +0 -248
  265. package/src/generation/foliage/SPEEDTREE_COMPARISON_2026_10_24.md +0 -694
  266. package/src/generation/foliage/TWO_FRONT_ENDS_BRIEF_2026_10_28.md +0 -552
  267. package/src/shade/RENDERER_CONTRACT.md +0 -697
  268. package/src/shade/SHADER_F16_PROPOSAL_2026_10_24.md +0 -263
  269. package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +0 -569
  270. package/src/shade/device/PASS_ENCODER_PROPOSAL_2026_08_31.md +0 -755
  271. package/src/shade/device/mock/EXTENSION_PROPOSAL_2026_08_22.md +0 -458
  272. package/src/shade/device/mock/IMPLEMENTATION_PLAN.md +0 -255
  273. package/src/shade/device/mock/REVIEW_2026_08_22.md +0 -527
  274. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +0 -894
  275. package/src/shade/device/timing/profile/SGPT_FORMAT.md +0 -345
  276. package/src/shade/renderer/NOTES.md +0 -78
  277. package/src/shade/renderer/PLAN.md +0 -8
  278. package/src/shade/renderer/animation/ADDITIVE_BLENDING_PLAN.md +0 -145
  279. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +0 -357
  280. package/src/shade/renderer/atmosphere/NOTES.md +0 -6
  281. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts +0 -76
  282. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts.map +0 -1
  283. package/src/shade/renderer/camera/orbital/MomentumAxis.js +0 -164
  284. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts +0 -45
  285. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts.map +0 -1
  286. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.js +0 -66
  287. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts +0 -98
  288. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts.map +0 -1
  289. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.js +0 -184
  290. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts +0 -175
  291. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts.map +0 -1
  292. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.js +0 -411
  293. package/src/shade/renderer/camera/orbital/README.md +0 -81
  294. package/src/shade/renderer/camera/orbital/index.d.ts +0 -6
  295. package/src/shade/renderer/camera/orbital/index.d.ts.map +0 -1
  296. package/src/shade/renderer/camera/orbital/index.js +0 -9
  297. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +0 -964
  298. package/src/shade/renderer/fluid/GPU_FLUID_OCCUPANCY_AND_SOTA_REVIEW_2026_10_26.md +0 -361
  299. package/src/shade/renderer/geometry/bvh/BLAS_REFIT_BATCH_PLAN.md +0 -619
  300. package/src/shade/renderer/geometry/meshlet/COMPACTION_PLAN_2026_09_16.md +0 -674
  301. package/src/shade/renderer/geometry/sdf/NOTES.md +0 -6
  302. package/src/shade/renderer/geometry/virtual/ATTRIBUTE_PLAN_2026_09_18.md +0 -540
  303. package/src/shade/renderer/geometry/virtual/MICRON_COMPARISON_2026_08_20.md +0 -305
  304. package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +0 -913
  305. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +0 -787
  306. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +0 -481
  307. package/src/shade/renderer/geometry/virtual/VK_LOD_CLUSTERS_COMPARISON_2026_08_20.md +0 -301
  308. package/src/shade/renderer/geometry/virtual/WEDGE_PLAN_2026_09_18.md +0 -448
  309. package/src/shade/renderer/geometry/virtual/runtime/CULL_PLAN_2026_09_16.md +0 -385
  310. package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +0 -656
  311. package/src/shade/renderer/geometry/virtual/runtime/REVIEW_LEDGER.md +0 -308
  312. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +0 -657
  313. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN_2026_09_14.md +0 -551
  314. package/src/shade/renderer/geometry/virtual/runtime/SHADOW_PLAN_2026_09_28.md +0 -529
  315. package/src/shade/renderer/global_illumination/lpv/NOTES.md +0 -3
  316. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_CLIPMAP_PROPOSAL_2026_10_27.md +0 -4306
  317. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md +0 -410
  318. package/src/shade/renderer/global_illumination/sharc/NOTES.md +0 -8
  319. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +0 -1369
  320. package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +0 -202
  321. package/src/shade/renderer/gpu_primitive/indirect/NOTES.md +0 -94
  322. package/src/shade/renderer/hiz/NOTES.md +0 -3
  323. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts +0 -8
  324. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +0 -1
  325. package/src/shade/renderer/light/cluster/NOTES.md +0 -11
  326. package/src/shade/renderer/light/cluster/PROPOSAL_ALGORITHM_2026_10_16.md +0 -524
  327. package/src/shade/renderer/light/cluster/PROPOSAL_HIERARCHICAL_2026_10_16.md +0 -1101
  328. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts +0 -24
  329. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts.map +0 -1
  330. package/src/shade/renderer/material/resident/shader_resident_texture_means.js +0 -120
  331. package/src/shade/renderer/particles/DESIGN.md +0 -1011
  332. package/src/shade/renderer/particles/EMITTER_GLOBAL_STATE_PLAN_2026_10_29.md +0 -280
  333. package/src/shade/renderer/particles/ORIENTATION_PLAN_2026_10_15.md +0 -453
  334. package/src/shade/renderer/path_tracer/PATH_TRACED_MEDIA_PLAN_2026_10_02.md +0 -520
  335. package/src/shade/renderer/postprocess/bloom/NOTES.md +0 -6
  336. package/src/shade/renderer/postprocess/fsr/NOTES.md +0 -4
  337. package/src/shade/renderer/postprocess/gtao/NOTES.md +0 -6
  338. package/src/shade/renderer/postprocess/nss/NOTES.md +0 -332
  339. package/src/shade/renderer/postprocess/perspective/NOTES.md +0 -3
  340. package/src/shade/renderer/postprocess/sscs/NOTES.md +0 -5
  341. package/src/shade/renderer/postprocess/ssr/NOTES.md +0 -14
  342. package/src/shade/renderer/postprocess/ssr/REPROJECTION_PROPOSAL.md +0 -286
  343. package/src/shade/renderer/postprocess/ssr/prefilter/NOTES.md +0 -39
  344. package/src/shade/renderer/postprocess/taa/NOTES.md +0 -11
  345. package/src/shade/renderer/postprocess/upscale-dither/NOTES.md +0 -78
  346. package/src/shade/renderer/postprocess/vrs/NOTES.md +0 -4
  347. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_LEDGER.md +0 -1218
  348. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_PLAN_2026_09_03.md +0 -600
  349. package/src/shade/renderer/restir/reservoir/NOTES.md +0 -6
  350. package/src/shade/renderer/shader/chunk/atmosphere/NOTES.md +0 -6
  351. package/src/shade/renderer/shader/chunk/brdf/FRESNEL_BRIEF_2026_10_16.md +0 -391
  352. package/src/shade/renderer/shader/chunk/color/tonemap/ACES_TONE_MAPPING_BRIEF_2026_10_17.md +0 -153
  353. package/src/shade/renderer/shader/chunk/color/tonemap/HDR_TONE_MAPPING_FORMS_BRIEF_2026_10_16.md +0 -322
  354. package/src/shade/renderer/shader/chunk/color/tonemap/SDR_TONE_MAPPING_BRIEF_2026_10_16.md +0 -247
  355. package/src/shade/renderer/shader/chunk/fsr/NOTES.md +0 -3
  356. package/src/shade/renderer/shader/chunk/fsr/spd/NOTES.md +0 -4
  357. package/src/shade/renderer/shader/chunk/geometry/ray/NOTES.md +0 -4
  358. package/src/shade/renderer/shader/chunk/material/MULTISCATTER.md +0 -159
  359. package/src/shade/renderer/shader/chunk/mboit/NOTES.md +0 -38
  360. package/src/shade/renderer/shader/chunk/spherical_harmonics/l1i/NOTES.md +0 -3
  361. package/src/shade/renderer/shader/chunk/spherical_harmonics/sampling/NOTES.md +0 -8
  362. package/src/shade/renderer/shader/compiler/SHADER_SWITCH_PROPOSAL_2026_10_16.md +0 -927
  363. package/src/shade/renderer/shadow/contact/FINDINGS.md +0 -413
  364. package/src/shade/renderer/shadow/map/csm/NOTES.md +0 -5
  365. package/src/shade/renderer/shadow/ray/NOTES.md +0 -31
  366. package/src/shade/renderer/shadow/sdf/NOTES.md +0 -9
  367. package/src/shade/renderer/shadow/sdf/brixelizer/NOTES.md +0 -5
  368. package/src/shade/renderer/shadow/sdf/cascade/NOTES.md +0 -9
  369. package/src/shade/renderer/shadow/vsm/NOTES.md +0 -12
  370. package/src/shade/renderer/shadow/vsm/PLAN.md +0 -442
  371. package/src/shade/renderer/shadow/vsm/REVIEW_LEDGER.md +0 -43
  372. package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +0 -2444
  373. package/src/shade/renderer/texture/mipmaps/NOTES.md +0 -3
  374. package/src/shade/renderer/volumetrics/NOTES.md +0 -1329
  375. package/src/shade/renderer/volumetrics/VOLUMETRIC_NOISE_PLAN_2026_10_15.md +0 -336
  376. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts +0 -23
  377. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts.map +0 -1
  378. package/src/shade/renderer/water/medium/GPUWaterBodies.js +0 -75
  379. package/src/shade/wgsl/NOTES.md +0 -4
@@ -1,1011 +0,0 @@
1
- # GPU-Driven Particle System — Design
2
-
3
- A specialized, deeply-integrated, GPU-driven particle engine for the v2 renderer.
4
- Destiny-style: **one** simulation dispatch runs **all** particles of **all** emitters by
5
- interpreting a compact per-emitter bytecode program (a shader VM). Effects are authored as
6
- node graphs and lowered to that bytecode by a testable JS compiler. Emitters run at entities'
7
- rows, and the decision to spawn is the GPU's.
8
-
9
- ## Goals (from the brief)
10
-
11
- - Single simulation execution shader (data-driven VM, not one-pipeline-per-effect).
12
- - Projection modes: camera-facing billboard (+ velocity-stretched, axis-locked, world-oriented),
13
- each a frame a particle's own orientation (a quaternion) turns the quad inside. See "Orientation".
14
- - Order-independent transparency: the particles are a side channel of the AVBOIT pipeline, so they
15
- composite against the transparent meshes with no depth ordering of any kind.
16
- - Node-based composition → shader VM (compile graph → bytecode).
17
- - Frustum culling of spawning (optional, flagged, sleep-when-culled + bounded catch-up).
18
- - Flipbooks (atlas sub-rect + frame grid).
19
- - Texture lookup via atlas (region table; mirrors meep `AtlasPatch` uv offset/scale).
20
- - Soft-depth fade (flagged).
21
- - `GPUDatabase` for CPU-managed, low-churn tables (emitters).
22
- - Scene / bone attachment: an emitter stands at an entity with a row; its world matrix is that row
23
- of the scene's `transforms` table, composed by the GPU hierarchy pass.
24
- - Distinct per-particle lifecycle: **no** built-in age/position — the schema + program define state.
25
- - Lighting per emitter (`EMITTER_FLAG.LIGHTING`): the scene's lights from the froxel irradiance
26
- volume the fog pass writes, plus the environment map. See "Lighting" below.
27
- - Blend modes (alpha + additive) via a **single** premultiplied blend setup.
28
- - Authored curves and RGB/RGBA gradients compiled into arithmetic for simulation.
29
- - Spawning decided on the GPU, per emitter, with the CPU able to ask for bursts through the same path.
30
- - No per-frame allocation on the CPU beyond the frame graph's own records.
31
-
32
- ## Layered architecture
33
-
34
- ```
35
- authoring: NodeGraph (nodes+ports) ──compile──▶ Program (bytecode: INIT + UPDATE)
36
- standard node library │
37
- ▼
38
- runtime CPU: ParticleEmitter (at an entity) ── Scene ──▶ GPUSceneContext (transforms table)
39
- EmitterRegistry ── GPUDatabase(emitters + emitter_state + field_bindings) ─┤ ProgramHeap ([header][constants][code][fields])
40
- GPUParticleSystem (owns every buffer, records the frame)
41
- ▼
42
- runtime GPU: particle pool (array<u32>, uniform stride) + alive/dead lists + counters
43
- emitter state table (GPU-owned: generation, accumulator, age, sleep, pending)
44
- ┌─ field bind (per binding row: a bound transform → its field; only while rows exist)
45
- ├─ spawn commands (CPU bursts → pending)
46
- ├─ emitter tick (per row: cull on the published box, integrate rate, take budget → count)
47
- ├─ scan (csdldf) → emit dispatch
48
- ├─ emit (VM INIT program) ── per newborn: find emitter in scan, pop dead → init → append alive_new
49
- ├─ simulate (VM UPDATE program) ── update → (kill|orphan→dead | keep→alive_new)
50
- ├─ bucket (histogram → csdldf scan → scatter) ── alive → program-coherent sim list
51
- ├─ bounds (per particle → per emitter box, published to the state row)
52
- └─ build indirect (dispatch + draw args)
53
-
54
- the AVBOIT transparency pipeline then draws the alive list through the side channel:
55
- ├─ occupancy (per emitter box → depth slices)
56
- ├─ splat (extinction into the voxel volume)
57
- └─ draw (instanced billboard quads → the four accumulators)
58
- ```
59
-
60
- ### Vocabulary: fields
61
-
62
- State is a set of **fields**, named and laid out by a `ParticleLayout`, one kind per scope:
63
- `particle_field`s live in a particle's pool record, `emitter_field`s in its emitter's block and
64
- `global_field`s in the system's shared section. Particle fields were once called attributes, and
65
- saves under that name still load: graph node types `attribute` and `setAttribute`
66
- (`PARTICLE_NODE_TYPE_RENAMES`), a document's `attributes` key, and the assembly mnemonics `LOAD_ATTR`
67
- and `STORE_ATTR`. The binary effect format never wrote the word.
68
-
69
- ### Emitter fields
70
-
71
- The program is a class and an emitter an instance of it. An effect declares **emitter fields** — an
72
- `emitter_layout`, a `ParticleLayout.emitter` packed from word zero with no fixed size — and every
73
- emitter of it carries its own values for them (`emitter_fields`, f32 bit patterns). Two emitters of
74
- one effect that differ only there share one program, one heap placement and one simulation bucket.
75
-
76
- - **Storage.** The packed program buffer's fourth section, after the code:
77
- `[header][constants][code][fields]`, header word `PARTICLE_PROGRAM_FIELD_BASE_WORD` saying where it
78
- starts. The section opens with `PARTICLE_GLOBAL_FIELD_WORDS` reserved for global fields; each
79
- emitter's block is an allocation of the same `OffsetAllocator` kind the other sections use, held
80
- by its context as a `FieldBlock` and moved in place by a repack, which moves `placement_version`
81
- because the record carries the block's offset (`field_offset`). The fields share the program
82
- buffer's binding.
83
- - **Reading.** The engine's builtins are the emitter's members as the engine declares them, and an
84
- effect's emitter fields are the members it declares, so they are read through the same
85
- instruction: a `LOAD_BUILTIN` id from `VM_BUILTIN_EMITTER_FIELD_BASE` up reads the
86
- instruction's width in words from the emitter's block, as `LOAD_CONST` reads the pool.
87
- `vm_ctx.emitter_field_offset` is set beside `emitter_position` by `particle_setup_context`,
88
- so emit, simulate and the warm-up all have it. The offset is per lane, as the emitter's position
89
- is: a simulate wave is one program, not one emitter, so the read is a vector load where
90
- `LOAD_CONST`'s is scalar. The plan's Outcome has what that costs.
91
- - **Writing.** The CPU's: gameplay writes the component's values in place, `apply_particle_effect`
92
- finds the change by comparison and copies it to the emitter, and the registry writes the block
93
- when it restages the row. The heap tracks the changed span, and `GPUParticleSystem` writes that
94
- span into the existing buffer rather than rebuilding it.
95
- - **Authoring.** `ParticleEffectDocument.emitter_fields` declares them with the value an emitter
96
- starts from; the `emitterField` node reads one. `ParticleEffect` serializes them at version 3.
97
-
98
- ### Global fields
99
-
100
- The system's static members, read by every effect by name — `TIME` and `DELTA_TIME` are the
101
- engine's, a gust strength or the player's position a game's.
102
-
103
- - **Layout.** `ParticleGlobalFields`, owned by the heap: the union of what is loaded, append-only,
104
- in the `PARTICLE_GLOBAL_FIELD_WORDS` reserved at the start of the field section, so a global field
105
- is addressed from zero and never moves. A program placing a name the system does not have
106
- declares it, zero-filled; a game may declare names first. A name is one field: a later reader may
107
- not want more components than it was declared with.
108
- - **Refusal.** A program whose imports the layout cannot take is refused before anything is claimed
109
- for it (`ParticleGlobalFields.check`): no id, range or declaration is left behind. The registry's
110
- flush keeps the emitter on the program it had, with that program's emitter layout — its block,
111
- values and bindings, even when the emitter's layout changed along with the refused program — and
112
- does not offer the refused one again until it changes; `GPUParticleEmitterSystem` does the same for an effect it cannot register, and for a
113
- global value set at a width the layout refuses, so one bad effect never stops the others.
114
- - **Linking.** A compiled program cannot know a scene's offsets, so it carries `global_imports`
115
- (name and width, in the order it first reads them) and its global `LOAD_BUILTIN` ids are import
116
- indices. `ProgramHeap` rewrites them to offsets as it copies the code into place
117
- (`particle_link_global_fields`); the program it is keyed by stays unlinked, so deduplication
118
- holds. Assembly carries imports as `.global name n`; the optimizers carry them through.
119
- - **Values.** The CPU's: `GPUParticleSystem.global_fields.set(name, values)` writes words and marks
120
- the span, which goes out as one `writeBuffer`; nothing is restaged and no program changes. For a
121
- game, `GPUParticleEmitterSystem.set_global_field(name, values)` holds the value until a particle
122
- system exists and hands it over again to a replacement one. A global nobody has set reads 0.
123
- - **Authoring.** `ParticleEffectDocument.global_fields` declares the imports with the value the
124
- preview gives them; the `globalField` node reads one; the preview links its own copy of the
125
- program to its own layout.
126
-
127
- ### Transform bindings
128
-
129
- A field — an emitter's or a global one — can be bound to a row of the scene's `transforms` table,
130
- and the GPU writes that row's composed `global` into it every frame: a beam's far end, a homing
131
- target, a joint the CPU never sees.
132
-
133
- - **Table.** `field_bindings`, the particle database's third table (CPU-staged): `source` (a
134
- non-owning link to `transforms`, as the emitter's `node` is), `word` (from the field section's
135
- base) and `channel` — `POSITION`, `DIRECTION`, `UP` (three words, computed as the emitter's
136
- builtins of the same names are) or `ORIENTATION` (a quaternion, scale divided out). A field is
137
- bound only to a channel exactly as wide as it.
138
- - **Pass.** `shader_particle_field_bind`: one thread per binding row, a page group per workgroup,
139
- after the counter reset and before the emitter tick, so every VM pass reads this frame's
140
- transforms. The program buffer is bound read-write in this pass only. Recorded only while the
141
- table has rows.
142
- - **Authority.** A bound word is rewritten from scratch every frame, so a span upload or a buffer
143
- rebuild landing on it is overwritten before anything reads it. Unbinding hands the word
144
- back to its own value — the emitter's, or the global's last set value or zero — which goes out
145
- with the next upload.
146
- - **Mirrors.** A mirrored transform is a reflection, which no quaternion is: `ORIENTATION` takes the
147
- mirror out of the x axis and writes the rotation that is left.
148
- - **Bookkeeping.** `EmitterRegistry.bind_emitter_field` / `bind_global_field` and their unbinds. An
149
- emitter's bindings are restaged with it, because the word they write is its block's offset plus
150
- the field's, and the block moves; one whose field the layout no longer has is dropped, and they
151
- go with the emitter.
152
- - **ECS.** `ParticleEffect.bindings` (`{field, entity, channel}`) and
153
- `GPUParticleEmitterSystem.bind_global_field`: entity references, resolved with `row_of_entity`,
154
- the row held through the scene's rows while bound. Runtime data and not saved: nothing in the
155
- engine's saves carries an entity reference across a load. A target is let go on its own
156
- `EntityRemoved`, before the entity is gone; a request the registry refuses — or that names an
157
- entity with no `Transform64` — is remembered and not retried until it, or the emitter's layout,
158
- changes; one field asked for twice is bound to the first request.
159
-
160
- ### Particle storage — single shared pool, uniform stride
161
-
162
- All particles live in one `array<u32>` pool with a fixed `RECORD_WORDS` stride (config, default 32
163
- words = 128 B). Word 0 is a reserved header holding the owning **emitter row** and that row's
164
- **generation** (`data/particle_header.js`); particle fields occupy words `1..RECORD_WORDS`. A
165
- single pool + single alive/dead lists means **one** emit dispatch and **one** simulate dispatch
166
- cover every emitter — the Destiny single-shader property.
167
-
168
- Lifecycle uses the canonical GPU dead-list / alive-list pair:
169
- - `dead_list`: stack of free slot indices (+ atomic count). Filled `dead[i] = i` at construction by
170
- `shaders/shader_particle_init_free_list.js` — a dispatch, not a host upload, which is why the
171
- buffer carries no `COPY_DST`.
172
- - `alive_list`: a compact list of live slot indices (+ count), rebuilt from scratch every frame.
173
- - `counters`: atomics {alive_count, dead_count, spawn budget, …} + derived indirect dispatch/draw args.
174
-
175
- Emit: pop dead → run INIT → append `alive_new`. Simulate: read the **coherence list** last frame's
176
- bucketing pass built from `alive_current` → run UPDATE → kill pushes to `dead`, survival appends to
177
- `alive_new`.
178
-
179
- There is no ping-pong pair, although the shape invites one. The frame appends to the new list from a
180
- counter its first pass zeroes and the draws walk that same new list, so the previous frame's list has
181
- exactly one reader in the whole design: the bucketing rebuild on a frame whose program assignments
182
- changed. So the list is taken from the graphics context's buffer allocator and given back every
183
- frame, before the next is asked for — the pool answers with the same memory, the reserved footprint
184
- is one list, and only a rebuild frame holds two. See `GPUParticleSystem#swap_alive_list`.
185
-
186
- **The loop is strictly GPU-driven.** The build-indirect pass snapshots the padded length of the
187
- coherence list into `counters[ALIVE_IN]` and writes the indirect dispatch args next frame's simulate
188
- consumes (`dispatchWorkgroupsIndirect`) plus the indirect draw args this frame's draws consume
189
- (`drawIndirect`, sized by the *unpadded* alive count); simulate bound-checks against the ALIVE_IN
190
- snapshot. The bounds pass takes the dispatch the bucket reset writes from the alive count. Sizing
191
- any of this from a CPU readback is a correctness bug, not a shortcut: the readback is inevitably
192
- stale, so a growing population orphans the tail of the alive list (slots leak into limbo)
193
- and a shrinking one re-simulates stale entries — double-freeing slots so two live particles later
194
- share one record. The emit pass's dead-list pop guards against wrapped counters
195
- (`old == 0 || old > capacity`), so racing an exhausted pool drops spawns cleanly instead of handing
196
- out garbage slots.
197
-
198
- ### Spawning is the GPU's decision
199
-
200
- The CPU never walks the emitters per frame and never counts particles. Per frame:
201
-
202
- 1. **spawn commands** (`shaders/shader_particle_spawn_commands.js`, only when the host queued any):
203
- one thread per `[row, count]` pair the host authored, `atomicAdd` into that row's `PENDING` in
204
- the GPU-owned emitter state. This is `GPUParticleSystem#spawn` — the CPU's facade over the GPU
205
- path, not a second path: a burst goes through the same budget, expansion and INIT program as the
206
- emitter's rate. It is also the channel a VM opcode would use to spawn from the GPU (a sub-emitter
207
- or trail would `atomicAdd` its target's `PENDING`).
208
- 2. **emitter tick** (`shaders/shader_particle_emitter_tick.js`): one thread per emitter table row,
209
- walked with the table's page iterator so an unallocated page costs one word and a freed row one
210
- bit. Per live row: recognise the row by generation (a mismatch starts the state over — new
211
- emitter, or a reused row, with nothing for the CPU to clear); take `PENDING`; publish the
212
- bounding box emit and simulate measured last frame into the state row and reset the accumulator
213
- (see **Bounds**); if the emitter opts into culling, test that box against the camera frustum and
214
- sleep while culled, paying back a bounded catch-up on wake; integrate
215
- `spawn_rate * dt`; turn the whole part of the accumulator into this frame's count, clamped by the
216
- **spawn budget** the reset pass seeded from the dead count — so an emitter is only ever debited
217
- for particles the pool can hold; write the count to `spawn_counts.elements[row]`.
218
- 3. **scan**: `graph_prefix_scan_csdldf` over the counts — each row's batch ends at its inclusive sum,
219
- and the last sum is the number of particles born.
220
- 4. **spawn args**: `shader_prefix_sum_to_command` (the rasteriser's own) turns that total into the
221
- emit dispatch.
222
- 5. **emit**: one thread per newborn, locating its emitter by `lower_bound_branchless` over the sums
223
- and its batch fraction from the sum before — the meshes-to-meshlets expansion applied to
224
- particles. No per-emitter cap, no thread loops over its particles.
225
-
226
- On a frame that registered emitters with a `prewarm`, and only then, a loop of the warm-up pipeline
227
- (`warmup/`) runs after the frame's own simulate: the same decision-scan-emit shape per tick over a
228
- transient population, plus an advance of its own, then a migration into the main pool. See
229
- **Pre-warm**.
230
-
231
- The host contributes two numbers to all of that: the emitter table's element capacity (the scan's
232
- length — static topology) and the length of the command list it wrote itself.
233
-
234
- ### The emitter state (`data/PARTICLE_EMITTER_STATE.js`)
235
-
236
- What the GPU integrates per emitter — generation, accumulator, age, sleep, pending — is the
237
- `emitter_state` table of the same database, one row per emitter at the emitter's row, **not**
238
- fields of the emitter record. The record is CPU-shadowed and restaged whole whenever the author
239
- changes a field, and that restage would overwrite an accumulator the GPU has been adding to for a
240
- hundred frames. The scene's transforms table mixes CPU fields with a GPU-written `global` only
241
- because `global` is recomputed from scratch every frame; nothing here is. In its own table the CPU
242
- touches a row exactly twice — a zero record when the emitter's row is claimed, the table's own
243
- zero-fill when it is released — and the database's resize copies pages on the GPU, so growth keeps
244
- what the tick has written. Nothing in it is atomic: each row is one tick thread's, and the
245
- spawn-command pass is handed one command per row (the system merges bursts on the CPU). The
246
- emitter's `age` here is the VM's `EMITTER_AGE` builtin — and the one field the warm-up pipeline
247
- writes, as it runs an emitter's clock forward (see **Pre-warm**).
248
-
249
- ### Eight storage buffers
250
-
251
- A compute stage gets eight storage buffers by default, and the design does not ask for more. Two
252
- things keep every pass under it: the emitter record, the GPU-owned state and the transform bindings
253
- are three tables of one database buffer, read through one binding; and the VM's constants, code and
254
- fields are one packed buffer — `[header][constants][code][fields]`, the header's first two words
255
- saying where the code and the fields start (`ParticleConstants.PARTICLE_PROGRAM_HEADER_WORDS`). Emit and simulate bind exactly eight; their
256
- wide-register variants bind nine, which is the one place the system spends past the default and is
257
- still inside the ten the engine demands of an adapter. `particle_shaders_wgsl_validity.spec` pins
258
- the count for every pass.
259
-
260
- Every pass that runs the VM — emit, simulate, their wide variants and the warm-up's advance — also
261
- binds the scene's published wind field (`../wind/`, `GPUSceneWind.bind`, per frame): one uniform, one
262
- 3-D texture and a sampler, and no storage buffer, so it costs none of the eight. It is bound with or
263
- without a producer; with none it reads still air. `graph_particles_wind.spec` holds every one of
264
- those passes to it.
265
-
266
- ### The VM (`ParticleVM`)
267
-
268
- Register machine, emulator-safe (bounded instruction loop + big `switch`, no subgroup ops, no
269
- recursion, no data-dependent jumps — conditionals are predicated via CMP/SELECT, destruction via
270
- `KILL`).
271
-
272
- - Register file: `array<f32>`, a register being one slot and a value of width *w* occupying *w*
273
- consecutive ones. The interpreter reaches it through `vm_reg_read` / `vm_reg_write`, which a
274
- **backing chunk** supplies, and the two backings are the two interpreters:
275
- `vm/chunk_particle_vm_registers_fast.js` puts the file in `var<private>`
276
- (`PARTICLE_VM_FAST_REGISTER_SLOTS`, dynamically indexed, so its size is the shader's register
277
- footprint), and `vm/chunk_particle_vm_registers_wide.js` puts it in a storage buffer
278
- (`PARTICLE_VM_REGISTER_SLOTS` — every slot an 8-bit operand base can name). See **Two
279
- interpreters** below.
280
- - Instruction: fixed **4×u32** words `[op | width << 8 | kinds << 16 | dst << 24, s0, s1, s2]`.
281
- Word 0 packs the opcode (bits 0..7), the lane count (8..10), each source's `VM_KIND` (two bits per
282
- slot at 16..21) and the destination slot (24..31). A source word means what its kind says: `REG`
283
- is a base slot plus a swizzle, `IMM` is an `f32` bit pattern read in every lane, and `NONE` is a
284
- raw field the opcode reads for itself (particle field word-offset+comp / builtin id /
285
- pool index).
286
- - Constant pool: `array<vec4f>` per program, referenced by `LOAD_CONST`.
287
- - Two entry sections per program: **INIT** and **UPDATE** (offsets in the emitter record). INIT
288
- gives an already-allocated record its state; it has no say in whether the particle exists.
289
- - Builtins (`LOAD_BUILTIN`): DT, EMITTER_AGE, EMITTER_POSITION, EMITTER_DIRECTION, EMITTER_UP,
290
- PARTICLE_INDEX, SPAWN_FRACTION, TIME … assembled per-invocation by the host shader. The emitter's
291
- position and axes are its node's `global` out of the scene database.
292
- - `SAMPLE_WIND dst, position` (GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md §13): the scene's wind at a
293
- world position, m/s — three lanes in, three out (a fourth, if asked for, is zero). The one opcode
294
- that reads a resource the host binds: the interpreter calls `particle_vm_sample_wind`, which every
295
- particle host defines over the published wind field (`vm/chunk_particle_vm_wind_field.js`), and the
296
- JS reference calls its `sample_wind` host function — still air when a host passes none. To the
297
- optimizer it is a pure read: removable, shareable within a run, narrowable to three, never folded.
298
-
299
- #### Two interpreters
300
-
301
- The register file normally lives in shader registers, which is what makes it fast and what makes it
302
- small. An effect whose compiled program wants more slots than it holds is not an error and not an
303
- authoring ceiling: the whole system swaps emit and simulate for variants whose file is a storage
304
- buffer, and everything runs, slowly.
305
-
306
- - **The trigger is the set, not the program.** `ProgramHeap` reports `max_register_count` over the
307
- programs it holds, and `GPUParticleSystem` is where that number is compared against
308
- `PARTICLE_VM_FAST_REGISTER_SLOTS` — the store has no opinion about what a register budget means,
309
- and which backing a pass is compiled against is the VM's business. One hungry effect moves every
310
- emitter onto the wide pair; there is one pipeline per pass and it is chosen once, per frame, in
311
- `graph_particles`. It moves back when that effect is unregistered: the heap knows what it holds,
312
- so the number is a maximum over the live set rather than a high-water mark.
313
- - **The launch is bounded.** The wide passes need a register file per invocation in flight, so they
314
- do not run one lane per particle — that would size the buffer by pool capacity. They launch
315
- `PARTICLE_VM_WIDE_LAUNCH_LANES` lanes whatever the workload and walk their input with a grid-stride
316
- loop, so the file is `LANES * SLOTS * 4` bytes and nothing else. The stride is a whole number of
317
- workgroups, which is what keeps a wave inside one coherence bucket and the wave-uniform section
318
- decode sound.
319
- - **Slot-major, and never cleared.** `vm_registers[slot * LANES + lane]`, so a wave reading one slot
320
- reads a coalesced run. The buffer is transient and no pass zeroes it: a register is undefined until
321
- written, and a program that reads one first is corrupt. That is the one place the two backings are
322
- not interchangeable — the fast file zeroes, so the reference executor and the fast VM agree on a
323
- value the wide VM has no opinion about.
324
- - **Held to the same bar.** `vm/chunk_particle_vm.spec.js` runs every parity case, fuzz included,
325
- against BOTH backings; `shaders/particle_wide_register_passes.spec.js` runs the wide passes against
326
- the fast ones under the emulator and exercises the grid-stride loop.
327
-
328
- **Parity strategy.** The opcode numbers + operand semantics live in one source of truth
329
- (`isa/ParticleVMISA.js`). Two executors implement it: a JS reference VM (`vm/ParticleVMReference.js`)
330
- and a hand-written WGSL interpreter (`vm/chunk_particle_vm.js`). A spec runs identical programs
331
- through both (WGSL via the emulator) and asserts bit-for-bit-close equality. A meta-test asserts both
332
- executors cover every opcode in the ISA table.
333
-
334
- ### Node graph → bytecode
335
-
336
- A core `NodeGraph` holds particle nodes (ports + parameters) for INIT and UPDATE. `compile()`:
337
- 1. topologically orders reachable nodes,
338
- 2. lowers each node to VM ops (nodes emit via a small builder that allocates result registers),
339
- 3. linear-scan register allocation with liveness,
340
- 4. interns constants into the pool,
341
- 5. resolves particle, emitter and global field references,
342
- 6. emits `ParticleProgram { init, update, constants, register_count, global_imports }`.
343
-
344
- Standard node library: constants, particle field get/set, emitter and global field get,
345
- arithmetic/vector/math, random (uniform/sphere/disk/cone), authored curves and RGB/RGBA gradients,
346
- noise (curl), integrate, gravity, emit-shapes, compare/select, kill-if, and `wind` (a world position
347
- in, the scene's wind there out: `SAMPLE_WIND`). Curves and gradients expand into graph nodes that
348
- emit ordinary VM arithmetic; no animation-table binding or sampling opcode is needed.
349
-
350
- ### Emitters run at entities' rows
351
-
352
- A `ParticleEmitter` is placed by registration: `GPUParticleSystem#add_emitter(emitter,
353
- transform_row)` gives it a row in the emitter table pointed at a `transforms` row — an entity's,
354
- one on, held through the scene context's rows by whoever owns the entity — and `remove_emitter`
355
- retires the row and frees its particles. The GPU hierarchy pass composes that row's `global` from
356
- its pose and its parents' every frame, animation included, so an emitter registered at a joint's
357
- row follows the joint with nothing copied through the CPU — which a CPU-side copy of a world
358
- matrix could not do, since the CPU never sees the matrix of a row the GPU owns. Nothing walks the
359
- scene: an emitter is where its owner says it is, and per-frame upkeep is proportional to what
360
- changed rather than to how many emitters there are.
361
-
362
- **The emitter knows nothing about the GPU, and nothing about where.** A `ParticleEmitter` is a
363
- particle field `layout`, a compiled `program`, an `emitter_layout` with this emitter's
364
- `emitter_fields`, a `texture` URL, and the emission parameters — all of
365
- it the author's, all of it meaning the same thing with no device present, all of it what a saved
366
- effect is made of. Everything the system assigns is a `GPUParticleEmitterContext`, held by the
367
- `EmitterRegistry` against the emitter and reached through `registry.context(emitter)`: the table
368
- row, the generation, the program placement, the `transforms` row it is placed at, the atlas patch.
369
- So an emitter cannot be saved with a row in it, cannot be given one by hand, and "is this emitter
370
- live?" is asked of the registry rather than read off a `-1` that anything could have written. The
371
- record writer is where the two meet — `write_emitter_record(record, emitter, context)`, next to
372
- the struct it fills.
373
-
374
- Restaging is driven by `ParticleEmitter#version`, the emitter's own change counter; the context
375
- remembers the version its row was staged from. An author who edits an emitter says so with
376
- `needsUpdate = true`, and there is no second flag to forget. A change to the context itself (a
377
- `transforms` row, a repacked atlas patch) moves no version, so the context marks itself instead.
378
-
379
- ### Emitter table (`GPUDatabase`)
380
-
381
- One `emitters` table (CPU-managed, low churn). Row = `PARTICLE_EMITTER_STRUCT`: program
382
- offsets+lengths, constant-pool offset, seed, spawn rate, **node** (transforms row), **generation**,
383
- flags (lighting/soft/cull/blend/projection), render binding (attr offsets for
384
- position/size/color/orientation/frame/velocity), atlas region + flipbook grid, and the program id
385
- the coherence pass buckets on. The atlas region is resolved, not authored: the emitter names an
386
- image and the system packs it, the way `ShaderManager` packs the old engine's sprites, so a repack
387
- moves every patch without any emitter changing. The seed is resolved too — an emitter left at 0 gets
388
- its registration's generation, which no other emitter of the registry shares, rather than being
389
- written to. Programs+constants live in a packed `ProgramHeap` buffer
390
- (variable length → not a fixed-stride table).
391
-
392
- The struct is the ONLY statement of that layout. The JS writer is the database's
393
- `write_wgsl_type_value`, and the WGSL reader — the `ParticleEmitter` struct declaration plus
394
- `database_read_..._element` — is generated from the same struct by the table descriptor. There is no
395
- hand-packed word offset table to drift out of step with the shaders, and no hand-written reader.
396
- The bucket histogram, which runs once per live particle and wants only `program_id`, reads it
397
- through `chunk_read_field` rather than decoding a whole row.
398
-
399
- **Editing the set at runtime is the point of using a table.** `EmitterRegistry.add` stages one
400
- record and `remove` frees one row; neither touches the others. Rows are stable for an emitter's
401
- lifetime because a live particle's record header holds the row of the emitter that spawned it —
402
- renumbering a survivor would re-point its particles at somebody else. A removed row is handed back
403
- to the next `add`, so the id space does not grow without bound.
404
-
405
- **Generations make that reuse safe.** Every add takes the next value of one running counter, never
406
- zero, and the particles of an emitter carry its generation in their header. The simulate pass kills
407
- any particle whose generation is not its row's current one — the row is zero-filled (generation 0)
408
- or a later tenant's — so a hard removal frees the emitter's particles within a frame, and a reused
409
- row cannot adopt a stranger's. Graceful retirement (rate to zero, remove once the tail has died)
410
- remains an author's choice rather than a correctness rule.
411
-
412
- **Programs are edited the same way.** `ProgramHeap` is a pair of range allocators
413
- (`OffsetAllocator`) over the two sections of the packed buffer, keyed by a `HashMap` on the bytecode
414
- itself — `ParticleProgram` answers `equals`/`hash`, so content deduplication is the map's doing
415
- rather than a stringified key's. `acquire` takes a reference and `release` gives one back, so the
416
- last emitter of an effect is what unpacks it, and `EmitterRegistry.set_program` swaps an emitter onto
417
- different bytecode while it is live. What makes that possible is that a program is **relocatable**:
418
- instructions carry no absolute addresses and constant references are program-local, so the heap can
419
- move one by copying its words and rewriting the placement.
420
-
421
- Two things are deliberately NOT reclaimed. **Program ids** are reused rather than renumbered — they
422
- are baked into every emitter record, so compacting them would mean rewriting the whole table — and
423
- `bucketCount()` is sized from the id capacity, the set's peak, so it does not move every time an
424
- effect comes or goes. An id below the peak with nothing in it is an empty bin, which rounds up to
425
- zero workgroups and shifts nothing after it. **Section capacity** only rises: a repack (which happens
426
- when a section has no contiguous run long enough) compacts what is live and grows only if it must,
427
- so a set that peaked at a hundred effects does not pay for a resize every time it returns to three.
428
-
429
- A repack is the one change no emitter announces, since it is not an edit to any of them.
430
- `EmitterRegistry.flush` asks the heap once per frame — `placement_version` — and restages every row
431
- when it has moved.
432
-
433
- Programs are immutable after compilation. Both `registry.set_program(emitter, program)` and replacing
434
- `emitter.program` followed by `needsUpdate = true` are supported. Flush reconciles replacements
435
- before inspecting heap relocation, since acquiring a replacement may move other programs too.
436
- The layout must remain compatible with particles already in flight.
437
-
438
- A live program assignment increments `program_assignment_version`. On the next frame, before reset
439
- or simulation, the previous compact alive list is bucketed again and its padded count and dispatch
440
- are rebuilt. This prevents a previously shared wave from executing one emitter's newly edited program
441
- over another emitter's particles. Ordinary frames keep the existing end-of-frame bucketing only;
442
- content-identical replacements need no repair. Both bucket runs on an edit frame reuse the latest
443
- graph handles for their scratch, so it is created once and both runs share one dependency chain.
444
-
445
- The coherence list is persistent GPU state, including its padding — the one thing the bucketing
446
- leaves behind. Growing the bucket capacity copies it to the replacement buffer before destroying the
447
- old one; the old count and indirect dispatch remain valid across growth. The histogram, cursors, keys
448
- and dispatch args beside it live and die inside one frame graph, so the passes that write them
449
- declare them (a `BufferResourceDescriptor` in place of a handle) and the graph allocates and releases
450
- them around their users. None asks for a clear: the reset pass already covers every word it will
451
- read, and `keys` is written by the same indirect dispatch that reads it.
452
-
453
- Every row of a frame is staged through one module-static record (`EmitterRegistry`), so a flush
454
- allocates nothing however many rows it restages.
455
-
456
- ### Bounds
457
-
458
- An emitter has no bounds it could declare. Its particles are simulated in world space by arbitrary
459
- bytecode: they go where the program sends them, at whatever size it gives them, for as long as it
460
- keeps them alive. The only bound an author could write down and still be right is an infinite one,
461
- which culls nothing and reserves every depth slice. So the box is **measured**, on the GPU, every
462
- frame.
463
-
464
- Two passes of its own (`bounds/`), at the end of the frame, after emit and simulate have both
465
- finished appending to the alive list:
466
-
467
- - **accumulate** — one thread per live particle: read where its quad is and merge it into its
468
- emitter's accumulator. The centre is the record's `render_position`; the dilation is
469
- `particle_billboard_radius`, the largest offset `particle_billboard_offset` can produce for that
470
- size, velocity and projection, over every corner, every orientation and any camera. The box has to hold
471
- the *quads*, not the centres, and it has to be camera-independent, because it is read on a later
472
- frame. The merge is `atomic_min_f32_storage` / `atomic_max_f32_storage`, the same CAS-spin the
473
- skinned-mesh bounds refresh uses; both load first and return without a CAS once the box holds the
474
- contribution, so what survives is load traffic, not exchanges. Dispatched indirectly from
475
- `sim_dispatch_args`, the args the coherence reset wrote from the GPU's own alive count.
476
- - **publish** — one thread per emitter row: write the accumulation to `bounds_min` / `bounds_max` of
477
- the state row (`data/PARTICLE_EMITTER_STATE.js` — measured state is GPU-owned state, and on the
478
- record it would be clobbered by the next restage), and reset the accumulator. The reset is
479
- unconditional, which is what lets a row that changes hands start clean.
480
-
481
- **Why a pass and not a tail on emit and simulate.** It was that first. Booking from inside those two
482
- measures no single population — simulate sees last frame's survivors and emit sees this frame's
483
- newborns, so the union spans two frames and is looser than either — and it pays six atomics per
484
- particle inside the pass whose register pressure decides the system's occupancy, to compute
485
- something that needs none of the VM, none of the scene, and five words of the record. Over the alive
486
- list instead, the measurement is of exactly the set that is alive and about to be drawn.
487
-
488
- **Where the accumulator lives.** Six words per emitter row, in the **counters** buffer, after the
489
- counters themselves (`data/PARTICLE_COUNTERS.js`). It has to be an `array<atomic<u32>>` binding, and
490
- the emitter database cannot be it — the passes that read a record read it out of an `array<u32>`
491
- binding, and one buffer under two access types is aliasing. The counters buffer is already bound in
492
- both bounds passes, already atomic, already the system's cross-pass scratch, so a buffer of its own
493
- would add a binding and a growth path to say the same thing. It therefore grows with the emitter
494
- table, by copy — the free-list length in it is GPU-owned and unreconstructable — and the rows the
495
- growth adds are written to the empty interval, because a zeroed accumulator reads back as a
496
- degenerate box at the world origin rather than as an absence.
497
-
498
- **The empty case.** An emitter with no measured particles has infinite, unknown bounds:
499
- `bounds_min = [-Infinity, -Infinity, -Infinity]`, `bounds_max = [Infinity, Infinity, Infinity]`.
500
- The CPU stages this on registration and the publish pass restores it whenever the population is
501
- empty. The tick treats unknown bounds as visible, so a slow emitter can accumulate its first spawn
502
- without prewarm even when its node is offscreen. A depleted population can restart the same way.
503
- AVBOIT occupancy skips unknown bounds because there are no live particles to splat. Both readers
504
- test the sentinel before geometric arithmetic, avoiding NaNs from operations on infinities.
505
- The sentinel check compares integer bits. The publish pass swaps the empty accumulator's loaded
506
- endpoints; neither path constructs an infinite floating-point constant, which WGSL rejects at
507
- shader creation time.
508
- Measured, nonempty populations continue to use their finite bounds for culling. No bounds are authored.
509
- Prewarm controls the initial age distribution only; it is not required for culling correctness.
510
-
511
- ### Pre-warm
512
-
513
- A continuous effect that starts empty grows into its steady state over one particle lifetime, in
514
- front of whoever is watching. `ParticleEmitter.prewarm` — seconds, `0` for off — buys that lifetime
515
- back: an emitter registered with one is run forward by that long before it is first drawn.
516
-
517
- **A warm-up is the ordinary loop, run over a population of its own.** Spawn, init, advance, per
518
- tick, until every emitter in the batch has been running for its own `prewarm`. It is *not* a batch
519
- of particles dumped in one tick and aged: the emitter keeps spawning throughout, what dies is
520
- replaced as it is in a live scene, and whatever drives the rate — a spawn curve, `EMITTER_AGE`, the
521
- program itself — drives it here, because it is the same program being run. What the loop leaves is
522
- the population the emitter would have had.
523
-
524
- **It is a pipeline apart from the main one** (`warmup/`), because the main one is tuned and must not
525
- pay for this. Emit and simulate are the passes whose register pressure sets the system's occupancy,
526
- and their waves are kept program-uniform by the bucketing; a warm-up folded into either would spend
527
- registers on every frame for work that happens on one, and a loop inside a lane would break the
528
- uniform flow across a wave. So the warm-up shares code with the main frame and integrates nothing
529
- into it: the main **emit pass is reused unchanged**, bound to the warm-up's buffers; the tick is a
530
- pass of the warm-up's own over a host-written queue (`shader_particle_warmup_tick`) rather than over
531
- the emitter table; the **advance** is a pass of the warm-up's own (`chunk_particle_warmup_step.js`),
532
- decoding the program section per lane — it reads the raw alive list, so it cannot lean on the
533
- bucketing's wave-uniformity, and its occupancy is its own business. The main passes' compiled code
534
- is byte-identical whether `warmup/` exists or not.
535
-
536
- **The population is transient.** Pool, both alive lists, free list and counters are frame-graph
537
- transients that exist for the loop and are then moved, record by record, into the main pool by the
538
- **migrate** pass — a pop from the main free list per particle, and never a push, so it is the one
539
- point of contact and it is safe by construction. The loop's own free-list traffic never touches the
540
- main list, and the main passes never see a half-warmed particle.
541
-
542
- **Bounded.** `warmup/warmup_schedule.js`: at most `PARTICLE_WARMUP_MAX_TICKS` ticks, no shorter than
543
- `PARTICLE_WARMUP_TICK_SECONDS`, the tick *length* stretching to cover the longest pre-warm in the
544
- batch rather than the count growing to cover it — the count is what the frame pays for, at six or
545
- seven dispatches a tick. An emitter takes part in a tick once the loop's clock is within its own
546
- pre-warm of now, so two emitters queued with different durations each end having run for exactly
547
- their own; the shorter one sat out the start.
548
-
549
- **Jittered, so it does not arrive in bands.** Every particle born in one tick would otherwise reach
550
- the end with exactly that tick's age, and N ticks would read as N cohorts. The advance jitters the
551
- tick per particle (`PARTICLE_WARMUP_DT_JITTER`, hashed from slot and tick — a hash, not a draw, so
552
- the program's own random stream is untouched), and the host jitters each emitter's clock and starting
553
- accumulator (`PARTICLE_WARMUP_EMITTER_JITTER`), so emitters in one batch do not spawn on the same
554
- beat either. A program does not care what `DELTA_TIME` it is handed; the smear is free.
555
-
556
- **Where it sits.** After the frame's own emit and simulate, before the bucketing. Migrate appends to
557
- the same alive list they did, so the bucketing puts the warmed particles in next frame's simulate
558
- list, the bounds pass measures them into their emitter's box this frame, and the draw draws them
559
- this frame. The tick also writes the emitter's
560
- `age` into its state row as it goes, so `EMITTER_AGE` reads as it would have, and the next real tick
561
- adds its `dt` to a row that says `prewarm`.
562
-
563
- **Only on the frame that needs it.** The host knows: an emitter is queued when it is registered with
564
- a `prewarm` (`GPUParticleSystem#sync_membership`), the queue is uploaded and consumed by the frame
565
- that records the loop, and an ordinary frame records nothing of this.
566
-
567
- The editor's CPU preview (`editor/particles/effect/ParticleReferenceSimulation.js`) runs the same
568
- loop over the same schedule before its first frame, and restarts the effect when the duration
569
- changes: a pre-warm is a statement about how an effect starts, so the only way to show one is to
570
- start it.
571
-
572
- ### Culling & attach
573
-
574
- An emitter that sets `EMITTER_FLAG.CULL` has the box on its state row tested against the camera
575
- frustum by the tick pass, with the renderer's shared `chunk_aabb3_intersects_frustum`. The tick does
576
- not measure it — it reads what the bounds passes published at the end of the previous frame — so all
577
- that is in the spawn path is one frustum test. Because the box is where the particles are rather
578
- than where the emitter is, an effect whose particles have drifted on screen keeps spawning even when
579
- its emitter has not. Culled emitters spawn nothing and accrue sleep; on wake the tick adds a
580
- catch-up of at most `max_catchup_seconds` of rate. Live particles keep simulating while their
581
- emitter is culled, so its box keeps moving and keeps being published. HiZ occlusion is not
582
- written.
583
-
584
- ### Two orderings, and why they are separate
585
-
586
- There are two independent permutations of the live particles each frame. They are produced by
587
- different passes into different buffers and must not be conflated or derived from one another.
588
-
589
- **Simulation order** (`coherence/`) — camera-independent, changes only when emitters or their
590
- programs change. The alive list comes out of compaction (`atomicAdd(&counters[ALIVE])`), so its
591
- order says nothing about which emitter a particle belongs to and a wave routinely holds particles
592
- running different VM programs. That costs twice: the interpreter's ~40-case opcode `switch`
593
- diverges, so a wave serially executes the union of the opcodes its lanes want; and `vm_reg[dst]` /
594
- `vm_reg[s0]` index a VGPR-resident register file dynamically, which AMD reaches with
595
- `v_movrels`/`v_movreld` behind a waterfall loop whose trip count is the number of *distinct*
596
- register indices live in the wave. Grouping the wave by program collapses both at once.
597
-
598
- The grouping is also **load-bearing**: the simulate shader reads its section parameters (code offset,
599
- instruction count, constant offset) from the wave's first lane through `subgroupBroadcastFirst`
600
- (`particle_wave_uniform`), which is what lets the driver fetch instruction words through the scalar
601
- unit, branch the opcode switch on a scalar and index the register file through M0 without a
602
- waterfall. A wave holding two programs would run one over the other's particles, so the coherence
603
- pass is part of simulate's correctness. Emit keeps a per-lane decode: its waves span emitters.
604
-
605
- The bucket key is the **program**, not the emitter: `ProgramHeap` deduplicates by content and hands
606
- back a `program_id` the emitter record carries, so emitters compiled from the same graph share
607
- one bucket. Each bucket is padded out to `PARTICLE_SIMULATE_WORKGROUP_SIZE` (64 — a multiple of both
608
- plausible subgroup widths, so no shader needs to query `subgroup_size`), which is what makes the
609
- grouping hold at wave granularity: an unpadded boundary lands mid-wave and that wave spans two
610
- programs again. Padding entries carry `PARTICLE_SIM_PAD_SLOT` and the simulate shader drops them
611
- *before* the compaction atomics, so they never enter the alive or dead list.
612
-
613
- It is an **indirection** list. Particle records never move: slots are referenced by the dead list and
614
- across frames, so relocating a record would leave both pointing at the wrong particle.
615
-
616
- It is a counting sort in three phases, with the padding added: histogram (atomic per bucket) →
617
- round each bin up to a whole workgroup → `graph_prefix_scan_csdldf` (reused as-is — the one scan here
618
- whose only cross-workgroup wait is CSDLDF's bounded spin-then-fallback) → scatter → fill the tails
619
- with the sentinel. Nothing reads back to the CPU; the only host-supplied number is the bucket count,
620
- which is static topology.
621
-
622
- **There is no render order.** The draws walk the alive list exactly as compaction left it, because
623
- everything downstream of them is a sum: the AVBOIT volume accumulates extinction and the four
624
- accumulators accumulate colour, coverage and emission, and no arrangement of the instances changes
625
- any of those. So the system spends nothing on ordering: no key pass, no scan, no scatter, and no
626
- per-particle key and index lists to hold them in.
627
-
628
- The two lists are still distinct and must stay so: the simulation list is permuted and padded with
629
- sentinels, so drawing it would draw them, and depth-ordering it would destroy the coherence. They
630
- share no buffer and no counter slot — `SIM_PADDED` is the simulation list's length, `ALIVE` the
631
- draw's.
632
-
633
- ### Rendering
634
-
635
- A 6-vertex quad is expanded per instance by `chunk_particle_billboard_vertex`, on the basis the
636
- particle's projection mode and orientation compose (below). Fragment: sample atlas (region + flipbook frame), optional
637
- soft-depth fade, optional lighting (below). Soft-depth fading scales both RGB and alpha for
638
- premultiplied inputs; straight-alpha and additive inputs have alpha scaled before premultiplication.
639
-
640
- ### Orientation
641
-
642
- **The record says how a particle is turned; the projection mode says how a view sees it.**
643
-
644
- offset = F(mode, camera, velocity) · Q · (corner.x · size, corner.y · size · stretch, 0)
645
-
646
- - **`Q` is the `orientation` render channel**, a 4-word particle field holding a quaternion (x, y,
647
- z, w) — the only rotation a particle has. It is normalised at the draw, because a program
648
- integrating a spin drifts off unit length and an unnormalised quaternion scales what it turns by
649
- its squared length. The zero quaternion, which is what a particle field nobody wrote holds, reads
650
- as no turn with no branch, and so does an unbound channel.
651
- - **`F` is the mode's frame**, a proper rotation whose x and y are the sprite's right and up and whose
652
- z is the side the mode shows: BILLBOARD the camera's axes; STRETCHED the screen velocity as its up
653
- and the camera's axes at rest; VERTICAL world +Y as up and a horizontal face toward the eye;
654
- HORIZONTAL world +X and -Z, facing +Y; FACING_WORLD the world's axes. Every one of them shows the
655
- sprite the way round BILLBOARD does from its front, rather than its mirror image — three did not,
656
- until the frames were written down as frames.
657
- - **`stretch`** is STRETCHED's `1 + |v_screen|` and 1 otherwise. It belongs to the sprite and
658
- elongates it along its own +Y, before the turn.
659
-
660
- So FACING_WORLD with an orientation is a quad turned any way in the world, BILLBOARD with one is a
661
- quad turned relative to the eye — a turn about +Z is a roll on the screen, which is how the bonfire's
662
- flame, smoke and soot spin — and the other modes take a turn inside their frames with no rule of
663
- their own. The frame is the renderer's and the rotation the record's because the frame is the one
664
- part a view decides: the VM has no camera, one record is drawn by every view, and no quaternion
665
- written into it could face all of them.
666
-
667
- `particle_basis` composes the two once per vertex and feeds both the offset and the normal, so they
668
- cannot disagree about which way a mode faces. **Bounds do not change**: the basis is a rotation, so no
669
- corner reaches past `sqrt(2) · size · stretch`, which is what `particle_billboard_radius` already
670
- states; the bounds pass never reads the channel. **The VM does not change** either: a program writes
671
- a vec4 particle field like any other, and the node library has what it takes to build one —
672
- `quatFromAxisAngle`, `quatLookRotation`, `quatRotate`, `quatMultiply`, `quatIntegrate` (exact for a
673
- constant angular velocity at any step, which the warm-up's stretched ticks need), `sampleRotation`
674
- (uniform) and `emitterRotation` (the node's own rotation; `EMITTER_DIRECTION` is its -Z).
675
-
676
- There is no scalar roll channel beside it. A version-1 `ParticleEffect`, whose bytecode wrote a roll
677
- angle there, loads with its orientation unbound. `ORIENTATION_PLAN_2026_10_15.md` has the
678
- alternatives and why each lost.
679
-
680
- Additive vs. alpha is never a pipeline switch: the shader emits premultiplied RGB with alpha 0
681
- (additive) or alpha a (normal), so one setup covers both and the choice stays per particle and
682
- GPU-driven. Under AVBOIT that choice picks which accumulators the fragment writes.
683
-
684
- ### Drawing: an AVBOIT side channel
685
-
686
- The particles are drawn by the AVBOIT transparency pipeline (`rasterize/native/avboit/`) as a
687
- **side channel** — the contract in `AVBOITSideChannel.js` — so they composite order-independently
688
- with the transparent meshes and against the opaque scene, through the same volume and the same
689
- accumulators. Quads are not packed into meshlets; the orchestrator asks the channel for its
690
- contribution at the three points where the meshlet buckets have just made theirs:
691
-
692
- 1. **occupancy** (`shader_particle_avboit_occupancy`): one thread per emitter row, the emitter's
693
- measured box (the culling box, off the state row) as a view-depth interval, dilated and OR-ed
694
- into the occupancy bits. The interval is the box's centre depth plus or minus its extent along
695
- the view axis — a support projection, not eight transformed corners. Per emitter, not per
696
- particle — no contention, and a particle that strays lands on a run boundary rather than anywhere
697
- wrong. Additive emitters mark nothing. The box matters here even for an emitter that never opted
698
- into culling, which is the second reason it is measured for every emitter rather than only for
699
- the ones that cull.
700
- 2. **splat** (`shader_particle_avboit_splat`): the billboard quads rasterized at the volume's
701
- resolution, coverage from the atlas alpha, extinction through `avboit_splat_slice` — the same
702
- write path as a surface. Additive particles write nothing. A soft-depth particle is faded here
703
- against the same scene depth the draw fades it against, averaged over the voxel's footprint at the
704
- four standard 4× multisample positions (`chunk_particle_avboit_voxel_fade`): what the volume
705
- records has to be the coverage the accumulators are weighted by, or a card that fades out where
706
- it meets the floor goes on darkening the floor through itself. (One read at the voxel's corner
707
- recorded up to 2.4× the coverage on a floor seen at a distance; AVBOIT ledger entry 26.)
708
- 3. **draw** (`shader_particle_avboit_draw`): the quads at internal resolution, depth-tested read-only
709
- against the scene (which lets the soft fade read the same depth), fog composited, transmittance
710
- from the integral. Which of the two it is, is decided by coverage rather than by the blend enum,
711
- so an additive emitter and a premultiplied sprite with no alpha take the same path — as they do
712
- in the splat. A covering particle writes `color / norm / extinction` as a surface does, in-scatter
713
- included, weighted by the coverage it took. An emitting one writes the fourth accumulator,
714
- **emission**: light attenuated by the fog and by the volume in front of it, and carrying none of
715
- the fog's own in-scatter, because the resolve adds emission outside the normalization and the
716
- scene behind already has it. That accumulator is the one addition to AVBOIT's resolve for this.
717
- The whole decision is `chunk_particle_avboit_contribution`, and it is spec'd on the emulator.
718
-
719
- All three read the alive list and the draw args straight from `simulate`; the vertex stage is one
720
- chunk (`chunk_particle_billboard_vertex`), shared by the splat and the draw. `GPUParticleSystem`
721
- implements the three methods; `Renderer.feature_particles_enabled`
722
- runs `execute` before the transparency pass and hands the system to the orchestrator. MBOIT is legacy
723
- and gets no particle path, so under it the particles simulate and are not drawn.
724
-
725
- The particles are no longer the only side channel: the dynamic meshes' (`DynamicMeshAVBOITChannel`,
726
- which trails are drawn through) meets the same three contracts, and the two share what they have in
727
- common rather than each keeping a copy — the box's depth interval and the slice marking
728
- (`aabb3_view_depth_range`, `avboit_occupancy_mark`), the coverage clamp (`avboit_coverage`) and
729
- everything `chunk_particle_avboit_contribution` does after its premultiply
730
- (`avboit_premultiplied_contribution`). A change to any of those changes both channels.
731
-
732
- ### The ECS: `ParticleEffect` + `Transform64`
733
-
734
- `engine/graphics3/GPUParticleEmitterSystem.js` is how a game reaches this system. It is an ordinary
735
- ECS system over the pair `[ParticleEffect, Transform64]` — the component says what an entity emits,
736
- the transform says where — in exactly the shape `DecalSystem` and `MeshSystem` use for their own
737
- content. Construct it with `(graphicsEngine, scene, assetManager)` and add it to the EntityManager.
738
- It is opt-in.
739
-
740
- ```js
741
- entityManager.addSystem(new GPUParticleEmitterSystem(graphics, scene, assetManager));
742
-
743
- new Entity()
744
- .add(new Transform64())
745
- .add(ParticleEffect.from({ ...create_particle_effect({ layout, init, update }), spawn_rate: 400 }))
746
- .build(dataset);
747
- ```
748
-
749
- `engine/graphics/ecs/particles/ParticleEffect.js` carries everything a `ParticleEmitter` node carries
750
- except the placement: the layout and compiled program, the emitter layout and this entity's emitter
751
- field values, the texture URL, the rate, `emitting`, the pre-warm, the flags, the render bindings,
752
- the flipbook grid and the transform `bindings`. Nothing on it depends on a device, which is what
753
- makes it the unit an editor inspects and a prefab holds.
754
-
755
- **All of it serializes, the compiled program included; the transform bindings do not** —
756
- `ParticleEffectSerializationAdapter`, version 3, registered in `populateEngineSerializationRegistry`. The instruction set was designed to be a durable
757
- format, so a level carries bytecode the way it carries a mesh's indices and a shipped build needs no
758
- compiler: authoring is an editor's job and it is not in the runtime. Two things are consequently wire
759
- format and cannot be renumbered without a version bump and an upgrader — the **opcode numbers** in
760
- `isa/ParticleVMISA.js`, and the **order of the fields** in a layout, because a layout assigns its
761
- word offsets from that order: the record's render bindings are particle field offsets, and saved
762
- emitter field values are words in emitter layout order. The
763
- constant pool goes out as `Uint32` bit patterns rather than floats, because that is the form the
764
- pool is already compared and hashed in (`ParticleProgram#constant_words`): `-0` and `+0` are the
765
- same number and different constants, and whether a NaN's payload survives a round trip through a JS
766
- `number` is implementation-defined even though V8 happens to preserve it.
767
-
768
- The system builds one `ParticleEmitter` per linked entity and owns it — registered with the
769
- scene's particle system at the entity's row (`row_of_entity`, held through the scene context's
770
- rows) when the component has a compiled effect and a context exists, its row marked from the
771
- entity's transform on `TRANSFORM64_EVENT_CHANGE`, and retired with the entity. Registration runs at
772
- link, at every `update` and at `FrameStart`, so an entity spawned mid-level stands where it should
773
- from its first frame and a device restart re-registers every entry with the context that replaced
774
- the old one. `emitter_of(entity)` hands the emitter out for what an entity reference cannot express
775
- (asking the particle system about it); `burst(entity, count)` is the entity-addressed one-shot,
776
- queued and drained inside the frame so it works before the first frame has run.
777
-
778
- Component changes are found by **comparison** rather than announced: once a frame each entity's
779
- component values, emitter field values included, are compared against what was last written to its
780
- emitter, and the emitter is marked dirty
781
- only where they differ, so a component nobody touched costs a run of integer comparisons and no
782
- upload. That is what lets gameplay write `effect.spawn_rate = 0` from code that has never heard of
783
- this system.
784
-
785
- A caller with no ECS — a playground, a tool — registers a `ParticleEmitter` with the scene's
786
- `GPUParticleSystem` directly, at a row of an entity it placed itself. Such an emitter has nothing
787
- in the ECS system's atlas and is given the white texel; see below.
788
-
789
- `shade/playground/particle_ecs/` is the worked example: a bonfire whose flame, smoke, embers and soot
790
- are four entities, alongside `ShadedGeometry` and `Light` entities in the same scene.
791
-
792
- The system loads image URLs through `AssetManager` (`GameAssetType.Image`) and shares one patch per
793
- URL. It reuses `TextureAtlas`, whose packer is `MaxRectanglesPacker`. When the last entity releases
794
- an image, its patch is removed. Loads completing after a URL change, removal or shutdown are ignored.
795
- Failed images are reported and remain transparent until the URL changes or the emitter is re-added.
796
- Untextured emitters use a white texel; pending images use a transparent texel.
797
-
798
- At `FrameStart`, after the scene context has established transform rows, the system registers
799
- any emitter not yet registered with the frame's context, uploads changed atlas pixels, and refreshes
800
- the patch region of every emitter the particle system holds — the ones it built get their entity's
801
- image, and any other gets the white texel, because the atlas is the scene's and an emitter it did
802
- not build has nothing in it.
803
- This occurs before particle simulation stages emitter rows. Every repack or resize therefore updates
804
- existing emitters too. The extension enables the renderer's GPU particle feature for its scene;
805
- rendering uses the existing AVBOIT path. The system owns a `GPUTextureContext` for the atlas, using
806
- its resize and cached-view handling and advancing its version after each pixel upload. Shutdown
807
- restores the default atlas and destroys the owned context. A replacement device gets a fresh upload
808
- from the retained CPU atlas.
809
-
810
- `GPUParticleSystem` itself does no asset loading or packing. Standalone callers can still supply
811
- `atlas`/`atlas_sampler` and call `registry.set_atlas_region()` themselves. The `atlas` binding remains
812
- a `GPUTextureView`, obtained from its owner's context; the default white texture is also owned
813
- through `GPUTextureContext`.
814
-
815
- ### Ownership: `GPUParticleSystem`
816
-
817
- Constructed once against a `GPUSceneContext`, in the mould of `ReSTIRDI`. It owns persistent buffers
818
- and only those — a buffer is reserved here if and only if its contents cross the frame boundary:
819
- pool, alive list, dead list, counters, the coherence list, simulate's indirect dispatch args, the
820
- emitter database (both tables), the packed program buffer, the spawn-command ring and the warm-up
821
- queue — and grows the ones that depend on the emitter set inside `execute`. Everything else a frame
822
- touches is the frame graph's, declared by the pass that first writes it: the bucketing scratch, the
823
- draw args, the spawn counts and the wide register file. The default atlas is a 1x1 white texel.
824
- `execute({graph, camera, scene_database, dt})` sweeps the scene, pushes what changed (on a command
825
- context opened only when something needs one), and records the simulation through `graph_particles`,
826
- returning the handles the side channels draw from. It touches no colour target — the drawing is the
827
- transparency pipeline's — and an integrator does not encode a particle pass by hand.
828
-
829
- The frame reads nothing back, but a HUD or an inspector wants to: `read_stats()` copies the counters
830
- and the dispatch args out and returns them named — alive, free, the spawn budget, both list lengths
831
- and the workgroups the next simulate will launch. That is the whole of the readback, and the only
832
- reason the dispatch args carry `COPY_SRC` (the counters and the coherence list carry it to be copied
833
- forward when they grow). Nothing it returns may size a pass: the numbers describe a frame already
834
- submitted, and what sizing from a stale count costs is set out under the finalize pass above. The
835
- draw args are not among them and cannot be — they are the graph's, released after the last draw that
836
- reads them — and their instance count is the alive count regardless.
837
-
838
- ## File layout
839
-
840
- ```
841
- src/shade/renderer/particles/
842
- DESIGN.md
843
- ParticleConstants.js pool stride, reg count, workgroup sizes, limits
844
- isa/ParticleVMISA.js opcode table (single source of truth)
845
- isa/ParticleProgram.js Program value type + (de)serialization to u32
846
- isa/ParticleAssembler.js tiny assembler (build programs in tests/library)
847
- isa/particle_link_global_fields.js global field import indices → section offsets, at placement
848
- vm/ParticleVMReference.js JS reference executor
849
- vm/chunk_particle_vm.js WGSL interpreter CodeChunk (parity target)
850
- vm/chunk_particle_vm_registers_fast.js register file in var<private> — the interpreter that runs
851
- vm/chunk_particle_vm_registers_wide.js register file in storage — the fallback
852
- vm/chunk_particle_vm_wind_field.js the VM's wind read, off the scene's published wind field
853
- shaders/chunk_particle_simulate_step.js one particle's simulate, shared by both simulate shaders
854
- shaders/chunk_particle_emit_step.js one newborn's emit, shared by both emit shaders
855
- graph/ParticleNodeDescription.js node type base (ports + lowering)
856
- graph/ParticleNodeRegistry.js standard node library (a NodeRegistry)
857
- graph/particle_graph_authoring.js authoring sugar over core/model/node-graph
858
- graph/compile_particle_graph.js graph → Program (register alloc, const intern)
859
- layout/ParticleLayout.js particle field schema ↔ record word offsets
860
- data/PARTICLE_EMITTER_STRUCT.js the emitter row + flag/blend/projection encoding
861
- data/particle_emitter_record.js emitter + context -> one row of that struct
862
- data/PARTICLE_EMITTER_STATE.js the GPU-owned per-emitter state row (second table of the database)
863
- data/PARTICLE_DATABASE_SPEC.js the GPUDatabase schema, and the emitter table's descriptor
864
- data/PARTICLE_FIELD_BINDING.js a transform binding row (third table of the database) + channels
865
- data/PARTICLE_COUNTERS.js counter slot indices
866
- data/particle_header.js record header encoding (row + generation), JS + WGSL
867
- coherence/particle_program_buckets.js CPU reference for the simulation-order bucketing
868
- coherence/shader_particle_bucket.js reset / histogram / pad / scatter / fill
869
- coherence/graph_particle_bucket.js frame-graph wiring, around graph_prefix_scan_csdldf
870
- runtime/ProgramHeap.js the one packed [header][constants][code][fields] buffer, allocator-backed
871
- runtime/ParticleGlobalFields.js the global field layout: append-only, addressed from zero
872
- runtime/ProgramPlacement.js where one program sits in it — what an emitter record carries
873
- runtime/ParticleEmitter.js one emitter — authoring data for an effect, with no GPU in it
874
- runtime/GPUParticleEmitterContext.js what a registry assigns one: row, generation, placement, patch
875
- runtime/EmitterRegistry.js GPUDatabase(emitters) + the live emitters + their contexts + the program heap
876
- runtime/create_particle_effect.js layout + INIT/UPDATE graphs → compiled effect
877
- shaders/shader_particle_spawn_commands.js CPU bursts → pending spawns
878
- shaders/shader_particle_emitter_tick.js per-emitter spawn decision
879
- shaders/shader_particle_emit.js per-newborn INIT, sized by the scan
880
- shaders/shader_particle_simulate.js per-particle UPDATE + compaction + orphan kill
881
- shaders/shader_particle_finalize.js reset counters (+ budget) / build indirect args
882
- shaders/shader_particle_field_bind.js bound transforms → their fields, before the tick
883
- shaders/chunk_particle_setup_context.js VM builtins from the emitter's node, + the RNG seed
884
- shaders/chunk_particle_emitter_age.js the tick pass's running age, off the emitter_state table
885
- shaders/chunk_particle_spawn_budget.js take from the frame's spawn budget; both ticks spend it
886
- warmup/warmup_schedule.js ticks and tick length for a batch of pre-warms
887
- warmup/shader_particle_warmup.js init / reset / tick over the host's queue / migrate
888
- warmup/chunk_particle_warmup_step.js one particle's advance: per-lane decode, jittered tick
889
- warmup/shader_particle_warmup_advance.js the advance over the fast file (+ _wide.js)
890
- warmup/graph_particle_warmup.js the loop, recorded on a frame that registered a pre-warm
891
- shaders/chunk_particle_billboard_vertex.js the billboard vertex stage, shared by every draw
892
- shaders/shader_particle_avboit_*.js occupancy / splat / draw through AVBOIT
893
- bounds/* the measured emitter box: accumulate, publish
894
- graph_particles.js the simulation, to the draw args
895
- graph_particles_avboit.js the AVBOIT side channel: occupancy, splat, draw
896
- GPUParticleSystem.js the feature: owns the buffers, sweeps the scene, execute()
897
- *.spec.js co-located tests (JS + emulator + software device)
898
- ```
899
-
900
- ## Testing
901
-
902
- - Pure JS: layout packing, assembler, register allocator, node compiler, heap/emitter/registry
903
- bookkeeping (via `SoftwareGPUDevice`), the header encoding.
904
- - WGSL via emulator: VM opcode parity (JS vs WGSL), curve sampling, atlas+flipbook UV math,
905
- billboard expansion, soft-depth fade, and every compute pass on small fixtures — the tick
906
- (rate integration, generations, pending, budget, culling, page-group walk), emit (expansion over
907
- the scan, node position, orphans), simulate, spawn commands, finalize, bucket. Each such spec
908
- carries its own copy of the scaffolding: a real scene with a `GPUSceneContext` and a real registry,
909
- laid out by `gpu_database_words` the way the GPU holds both databases, so the shaders read rows the
910
- real writers wrote.
911
- - Multi-frame capstone (`particle_lifecycle.spec`): reset → commands → tick → (JS scan) → emit →
912
- simulate across frames, with the GPU deciding to spawn, a CPU burst, the budget, and death.
913
- - Orchestration on `SoftwareGPUDevice`: `GPUParticleSystem.spec` (scene sweep, growth, uploads only
914
- when needed, the ring, the `read_stats` decode), `graph_particles.spec` (pass sequence, indirect
915
- sizing, the two lists, every persistent buffer imported exactly once), and the playground's
916
- `particle_scene.spec` (the integration: geometry → hierarchy → particles → draw).
917
- - Cross-parity meta-tests: every ISA opcode implemented by both executors.
918
-
919
- Regression checks use the existing tools: seeded software-device buffer copies for list growth,
920
- recorded pass ordering for program edits, emulator math for blend/fade and unknown bounds, and real
921
- AssetManager/TextureAtlas bookkeeping with a stub image loader for asynchronous atlas lifetimes.
922
- No full software WebGPU implementation or cross-frame shader simulator is added. The emulator's
923
- `subgroupBroadcastFirst` is identity, so grouping correctness is checked at its input contract.
924
-
925
- End-to-end GPU is out of scope for node (no binary GPU dep); emulator + JS references give the
926
- coverage the brief asks for.
927
-
928
- ### Emulator caveats discovered (all correct on real hardware)
929
-
930
- The in-repo WGSL emulator (unit-test tool, not GPU parity) has limitations the shaders are written
931
- around, documented in the memory note: `switch` case bodies are dropped (use if/else); repeated
932
- read-modify-write of one location across a loop is corrupted (RNG is verified in isolation, not
933
- through the interpreter loop); u32 multiply is imprecise for large operands (PCG can't be checked
934
- through it); `/` is FLOAT division, so integer division only truncates where the result is stored
935
- straight into a typed array — `(n + 63u) / 64u * 64u` reads as 66 there and 64 on a device, and a
936
- binary search's `mid` is a shift, not a divide; matrix column indexing (`m[0]`) is not evaluated,
937
- so an axis scale is read as `m * vec4(1,0,0,0)`. `array<atomic<u32>>` bindings are modeled as
938
- `{value}` objects in tests; a pass that binds the same buffer as plain `array<u32>` gets a
939
- `Uint32Array`. The scene context writes a row's `global` one publish behind (the hierarchy pass
940
- recomposes it on a device), so a node's matrix under the emulator is the one it had when its row
941
- was seeded.
942
-
943
- ### Renderer integration
944
-
945
- Done: `Renderer.feature_particles_enabled` (off by default), `Renderer.particles(scene_ctx)` creating
946
- one system per scene context on first use, `simulate` recorded before the transparency pass and the
947
- system passed to the AVBOIT orchestrator as its side channel.
948
-
949
- ### Lighting
950
-
951
- An emitter flagged `EMITTER_FLAG.LIGHTING` treats its colour as a diffuse albedo; an emitter without
952
- it is its own colour, which is what additive (emissive) emitters want and stay. The flag is read per
953
- fragment from the emitter record, so lit and unlit particles share one pipeline and one draw. All of
954
- it is evaluated per pixel (`chunk_particle_lighting`, the model in `chunk_particle_lighting_model`):
955
- a vertex need not be on screen, and one quad can span the frustum.
956
-
957
- - **Direct light comes from the froxel irradiance volume** (`volumetrics/chunk_volumetrics_irradiance_sh`),
958
- which `shader_volumetrics_build_irradiance` writes: every directional, point and spot light at a
959
- point jittered through the froxel every frame, shadowed, stored as luma SH2 + constant chroma (Metro's L1 YCoCg, 6 coefficients
960
- in two `rgba16float` volumes, since `rg16float` is not a storage format without
961
- `texture-formats-tier1`). The volume rides the orchestrator to side channels as
962
- `volumetrics_irradiance`. Resolution is the fog's froxel grid — 16 px tiles and 64 slices — so light
963
- and shadow edges inside a particle are soft. idTech 8 builds the same volume (Sousa, SIGGRAPH 2025,
964
- p.29); Ghost of Tsushima lights particles from its haze grid the same way. idTech 8 builds it in the
965
- pass that lights its fog, and this one did too until 2026-10-09, dimming each light by the medium
966
- on its way in. That tied particle lighting to the fog's whole chain (media, sun cascades, sky LUTs),
967
- so the fog could not be skipped in a scene without volumes. It now has its own pass with no medium
968
- in it: a lit particle is lit as the surfaces beside it are, which the deferred and forward paths
969
- never dimmed either, and the fog is composited over both. Chosen over per-pixel forward shading (UE's most expensive mode, and a
970
- flat card lit that way goes black from behind), per-vertex/per-particle lighting (the light
971
- clusters are screen-space: a point off screen has no light list, the artefact that moved idTech 8
972
- off it) and a per-particle lighting atlas (DOOM 2016, CoD IW: best-looking, but it needs a
973
- world-space light lookup this engine does not have).
974
- - **The SH is evaluated at the normal and the irradiance clamped at zero.** One light gives
975
- `E max(0, 1/4 + cos/2)`. SH2 cannot keep two lights apart: a light's lobe is `-E/4` facing away
976
- from it, so a light behind a particle subtracts from the ones in front before the clamp sees a
977
- total. Measured on the bonfire before transmission, the moon behind the smoke brought the fire-lit
978
- side down to about half (display red 10 against 21).
979
- - **Chroma has no direction**: two lights of different colours from opposite sides tint both sides
980
- their mix. Measured on the bonfire, the blue moon moves the fire-lit underside of the smoke from
981
- (22.9, 5.3, 1.5) to (15.9, 4.7, 2.6) in display values; a same-coloured moon only brightens it.
982
- - **Indirect light is in the same volume**, whatever the renderer's `indirect_lighting_mode`. The
983
- froxel GI stage (`volumetrics/gi/`) starts the volume with one concrete module per mode — IBL,
984
- Brick4, LPV — each asking its source what radiance arrives at the froxel's centre, as RGB SH2,
985
- and `shader_volumetrics_build_irradiance` adds the lights to that. The particle knows no mode: a new
986
- GI system is one more module. IBL's source is `GPULightCollection.environment_sh`, the
987
- environment projected to radiance SH2 when it changes (`shader_project_environment_sh`); Brick4
988
- interpolates its probes (SH3, truncated) with a zero normal, which weighs every probe of the
989
- brick alike; LPV interpolates the tetrahedron the froxel falls in, and gives nothing outside the
990
- mesh, as the deferred LPV pass does. Measured at 1898x926 (RTX 4090): the IBL module 5-6 us, the
991
- Brick4 module 31 us over an empty map. Reading the environment per pixel instead, as the forward
992
- mesh path does, had been 880 us of lighting's 1,090 us — the particle draw is all overdraw.
993
- - **Back-face transmission** (Persson, "Practical Particle Lighting", GDC 2012, pp.34-37): the same
994
- light evaluated at `-normal`, passed on in proportion to `1 - alpha`. The dense core of a puff
995
- holds light from behind back and its thin edges let it through — smoke backlit by the moon has an
996
- edge instead of being black. It costs nothing measurable: the draw is bound by its texture reads.
997
- - **The normal** is the quad's own plane — the face of the basis its mode and orientation lay it
998
- on — turned to the side the eye is on and bent out toward the corners
999
- (`particle_billboard_normal`), so a sprite shades as a puff, and a turned one as it turns.
1000
- - **The directional light skips the atmosphere's transmittance** the fog applies: meshes are lit by
1001
- `sun.color` as it stands, and a lit particle has to agree with the surface beside it.
1002
-
1003
- Cost, bonfire (~1,300 live, smoke and soot lit), RTX 4090, lit minus unlit p10 in one page: the
1004
- draw +90..275 us at 1898x926 (of ~450) across runs, +0.8..1.2 ms at 3818x2006 (of ~2.1 ms). The fog pass does
1005
- not get slower for the two extra stores — paired against the tree before it, it measured 77 vs 89 us
1006
- and 280 vs 320 us, reason not established. The volume has no temporal filter of its own: with the
1007
- simulation frozen, lit and unlit smoke change equally from frame to frame even with the screen TAA
1008
- off (0.09 vs 0.09 mean display units). That scene has no shadow edge through the smoke, which is
1009
- where a one-sample-per-froxel volume would show it first.
1010
-
1011
- Open: a particle does not shade the fog or other particles (the volume holds no particle density).