@woosh/meep-engine 3.60.4 → 3.60.6
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.
- package/CHANGELOG.md +2621 -0
- package/CONTRIBUTING.md +7 -0
- package/MIGRATION_2_TO_3.md +136 -0
- package/SECURITY.md +4 -0
- package/editor/EditorCameraNavigation.d.ts +2 -1
- package/editor/EditorCameraNavigation.d.ts.map +1 -1
- package/editor/EditorCameraNavigation.js +51 -57
- package/package.json +6 -2
- package/src/engine/EngineHarness.d.ts +3 -9
- package/src/engine/EngineHarness.d.ts.map +1 -1
- package/src/engine/EngineHarness.js +6 -8
- package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts +9 -6
- package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts.map +1 -1
- package/src/engine/graphics/camera/makeOrbitalCameraController.js +44 -71
- package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts +64 -0
- package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts.map +1 -0
- package/src/engine/graphics/camera/orbital/MomentumAxis.js +187 -0
- package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts +86 -0
- package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts.map +1 -0
- package/src/engine/graphics/camera/orbital/OrbitalCameraRig.js +319 -0
- package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts +34 -10
- package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts.map +1 -1
- package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js +116 -30
- package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts +1 -7
- package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.js +1 -37
- package/src/engine/graphics/ecs/water/Water.d.ts +8 -0
- package/src/engine/graphics/ecs/water/Water.d.ts.map +1 -1
- package/src/engine/graphics/ecs/water/Water.js +8 -0
- package/src/engine/graphics3/WaterSystem.d.ts.map +1 -1
- package/src/engine/graphics3/WaterSystem.js +731 -734
- package/src/engine/graphics3/decal/DecalTileShader.d.ts +61 -0
- package/src/engine/graphics3/decal/DecalTileShader.d.ts.map +1 -0
- package/src/engine/graphics3/decal/DecalTileShader.js +138 -0
- package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts +10 -4
- package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts.map +1 -1
- package/src/engine/graphics3/decal/GPUDecalRenderer.js +149 -44
- package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts +25 -0
- package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts.map +1 -0
- package/src/engine/graphics3/decal/chunk_decal_tile_vertex.js +55 -0
- package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts +22 -0
- package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts.map +1 -0
- package/src/engine/graphics3/decal/graph_gather_decal_tiles.js +70 -0
- package/src/engine/graphics3/decal/shader_decal_composite.d.ts +7 -5
- package/src/engine/graphics3/decal/shader_decal_composite.d.ts.map +1 -1
- package/src/engine/graphics3/decal/shader_decal_composite.js +48 -44
- package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts +12 -0
- package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts.map +1 -0
- package/src/engine/graphics3/decal/shader_decal_tile_copy.js +78 -0
- package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts +11 -0
- package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts.map +1 -0
- package/src/engine/graphics3/decal/shader_gather_decal_tiles.js +80 -0
- package/src/engine/input/devices/mouse/MouseEventButtons.d.ts +13 -0
- package/src/engine/input/devices/mouse/MouseEventButtons.d.ts.map +1 -0
- package/src/engine/input/devices/mouse/MouseEventButtons.js +23 -0
- package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts +4 -0
- package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts.map +1 -1
- package/src/engine/input/ecs/controllers/KeyboardCameraController.js +9 -45
- package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
- package/src/shade/playground/water_refraction/README.md +5 -6
- package/src/shade/playground/water_refraction/index.html +1 -1
- package/src/shade/renderer/Renderer.d.ts +11 -0
- package/src/shade/renderer/Renderer.d.ts.map +1 -1
- package/src/shade/renderer/Renderer.js +50 -6
- package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts +40 -80
- package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts.map +1 -1
- package/src/shade/renderer/camera/orbital/OrbitalCameraController.js +197 -147
- package/src/shade/renderer/fluid/GPUFluidCascade.d.ts +3 -3
- package/src/shade/renderer/fluid/GPUFluidCascadeStack.d.ts +1 -1
- package/src/shade/renderer/fluid/forcing/GPUFluidForcing.d.ts +6 -6
- package/src/shade/renderer/fluid/pressure/GPUFluidPressureSolve.d.ts +1 -1
- package/src/shade/renderer/fluid/query/GPUFluidQueries.d.ts +1 -1
- package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts +28 -45
- package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts.map +1 -1
- package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +111 -119
- package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts +22 -3
- package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts.map +1 -1
- package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.js +84 -3
- package/src/shade/renderer/geometry/virtual/proxy/vgeo_proxy_triangle_limit.d.ts +19 -0
- package/src/shade/renderer/geometry/virtual/proxy/vgeo_proxy_triangle_limit.d.ts.map +1 -0
- package/src/shade/renderer/geometry/virtual/proxy/vgeo_proxy_triangle_limit.js +25 -0
- package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts +31 -0
- package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts.map +1 -0
- package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.js +343 -0
- package/src/shade/renderer/geometry/virtual/runtime/VirtualGeometry.d.ts +15 -0
- package/src/shade/renderer/geometry/virtual/runtime/VirtualGeometry.d.ts.map +1 -1
- package/src/shade/renderer/geometry/virtual/runtime/VirtualGeometry.js +38 -0
- package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts +17 -0
- package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts.map +1 -0
- package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.js +29 -0
- package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.d.ts.map +1 -1
- package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.js +7 -13
- package/src/shade/renderer/geometry/virtual/sdf/VGeoSDFProxies.d.ts +28 -59
- package/src/shade/renderer/geometry/virtual/sdf/VGeoSDFProxies.d.ts.map +1 -1
- package/src/shade/renderer/geometry/virtual/sdf/VGeoSDFProxies.js +84 -789
- package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.d.ts.map +1 -1
- package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.js +1 -8
- package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts +7 -6
- package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts.map +1 -1
- package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.js +12 -11
- package/src/shade/renderer/global_illumination/probe_clipmap/brick/mesh/chunk_mesh_brick_instance.d.ts +2 -2
- package/src/shade/renderer/global_illumination/probe_clipmap/brick/mesh/chunk_mesh_brick_instance.js +2 -2
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts +1 -4
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts.map +1 -1
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.js +1 -10
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts +5 -15
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts.map +1 -1
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.js +6 -34
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts.map +1 -1
- package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.js +1 -7
- package/src/shade/renderer/hiz/HZBFootprint.d.ts +14 -0
- package/src/shade/renderer/hiz/HZBFootprint.d.ts.map +1 -0
- package/src/shade/renderer/hiz/HZBFootprint.js +16 -0
- package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts +10 -0
- package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts.map +1 -0
- package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.js +11 -0
- package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts +10 -0
- package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts.map +1 -0
- package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.js +11 -0
- package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts +0 -7
- package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
- package/src/shade/renderer/hiz/HierarchicalZBuffer.js +405 -350
- package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts +12 -0
- package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts.map +1 -0
- package/src/shade/renderer/hiz/chunk_hzb_footprint_x.js +27 -0
- package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts +12 -0
- package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts.map +1 -0
- package/src/shade/renderer/hiz/chunk_hzb_footprint_y.js +27 -0
- package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts +14 -0
- package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts.map +1 -0
- package/src/shade/renderer/hiz/hzb_footprint_texels.js +34 -0
- package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts +0 -5
- package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
- package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +37 -57
- package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts +11 -0
- package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts.map +1 -0
- package/src/shade/renderer/hiz/{shader_hzb_reduce_mip0_clip.js → shader_hzb_reduce_mip0.js} +120 -111
- package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
- package/src/shade/renderer/material/GPUMaterialManager.d.ts +11 -1
- package/src/shade/renderer/material/GPUMaterialManager.d.ts.map +1 -1
- package/src/shade/renderer/material/GPUMaterialManager.js +16 -8
- package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts +12 -12
- package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
- package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +17 -118
- package/src/shade/renderer/postprocess/gtao/GTAO.d.ts +7 -3
- package/src/shade/renderer/postprocess/gtao/GTAO.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/gtao/GTAO.js +26 -13
- package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.d.ts +14 -0
- package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.js +196 -68
- package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.js +42 -28
- package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.js +429 -414
- package/src/shade/renderer/postprocess/gtao/shader_gtao_spatial_denoise.d.ts +16 -0
- package/src/shade/renderer/postprocess/gtao/shader_gtao_spatial_denoise.d.ts.map +1 -0
- package/src/shade/renderer/postprocess/gtao/shader_gtao_spatial_denoise.js +156 -0
- package/src/shade/renderer/scene/GPUSceneContext.d.ts +2 -2
- package/src/shade/renderer/scene/GPUSceneContext.js +5 -5
- package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts.map +1 -1
- package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.js +7 -2
- package/src/shade/renderer/water/GPUWaterRenderer.d.ts +0 -6
- package/src/shade/renderer/water/GPUWaterRenderer.d.ts.map +1 -1
- package/src/shade/renderer/water/GPUWaterRenderer.js +12 -17
- package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts +35 -0
- package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts.map +1 -0
- package/src/shade/renderer/water/medium/graph_water_meshlets.js +89 -0
- package/src/shade/renderer/water/query/GPUWaterQueries.d.ts +1 -1
- package/src/shade/renderer/water/shader_water.d.ts.map +1 -1
- package/src/shade/renderer/water/shader_water.js +22 -13
- package/src/shade/renderer/water/shader_water_avboit.d.ts.map +1 -1
- package/src/shade/renderer/water/shader_water_avboit.js +7 -7
- package/src/shade/renderer/water/surface/graph_water_tessellation.d.ts +1 -1
- package/src/shade/renderer/water/surface/graph_water_tessellation.js +1 -1
- package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +9 -1
- package/editor/REWORK_PLAN_2026_08.md +0 -692
- package/src/CODEC_LAYOUT_VERIFICATION.md +0 -419
- package/src/CORRECTNESS_HUNT_2026_07_24.md +0 -142
- package/src/DUPLICATION_AUDIT_2026_08_07/00_core_reuse_surface.md +0 -738
- package/src/DUPLICATION_AUDIT_2026_08_07/A_core_math_geom.md +0 -79
- package/src/DUPLICATION_AUDIT_2026_08_07/B_core_data.md +0 -93
- package/src/DUPLICATION_AUDIT_2026_08_07/C_gfx_render.md +0 -80
- package/src/DUPLICATION_AUDIT_2026_08_07/D_gfx_features.md +0 -164
- package/src/DUPLICATION_AUDIT_2026_08_07/E_physics.md +0 -69
- package/src/DUPLICATION_AUDIT_2026_08_07/F_sound.md +0 -110
- package/src/DUPLICATION_AUDIT_2026_08_07/G_net_save.md +0 -129
- package/src/DUPLICATION_AUDIT_2026_08_07/H_engine_misc.md +0 -72
- package/src/DUPLICATION_AUDIT_2026_08_07/I_generation_view.md +0 -74
- package/src/DUPLICATION_AUDIT_2026_08_07/K_crosscutting.md +0 -169
- package/src/DUPLICATION_AUDIT_2026_08_07.md +0 -254
- package/src/DUPLICATION_AUDIT_REMEDIATION.md +0 -250
- package/src/DUPLICATION_REVIEW_2026_10_24.md +0 -1072
- package/src/REVIEW_2026_08_06/adv-core.md +0 -511
- package/src/REVIEW_2026_08_06/adv-gfx.md +0 -480
- package/src/REVIEW_2026_08_06/adv-view.md +0 -446
- package/src/REVIEW_2026_08_06/api-core.md +0 -296
- package/src/REVIEW_2026_08_06/api-engine.md +0 -195
- package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +0 -507
- package/src/REVIEW_2026_08_06/core-binary.md +0 -348
- package/src/REVIEW_2026_08_06/core-collection.md +0 -376
- package/src/REVIEW_2026_08_06/core-math.md +0 -221
- package/src/REVIEW_2026_08_06/core-process.md +0 -266
- package/src/REVIEW_2026_08_06/core-science.md +0 -223
- package/src/REVIEW_2026_08_06/engine-ai-nav.md +0 -279
- package/src/REVIEW_2026_08_06/engine-animation.md +0 -167
- package/src/REVIEW_2026_08_06/engine-asset.md +0 -166
- package/src/REVIEW_2026_08_06/engine-ecs.md +0 -920
- package/src/REVIEW_2026_08_06/engine-input.md +0 -197
- package/src/REVIEW_2026_08_06/engine-misc.md +0 -248
- package/src/REVIEW_2026_08_06/engine-terrain.md +0 -52
- package/src/REVIEW_2026_08_06/generation.md +0 -176
- package/src/REVIEW_2026_08_06/geom-2d.md +0 -434
- package/src/REVIEW_2026_08_06/geom-3d.md +0 -117
- package/src/REVIEW_2026_08_06/gfx-gi.md +0 -143
- package/src/REVIEW_2026_08_06/gfx-render.md +0 -740
- package/src/REVIEW_2026_08_06/gfx-texture.md +0 -690
- package/src/REVIEW_2026_08_06/prior-criticals.md +0 -49
- package/src/REVIEW_2026_08_06/prior-highs.md +0 -138
- package/src/REVIEW_2026_08_06/test-gaps.md +0 -364
- package/src/REVIEW_2026_08_06/view.md +0 -667
- package/src/REVIEW_2026_08_06.md +0 -610
- package/src/core/binary/lz4/LZ4_HIGH_COMPRESSION_PLAN_2026_09_14.md +0 -542
- package/src/core/bvh2/bvh3/NOTES.md +0 -2
- package/src/core/bvh8/NOTES.md +0 -63
- package/src/core/cache/wtinylfu/benchmark/CacheWTinylfu.bench.md +0 -243
- package/src/core/color/COLOR_FIX_PLAN_2026_08_28.md +0 -417
- package/src/core/color/COLOR_REVIEW_2026_08_28.md +0 -779
- package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +0 -433
- package/src/core/geom/3d/atlas/ATLAS_PORT_PLAN.md +0 -486
- package/src/core/geom/3d/atlas/BFF_PLAN.md +0 -174
- package/src/core/geom/3d/atlas/REVIEW_02.md +0 -392
- package/src/core/geom/3d/gjk/GJK_REVIEW_NOTES.md +0 -146
- package/src/core/geom/3d/gjk/NOTES.md +0 -8
- package/src/core/geom/3d/topology/TOPO_MESH_RETIREMENT_PLAN.md +0 -135
- package/src/core/geom/GEOMETRY_REVIEW_002_API_ISSUES.md +0 -381
- package/src/core/geom/GEOMETRY_REVIEW_002_NAMING.md +0 -684
- package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +0 -554
- package/src/core/model/reactive/evaluation/PREDICATE_APPLICABILITY_PROPOSAL.md +0 -446
- package/src/engine/REVIEW_2026_07_03.md +0 -211
- package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +0 -815
- package/src/engine/control/first-person/DESIGN.md +0 -658
- package/src/engine/control/first-person/DESIGN_COLLISION.md +0 -382
- package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +0 -563
- package/src/engine/control/first-person/TODO.md +0 -160
- package/src/engine/ecs/ASYNC_SYSTEMS_PROPOSAL.md +0 -531
- package/src/engine/ecs/HIERARCHY_REVIEW.md +0 -717
- package/src/engine/ecs/async/WORKER_SYSTEM_PLAN.md +0 -262
- package/src/engine/ecs/ik/PLAN.md +0 -179
- package/src/engine/ecs/storage/binary/SERIALIZATION_COVERAGE.md +0 -272
- package/src/engine/ecs/storage/binary/object-v2/DESIGN.md +0 -199
- package/src/engine/ecs/transform/TRANSFORM64_MIGRATION_PLAN_2026_09_04.md +0 -521
- package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +0 -463
- package/src/engine/graphics/ecs/camera/pp/NOTES.md +0 -48
- package/src/engine/graphics/render/frame_graph/CORRECTNESS_FIXES_2026_09_04.md +0 -659
- package/src/engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md +0 -235
- package/src/engine/graphics/sh3/path_tracer/REVIEW_001_ACTION_PLAN.md +0 -490
- package/src/engine/graphics/texture/sampler/compare/FLIP_IMPLEMENTATION_PLAN.md +0 -466
- package/src/engine/input/GAMEPAD_API_PROPOSAL.md +0 -717
- package/src/engine/input/ecs/ism/ISM_DESIGN.md +0 -1523
- package/src/engine/input/ecs/ism/ISM_IMPLEMENTATION_PLAN.md +0 -627
- package/src/engine/navigation/mesh/OVERLAPPING_SURFACES_2026_08_25.md +0 -661
- package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +0 -229
- package/src/engine/network/CONGESTION_CONTROL.md +0 -87
- package/src/engine/network/MESSAGE_TRANSPORT.md +0 -364
- package/src/engine/network/SMOOTHNESS_DESIGN.md +0 -400
- package/src/engine/physics/cloth/MEASUREMENTS.md +0 -5304
- package/src/engine/physics/cloth/PLAN.md +0 -3056
- package/src/engine/physics/fluid/GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md +0 -1382
- package/src/engine/physics/fluid/REVIEW_02_PLAN.md +0 -243
- package/src/engine/physics/fluid/solver/NOTES.md +0 -46
- package/src/engine/sound/SOUND_EMITTER_RETIREMENT_PLAN.md +0 -536
- package/src/engine/sound/simulation/IMPLEMENTATION_PLAN.md +0 -432
- package/src/engine/sound/simulation/PROBE_PLACEMENT.md +0 -250
- package/src/engine/sound/simulation/PROBE_TRANSFER_RESEARCH.md +0 -322
- package/src/engine/sound/simulation/REFLECTION_IR_PLAN.md +0 -261
- package/src/engine/sound/simulation/VISIBILITY_GRAPH_PLAN.md +0 -121
- package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +0 -999
- package/src/format/image/avif/DECISIONS.md +0 -545
- package/src/format/image/jpeg/NOTES.md +0 -3
- package/src/format/scene/gltf/MESHOPT_COMPRESSION_PLAN.md +0 -814
- package/src/generation/COORDINATES.md +0 -54
- package/src/generation/REVIEW_01_ACTION_PLAN.md +0 -628
- package/src/generation/foliage/ANNUAL_SHOOTS_BRIEF_2026_10_25.md +0 -220
- package/src/generation/foliage/CROWN_FILLING_BRIEF_2026_10_26.md +0 -172
- package/src/generation/foliage/CROWN_FULLNESS_BRIEF_2026_10_26.md +0 -86
- package/src/generation/foliage/FOLIAGE_DESIGN.md +0 -547
- package/src/generation/foliage/FOLIAGE_PLAN.md +0 -198
- package/src/generation/foliage/LEAF_ARCHITECTURE_BRIEF_2026_10_26.md +0 -158
- package/src/generation/foliage/LOD_REWORK_BRIEF_2026_10_27.md +0 -521
- package/src/generation/foliage/REAL_TIME_TREES_BRIEF_2026_10_27.md +0 -86
- package/src/generation/foliage/SELF_ORGANIZING_FRAMEWORK_BRIEF_2026_10_26.md +0 -347
- package/src/generation/foliage/SHOOT_SYSTEMS_ALL_SPECIES_BRIEF_2026_10_26.md +0 -248
- package/src/generation/foliage/SPEEDTREE_COMPARISON_2026_10_24.md +0 -694
- package/src/generation/foliage/TWO_FRONT_ENDS_BRIEF_2026_10_28.md +0 -552
- package/src/shade/RENDERER_CONTRACT.md +0 -697
- package/src/shade/SHADER_F16_PROPOSAL_2026_10_24.md +0 -263
- package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +0 -569
- package/src/shade/device/PASS_ENCODER_PROPOSAL_2026_08_31.md +0 -755
- package/src/shade/device/mock/EXTENSION_PROPOSAL_2026_08_22.md +0 -458
- package/src/shade/device/mock/IMPLEMENTATION_PLAN.md +0 -255
- package/src/shade/device/mock/REVIEW_2026_08_22.md +0 -527
- package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +0 -894
- package/src/shade/device/timing/profile/SGPT_FORMAT.md +0 -345
- package/src/shade/renderer/NOTES.md +0 -78
- package/src/shade/renderer/PLAN.md +0 -8
- package/src/shade/renderer/animation/ADDITIVE_BLENDING_PLAN.md +0 -145
- package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +0 -357
- package/src/shade/renderer/atmosphere/NOTES.md +0 -6
- package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts +0 -76
- package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts.map +0 -1
- package/src/shade/renderer/camera/orbital/MomentumAxis.js +0 -164
- package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts +0 -45
- package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts.map +0 -1
- package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.js +0 -66
- package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts +0 -98
- package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts.map +0 -1
- package/src/shade/renderer/camera/orbital/OrbitalCameraInput.js +0 -184
- package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts +0 -175
- package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts.map +0 -1
- package/src/shade/renderer/camera/orbital/OrbitalCameraRig.js +0 -411
- package/src/shade/renderer/camera/orbital/README.md +0 -81
- package/src/shade/renderer/camera/orbital/index.d.ts +0 -6
- package/src/shade/renderer/camera/orbital/index.d.ts.map +0 -1
- package/src/shade/renderer/camera/orbital/index.js +0 -9
- package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +0 -964
- package/src/shade/renderer/fluid/GPU_FLUID_OCCUPANCY_AND_SOTA_REVIEW_2026_10_26.md +0 -361
- package/src/shade/renderer/geometry/bvh/BLAS_REFIT_BATCH_PLAN.md +0 -619
- package/src/shade/renderer/geometry/meshlet/COMPACTION_PLAN_2026_09_16.md +0 -674
- package/src/shade/renderer/geometry/sdf/NOTES.md +0 -6
- package/src/shade/renderer/geometry/virtual/ATTRIBUTE_PLAN_2026_09_18.md +0 -540
- package/src/shade/renderer/geometry/virtual/MICRON_COMPARISON_2026_08_20.md +0 -305
- package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +0 -913
- package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +0 -787
- package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +0 -481
- package/src/shade/renderer/geometry/virtual/VK_LOD_CLUSTERS_COMPARISON_2026_08_20.md +0 -301
- package/src/shade/renderer/geometry/virtual/WEDGE_PLAN_2026_09_18.md +0 -448
- package/src/shade/renderer/geometry/virtual/runtime/CULL_PLAN_2026_09_16.md +0 -385
- package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +0 -656
- package/src/shade/renderer/geometry/virtual/runtime/REVIEW_LEDGER.md +0 -308
- package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +0 -657
- package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN_2026_09_14.md +0 -551
- package/src/shade/renderer/geometry/virtual/runtime/SHADOW_PLAN_2026_09_28.md +0 -529
- package/src/shade/renderer/geometry/virtual/sdf/VGEO_SDF.d.ts +0 -47
- package/src/shade/renderer/geometry/virtual/sdf/VGEO_SDF.d.ts.map +0 -1
- package/src/shade/renderer/geometry/virtual/sdf/VGEO_SDF.js +0 -80
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_collect.d.ts +0 -16
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_collect.d.ts.map +0 -1
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_collect.js +0 -67
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_decode.d.ts +0 -17
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_decode.d.ts.map +0 -1
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_decode.js +0 -145
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_traverse.d.ts +0 -12
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_traverse.d.ts.map +0 -1
- package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_traverse.js +0 -105
- package/src/shade/renderer/global_illumination/lpv/NOTES.md +0 -3
- package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_CLIPMAP_PROPOSAL_2026_10_27.md +0 -4306
- package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md +0 -410
- package/src/shade/renderer/global_illumination/sharc/NOTES.md +0 -8
- package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +0 -1369
- package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +0 -202
- package/src/shade/renderer/gpu_primitive/indirect/NOTES.md +0 -94
- package/src/shade/renderer/hiz/NOTES.md +0 -3
- package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts +0 -8
- package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +0 -1
- package/src/shade/renderer/light/cluster/NOTES.md +0 -11
- package/src/shade/renderer/light/cluster/PROPOSAL_ALGORITHM_2026_10_16.md +0 -524
- package/src/shade/renderer/light/cluster/PROPOSAL_HIERARCHICAL_2026_10_16.md +0 -1101
- package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts +0 -24
- package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts.map +0 -1
- package/src/shade/renderer/material/resident/shader_resident_texture_means.js +0 -120
- package/src/shade/renderer/particles/DESIGN.md +0 -1011
- package/src/shade/renderer/particles/EMITTER_GLOBAL_STATE_PLAN_2026_10_29.md +0 -280
- package/src/shade/renderer/particles/ORIENTATION_PLAN_2026_10_15.md +0 -453
- package/src/shade/renderer/path_tracer/PATH_TRACED_MEDIA_PLAN_2026_10_02.md +0 -520
- package/src/shade/renderer/postprocess/bloom/NOTES.md +0 -6
- package/src/shade/renderer/postprocess/fsr/NOTES.md +0 -4
- package/src/shade/renderer/postprocess/gtao/NOTES.md +0 -6
- package/src/shade/renderer/postprocess/nss/NOTES.md +0 -332
- package/src/shade/renderer/postprocess/perspective/NOTES.md +0 -3
- package/src/shade/renderer/postprocess/sscs/NOTES.md +0 -5
- package/src/shade/renderer/postprocess/ssr/NOTES.md +0 -14
- package/src/shade/renderer/postprocess/ssr/REPROJECTION_PROPOSAL.md +0 -286
- package/src/shade/renderer/postprocess/ssr/prefilter/NOTES.md +0 -39
- package/src/shade/renderer/postprocess/taa/NOTES.md +0 -11
- package/src/shade/renderer/postprocess/upscale-dither/NOTES.md +0 -78
- package/src/shade/renderer/postprocess/vrs/NOTES.md +0 -4
- package/src/shade/renderer/rasterize/native/avboit/AVBOIT_LEDGER.md +0 -1218
- package/src/shade/renderer/rasterize/native/avboit/AVBOIT_PLAN_2026_09_03.md +0 -600
- package/src/shade/renderer/restir/reservoir/NOTES.md +0 -6
- package/src/shade/renderer/shader/chunk/atmosphere/NOTES.md +0 -6
- package/src/shade/renderer/shader/chunk/brdf/FRESNEL_BRIEF_2026_10_16.md +0 -391
- package/src/shade/renderer/shader/chunk/color/tonemap/ACES_TONE_MAPPING_BRIEF_2026_10_17.md +0 -153
- package/src/shade/renderer/shader/chunk/color/tonemap/HDR_TONE_MAPPING_FORMS_BRIEF_2026_10_16.md +0 -322
- package/src/shade/renderer/shader/chunk/color/tonemap/SDR_TONE_MAPPING_BRIEF_2026_10_16.md +0 -247
- package/src/shade/renderer/shader/chunk/fsr/NOTES.md +0 -3
- package/src/shade/renderer/shader/chunk/fsr/spd/NOTES.md +0 -4
- package/src/shade/renderer/shader/chunk/geometry/ray/NOTES.md +0 -4
- package/src/shade/renderer/shader/chunk/material/MULTISCATTER.md +0 -159
- package/src/shade/renderer/shader/chunk/mboit/NOTES.md +0 -38
- package/src/shade/renderer/shader/chunk/spherical_harmonics/l1i/NOTES.md +0 -3
- package/src/shade/renderer/shader/chunk/spherical_harmonics/sampling/NOTES.md +0 -8
- package/src/shade/renderer/shader/compiler/SHADER_SWITCH_PROPOSAL_2026_10_16.md +0 -927
- package/src/shade/renderer/shadow/contact/FINDINGS.md +0 -413
- package/src/shade/renderer/shadow/map/csm/NOTES.md +0 -5
- package/src/shade/renderer/shadow/ray/NOTES.md +0 -31
- package/src/shade/renderer/shadow/sdf/NOTES.md +0 -9
- package/src/shade/renderer/shadow/sdf/brixelizer/NOTES.md +0 -5
- package/src/shade/renderer/shadow/sdf/cascade/NOTES.md +0 -9
- package/src/shade/renderer/shadow/vsm/NOTES.md +0 -12
- package/src/shade/renderer/shadow/vsm/PLAN.md +0 -442
- package/src/shade/renderer/shadow/vsm/REVIEW_LEDGER.md +0 -43
- package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +0 -2444
- package/src/shade/renderer/texture/mipmaps/NOTES.md +0 -3
- package/src/shade/renderer/volumetrics/NOTES.md +0 -1329
- package/src/shade/renderer/volumetrics/VOLUMETRIC_NOISE_PLAN_2026_10_15.md +0 -336
- package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts +0 -23
- package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts.map +0 -1
- package/src/shade/renderer/water/medium/GPUWaterBodies.js +0 -75
- 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).
|