@woosh/meep-engine 3.53.0 → 3.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +1 -1
  2. package/src/core/math/noise/worley_noise_2d.d.ts +14 -0
  3. package/src/core/math/noise/worley_noise_2d.d.ts.map +1 -0
  4. package/src/core/math/noise/worley_noise_2d.js +74 -0
  5. package/src/engine/graphics/texture/atlas/CachingTextureAtlas.d.ts +6 -1
  6. package/src/engine/graphics/texture/atlas/CachingTextureAtlas.d.ts.map +1 -1
  7. package/src/engine/graphics/texture/atlas/CachingTextureAtlas.js +363 -325
  8. package/src/engine/graphics/texture/atlas/ManagedTextureAtlas.d.ts +93 -14
  9. package/src/engine/graphics/texture/atlas/ManagedTextureAtlas.d.ts.map +1 -1
  10. package/src/engine/graphics/texture/atlas/ManagedTextureAtlas.js +322 -183
  11. package/src/engine/graphics/texture/atlas/ReferencedTextureAtlas.d.ts +9 -0
  12. package/src/engine/graphics/texture/atlas/ReferencedTextureAtlas.d.ts.map +1 -1
  13. package/src/engine/graphics/texture/atlas/ReferencedTextureAtlas.js +110 -83
  14. package/src/engine/graphics/texture/atlas/SpriteAtlas.d.ts +42 -0
  15. package/src/engine/graphics/texture/atlas/SpriteAtlas.d.ts.map +1 -0
  16. package/src/engine/{graphics3/decal/DecalAtlas.js → graphics/texture/atlas/SpriteAtlas.js} +202 -194
  17. package/src/engine/graphics/texture/atlas/SpriteAtlasFill.d.ts +12 -0
  18. package/src/engine/graphics/texture/atlas/SpriteAtlasFill.d.ts.map +1 -0
  19. package/src/engine/graphics/texture/atlas/SpriteAtlasFill.js +30 -0
  20. package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts +7 -0
  21. package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
  22. package/src/engine/graphics/texture/atlas/TextureAtlas.js +11 -1
  23. package/src/engine/graphics/texture/atlas/sprite_atlas_border.d.ts +52 -0
  24. package/src/engine/graphics/texture/atlas/sprite_atlas_border.d.ts.map +1 -0
  25. package/src/engine/{graphics3/decal/decal_atlas_border.js → graphics/texture/atlas/sprite_atlas_border.js} +69 -69
  26. package/src/engine/graphics3/DecalSystem.d.ts.map +1 -1
  27. package/src/engine/graphics3/DecalSystem.js +447 -450
  28. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +9 -22
  29. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
  30. package/src/engine/graphics3/GPUParticleEmitterSystem.js +892 -951
  31. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  32. package/src/generation/foliage/FOLIAGE_DESIGN.md +2 -1
  33. package/src/generation/foliage/config/BarkMaterialConfig.d.ts +4 -3
  34. package/src/generation/foliage/config/BarkMaterialConfig.d.ts.map +1 -1
  35. package/src/generation/foliage/config/BarkMaterialConfig.js +6 -6
  36. package/src/generation/foliage/config/FoliageBarkType.d.ts +11 -0
  37. package/src/generation/foliage/config/FoliageBarkType.d.ts.map +1 -0
  38. package/src/generation/foliage/config/FoliageBarkType.js +16 -0
  39. package/src/generation/foliage/config/FoliageMaterialConfig.d.ts +58 -5
  40. package/src/generation/foliage/config/FoliageMaterialConfig.d.ts.map +1 -1
  41. package/src/generation/foliage/config/FoliageMaterialConfig.js +279 -181
  42. package/src/generation/foliage/index.d.ts +2 -1
  43. package/src/generation/foliage/index.js +44 -43
  44. package/src/generation/foliage/material/make_bark_material.d.ts.map +1 -1
  45. package/src/generation/foliage/material/make_bark_material.js +23 -95
  46. package/src/generation/foliage/material/make_foliage_material.d.ts +29 -3
  47. package/src/generation/foliage/material/make_foliage_material.d.ts.map +1 -1
  48. package/src/generation/foliage/material/make_foliage_material.js +605 -359
  49. package/src/generation/foliage/mesh/branch_collar_project.d.ts.map +1 -1
  50. package/src/generation/foliage/mesh/branch_collar_project.js +205 -203
  51. package/src/generation/foliage/mesh/foliage_collect_attachments.d.ts +15 -0
  52. package/src/generation/foliage/mesh/foliage_collect_attachments.d.ts.map +1 -1
  53. package/src/generation/foliage/mesh/foliage_collect_attachments.js +569 -502
  54. package/src/generation/foliage/mesh/tree_mesh_build_bark.d.ts.map +1 -1
  55. package/src/generation/foliage/mesh/tree_mesh_build_bark.js +1049 -1047
  56. package/src/generation/foliage/mesh/tree_mesh_build_foliage.d.ts +2 -1
  57. package/src/generation/foliage/mesh/tree_mesh_build_foliage.d.ts.map +1 -1
  58. package/src/generation/foliage/mesh/tree_mesh_build_foliage.js +454 -415
  59. package/src/generation/foliage/presets/make_tree_species_preset.d.ts.map +1 -1
  60. package/src/generation/foliage/presets/make_tree_species_preset.js +870 -837
  61. package/src/generation/foliage/skeleton/crown_envelope_evaluate.js +121 -121
  62. package/src/generation/foliage/skeleton/tree_skeleton_compute_pipe_radii.d.ts +10 -7
  63. package/src/generation/foliage/skeleton/tree_skeleton_compute_pipe_radii.d.ts.map +1 -1
  64. package/src/generation/foliage/skeleton/tree_skeleton_compute_pipe_radii.js +206 -179
  65. package/src/generation/foliage/skeleton/tree_skeleton_grow.d.ts.map +1 -1
  66. package/src/generation/foliage/skeleton/tree_skeleton_grow.js +1871 -1104
  67. package/src/shade/playground/foliage_wind/foliage_world.d.ts +6 -0
  68. package/src/shade/playground/foliage_wind/foliage_world.d.ts.map +1 -1
  69. package/src/shade/playground/foliage_wind/foliage_world.js +602 -593
  70. package/src/shade/playground/foliage_wind/index.html +260 -254
  71. package/src/shade/playground/foliage_wind/main.js +275 -263
  72. package/src/shade/renderer/fluid/GPUFluidCascade.d.ts +3 -3
  73. package/src/shade/renderer/fluid/GPUFluidCascadeStack.d.ts +1 -1
  74. package/src/shade/renderer/fluid/forcing/GPUFluidForcing.d.ts +6 -6
  75. package/src/shade/renderer/fluid/pressure/GPUFluidPressureSolve.d.ts +1 -1
  76. package/src/shade/renderer/fluid/query/GPUFluidQueries.d.ts +1 -1
  77. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +1 -1
  78. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts +3 -3
  79. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  80. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +350 -294
  81. package/src/shade/renderer/hiz/generate_downsampler_code.d.ts +0 -2
  82. package/src/shade/renderer/hiz/generate_downsampler_code.d.ts.map +1 -1
  83. package/src/shade/renderer/hiz/generate_downsampler_code.js +99 -100
  84. package/src/shade/renderer/hiz/hzb_pick_alignment.d.ts +15 -0
  85. package/src/shade/renderer/hiz/hzb_pick_alignment.d.ts.map +1 -0
  86. package/src/shade/renderer/hiz/hzb_pick_alignment.js +148 -0
  87. package/src/shade/renderer/hiz/hzb_plan_reduction_steps.d.ts +28 -0
  88. package/src/shade/renderer/hiz/hzb_plan_reduction_steps.d.ts.map +1 -0
  89. package/src/shade/renderer/hiz/hzb_plan_reduction_steps.js +58 -0
  90. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts +5 -5
  91. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
  92. package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +57 -67
  93. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts +3 -3
  94. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +1 -1
  95. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.js +111 -112
  96. package/src/shade/renderer/hiz/shader_hzb_spd.d.ts +5 -0
  97. package/src/shade/renderer/hiz/shader_hzb_spd.d.ts.map +1 -0
  98. package/src/shade/renderer/hiz/shader_hzb_spd.js +160 -0
  99. package/src/shade/renderer/light/GPULightCollection.d.ts +8 -0
  100. package/src/shade/renderer/light/GPULightCollection.d.ts.map +1 -1
  101. package/src/shade/renderer/light/GPULightCollection.js +67 -0
  102. package/src/shade/renderer/light/environment/FAR_FIELD_IRRADIANCE_SH_RESOURCE.d.ts +7 -0
  103. package/src/shade/renderer/light/environment/FAR_FIELD_IRRADIANCE_SH_RESOURCE.d.ts.map +1 -0
  104. package/src/shade/renderer/light/environment/FAR_FIELD_IRRADIANCE_SH_RESOURCE.js +13 -0
  105. package/src/shade/renderer/light/environment/shader_project_far_field_irradiance_sh.d.ts +10 -0
  106. package/src/shade/renderer/light/environment/shader_project_far_field_irradiance_sh.d.ts.map +1 -0
  107. package/src/shade/renderer/light/environment/shader_project_far_field_irradiance_sh.js +72 -0
  108. package/src/shade/renderer/light/world_cluster/WORLD_LIGHT_CLUSTER.d.ts +39 -0
  109. package/src/shade/renderer/light/world_cluster/WORLD_LIGHT_CLUSTER.d.ts.map +1 -0
  110. package/src/shade/renderer/light/world_cluster/WORLD_LIGHT_CLUSTER.js +90 -0
  111. package/src/shade/renderer/light/world_cluster/chunk_world_light_cluster_lookup.d.ts +12 -0
  112. package/src/shade/renderer/light/world_cluster/chunk_world_light_cluster_lookup.d.ts.map +1 -0
  113. package/src/shade/renderer/light/world_cluster/chunk_world_light_cluster_lookup.js +56 -0
  114. package/src/shade/renderer/light/world_cluster/graph_build_world_light_clusters.d.ts +31 -0
  115. package/src/shade/renderer/light/world_cluster/graph_build_world_light_clusters.d.ts.map +1 -0
  116. package/src/shade/renderer/light/world_cluster/graph_build_world_light_clusters.js +196 -0
  117. package/src/shade/renderer/light/world_cluster/shader_world_cluster_assign_lights.d.ts +10 -0
  118. package/src/shade/renderer/light/world_cluster/shader_world_cluster_assign_lights.d.ts.map +1 -0
  119. package/src/shade/renderer/light/world_cluster/shader_world_cluster_assign_lights.js +163 -0
  120. package/src/shade/renderer/light/world_cluster/shader_world_cluster_cull_lights.d.ts +4 -0
  121. package/src/shade/renderer/light/world_cluster/shader_world_cluster_cull_lights.d.ts.map +1 -0
  122. package/src/shade/renderer/light/world_cluster/shader_world_cluster_cull_lights.js +137 -0
  123. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  124. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  125. package/src/shade/renderer/particles/GPUParticleSystem.js +4 -3
  126. package/src/shade/renderer/particles/graph_particles_avboit.d.ts +4 -3
  127. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  128. package/src/shade/renderer/particles/graph_particles_avboit.js +28 -11
  129. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +9 -13
  130. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  131. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +25 -20
  132. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.d.ts.map +1 -1
  133. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.js +21 -38
  134. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  135. package/src/shade/renderer/shader/chunk/depth/chunk_query_depth_from_screen_space_bb.d.ts.map +1 -1
  136. package/src/shade/renderer/shader/chunk/depth/chunk_query_depth_from_screen_space_bb.js +62 -62
  137. package/src/shade/renderer/shader/chunk/random/lowbias/chunk_hash_lowbias32_vec2u.d.ts +6 -0
  138. package/src/shade/renderer/shader/chunk/random/lowbias/chunk_hash_lowbias32_vec2u.d.ts.map +1 -0
  139. package/src/shade/renderer/shader/chunk/random/lowbias/chunk_hash_lowbias32_vec2u.js +19 -0
  140. package/src/shade/renderer/shader/chunk/screen_space/chunk_ffx_sssr_get_mip_resolution.d.ts.map +1 -1
  141. package/src/shade/renderer/shader/chunk/screen_space/chunk_ffx_sssr_get_mip_resolution.js +16 -10
  142. package/src/shade/renderer/shader/chunk/screen_space/chunk_ffx_sssr_hierarchical_raymarch.d.ts.map +1 -1
  143. package/src/shade/renderer/shader/chunk/screen_space/chunk_ffx_sssr_hierarchical_raymarch.js +118 -117
  144. package/src/shade/renderer/texture/atlas/GPUTileAtlas3D.d.ts +41 -7
  145. package/src/shade/renderer/texture/atlas/GPUTileAtlas3D.d.ts.map +1 -1
  146. package/src/shade/renderer/texture/atlas/GPUTileAtlas3D.js +162 -58
  147. package/src/shade/renderer/volumetrics/GPUSceneVolumetrics.js +1 -1
  148. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts +10 -12
  149. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  150. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +583 -606
  151. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_sample.d.ts +24 -0
  152. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_sample.d.ts.map +1 -0
  153. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi_sample.d.ts.map +1 -1
  154. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +1 -1
  155. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +640 -640
  156. package/src/shade/renderer/volumetrics/irradiance_clipmap/GPUIrradianceClipmap.d.ts +113 -0
  157. package/src/shade/renderer/volumetrics/irradiance_clipmap/GPUIrradianceClipmap.d.ts.map +1 -0
  158. package/src/shade/renderer/volumetrics/irradiance_clipmap/GPUIrradianceClipmap.js +484 -0
  159. package/src/shade/renderer/volumetrics/irradiance_clipmap/IRRADIANCE_CLIPMAP.d.ts +50 -0
  160. package/src/shade/renderer/volumetrics/irradiance_clipmap/IRRADIANCE_CLIPMAP.d.ts.map +1 -0
  161. package/src/shade/renderer/volumetrics/irradiance_clipmap/IRRADIANCE_CLIPMAP.js +60 -0
  162. package/src/shade/renderer/volumetrics/irradiance_clipmap/IRRADIANCE_CLIPMAP_STRUCT.d.ts +25 -0
  163. package/src/shade/renderer/volumetrics/irradiance_clipmap/IRRADIANCE_CLIPMAP_STRUCT.d.ts.map +1 -0
  164. package/src/shade/renderer/volumetrics/irradiance_clipmap/IRRADIANCE_CLIPMAP_STRUCT.js +47 -0
  165. package/src/shade/renderer/volumetrics/irradiance_clipmap/IrradianceCascadePlacement.d.ts +74 -0
  166. package/src/shade/renderer/volumetrics/irradiance_clipmap/IrradianceCascadePlacement.d.ts.map +1 -0
  167. package/src/shade/renderer/volumetrics/irradiance_clipmap/IrradianceCascadePlacement.js +173 -0
  168. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_gi_sample.d.ts +26 -0
  169. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_gi_sample.d.ts.map +1 -0
  170. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_gi_sample.js +83 -0
  171. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_local_light.d.ts +31 -0
  172. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_local_light.d.ts.map +1 -0
  173. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_local_light.js +113 -0
  174. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_sample.d.ts +121 -0
  175. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_sample.d.ts.map +1 -0
  176. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_sample.js +356 -0
  177. package/src/shade/renderer/volumetrics/irradiance_clipmap/shader_irradiance_clipmap_build.d.ts +16 -0
  178. package/src/shade/renderer/volumetrics/irradiance_clipmap/shader_irradiance_clipmap_build.d.ts.map +1 -0
  179. package/src/shade/renderer/volumetrics/irradiance_clipmap/shader_irradiance_clipmap_build.js +191 -0
  180. package/src/shade/renderer/volumetrics/shader_volumetrics_classify_froxels.d.ts +16 -0
  181. package/src/shade/renderer/volumetrics/shader_volumetrics_classify_froxels.d.ts.map +1 -0
  182. package/src/shade/renderer/volumetrics/shader_volumetrics_lighting_regimes.d.ts +23 -0
  183. package/src/shade/renderer/volumetrics/shader_volumetrics_lighting_regimes.d.ts.map +1 -0
  184. package/src/shade/renderer/wind/GPUSceneWind.d.ts +1 -1
  185. package/src/shade/renderer/wind/GPUSceneWind.js +1 -1
  186. package/src/engine/graphics3/decal/DecalAtlasFill.js +0 -32
  187. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_froxel_centre_ws.js +0 -49
  188. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.js +0 -50
  189. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi_sample.js +0 -105
  190. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.js +0 -80
  191. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi.js +0 -51
  192. package/src/shade/renderer/volumetrics/gi/volumetrics_gi_resources.js +0 -39
  193. package/src/shade/renderer/volumetrics/graph_build_froxel_irradiance.js +0 -121
  194. package/src/shade/renderer/volumetrics/shader_volumetrics_build_irradiance.js +0 -237
@@ -1,808 +1,749 @@
1
- import { assert } from "../../core/assert.js";
2
- import { warn_limited } from "../../shade/util/warn_limited.js";
3
- import { FramePhase } from "../../shade/renderer/extension/FramePhase.js";
4
- import { RenderExtension } from "../../shade/renderer/extension/RenderExtension.js";
5
- import { ParticleEmitter } from "../../shade/renderer/particles/runtime/ParticleEmitter.js";
6
- import { GPUTextureContext } from "../../shade/renderer/texture/GPUTextureContext.js";
7
- import { System } from "../ecs/System.js";
8
- import Name from "../ecs/name/Name.js";
9
- import { Transform64 } from "../ecs/transform/Transform64.js";
10
- import { TRANSFORM64_EVENT_CHANGE } from "../ecs/transform/TRANSFORM64_EVENT_CHANGE.js";
11
- import { row_of_entity } from "../../shade/renderer/scene/rows/GPUSceneRows.js";
12
- import { GameAssetType } from "../asset/GameAssetType.js";
13
- import { ImageRGBADataLoader } from "../asset/loaders/image/ImageRGBADataLoader.js";
14
- import { ParticleEffect } from "../graphics/ecs/particles/ParticleEffect.js";
15
- import { DecalAtlas } from "./decal/DecalAtlas.js";
16
- import { DecalAtlasFill } from "./decal/DecalAtlasFill.js";
17
- import { TextureFilterType } from "../../shade/renderer/texture/TextureFilterType.js";
18
- import { Sampler2D } from "../graphics/texture/sampler/Sampler2D.js";
19
- import { sampler2d_ensure_uint8_RGBA } from "../graphics/texture/sampler/sampler2d_ensure_uint8_RGBA.js";
20
-
21
- /**
22
- * Bursts {@link GPUParticleEmitterSystem#burst} will hold before it starts dropping them. Reached
23
- * only when frames have stopped arriving — see the call site.
24
- *
25
- * @type {number}
26
- */
27
- const BURST_QUEUE_LIMIT = 256;
28
-
29
- /**
30
- * @typedef {object} ParticleAtlasTexture
31
- * @property {number} references Number of entities using this URL.
32
- * @property {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch|null} patch Null while loading or after failure.
33
- */
34
-
35
- /**
36
- * @typedef {object} ParticleEmitterEntry
37
- * @property {number} entity
38
- * @property {ParticleEffect} effect the entity's component — what to emit
39
- * @property {Transform64} transform the entity's transform — where to emit it
40
- * @property {ParticleEmitter|null} emitter the emitter, once the effect has a program to run
41
- * @property {import("../../shade/renderer/scene/GPUSceneContext.js").GPUSceneContext|null} context
42
- * the scene context the emitter is registered with, at the entity's row; null until a frame has
43
- * registered it, or after a device restart took that context away
44
- * @property {function():void} on_transform_changed
45
- * @property {string|null} url last observed {@link ParticleEffect#texture}
46
- * @property {ParticleAtlasTexture|null} texture shared image record, or null for an untextured effect
47
- */
48
-
49
- /**
50
- * Draws the game's GPU particle effects: one emitter per entity carrying
51
- * {@link ../graphics/ecs/particles/ParticleEffect.js ParticleEffect} and
52
- * {@link ../ecs/transform/Transform64.js Transform64}.
53
- *
54
- * ```js
55
- * entityManager.addSystem(new GPUParticleEmitterSystem(graphics, scene, assetManager));
56
- *
57
- * new Entity()
58
- * .add(new Transform64())
59
- * .add(ParticleEffect.from({ ...create_particle_effect({ layout, init, update }), spawn_rate: 400 }))
60
- * .build(dataset);
61
- * ```
62
- *
63
- * **The entity is the emitter.** The component says what it emits and the transform says where, the
64
- * same division every other placed thing in this engine uses — a `Decal`, an `SGMesh`, a `Light`.
65
- * Which means an emitter is spawned, moved, parented, attached to a bone, saved and destroyed by
66
- * exactly the machinery that does those things for everything else, and a particle effect stops
67
- * being a special case that needs its own scene-graph call.
68
- *
69
- * ## What this system does with the pair
70
- *
71
- * It builds a {@link ParticleEmitter} per entity and owns it: the emitter is registered with the
72
- * scene's particle system at the entity's row when the component has a compiled effect, so it
73
- * stands where the entity stands, carries whatever the component currently says, and is retired
74
- * when the entity goes. The emitter is not hidden — {@link emitter_of} hands it out — but it is
75
- * not the interface either: write to the component and the next frame carries it.
76
- *
77
- * Changes are found by **comparison** rather than announced. Once a frame each entity's
78
- * fields are compared against what was last written to its emitter, and the emitter is marked dirty only
79
- * where they differ; `EmitterRegistry` then restages exactly the rows that moved. A component nobody
80
- * touched costs a run of integer comparisons and no upload, so there is no change signal to fire and
81
- * none to forget. What that buys is that gameplay can write `effect.spawn_rate = 0` anywhere,
82
- * including from code that has never heard of this system.
83
- *
84
- * ## It owns the scene's sprite atlas
85
- *
86
- * Every {@link ParticleEffect#texture} is loaded through the supplied `AssetManager` and packed into
87
- * one {@link TextureAtlas} shared by the scene, one patch per distinct URL however many entities
88
- * name it, reference-counted so the last entity to let go is what unpacks it. A `FrameStart`
89
- * extension publishes the pixels and every current patch region before the renderer records the
90
- * particle passes — every repack moves UVs that nothing else changed, so the regions are rewritten
91
- * whole rather than when a URL changes. Pending and failed images sample a transparent texel;
92
- * effects with no image sample a white one.
93
- *
94
- * An emitter in this scene that this system did not build — one registered with the particle
95
- * system directly — gets the white texel. The atlas is the scene's and that emitter has nothing in
96
- * it, so the alternative is not "its own texture" but a UV rectangle from somebody else's image.
97
- *
98
- * ## What it is not
99
- *
100
- * It does not simulate anything and does not record a particle pass. `GPUParticleSystem` does both,
101
- * inside the renderer, driven from the emitter table these rows are in — see
102
- * `shade/renderer/particles/DESIGN.md`. This system's whole contribution is membership, placement,
103
- * the fields, and the sprite sheet.
104
- */
105
- export class GPUParticleEmitterSystem extends System {
106
- dependencies = [ParticleEffect, Transform64];
107
-
108
- /**
109
- * CPU atlas, exposed for inspection. Owned by this system.
110
- * @type {TextureAtlas}
111
- */
112
- atlas = new DecalAtlas(DecalAtlasFill.EdgeExtend, 64);
113
-
114
- /** @type {import("./GraphicsEngine.js").GraphicsEngine} */
115
- #graphics;
116
- /** @type {import("../../shade/renderer/scene/Scene.js").Scene} */
117
- #scene;
118
- /** @type {import("../asset/AssetManager.js").AssetManager} */
119
- #assets;
120
- /** @type {Map<number, ParticleEmitterEntry>} */
121
- #entries = new Map();
122
- /**
123
- * The same entries by the emitter they own, so that the emitters this system did not build can
124
- * be told apart from the ones it did without a linear search per emitter per frame.
125
- * @type {Map<ParticleEmitter, ParticleEmitterEntry>}
126
- */
127
- #by_emitter = new Map();
128
- /** @type {Map<string, ParticleAtlasTexture>} */
129
- #textures = new Map();
130
- /**
131
- * Bursts asked for through {@link burst} and not yet handed to the particle system. Flat pairs
132
- * of `entity, count`, drained every frame.
133
- * @type {number[]}
134
- */
135
- #bursts = [];
136
- /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
137
- #white;
138
- /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
139
- #transparent;
140
- /** @type {GPUParticleAtlasExtension|null} */
141
- #extension = null;
142
- /** @type {GPUTextureContext|null} */
143
- #texture = null;
144
- /** @type {GPUTextureView|null} */
145
- #view = null;
146
- /** @type {GPUDevice|null} */
147
- #device = null;
148
- /** @type {number} */
149
- #uploaded_version = -1;
150
- /** @type {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem|null} */
151
- #particles = null;
152
- /** @type {GPUTextureView|null} */
153
- #previous_atlas = null;
154
- /** @type {Float32Array} */
155
- #region = new Float32Array(4);
156
-
157
- /**
158
- * @param {import("./GraphicsEngine.js").GraphicsEngine} graphics
159
- * @param {import("../../shade/renderer/scene/Scene.js").Scene} scene scene the emitters are put
160
- * in, and whose particle atlas this system owns
161
- * @param {import("../asset/AssetManager.js").AssetManager} assets
162
- */
163
- constructor(graphics, scene, assets) {
164
- super();
165
-
166
- assert.defined(graphics, "graphics");
167
- assert.equal(scene.isScene, true, "scene.isScene !== true");
168
- assert.defined(assets, "assets");
169
-
170
- this.#graphics = graphics;
171
- this.#scene = scene;
172
- this.#assets = assets;
173
-
174
- this.#create_defaults();
175
- }
176
-
177
- /**
178
- * The scene the emitters are put in. Received rather than created: meshes and lights come from
179
- * systems of their own, and all of them have to be writing into one scene for any of it to be
180
- * visible together.
181
- *
182
- * @returns {import("../../shade/renderer/scene/Scene.js").Scene}
183
- */
184
- get scene() {
185
- return this.#scene;
186
- }
187
-
188
- /**
189
- * The emitters this system currently holds, in no particular order. Read-only — they follow
190
- * the entities.
191
- *
192
- * @returns {ParticleEmitter[]}
193
- */
194
- get emitters() {
195
- return Array.from(this.#by_emitter.keys());
196
- }
197
-
198
- /**
199
- * The emitter built for an entity.
200
- *
201
- * Handed out for what an entity reference cannot express — asking the particle system about
202
- * it — and for nothing routine: the component is how an effect is changed, and an emitter
203
- * written to behind the component's back is overwritten by the next comparison.
204
- *
205
- * @param {number} entity
206
- * @returns {ParticleEmitter|null} `null` for an entity this system has not linked, or one whose
207
- * component has no compiled effect yet
208
- */
209
- emitter_of(entity) {
210
- const entry = this.#entries.get(entity);
211
-
212
- if (entry === undefined) {
213
- return null;
214
- }
215
-
216
- return entry.emitter;
217
- }
218
-
219
- /**
220
- * Ask an entity's emitter for `count` particles on top of its rate, on the next frame.
221
- *
222
- * The one-shot half of the API, and the only thing here the CPU asks for rather than describes:
223
- * an impact, a footfall, a muzzle flash. It goes through the same GPU path as continuous
224
- * emission — the tick pass folds the burst into the emitter's accumulator, and the pool budget
225
- * and the culling apply — so it is a request rather than a guarantee, and an entity whose
226
- * emitter is not live by the time the frame starts is silently skipped.
227
- *
228
- * @param {number} entity
229
- * @param {number} count
230
- */
231
- burst(entity, count) {
232
- assert.isNonNegativeInteger(count, "count");
233
-
234
- if (count === 0) {
235
- return;
236
- }
237
-
238
- if (this.#bursts.length >= BURST_QUEUE_LIMIT * 2) {
239
- /*
240
- The queue is drained by a frame, so it only grows without bound when frames have stopped
241
- arriving — a scene nobody draws, or a device that never came up — and gameplay carries on
242
- asking. Dropping is right either way: nothing is going to spawn these, and a burst is a
243
- request about the moment it was made.
244
-
245
- The text is the key `warn_limited` counts on, so it carries no entity: one message per
246
- limit, not one per caller that happens to be unlucky.
247
- */
248
- warn_limited(
249
- `GPUParticleEmitterSystem: ${BURST_QUEUE_LIMIT} bursts are queued and no frame has `
250
- + "drained them; dropping the rest"
251
- );
252
-
253
- return;
254
- }
255
-
256
- this.#bursts.push(entity, count);
257
- }
258
-
259
- /**
260
- * @param {import("../ecs/EntityManager.js").EntityManager} entityManager
261
- * @returns {Promise<void>}
262
- */
263
- async startup(entityManager) {
264
- this.entityManager = entityManager;
265
-
266
- // the scene's rows are this dataset's entities; the first system to place one says so
267
- const dataset = entityManager.dataset;
268
- const scene = this.#scene;
269
-
270
- assert.ok(
271
- scene.dataset === null || scene.dataset === undefined || scene.dataset === dataset,
272
- 'the scene is bound to another dataset'
273
- );
274
-
275
- scene.dataset = dataset;
276
-
277
- if (!this.#assets.hasLoaderForType(GameAssetType.Image)) {
278
- await this.#assets.registerLoader(GameAssetType.Image, new ImageRGBADataLoader());
279
- }
280
-
281
- this.#extension = new GPUParticleAtlasExtension(this);
282
-
283
- this.#graphics.add_extension(this.#extension);
284
- }
285
-
286
- /** @returns {Promise<void>} */
287
- async shutdown() {
288
- if (this.#extension !== null) {
289
- this.#graphics.remove_extension(this.#extension);
290
- this.#extension = null;
291
- }
292
-
293
- for (const entity of Array.from(this.#entries.keys())) {
294
- this.#drop(entity);
295
- }
296
-
297
- this.#detach();
298
-
299
- this.#texture?.destroy();
300
- this.#texture = null;
301
- this.#view = null;
302
- this.#device = null;
303
- this.#uploaded_version = -1;
304
- this.#bursts.length = 0;
305
-
306
- // An in-flight load checks its record's identity here before adding a patch.
307
- this.#textures.clear();
308
-
309
- this.atlas.reset();
310
- this.#create_defaults();
311
- }
312
-
313
- /**
314
- * @param {ParticleEffect} effect
315
- * @param {Transform64} transform
316
- * @param {number} entity
317
- */
318
- link(effect, transform, entity) {
319
- /** @type {ParticleEmitterEntry} */
320
- const entry = {
321
- entity,
322
- effect,
323
- transform,
324
- emitter: null,
325
- context: null,
326
- on_transform_changed: null,
327
- url: null,
328
- texture: null
329
- };
330
-
331
- this.#entries.set(entity, entry);
332
-
333
- /*
334
- The emitter is placed the moment its transform moves rather than on a poll. A Transform64
335
- has no signals of its own, so the announcement comes from the dataset: whoever wrote the
336
- transform sends TRANSFORM64_EVENT_CHANGE and this wakes on it. Coarser than watching
337
- position, rotation and scale separately — a scale write wakes a placement that reads the
338
- whole transform anyway — and that is the trade the type makes everywhere.
339
- */
340
- entry.on_transform_changed = () => this.#place(entry);
341
-
342
- this.entityManager.dataset.addEntityEventListener(
343
- entity, TRANSFORM64_EVENT_CHANGE, entry.on_transform_changed
344
- );
345
-
346
- // Everything else — building the emitter, acquiring the texture — is what a frame does
347
- // anyway, and doing it here as well would be a second copy of it that has to stay in step.
348
- this.#sync(entry);
349
-
350
- // Registered now when there is a context to register with, so an entity spawned mid-level
351
- // stands where it should from its first frame.
352
- this.#register_all();
353
- }
354
-
355
- /**
356
- * @param {ParticleEffect} effect
357
- * @param {Transform64} transform
358
- * @param {number} entity
359
- */
360
- unlink(effect, transform, entity) {
361
- this.#drop(entity);
362
- }
363
-
364
- /**
365
- * Bring every emitter in line with its entity: build emitters for effects that have arrived,
366
- * retire emitters for effects that have gone, copy changed fields onto the ones that stay, and
367
- * follow the texture URLs.
368
- *
369
- * @param {number} [time_delta_seconds]
370
- * @returns {void}
371
- */
372
- update(time_delta_seconds) {
373
- for (const entry of this.#entries.values()) {
374
- this.#sync(entry);
375
- }
376
-
377
- /*
378
- Registered here as well as at FrameStart, so an emitter whose effect arrived during a
379
- simulation tick has its row written by the next frame's build rather than the one after:
380
- a frame builds the scene context before FrameStart runs, so a row acquired at FrameStart
381
- is not written until the frame after.
382
- */
383
- this.#register_all();
384
-
385
- this.atlas.update();
386
- }
387
-
388
- /**
389
- * The particle system a scene context's frames run, or null while there is no renderer.
390
- *
391
- * @param {import("../../shade/renderer/scene/GPUSceneContext.js").GPUSceneContext} context
392
- * @returns {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem|null}
393
- */
394
- #particles_of(context) {
395
- const renderer = this.#graphics.renderer;
396
-
397
- if (renderer === null || renderer === undefined) {
398
- return null;
399
- }
400
-
401
- return renderer.particles(context);
402
- }
403
-
404
- /**
405
- * {@link #register} against whatever context and particle system the scene has right now, if
406
- * it has any.
407
- */
408
- #register_all() {
409
- const context = this.#graphics.scene_context(this.#scene);
410
-
411
- if (context === null) {
412
- return;
413
- }
414
-
415
- const particles = this.#particles_of(context);
416
-
417
- if (particles !== null) {
418
- this.#register(particles, context);
419
- }
420
- }
421
-
422
- /**
423
- * Publish the atlas at FrameStart — after the scene context has established transform rows, and
424
- * before the renderer records the particle passes that read the emitter table.
425
- *
426
- * {@link update} is called from here as well as by the entity manager, because a frame must not
427
- * depend on a simulation tick having preceded it: an editor draws on demand, and a paused game
428
- * still renders. It is idempotent and costs a comparison per emitter when nothing has changed,
429
- * which is what makes running it twice a frame the cheap way to be right rather than a saving
430
- * worth chasing.
431
- *
432
- * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
433
- * @returns {void}
434
- */
435
- prepare(frame) {
436
- if (frame.view.scene.scene !== this.#scene) {
437
- return;
438
- }
439
-
440
- this.update();
441
-
442
- const renderer = this.#graphics.renderer;
443
-
444
- /*
445
- The renderer's GPU particle feature is this system's to turn on, and it is turned on every
446
- frame rather than once, because a device restart builds a renderer that has never heard of
447
- it. The consequence is worth stating: while this system is in the profile there is no way to
448
- switch GPU particles off through the renderer, because this would switch them back on at the
449
- next FrameStart. Switching them off means taking the system out — or, for one effect,
450
- `ParticleEffect#emitting`.
451
- */
452
- renderer.feature_particles_enabled = true;
453
-
454
- const particles = renderer.particles(frame.view.scene);
455
-
456
- if (particles !== this.#particles) {
457
- this.#detach();
458
- this.#particles = particles;
459
- this.#previous_atlas = particles.atlas;
460
- }
461
-
462
- this.#register(particles, frame.view.scene);
463
-
464
- this.#upload(renderer.graphics);
465
-
466
- particles.atlas = this.#view;
467
-
468
- this.#publish_regions(particles);
469
- this.#drain_bursts(particles);
470
- }
471
-
472
- /**
473
- * Register every emitter not yet registered with this context, at its entity's row.
474
- *
475
- * From {@link update} and {@link prepare} rather than at link, because a row needs a scene
476
- * context and the context needs a device, and neither is a system's to wait for: a level loads
477
- * before the device is up, and a device restart replaces the context under every entry. An
478
- * entry registered with a context that is no longer this one is registered again with the one
479
- * that is. Idempotent, and a comparison per entry when nothing has changed.
480
- *
481
- * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
482
- * @param {import("../../shade/renderer/scene/GPUSceneContext.js").GPUSceneContext} scene_context
483
- */
484
- #register(particles, scene_context) {
485
- const rows = scene_context.rows;
486
-
487
- for (const entry of this.#entries.values()) {
488
- if (entry.emitter === null || entry.context === scene_context) {
489
- continue;
490
- }
491
-
492
- rows.acquire(entry.entity);
493
-
494
- particles.add_emitter(entry.emitter, row_of_entity(entry.entity));
495
-
496
- entry.context = scene_context;
497
- }
498
- }
499
-
500
- /**
501
- * Point every emitter of this scene at its patch in the atlas.
502
- *
503
- * Every region, every frame: growth also moves the UVs of images that did not change, and there
504
- * is no signal for that which an emitter could subscribe to.
505
- *
506
- * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
507
- */
508
- #publish_regions(particles) {
509
- const registry = particles.registry;
510
- const emitters = registry.emitters;
511
- const region = this.#region;
512
-
513
- for (let i = 0; i < emitters.length; i++) {
514
- const emitter = emitters[i];
515
- const entry = this.#by_emitter.get(emitter);
516
-
517
- // Not ours: it has nothing in this atlas, so it gets the one patch that is true for
518
- // everybody — a white texel, which draws it as a flat quad of its own colour.
519
- const patch = entry === undefined || entry.texture === null
520
- ? this.#white
521
- : entry.texture.patch ?? this.#transparent;
522
-
523
- const uv = patch.uv;
524
-
525
- if (patch === this.#white || patch === this.#transparent) {
526
- // Sample the texel centre everywhere, avoiding interpolation into the gutter.
527
- region[0] = uv.position.x + uv.size.x * 0.5;
528
- region[1] = uv.position.y + uv.size.y * 0.5;
529
- region[2] = 0;
530
- region[3] = 0;
531
- } else {
532
- region[0] = uv.position.x;
533
- region[1] = uv.position.y;
534
- region[2] = uv.size.x;
535
- region[3] = uv.size.y;
536
- }
537
-
538
- registry.set_atlas_region(emitter, region);
539
- }
540
- }
541
-
542
- /**
543
- * Hand this frame's bursts to the particle system, now that membership has been swept and every
544
- * emitter that can have a row has one.
545
- *
546
- * The queue is emptied whether or not each burst could be delivered. A burst is a request about
547
- * *this* moment — a footfall, an impact — and holding one for an emitter that is not live yet
548
- * would deliver it seconds late, at whatever unrelated moment the emitter finally arrived.
549
- *
550
- * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
551
- */
552
- #drain_bursts(particles) {
553
- const bursts = this.#bursts;
554
-
555
- for (let i = 0; i < bursts.length; i += 2) {
556
- const entry = this.#entries.get(bursts[i]);
557
-
558
- if (entry === undefined || entry.emitter === null) {
559
- continue;
560
- }
561
-
562
- if (particles.registry.context(entry.emitter) === undefined) {
563
- continue;
564
- }
565
-
566
- particles.spawn(entry.emitter, bursts[i + 1]);
567
- }
568
-
569
- bursts.length = 0;
570
- }
571
-
572
- /**
573
- * One entity's emitter, brought in line with its component.
574
- *
575
- * @param {ParticleEmitterEntry} entry
576
- */
577
- #sync(entry) {
578
- const effect = entry.effect;
579
-
580
- /*
581
- Ahead of everything the effect gates, and deliberately: an image takes a fetch and a decode
582
- to arrive, and an entity that names one has said what it wants to draw with whether or not
583
- its program has finished compiling. Waiting would mean a first frame of transparent
584
- particles for every effect that arrives with its texture.
585
- */
586
- if (entry.url !== effect.texture) {
587
- this.#release(entry.url);
588
-
589
- entry.url = effect.texture;
590
- entry.texture = entry.url === null ? null : this.#acquire(entry.url);
591
- }
592
-
593
- if (effect.layout === null || effect.program === null) {
594
- // No effect (yet, or any more). An entity whose effect is still compiling is ordinary,
595
- // and so is one whose effect was cleared — both mean "emits nothing", and the emitter
596
- // is retired rather than keeping the last program it happened to have.
597
- this.#detach_emitter(entry);
598
-
599
- return;
600
- }
601
-
602
- if (entry.emitter === null) {
603
- const emitter = new ParticleEmitter();
604
-
605
- /*
606
- The emitter takes the entity's name when it has one, exactly as `ShadedGeometrySystem`
607
- does for a primitive. Nothing draws differently for it: what it buys is that a walk over
608
- the registry is readable in a debugger.
609
- */
610
- const name = this.entityManager.dataset.getComponent(entry.entity, Name);
611
-
612
- emitter.name = name === undefined || name === null
613
- ? `particles/entity ${entry.entity}`
614
- : name.getValue();
615
-
616
- entry.emitter = emitter;
617
-
618
- apply_particle_effect(emitter, effect);
619
-
620
- // registered at the entity's row by the next frame — see #register
621
- this.#by_emitter.set(emitter, entry);
622
- } else if (apply_particle_effect(entry.emitter, effect)) {
623
- // The emitter's own change counter, which is what EmitterRegistry restages a row from.
624
- entry.emitter.needsUpdate = true;
625
- }
626
- }
627
-
628
- /**
629
- * The entity moved: its row is rewritten from its transform by the context that holds it.
630
- *
631
- * @param {ParticleEmitterEntry} entry
632
- */
633
- #place(entry) {
634
- const context = entry.context;
635
-
636
- if (context === null || context !== this.#graphics.scene_context(this.#scene)) {
637
- return;
638
- }
639
-
640
- context.rows.mark_world(entry.entity);
641
- }
642
-
643
- /**
644
- * Retire an entity's emitter and let go of its row, leaving the entry linked.
645
- *
646
- * @param {ParticleEmitterEntry} entry
647
- */
648
- #detach_emitter(entry) {
649
- const emitter = entry.emitter;
650
-
651
- if (emitter === null) {
652
- return;
653
- }
654
-
655
- const context = entry.context;
656
-
657
- // A context that has been replaced took its rows and its particle system with it; only the
658
- // one that is running is handed anything back.
659
- if (context !== null && context === this.#graphics.scene_context(this.#scene)) {
660
- const particles = this.#particles_of(context);
661
-
662
- if (particles !== null && particles.registry.context(emitter) !== undefined) {
663
- particles.remove_emitter(emitter);
664
- }
665
-
666
- context.rows.release(entry.entity);
667
- }
668
-
669
- entry.context = null;
670
-
671
- this.#by_emitter.delete(emitter);
672
-
673
- entry.emitter = null;
674
- }
675
-
676
- /**
677
- * @param {number} entity
678
- */
679
- #drop(entity) {
680
- const entry = this.#entries.get(entity);
681
-
682
- if (entry === undefined) {
683
- return;
684
- }
685
-
686
- if (entry.on_transform_changed !== null) {
687
- this.entityManager.dataset.removeEntityEventListener(
688
- entity, TRANSFORM64_EVENT_CHANGE, entry.on_transform_changed
689
- );
690
-
691
- entry.on_transform_changed = null;
692
- }
693
-
694
- this.#detach_emitter(entry);
695
- this.#release(entry.url);
696
-
697
- entry.url = null;
698
- entry.texture = null;
699
-
700
- this.#entries.delete(entity);
701
- }
702
-
703
- /** @returns {void} */
704
- #create_defaults() {
705
- const white = Sampler2D.uint8(4, 1, 1);
706
-
707
- white.data.fill(255);
708
-
709
- this.#white = this.atlas.add(white);
710
- this.#transparent = this.atlas.add(Sampler2D.uint8(4, 1, 1));
711
- }
712
-
713
- /**
714
- * @param {string} url
715
- * @returns {ParticleAtlasTexture}
716
- */
717
- #acquire(url) {
718
- let texture = this.#textures.get(url);
719
-
720
- if (texture !== undefined) {
721
- texture.references++;
722
-
723
- return texture;
724
- }
725
-
726
- texture = { references: 1, patch: null };
727
-
728
- this.#textures.set(url, texture);
729
-
730
- this.#assets.promise(url, GameAssetType.Image).then(asset => {
731
- // Removed, changed URL, or shut down while loading: the completion owns nothing now.
732
- if (this.#textures.get(url) !== texture) {
733
- return;
734
- }
735
-
736
- texture.patch = this.atlas.add(sampler2d_ensure_uint8_RGBA(asset.create()));
737
- }).catch(error => {
738
- if (this.#textures.get(url) === texture) {
739
- console.warn(`GPU particle texture '${url}' could not be loaded`, error);
740
- }
741
- });
742
-
743
- return texture;
744
- }
745
-
746
- /**
747
- * @param {string|null} url
748
- * @returns {void}
749
- */
750
- #release(url) {
751
- if (url === null) {
752
- return;
753
- }
754
-
755
- const texture = this.#textures.get(url);
756
-
757
- if (texture === undefined || --texture.references !== 0) {
758
- return;
759
- }
760
-
761
- this.#textures.delete(url);
762
-
763
- if (texture.patch !== null) {
764
- this.atlas.remove(texture.patch);
765
- }
766
- }
767
-
768
- /**
769
- * @param {GPUDevice} device
770
- * @returns {void}
771
- */
1
+ import { assert } from "../../core/assert.js";
2
+ import { warn_limited } from "../../shade/util/warn_limited.js";
3
+ import { FramePhase } from "../../shade/renderer/extension/FramePhase.js";
4
+ import { RenderExtension } from "../../shade/renderer/extension/RenderExtension.js";
5
+ import { ParticleEmitter } from "../../shade/renderer/particles/runtime/ParticleEmitter.js";
6
+ import { GPUTextureContext } from "../../shade/renderer/texture/GPUTextureContext.js";
7
+ import { System } from "../ecs/System.js";
8
+ import Name from "../ecs/name/Name.js";
9
+ import { Transform64 } from "../ecs/transform/Transform64.js";
10
+ import { TRANSFORM64_EVENT_CHANGE } from "../ecs/transform/TRANSFORM64_EVENT_CHANGE.js";
11
+ import { row_of_entity } from "../../shade/renderer/scene/rows/GPUSceneRows.js";
12
+ import { GameAssetType } from "../asset/GameAssetType.js";
13
+ import { ImageRGBADataLoader } from "../asset/loaders/image/ImageRGBADataLoader.js";
14
+ import { ParticleEffect } from "../graphics/ecs/particles/ParticleEffect.js";
15
+ import { ManagedAtlas } from "../graphics/texture/atlas/ManagedTextureAtlas.js";
16
+ import { SpriteAtlas } from "../graphics/texture/atlas/SpriteAtlas.js";
17
+ import { SpriteAtlasFill } from "../graphics/texture/atlas/SpriteAtlasFill.js";
18
+ import { TextureFilterType } from "../../shade/renderer/texture/TextureFilterType.js";
19
+ import { Sampler2D } from "../graphics/texture/sampler/Sampler2D.js";
20
+
21
+ /**
22
+ * Bursts {@link GPUParticleEmitterSystem#burst} will hold before it starts dropping them. Reached
23
+ * only when frames have stopped arriving — see the call site.
24
+ *
25
+ * @type {number}
26
+ */
27
+ const BURST_QUEUE_LIMIT = 256;
28
+
29
+ /**
30
+ * @typedef {object} ParticleEmitterEntry
31
+ * @property {number} entity
32
+ * @property {ParticleEffect} effect the entity's component — what to emit
33
+ * @property {Transform64} transform the entity's transform — where to emit it
34
+ * @property {ParticleEmitter|null} emitter the emitter, once the effect has a program to run
35
+ * @property {import("../../shade/renderer/scene/GPUSceneContext.js").GPUSceneContext|null} context
36
+ * the scene context the emitter is registered with, at the entity's row; null until a frame has
37
+ * registered it, or after a device restart took that context away
38
+ * @property {function():void} on_transform_changed
39
+ * @property {string|null} url last observed {@link ParticleEffect#texture}
40
+ * @property {import("../graphics/texture/atlas/ManagedTextureAtlas.js").ManagedAtlasReference|null} texture shared image reference, or null for an untextured effect
41
+ */
42
+
43
+ /**
44
+ * Draws the game's GPU particle effects: one emitter per entity carrying
45
+ * {@link ../graphics/ecs/particles/ParticleEffect.js ParticleEffect} and
46
+ * {@link ../ecs/transform/Transform64.js Transform64}.
47
+ *
48
+ * ```js
49
+ * entityManager.addSystem(new GPUParticleEmitterSystem(graphics, scene, assetManager));
50
+ *
51
+ * new Entity()
52
+ * .add(new Transform64())
53
+ * .add(ParticleEffect.from({ ...create_particle_effect({ layout, init, update }), spawn_rate: 400 }))
54
+ * .build(dataset);
55
+ * ```
56
+ *
57
+ * **The entity is the emitter.** The component says what it emits and the transform says where, the
58
+ * same division every other placed thing in this engine uses — a `Decal`, an `SGMesh`, a `Light`.
59
+ * Which means an emitter is spawned, moved, parented, attached to a bone, saved and destroyed by
60
+ * exactly the machinery that does those things for everything else, and a particle effect stops
61
+ * being a special case that needs its own scene-graph call.
62
+ *
63
+ * ## What this system does with the pair
64
+ *
65
+ * It builds a {@link ParticleEmitter} per entity and owns it: the emitter is registered with the
66
+ * scene's particle system at the entity's row when the component has a compiled effect, so it
67
+ * stands where the entity stands, carries whatever the component currently says, and is retired
68
+ * when the entity goes. The emitter is not hidden — {@link emitter_of} hands it out — but it is
69
+ * not the interface either: write to the component and the next frame carries it.
70
+ *
71
+ * Changes are found by **comparison** rather than announced. Once a frame each entity's
72
+ * fields are compared against what was last written to its emitter, and the emitter is marked dirty only
73
+ * where they differ; `EmitterRegistry` then restages exactly the rows that moved. A component nobody
74
+ * touched costs a run of integer comparisons and no upload, so there is no change signal to fire and
75
+ * none to forget. What that buys is that gameplay can write `effect.spawn_rate = 0` anywhere,
76
+ * including from code that has never heard of this system.
77
+ *
78
+ * ## It owns the scene's sprite atlas
79
+ *
80
+ * Every {@link ParticleEffect#texture} is loaded through the supplied `AssetManager` and packed into
81
+ * one {@link SpriteAtlas} shared by the scene, one patch per distinct URL however many entities
82
+ * name it, reference-counted through {@link ManagedAtlas} so unreferenced sprites remain in the
83
+ * atlas graveyard and are only reaped when incoming sprites need the space. A `FrameStart`
84
+ * extension publishes the pixels and every current patch region before the renderer records the
85
+ * particle passes — every repack moves UVs that nothing else changed, so the regions are rewritten
86
+ * whole rather than when a URL changes. Pending and failed images sample a transparent texel;
87
+ * effects with no image sample a white one.
88
+ *
89
+ * An emitter in this scene that this system did not build — one registered with the particle
90
+ * system directly — gets the white texel. The atlas is the scene's and that emitter has nothing in
91
+ * it, so the alternative is not "its own texture" but a UV rectangle from somebody else's image.
92
+ *
93
+ * ## What it is not
94
+ *
95
+ * It does not simulate anything and does not record a particle pass. `GPUParticleSystem` does both,
96
+ * inside the renderer, driven from the emitter table these rows are in — see
97
+ * `shade/renderer/particles/DESIGN.md`. This system's whole contribution is membership, placement,
98
+ * the fields, and the sprite sheet.
99
+ */
100
+ export class GPUParticleEmitterSystem extends System {
101
+ dependencies = [ParticleEffect, Transform64];
102
+
103
+ /**
104
+ * CPU atlas, exposed for inspection. Owned by this system.
105
+ * @type {SpriteAtlas}
106
+ */
107
+ atlas = new SpriteAtlas(SpriteAtlasFill.EdgeExtend, 64);
108
+
109
+ /** @type {import("./GraphicsEngine.js").GraphicsEngine} */
110
+ #graphics;
111
+ /** @type {import("../../shade/renderer/scene/Scene.js").Scene} */
112
+ #scene;
113
+ /** @type {import("../asset/AssetManager.js").AssetManager} */
114
+ #assets;
115
+ /** @type {Map<number, ParticleEmitterEntry>} */
116
+ #entries = new Map();
117
+ /**
118
+ * The same entries by the emitter they own, so that the emitters this system did not build can
119
+ * be told apart from the ones it did without a linear search per emitter per frame.
120
+ * @type {Map<ParticleEmitter, ParticleEmitterEntry>}
121
+ */
122
+ #by_emitter = new Map();
123
+ /** @type {ManagedAtlas} */
124
+ #managed_atlas;
125
+ /**
126
+ * Bursts asked for through {@link burst} and not yet handed to the particle system. Flat pairs
127
+ * of `entity, count`, drained every frame.
128
+ * @type {number[]}
129
+ */
130
+ #bursts = [];
131
+ /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
132
+ #white;
133
+ /** @type {import("../graphics/texture/atlas/AtlasPatch.js").AtlasPatch} */
134
+ #transparent;
135
+ /** @type {GPUParticleAtlasExtension|null} */
136
+ #extension = null;
137
+ /** @type {GPUTextureContext|null} */
138
+ #texture = null;
139
+ /** @type {GPUTextureView|null} */
140
+ #view = null;
141
+ /** @type {GPUDevice|null} */
142
+ #device = null;
143
+ /** @type {number} */
144
+ #uploaded_version = -1;
145
+ /** @type {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem|null} */
146
+ #particles = null;
147
+ /** @type {GPUTextureView|null} */
148
+ #previous_atlas = null;
149
+ /** @type {Float32Array} */
150
+ #region = new Float32Array(4);
151
+
152
+ /**
153
+ * @param {import("./GraphicsEngine.js").GraphicsEngine} graphics
154
+ * @param {import("../../shade/renderer/scene/Scene.js").Scene} scene scene the emitters are put
155
+ * in, and whose particle atlas this system owns
156
+ * @param {import("../asset/AssetManager.js").AssetManager} assets
157
+ */
158
+ constructor(graphics, scene, assets) {
159
+ super();
160
+
161
+ assert.defined(graphics, "graphics");
162
+ assert.equal(scene.isScene, true, "scene.isScene !== true");
163
+ assert.defined(assets, "assets");
164
+
165
+ this.#graphics = graphics;
166
+ this.#scene = scene;
167
+ this.#assets = assets;
168
+ this.#managed_atlas = new ManagedAtlas({
169
+ assetManager: this.#assets,
170
+ atlas: this.atlas
171
+ });
172
+
173
+ this.#create_defaults();
174
+ }
175
+
176
+ /**
177
+ * The scene the emitters are put in. Received rather than created: meshes and lights come from
178
+ * systems of their own, and all of them have to be writing into one scene for any of it to be
179
+ * visible together.
180
+ *
181
+ * @returns {import("../../shade/renderer/scene/Scene.js").Scene}
182
+ */
183
+ get scene() {
184
+ return this.#scene;
185
+ }
186
+
187
+ /**
188
+ * The emitters this system currently holds, in no particular order. Read-only — they follow
189
+ * the entities.
190
+ *
191
+ * @returns {ParticleEmitter[]}
192
+ */
193
+ get emitters() {
194
+ return Array.from(this.#by_emitter.keys());
195
+ }
196
+
197
+ /**
198
+ * The emitter built for an entity.
199
+ *
200
+ * Handed out for what an entity reference cannot express — asking the particle system about
201
+ * it — and for nothing routine: the component is how an effect is changed, and an emitter
202
+ * written to behind the component's back is overwritten by the next comparison.
203
+ *
204
+ * @param {number} entity
205
+ * @returns {ParticleEmitter|null} `null` for an entity this system has not linked, or one whose
206
+ * component has no compiled effect yet
207
+ */
208
+ emitter_of(entity) {
209
+ const entry = this.#entries.get(entity);
210
+
211
+ if (entry === undefined) {
212
+ return null;
213
+ }
214
+
215
+ return entry.emitter;
216
+ }
217
+
218
+ /**
219
+ * Ask an entity's emitter for `count` particles on top of its rate, on the next frame.
220
+ *
221
+ * The one-shot half of the API, and the only thing here the CPU asks for rather than describes:
222
+ * an impact, a footfall, a muzzle flash. It goes through the same GPU path as continuous
223
+ * emission — the tick pass folds the burst into the emitter's accumulator, and the pool budget
224
+ * and the culling apply — so it is a request rather than a guarantee, and an entity whose
225
+ * emitter is not live by the time the frame starts is silently skipped.
226
+ *
227
+ * @param {number} entity
228
+ * @param {number} count
229
+ */
230
+ burst(entity, count) {
231
+ assert.isNonNegativeInteger(count, "count");
232
+
233
+ if (count === 0) {
234
+ return;
235
+ }
236
+
237
+ if (this.#bursts.length >= BURST_QUEUE_LIMIT * 2) {
238
+ /*
239
+ The queue is drained by a frame, so it only grows without bound when frames have stopped
240
+ arriving — a scene nobody draws, or a device that never came up — and gameplay carries on
241
+ asking. Dropping is right either way: nothing is going to spawn these, and a burst is a
242
+ request about the moment it was made.
243
+
244
+ The text is the key `warn_limited` counts on, so it carries no entity: one message per
245
+ limit, not one per caller that happens to be unlucky.
246
+ */
247
+ warn_limited(
248
+ `GPUParticleEmitterSystem: ${BURST_QUEUE_LIMIT} bursts are queued and no frame has `
249
+ + "drained them; dropping the rest"
250
+ );
251
+
252
+ return;
253
+ }
254
+
255
+ this.#bursts.push(entity, count);
256
+ }
257
+
258
+ /**
259
+ * @param {import("../ecs/EntityManager.js").EntityManager} entityManager
260
+ * @returns {Promise<void>}
261
+ */
262
+ async startup(entityManager) {
263
+ this.entityManager = entityManager;
264
+
265
+ // the scene's rows are this dataset's entities; the first system to place one says so
266
+ const dataset = entityManager.dataset;
267
+ const scene = this.#scene;
268
+
269
+ assert.ok(
270
+ scene.dataset === null || scene.dataset === undefined || scene.dataset === dataset,
271
+ 'the scene is bound to another dataset'
272
+ );
273
+
274
+ scene.dataset = dataset;
275
+
276
+ if (!this.#assets.hasLoaderForType(GameAssetType.Image)) {
277
+ await this.#assets.registerLoader(GameAssetType.Image, new ImageRGBADataLoader());
278
+ }
279
+
280
+ this.#extension = new GPUParticleAtlasExtension(this);
281
+
282
+ this.#graphics.add_extension(this.#extension);
283
+ }
284
+
285
+ /** @returns {Promise<void>} */
286
+ async shutdown() {
287
+ if (this.#extension !== null) {
288
+ this.#graphics.remove_extension(this.#extension);
289
+ this.#extension = null;
290
+ }
291
+
292
+ for (const entity of Array.from(this.#entries.keys())) {
293
+ this.#drop(entity);
294
+ }
295
+
296
+ this.#detach();
297
+
298
+ this.#texture?.destroy();
299
+ this.#texture = null;
300
+ this.#view = null;
301
+ this.#device = null;
302
+ this.#uploaded_version = -1;
303
+ this.#bursts.length = 0;
304
+
305
+ this.#managed_atlas.reset();
306
+ this.#create_defaults();
307
+ }
308
+
309
+ /**
310
+ * @param {ParticleEffect} effect
311
+ * @param {Transform64} transform
312
+ * @param {number} entity
313
+ */
314
+ link(effect, transform, entity) {
315
+ /** @type {ParticleEmitterEntry} */
316
+ const entry = {
317
+ entity,
318
+ effect,
319
+ transform,
320
+ emitter: null,
321
+ context: null,
322
+ on_transform_changed: null,
323
+ url: null,
324
+ texture: null
325
+ };
326
+
327
+ this.#entries.set(entity, entry);
328
+
329
+ /*
330
+ The emitter is placed the moment its transform moves rather than on a poll. A Transform64
331
+ has no signals of its own, so the announcement comes from the dataset: whoever wrote the
332
+ transform sends TRANSFORM64_EVENT_CHANGE and this wakes on it. Coarser than watching
333
+ position, rotation and scale separately — a scale write wakes a placement that reads the
334
+ whole transform anyway — and that is the trade the type makes everywhere.
335
+ */
336
+ entry.on_transform_changed = () => this.#place(entry);
337
+
338
+ this.entityManager.dataset.addEntityEventListener(
339
+ entity, TRANSFORM64_EVENT_CHANGE, entry.on_transform_changed
340
+ );
341
+
342
+ // Everything else — building the emitter, acquiring the texture — is what a frame does
343
+ // anyway, and doing it here as well would be a second copy of it that has to stay in step.
344
+ this.#sync(entry);
345
+
346
+ // Registered now when there is a context to register with, so an entity spawned mid-level
347
+ // stands where it should from its first frame.
348
+ this.#register_all();
349
+ }
350
+
351
+ /**
352
+ * @param {ParticleEffect} effect
353
+ * @param {Transform64} transform
354
+ * @param {number} entity
355
+ */
356
+ unlink(effect, transform, entity) {
357
+ this.#drop(entity);
358
+ }
359
+
360
+ /**
361
+ * Bring every emitter in line with its entity: build emitters for effects that have arrived,
362
+ * retire emitters for effects that have gone, copy changed fields onto the ones that stay, and
363
+ * follow the texture URLs.
364
+ *
365
+ * @param {number} [time_delta_seconds]
366
+ * @returns {void}
367
+ */
368
+ update(time_delta_seconds) {
369
+ for (const entry of this.#entries.values()) {
370
+ this.#sync(entry);
371
+ }
372
+
373
+ /*
374
+ Registered here as well as at FrameStart, so an emitter whose effect arrived during a
375
+ simulation tick has its row written by the next frame's build rather than the one after:
376
+ a frame builds the scene context before FrameStart runs, so a row acquired at FrameStart
377
+ is not written until the frame after.
378
+ */
379
+ this.#register_all();
380
+
381
+ this.#managed_atlas.update();
382
+ }
383
+
384
+ /**
385
+ * The particle system a scene context's frames run, or null while there is no renderer.
386
+ *
387
+ * @param {import("../../shade/renderer/scene/GPUSceneContext.js").GPUSceneContext} context
388
+ * @returns {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem|null}
389
+ */
390
+ #particles_of(context) {
391
+ const renderer = this.#graphics.renderer;
392
+
393
+ if (renderer === null || renderer === undefined) {
394
+ return null;
395
+ }
396
+
397
+ return renderer.particles(context);
398
+ }
399
+
400
+ /**
401
+ * {@link #register} against whatever context and particle system the scene has right now, if
402
+ * it has any.
403
+ */
404
+ #register_all() {
405
+ const context = this.#graphics.scene_context(this.#scene);
406
+
407
+ if (context === null) {
408
+ return;
409
+ }
410
+
411
+ const particles = this.#particles_of(context);
412
+
413
+ if (particles !== null) {
414
+ this.#register(particles, context);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Publish the atlas at FrameStart — after the scene context has established transform rows, and
420
+ * before the renderer records the particle passes that read the emitter table.
421
+ *
422
+ * {@link update} is called from here as well as by the entity manager, because a frame must not
423
+ * depend on a simulation tick having preceded it: an editor draws on demand, and a paused game
424
+ * still renders. It is idempotent and costs a comparison per emitter when nothing has changed,
425
+ * which is what makes running it twice a frame the cheap way to be right rather than a saving
426
+ * worth chasing.
427
+ *
428
+ * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
429
+ * @returns {void}
430
+ */
431
+ prepare(frame) {
432
+ if (frame.view.scene.scene !== this.#scene) {
433
+ return;
434
+ }
435
+
436
+ this.update();
437
+
438
+ const renderer = this.#graphics.renderer;
439
+
440
+ /*
441
+ The renderer's GPU particle feature is this system's to turn on, and it is turned on every
442
+ frame rather than once, because a device restart builds a renderer that has never heard of
443
+ it. The consequence is worth stating: while this system is in the profile there is no way to
444
+ switch GPU particles off through the renderer, because this would switch them back on at the
445
+ next FrameStart. Switching them off means taking the system out — or, for one effect,
446
+ `ParticleEffect#emitting`.
447
+ */
448
+ renderer.feature_particles_enabled = true;
449
+
450
+ const particles = renderer.particles(frame.view.scene);
451
+
452
+ if (particles !== this.#particles) {
453
+ this.#detach();
454
+ this.#particles = particles;
455
+ this.#previous_atlas = particles.atlas;
456
+ }
457
+
458
+ this.#register(particles, frame.view.scene);
459
+
460
+ this.#upload(renderer.graphics);
461
+
462
+ particles.atlas = this.#view;
463
+
464
+ this.#publish_regions(particles);
465
+ this.#drain_bursts(particles);
466
+ }
467
+
468
+ /**
469
+ * Register every emitter not yet registered with this context, at its entity's row.
470
+ *
471
+ * From {@link update} and {@link prepare} rather than at link, because a row needs a scene
472
+ * context and the context needs a device, and neither is a system's to wait for: a level loads
473
+ * before the device is up, and a device restart replaces the context under every entry. An
474
+ * entry registered with a context that is no longer this one is registered again with the one
475
+ * that is. Idempotent, and a comparison per entry when nothing has changed.
476
+ *
477
+ * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
478
+ * @param {import("../../shade/renderer/scene/GPUSceneContext.js").GPUSceneContext} scene_context
479
+ */
480
+ #register(particles, scene_context) {
481
+ const rows = scene_context.rows;
482
+
483
+ for (const entry of this.#entries.values()) {
484
+ if (entry.emitter === null || entry.context === scene_context) {
485
+ continue;
486
+ }
487
+
488
+ rows.acquire(entry.entity);
489
+
490
+ particles.add_emitter(entry.emitter, row_of_entity(entry.entity));
491
+
492
+ entry.context = scene_context;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Point every emitter of this scene at its patch in the atlas.
498
+ *
499
+ * Every region, every frame: growth also moves the UVs of images that did not change, and there
500
+ * is no signal for that which an emitter could subscribe to.
501
+ *
502
+ * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
503
+ */
504
+ #publish_regions(particles) {
505
+ const registry = particles.registry;
506
+ const emitters = registry.emitters;
507
+ const region = this.#region;
508
+
509
+ for (let i = 0; i < emitters.length; i++) {
510
+ const emitter = emitters[i];
511
+ const entry = this.#by_emitter.get(emitter);
512
+
513
+ // Not ours: it has nothing in this atlas, so it gets the one patch that is true for
514
+ // everybody — a white texel, which draws it as a flat quad of its own colour.
515
+ const patch = entry === undefined || entry.texture === null
516
+ ? this.#white
517
+ : entry.texture.getValue() ?? this.#transparent;
518
+
519
+ const uv = patch.uv;
520
+
521
+ if (patch === this.#white || patch === this.#transparent) {
522
+ // Sample the texel centre everywhere, avoiding interpolation into the gutter.
523
+ region[0] = uv.position.x + uv.size.x * 0.5;
524
+ region[1] = uv.position.y + uv.size.y * 0.5;
525
+ region[2] = 0;
526
+ region[3] = 0;
527
+ } else {
528
+ region[0] = uv.position.x;
529
+ region[1] = uv.position.y;
530
+ region[2] = uv.size.x;
531
+ region[3] = uv.size.y;
532
+ }
533
+
534
+ registry.set_atlas_region(emitter, region);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Hand this frame's bursts to the particle system, now that membership has been swept and every
540
+ * emitter that can have a row has one.
541
+ *
542
+ * The queue is emptied whether or not each burst could be delivered. A burst is a request about
543
+ * *this* moment — a footfall, an impact — and holding one for an emitter that is not live yet
544
+ * would deliver it seconds late, at whatever unrelated moment the emitter finally arrived.
545
+ *
546
+ * @param {import("../../shade/renderer/particles/GPUParticleSystem.js").GPUParticleSystem} particles
547
+ */
548
+ #drain_bursts(particles) {
549
+ const bursts = this.#bursts;
550
+
551
+ for (let i = 0; i < bursts.length; i += 2) {
552
+ const entry = this.#entries.get(bursts[i]);
553
+
554
+ if (entry === undefined || entry.emitter === null) {
555
+ continue;
556
+ }
557
+
558
+ if (particles.registry.context(entry.emitter) === undefined) {
559
+ continue;
560
+ }
561
+
562
+ particles.spawn(entry.emitter, bursts[i + 1]);
563
+ }
564
+
565
+ bursts.length = 0;
566
+ }
567
+
568
+ /**
569
+ * One entity's emitter, brought in line with its component.
570
+ *
571
+ * @param {ParticleEmitterEntry} entry
572
+ */
573
+ #sync(entry) {
574
+ const effect = entry.effect;
575
+
576
+ /*
577
+ Ahead of everything the effect gates, and deliberately: an image takes a fetch and a decode
578
+ to arrive, and an entity that names one has said what it wants to draw with whether or not
579
+ its program has finished compiling. Waiting would mean a first frame of transparent
580
+ particles for every effect that arrives with its texture.
581
+ */
582
+ if (entry.url !== effect.texture) {
583
+ entry.texture?.release();
584
+
585
+ entry.url = effect.texture;
586
+ entry.texture = entry.url === null ? null : this.#managed_atlas.acquire(entry.url);
587
+ }
588
+
589
+ if (effect.layout === null || effect.program === null) {
590
+ // No effect (yet, or any more). An entity whose effect is still compiling is ordinary,
591
+ // and so is one whose effect was cleared — both mean "emits nothing", and the emitter
592
+ // is retired rather than keeping the last program it happened to have.
593
+ this.#detach_emitter(entry);
594
+
595
+ return;
596
+ }
597
+
598
+ if (entry.emitter === null) {
599
+ const emitter = new ParticleEmitter();
600
+
601
+ /*
602
+ The emitter takes the entity's name when it has one, exactly as `ShadedGeometrySystem`
603
+ does for a primitive. Nothing draws differently for it: what it buys is that a walk over
604
+ the registry is readable in a debugger.
605
+ */
606
+ const name = this.entityManager.dataset.getComponent(entry.entity, Name);
607
+
608
+ emitter.name = name === undefined || name === null
609
+ ? `particles/entity ${entry.entity}`
610
+ : name.getValue();
611
+
612
+ entry.emitter = emitter;
613
+
614
+ apply_particle_effect(emitter, effect);
615
+
616
+ // registered at the entity's row by the next frame — see #register
617
+ this.#by_emitter.set(emitter, entry);
618
+ } else if (apply_particle_effect(entry.emitter, effect)) {
619
+ // The emitter's own change counter, which is what EmitterRegistry restages a row from.
620
+ entry.emitter.needsUpdate = true;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * The entity moved: its row is rewritten from its transform by the context that holds it.
626
+ *
627
+ * @param {ParticleEmitterEntry} entry
628
+ */
629
+ #place(entry) {
630
+ const context = entry.context;
631
+
632
+ if (context === null || context !== this.#graphics.scene_context(this.#scene)) {
633
+ return;
634
+ }
635
+
636
+ context.rows.mark_world(entry.entity);
637
+ }
638
+
639
+ /**
640
+ * Retire an entity's emitter and let go of its row, leaving the entry linked.
641
+ *
642
+ * @param {ParticleEmitterEntry} entry
643
+ */
644
+ #detach_emitter(entry) {
645
+ const emitter = entry.emitter;
646
+
647
+ if (emitter === null) {
648
+ return;
649
+ }
650
+
651
+ const context = entry.context;
652
+
653
+ // A context that has been replaced took its rows and its particle system with it; only the
654
+ // one that is running is handed anything back.
655
+ if (context !== null && context === this.#graphics.scene_context(this.#scene)) {
656
+ const particles = this.#particles_of(context);
657
+
658
+ if (particles !== null && particles.registry.context(emitter) !== undefined) {
659
+ particles.remove_emitter(emitter);
660
+ }
661
+
662
+ context.rows.release(entry.entity);
663
+ }
664
+
665
+ entry.context = null;
666
+
667
+ this.#by_emitter.delete(emitter);
668
+
669
+ entry.emitter = null;
670
+ }
671
+
672
+ /**
673
+ * @param {number} entity
674
+ */
675
+ #drop(entity) {
676
+ const entry = this.#entries.get(entity);
677
+
678
+ if (entry === undefined) {
679
+ return;
680
+ }
681
+
682
+ if (entry.on_transform_changed !== null) {
683
+ this.entityManager.dataset.removeEntityEventListener(
684
+ entity, TRANSFORM64_EVENT_CHANGE, entry.on_transform_changed
685
+ );
686
+
687
+ entry.on_transform_changed = null;
688
+ }
689
+
690
+ this.#detach_emitter(entry);
691
+ entry.texture?.release();
692
+
693
+ entry.url = null;
694
+ entry.texture = null;
695
+
696
+ this.#entries.delete(entity);
697
+ }
698
+
699
+ /** @returns {void} */
700
+ #create_defaults() {
701
+ const white = Sampler2D.uint8(4, 1, 1);
702
+
703
+ white.data.fill(255);
704
+
705
+ this.#white = this.atlas.add(white);
706
+ this.#transparent = this.atlas.add(Sampler2D.uint8(4, 1, 1));
707
+ }
708
+
709
+ /**
710
+ * @param {import("../../shade/renderer/GPUDeviceContext.js").GPUDeviceContext} graphics
711
+ * @returns {void}
712
+ */
772
713
  #upload(graphics) {
773
- const device = graphics.device;
774
- const source = this.atlas.sampler;
775
-
776
- if (this.#device !== device || this.#texture === null) {
777
- this.#texture?.destroy();
778
-
779
- this.#device = device;
780
- this.#texture = new GPUTextureContext(device);
781
-
782
- const descriptor = this.#texture.descriptor;
783
-
784
- descriptor.label = "particles/sprite atlas";
785
- descriptor.format = "rgba8unorm";
714
+ const device = graphics.device;
715
+ const source = this.atlas.sampler;
716
+
717
+ if (this.#device !== device || this.#texture === null) {
718
+ this.#texture?.destroy();
719
+
720
+ this.#device = device;
721
+ this.#texture = new GPUTextureContext(device);
722
+
723
+ const descriptor = this.#texture.descriptor;
724
+
725
+ descriptor.label = "particles/sprite atlas";
726
+ descriptor.format = "rgba8unorm";
786
727
  descriptor.usage = GPUTextureUsage.TEXTURE_BINDING
787
728
  | GPUTextureUsage.COPY_DST
788
- | GPUTextureUsage.RENDER_ATTACHMENT;
789
-
790
- this.#uploaded_version = -1;
791
- }
792
-
793
- const texture = this.#texture;
794
-
795
- if (texture.width !== source.width || texture.height !== source.height) {
729
+ | GPUTextureUsage.RENDER_ATTACHMENT;
730
+
731
+ this.#uploaded_version = -1;
732
+ }
733
+
734
+ const texture = this.#texture;
735
+
736
+ if (texture.width !== source.width || texture.height !== source.height) {
796
737
  texture.descriptor.mipLevelCount = 1 + Math.floor(Math.log2(Math.max(source.width, source.height)));
797
- texture.resize(source.width, source.height);
798
-
799
- this.#uploaded_version = -1;
800
- }
801
-
802
- if (this.#uploaded_version !== source.version) {
803
- device.queue.writeTexture({ texture: texture.gpu_texture }, source.data,
804
- { bytesPerRow: source.width * 4 }, [source.width, source.height]);
805
-
738
+ texture.resize(source.width, source.height);
739
+
740
+ this.#uploaded_version = -1;
741
+ }
742
+
743
+ if (this.#uploaded_version !== source.version) {
744
+ device.queue.writeTexture({ texture: texture.gpu_texture }, source.data,
745
+ { bytesPerRow: source.width * 4 }, [source.width, source.height]);
746
+
806
747
  if (texture.descriptor.mipLevelCount > 1) {
807
748
  graphics.textures.mipmaps.generateMipmap(
808
749
  texture.gpu_texture,
@@ -811,153 +752,153 @@ export class GPUParticleEmitterSystem extends System {
811
752
  );
812
753
  }
813
754
 
814
- texture.incrementVersion();
815
-
816
- this.#uploaded_version = source.version;
817
- }
818
-
819
- this.#view = texture.obtainView();
820
- }
821
-
822
- /** @returns {void} */
823
- #detach() {
824
- if (this.#particles !== null && this.#particles.atlas === this.#view) {
825
- this.#particles.atlas = this.#previous_atlas;
826
-
827
- for (const emitter of this.#particles.emitters) {
828
- this.#particles.registry.set_atlas_region(emitter, [0, 0, 1, 1]);
829
- }
830
- }
831
-
832
- this.#particles = null;
833
- this.#previous_atlas = null;
834
- }
835
- }
836
-
837
- /**
838
- * Copy a component onto the emitter built for it, reporting whether anything actually moved.
839
- *
840
- * Comparing while assigning rather than in a pass of its own is what makes change detection cost
841
- * nothing extra: the fields have to be read to be written, and the only addition is an `||` per
842
- * field. What it answers is whether the emitter's GPU row has to be restaged, and restaging one that
843
- * has not changed is an upload per emitter per frame.
844
- *
845
- * `spawn_rate` comes from {@link ParticleEffect#effective_spawn_rate}, so an effect switched off
846
- * with `emitting = false` reaches the GPU as a rate of zero and keeps its authored rate to come back
847
- * to. Layout and program are shared by reference, never copied — two entities of one effect belong
848
- * on one program id in the heap.
849
- *
850
- * Every field is compared by name, and that is load-bearing rather than style. Through a shared
851
- * `assign(target, field, value)` the one `target[field]` access saw all thirteen names, went
852
- * megamorphic, and read numbers back boxed, while the call boxed the value it was handed: 228 B and
853
- * ~120 ns per emitter per call, against nothing and ~9 ns written out like this
854
- * (`GPUParticleEmitterSystem.allocation.spec.js`). This runs for every live emitter twice a frame.
855
- *
856
- * @param {ParticleEmitter} emitter
857
- * @param {ParticleEffect} effect
858
- * @returns {boolean} whether the emitter differs from what it held before
859
- */
860
- export function apply_particle_effect(emitter, effect) {
861
- let changed = false;
862
-
863
- if (emitter.layout !== effect.layout) {
864
- emitter.layout = effect.layout;
865
- changed = true;
866
- }
867
-
868
- if (emitter.program !== effect.program) {
869
- emitter.program = effect.program;
870
- changed = true;
871
- }
872
-
873
- if (emitter.texture !== effect.texture) {
874
- emitter.texture = effect.texture;
875
- changed = true;
876
- }
877
-
878
- if (emitter.seed !== effect.seed) {
879
- emitter.seed = effect.seed;
880
- changed = true;
881
- }
882
-
883
- const spawn_rate = effect.effective_spawn_rate;
884
-
885
- if (emitter.spawn_rate !== spawn_rate) {
886
- emitter.spawn_rate = spawn_rate;
887
- changed = true;
888
- }
889
-
890
- if (emitter.prewarm !== effect.prewarm) {
891
- emitter.prewarm = effect.prewarm;
892
- changed = true;
893
- }
894
-
895
- if (emitter.flags !== effect.flags) {
896
- emitter.flags = effect.flags;
897
- changed = true;
898
- }
899
-
900
- if (emitter.render_position !== effect.render_position) {
901
- emitter.render_position = effect.render_position;
902
- changed = true;
903
- }
904
-
905
- if (emitter.render_size !== effect.render_size) {
906
- emitter.render_size = effect.render_size;
907
- changed = true;
908
- }
909
-
910
- if (emitter.render_color !== effect.render_color) {
911
- emitter.render_color = effect.render_color;
912
- changed = true;
913
- }
914
-
915
- if (emitter.render_orientation !== effect.render_orientation) {
916
- emitter.render_orientation = effect.render_orientation;
917
- changed = true;
918
- }
919
-
920
- if (emitter.render_frame !== effect.render_frame) {
921
- emitter.render_frame = effect.render_frame;
922
- changed = true;
923
- }
924
-
925
- if (emitter.render_velocity !== effect.render_velocity) {
926
- emitter.render_velocity = effect.render_velocity;
927
- changed = true;
928
- }
929
-
930
- const flipbook = emitter.flipbook;
931
-
932
- if (flipbook[0] !== effect.flipbook[0] || flipbook[1] !== effect.flipbook[1]) {
933
- flipbook.set(effect.flipbook);
934
-
935
- changed = true;
936
- }
937
-
938
- return changed;
939
- }
940
-
941
- class GPUParticleAtlasExtension extends RenderExtension {
942
- /** @type {string} */
943
- name = "gpu particle atlas";
944
- /** @type {FramePhase} */
945
- phase = FramePhase.FrameStart;
946
- /** @type {GPUParticleEmitterSystem} */
947
- #system;
948
-
949
- /** @param {GPUParticleEmitterSystem} system */
950
- constructor(system) {
951
- super();
952
-
953
- this.#system = system;
954
- }
955
-
956
- /**
957
- * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
958
- * @returns {void}
959
- */
960
- record(frame) {
961
- this.#system.prepare(frame);
962
- }
963
- }
755
+ texture.incrementVersion();
756
+
757
+ this.#uploaded_version = source.version;
758
+ }
759
+
760
+ this.#view = texture.obtainView();
761
+ }
762
+
763
+ /** @returns {void} */
764
+ #detach() {
765
+ if (this.#particles !== null && this.#particles.atlas === this.#view) {
766
+ this.#particles.atlas = this.#previous_atlas;
767
+
768
+ for (const emitter of this.#particles.emitters) {
769
+ this.#particles.registry.set_atlas_region(emitter, [0, 0, 1, 1]);
770
+ }
771
+ }
772
+
773
+ this.#particles = null;
774
+ this.#previous_atlas = null;
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Copy a component onto the emitter built for it, reporting whether anything actually moved.
780
+ *
781
+ * Comparing while assigning rather than in a pass of its own is what makes change detection cost
782
+ * nothing extra: the fields have to be read to be written, and the only addition is an `||` per
783
+ * field. What it answers is whether the emitter's GPU row has to be restaged, and restaging one that
784
+ * has not changed is an upload per emitter per frame.
785
+ *
786
+ * `spawn_rate` comes from {@link ParticleEffect#effective_spawn_rate}, so an effect switched off
787
+ * with `emitting = false` reaches the GPU as a rate of zero and keeps its authored rate to come back
788
+ * to. Layout and program are shared by reference, never copied — two entities of one effect belong
789
+ * on one program id in the heap.
790
+ *
791
+ * Every field is compared by name, and that is load-bearing rather than style. Through a shared
792
+ * `assign(target, field, value)` the one `target[field]` access saw all thirteen names, went
793
+ * megamorphic, and read numbers back boxed, while the call boxed the value it was handed: 228 B and
794
+ * ~120 ns per emitter per call, against nothing and ~9 ns written out like this
795
+ * (`GPUParticleEmitterSystem.allocation.spec.js`). This runs for every live emitter twice a frame.
796
+ *
797
+ * @param {ParticleEmitter} emitter
798
+ * @param {ParticleEffect} effect
799
+ * @returns {boolean} whether the emitter differs from what it held before
800
+ */
801
+ export function apply_particle_effect(emitter, effect) {
802
+ let changed = false;
803
+
804
+ if (emitter.layout !== effect.layout) {
805
+ emitter.layout = effect.layout;
806
+ changed = true;
807
+ }
808
+
809
+ if (emitter.program !== effect.program) {
810
+ emitter.program = effect.program;
811
+ changed = true;
812
+ }
813
+
814
+ if (emitter.texture !== effect.texture) {
815
+ emitter.texture = effect.texture;
816
+ changed = true;
817
+ }
818
+
819
+ if (emitter.seed !== effect.seed) {
820
+ emitter.seed = effect.seed;
821
+ changed = true;
822
+ }
823
+
824
+ const spawn_rate = effect.effective_spawn_rate;
825
+
826
+ if (emitter.spawn_rate !== spawn_rate) {
827
+ emitter.spawn_rate = spawn_rate;
828
+ changed = true;
829
+ }
830
+
831
+ if (emitter.prewarm !== effect.prewarm) {
832
+ emitter.prewarm = effect.prewarm;
833
+ changed = true;
834
+ }
835
+
836
+ if (emitter.flags !== effect.flags) {
837
+ emitter.flags = effect.flags;
838
+ changed = true;
839
+ }
840
+
841
+ if (emitter.render_position !== effect.render_position) {
842
+ emitter.render_position = effect.render_position;
843
+ changed = true;
844
+ }
845
+
846
+ if (emitter.render_size !== effect.render_size) {
847
+ emitter.render_size = effect.render_size;
848
+ changed = true;
849
+ }
850
+
851
+ if (emitter.render_color !== effect.render_color) {
852
+ emitter.render_color = effect.render_color;
853
+ changed = true;
854
+ }
855
+
856
+ if (emitter.render_orientation !== effect.render_orientation) {
857
+ emitter.render_orientation = effect.render_orientation;
858
+ changed = true;
859
+ }
860
+
861
+ if (emitter.render_frame !== effect.render_frame) {
862
+ emitter.render_frame = effect.render_frame;
863
+ changed = true;
864
+ }
865
+
866
+ if (emitter.render_velocity !== effect.render_velocity) {
867
+ emitter.render_velocity = effect.render_velocity;
868
+ changed = true;
869
+ }
870
+
871
+ const flipbook = emitter.flipbook;
872
+
873
+ if (flipbook[0] !== effect.flipbook[0] || flipbook[1] !== effect.flipbook[1]) {
874
+ flipbook.set(effect.flipbook);
875
+
876
+ changed = true;
877
+ }
878
+
879
+ return changed;
880
+ }
881
+
882
+ class GPUParticleAtlasExtension extends RenderExtension {
883
+ /** @type {string} */
884
+ name = "gpu particle atlas";
885
+ /** @type {FramePhase} */
886
+ phase = FramePhase.FrameStart;
887
+ /** @type {GPUParticleEmitterSystem} */
888
+ #system;
889
+
890
+ /** @param {GPUParticleEmitterSystem} system */
891
+ constructor(system) {
892
+ super();
893
+
894
+ this.#system = system;
895
+ }
896
+
897
+ /**
898
+ * @param {import("../../shade/renderer/extension/FrameContext.js").FrameContext} frame
899
+ * @returns {void}
900
+ */
901
+ record(frame) {
902
+ this.#system.prepare(frame);
903
+ }
904
+ }