@woosh/meep-engine 3.2.0 → 3.4.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 (893) hide show
  1. package/editor/process/SymbolicDisplayProcess.js +106 -106
  2. package/editor/process/symbolic/{SoundEmitterSymbolicDisplay.js → AudioEmitterSymbolicDisplay.js} +67 -57
  3. package/package.json +94 -94
  4. package/src/DUPLICATION_AUDIT_2026_08_07/F_sound.md +110 -102
  5. package/src/avif/encoder_worker.js +5 -18
  6. package/src/core/bvh2/bvh3/ebvh_build_hierarchy_radix.d.ts +54 -0
  7. package/src/core/bvh2/bvh3/ebvh_build_hierarchy_radix.d.ts.map +1 -1
  8. package/src/core/bvh2/bvh3/ebvh_build_hierarchy_radix.js +10 -3
  9. package/src/core/color/PQ/PQ_constants.d.ts +43 -0
  10. package/src/core/color/PQ/PQ_constants.d.ts.map +1 -0
  11. package/src/core/color/PQ/PQ_constants.js +50 -0
  12. package/src/core/color/PQ/PQ_to_linear.d.ts +24 -0
  13. package/src/core/color/PQ/PQ_to_linear.d.ts.map +1 -0
  14. package/src/core/color/PQ/PQ_to_linear.js +35 -0
  15. package/src/core/color/PQ/linear_to_PQ.d.ts +27 -0
  16. package/src/core/color/PQ/linear_to_PQ.d.ts.map +1 -0
  17. package/src/core/color/PQ/linear_to_PQ.js +32 -0
  18. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_other_face.d.ts +32 -0
  19. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_other_face.d.ts.map +1 -0
  20. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_other_face.js +59 -0
  21. package/src/core/geom/3d/topology/struct/binary/query/bt_face_third_vertex.d.ts +26 -0
  22. package/src/core/geom/3d/topology/struct/binary/query/bt_face_third_vertex.d.ts.map +1 -0
  23. package/src/core/geom/3d/topology/struct/binary/query/bt_face_third_vertex.js +55 -0
  24. package/src/engine/EntityCreator.d.ts +19 -11
  25. package/src/engine/EntityCreator.d.ts.map +1 -1
  26. package/src/engine/EntityCreator.js +116 -94
  27. package/src/engine/achievements/AchievementManager.d.ts.map +1 -1
  28. package/src/engine/achievements/AchievementManager.js +437 -433
  29. package/src/engine/animation/AnimatedActions.d.ts +6 -1
  30. package/src/engine/animation/AnimatedActions.d.ts.map +1 -1
  31. package/src/engine/animation/AnimatedActions.js +111 -81
  32. package/src/engine/animation/AnimationUtils.d.ts.map +1 -1
  33. package/src/engine/animation/AnimationUtils.js +234 -252
  34. package/src/engine/ecs/storage/binary/SERIALIZATION_COVERAGE.md +275 -269
  35. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  36. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +290 -265
  37. package/src/engine/ecs/util/hideEntityGracefully.d.ts.map +1 -1
  38. package/src/engine/ecs/util/hideEntityGracefully.js +196 -208
  39. package/src/engine/graphics3/VolumetricLightMap.d.ts +90 -0
  40. package/src/engine/graphics3/VolumetricLightMap.d.ts.map +1 -0
  41. package/src/engine/graphics3/VolumetricLightMap.js +160 -0
  42. package/src/engine/graphics3/VolumetricLightMapSerializationAdapter.d.ts +32 -0
  43. package/src/engine/graphics3/VolumetricLightMapSerializationAdapter.d.ts.map +1 -0
  44. package/src/engine/graphics3/VolumetricLightMapSerializationAdapter.js +67 -0
  45. package/src/engine/graphics3/VolumetricLightMapSystem3.d.ts +98 -0
  46. package/src/engine/graphics3/VolumetricLightMapSystem3.d.ts.map +1 -0
  47. package/src/engine/graphics3/VolumetricLightMapSystem3.js +261 -0
  48. package/src/engine/intelligence/behavior/primitive/ActionBehavior.d.ts +3 -1
  49. package/src/engine/intelligence/behavior/primitive/ActionBehavior.d.ts.map +1 -1
  50. package/src/engine/intelligence/behavior/primitive/ActionBehavior.js +4 -2
  51. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  52. package/src/engine/sound/SOUND_EMITTER_RETIREMENT_PLAN.md +536 -0
  53. package/src/engine/sound/SoundEngine.d.ts +2 -2
  54. package/src/engine/sound/SoundEngine.js +124 -124
  55. package/src/engine/sound/ecs/audio/AudioEmitter.d.ts +20 -0
  56. package/src/engine/sound/ecs/audio/AudioEmitter.d.ts.map +1 -1
  57. package/src/engine/sound/ecs/audio/AudioEmitter.js +25 -2
  58. package/src/engine/sound/ecs/audio/AudioEmitterSystem.d.ts +6 -6
  59. package/src/engine/sound/ecs/audio/AudioEmitterSystem.d.ts.map +1 -1
  60. package/src/engine/sound/ecs/audio/AudioEmitterSystem.js +338 -314
  61. package/src/engine/sound/ecs/trigger/AudioEventTrigger.d.ts +143 -0
  62. package/src/engine/sound/ecs/trigger/AudioEventTrigger.d.ts.map +1 -0
  63. package/src/engine/sound/ecs/trigger/AudioEventTrigger.js +191 -0
  64. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationAdapter.d.ts +33 -0
  65. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationAdapter.d.ts.map +1 -0
  66. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationAdapter.js +81 -0
  67. package/src/engine/sound/ecs/trigger/AudioEventTriggerSystem.d.ts +65 -0
  68. package/src/engine/sound/ecs/trigger/AudioEventTriggerSystem.d.ts.map +1 -0
  69. package/src/engine/sound/ecs/trigger/AudioEventTriggerSystem.js +184 -0
  70. package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +999 -993
  71. package/src/engine/sound/sopra/README.md +644 -643
  72. package/src/engine/sound/sopra/SopraEngine.d.ts +2 -2
  73. package/src/engine/sound/sopra/SopraEngine.js +432 -432
  74. package/src/engine/sound/sopra/legacy/SoundAttenuationFunction.d.ts.map +1 -0
  75. package/src/engine/sound/{ecs → sopra/legacy}/SoundController.d.ts +3 -3
  76. package/src/engine/sound/sopra/legacy/SoundController.d.ts.map +1 -0
  77. package/src/engine/sound/{ecs → sopra/legacy}/SoundController.js +176 -176
  78. package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitter.d.ts +11 -27
  79. package/src/engine/sound/sopra/legacy/SoundEmitter.d.ts.map +1 -0
  80. package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitter.js +352 -382
  81. package/src/engine/sound/sopra/legacy/SoundEmitterFlags.d.ts.map +1 -0
  82. package/src/engine/sound/sopra/legacy/SoundEmitterSerializationAdapter.d.ts.map +1 -0
  83. package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationUpgrader_0_1.d.ts.map +1 -1
  84. package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationUpgrader_1_2.d.ts.map +1 -1
  85. package/src/engine/sound/sopra/legacy/SoundPanningModelType.d.ts.map +1 -0
  86. package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundTrack.d.ts +11 -55
  87. package/src/engine/sound/sopra/legacy/SoundTrack.d.ts.map +1 -0
  88. package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundTrack.js +278 -357
  89. package/src/engine/sound/sopra/legacy/SoundTrackFlags.d.ts.map +1 -0
  90. package/src/engine/sound/sopra/legacy/convertLegacySoundComponents.d.ts +34 -0
  91. package/src/engine/sound/sopra/legacy/convertLegacySoundComponents.d.ts.map +1 -0
  92. package/src/engine/sound/sopra/legacy/convertLegacySoundComponents.js +279 -0
  93. package/src/engine/sound/sopra/legacy/soundEmitterToEventDescription.d.ts +3 -3
  94. package/src/engine/sound/sopra/legacy/soundEmitterToEventDescription.d.ts.map +1 -1
  95. package/src/engine/sound/sopra/legacy/soundEmitterToEventDescription.js +122 -122
  96. package/src/shade/device/mock/SoftwareGPUDevice.d.ts.map +1 -1
  97. package/src/shade/device/mock/SoftwareGPUDevice.js +7 -1
  98. package/src/shade/device/timing/GPUTimerArray.d.ts.map +1 -1
  99. package/src/shade/device/timing/GPUTimerArray.js +10 -0
  100. package/src/shade/playground/add_random_meshes.d.ts +19 -3
  101. package/src/shade/playground/add_random_meshes.d.ts.map +1 -1
  102. package/src/shade/playground/add_random_meshes.js +132 -84
  103. package/src/shade/playground/bvh_repro/README.md +57 -0
  104. package/src/shade/playground/bvh_repro/bvh_repro_device.d.ts +33 -0
  105. package/src/shade/playground/bvh_repro/bvh_repro_device.d.ts.map +1 -0
  106. package/src/shade/playground/bvh_repro/bvh_repro_device.js +90 -0
  107. package/src/shade/playground/bvh_repro/index.html +124 -0
  108. package/src/shade/playground/bvh_repro/main.d.ts.map +1 -0
  109. package/src/shade/playground/bvh_repro/main.js +254 -0
  110. package/src/shade/playground/load_default_environment.d.ts +10 -1
  111. package/src/shade/playground/load_default_environment.d.ts.map +1 -1
  112. package/src/shade/playground/load_default_environment.js +44 -20
  113. package/src/shade/playground/make_playground_asset_manager.d.ts +16 -0
  114. package/src/shade/playground/make_playground_asset_manager.d.ts.map +1 -0
  115. package/src/shade/playground/make_playground_asset_manager.js +33 -0
  116. package/src/shade/playground/skinned_blas_refit/README.md +103 -0
  117. package/src/shade/playground/skinned_blas_refit/add_floor.d.ts +47 -0
  118. package/src/shade/playground/skinned_blas_refit/add_floor.d.ts.map +1 -0
  119. package/src/shade/playground/skinned_blas_refit/add_floor.js +71 -0
  120. package/src/shade/playground/skinned_blas_refit/diff_traced_against_rasterized.d.ts +28 -0
  121. package/src/shade/playground/skinned_blas_refit/diff_traced_against_rasterized.d.ts.map +1 -0
  122. package/src/shade/playground/skinned_blas_refit/diff_traced_against_rasterized.js +745 -0
  123. package/src/shade/playground/skinned_blas_refit/flat_environment.d.ts +30 -0
  124. package/src/shade/playground/skinned_blas_refit/flat_environment.d.ts.map +1 -0
  125. package/src/shade/playground/skinned_blas_refit/flat_environment.js +67 -0
  126. package/src/shade/playground/skinned_blas_refit/index.html +168 -0
  127. package/src/shade/playground/skinned_blas_refit/main.d.ts +8 -0
  128. package/src/shade/playground/skinned_blas_refit/main.d.ts.map +1 -0
  129. package/src/shade/playground/skinned_blas_refit/main.js +502 -0
  130. package/src/shade/playground/skinned_blas_refit/place_character.d.ts +81 -0
  131. package/src/shade/playground/skinned_blas_refit/place_character.d.ts.map +1 -0
  132. package/src/shade/playground/skinned_blas_refit/place_character.js +116 -0
  133. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.d.ts +21 -0
  134. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.d.ts.map +1 -0
  135. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.js +104 -0
  136. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.d.ts +23 -0
  137. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.d.ts.map +1 -0
  138. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.js +94 -0
  139. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.d.ts +26 -0
  140. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.d.ts.map +1 -0
  141. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.js +134 -0
  142. package/src/shade/playground/skinned_blas_refit/sweep_views.d.ts +40 -0
  143. package/src/shade/playground/skinned_blas_refit/sweep_views.d.ts.map +1 -0
  144. package/src/shade/playground/skinned_blas_refit/sweep_views.js +135 -0
  145. package/src/shade/playground/skinned_blas_refit/verify_instance_bounds.d.ts +31 -0
  146. package/src/shade/playground/skinned_blas_refit/verify_instance_bounds.d.ts.map +1 -0
  147. package/src/shade/playground/skinned_blas_refit/verify_instance_bounds.js +240 -0
  148. package/src/shade/playground/skinned_blas_refit/verify_leaf_triangles.d.ts +20 -0
  149. package/src/shade/playground/skinned_blas_refit/verify_leaf_triangles.d.ts.map +1 -0
  150. package/src/shade/playground/skinned_blas_refit/verify_leaf_triangles.js +202 -0
  151. package/src/shade/playground/skinned_blas_refit/verify_skinned_blas_refit.d.ts +50 -0
  152. package/src/shade/playground/skinned_blas_refit/verify_skinned_blas_refit.d.ts.map +1 -0
  153. package/src/shade/playground/skinned_blas_refit/verify_skinned_blas_refit.js +366 -0
  154. package/src/shade/playground/skinned_blas_refit/verify_traversal_reachability.d.ts +17 -0
  155. package/src/shade/playground/skinned_blas_refit/verify_traversal_reachability.d.ts.map +1 -0
  156. package/src/shade/playground/skinned_blas_refit/verify_traversal_reachability.js +249 -0
  157. package/src/shade/playground/sponza_path_trace/README.md +57 -0
  158. package/src/shade/playground/sponza_path_trace/index.html +164 -0
  159. package/src/shade/playground/sponza_path_trace/main.d.ts +8 -0
  160. package/src/shade/playground/sponza_path_trace/main.d.ts.map +1 -0
  161. package/src/shade/playground/sponza_path_trace/main.js +363 -0
  162. package/src/shade/renderer/Renderer.d.ts +16 -0
  163. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  164. package/src/shade/renderer/Renderer.js +23 -4
  165. package/src/shade/renderer/animation/GPUAnimationManager.d.ts +5 -1
  166. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  167. package/src/shade/renderer/animation/GPUAnimationManager.js +28 -3
  168. package/src/shade/renderer/animation/Skin.d.ts +9 -7
  169. package/src/shade/renderer/animation/Skin.d.ts.map +1 -1
  170. package/src/shade/renderer/animation/Skin.js +9 -7
  171. package/src/shade/renderer/animation/compute_skin_world_bounds.d.ts +35 -0
  172. package/src/shade/renderer/animation/compute_skin_world_bounds.d.ts.map +1 -0
  173. package/src/shade/renderer/animation/compute_skin_world_bounds.js +146 -0
  174. package/src/shade/renderer/animation/skinning/GPUMeshSkinningContext.d.ts.map +1 -1
  175. package/src/shade/renderer/animation/skinning/GPUMeshSkinningContext.js +41 -0
  176. package/src/shade/renderer/geometry/GPUGeometryManager.d.ts.map +1 -1
  177. package/src/shade/renderer/geometry/GPUGeometryManager.js +65 -15
  178. package/src/shade/renderer/geometry/bvh/GPUGeometryBVHManager.d.ts +78 -7
  179. package/src/shade/renderer/geometry/bvh/GPUGeometryBVHManager.d.ts.map +1 -1
  180. package/src/shade/renderer/geometry/bvh/GPUGeometryBVHManager.js +302 -77
  181. package/src/shade/renderer/geometry/bvh/GPUGeometryMeshletsBVH.d.ts +18 -6
  182. package/src/shade/renderer/geometry/bvh/GPUGeometryMeshletsBVH.d.ts.map +1 -1
  183. package/src/shade/renderer/geometry/bvh/GPUGeometryMeshletsBVH.js +19 -6
  184. package/src/shade/renderer/geometry/bvh/bvh2_check_bounds.d.ts +51 -0
  185. package/src/shade/renderer/geometry/bvh/bvh2_check_bounds.d.ts.map +1 -0
  186. package/src/shade/renderer/geometry/bvh/bvh2_check_bounds.js +260 -0
  187. package/src/shade/renderer/geometry/bvh/bvh2_derive_topology.d.ts +23 -0
  188. package/src/shade/renderer/geometry/bvh/bvh2_derive_topology.d.ts.map +1 -0
  189. package/src/shade/renderer/geometry/bvh/bvh2_derive_topology.js +82 -0
  190. package/src/shade/renderer/geometry/bvh/record_blas_refit.d.ts +25 -0
  191. package/src/shade/renderer/geometry/bvh/record_blas_refit.d.ts.map +1 -0
  192. package/src/shade/renderer/geometry/bvh/record_blas_refit.js +102 -0
  193. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.d.ts +30 -0
  194. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.d.ts.map +1 -0
  195. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.js +122 -0
  196. package/src/shade/renderer/global_illumination/brick4/cpu/brick4_empty_gpu_structure.d.ts +23 -0
  197. package/src/shade/renderer/global_illumination/brick4/cpu/brick4_empty_gpu_structure.d.ts.map +1 -0
  198. package/src/shade/renderer/global_illumination/brick4/cpu/brick4_empty_gpu_structure.js +34 -0
  199. package/src/shade/renderer/gpu_primitive/bvh/BVH_BUFFER_LAYOUT.d.ts +55 -0
  200. package/src/shade/renderer/gpu_primitive/bvh/BVH_BUFFER_LAYOUT.d.ts.map +1 -0
  201. package/src/shade/renderer/gpu_primitive/bvh/BVH_BUFFER_LAYOUT.js +70 -0
  202. package/src/shade/renderer/gpu_primitive/bvh/BVH_BUILD_SETTINGS_STRUCT.d.ts +37 -0
  203. package/src/shade/renderer/gpu_primitive/bvh/BVH_BUILD_SETTINGS_STRUCT.d.ts.map +1 -0
  204. package/src/shade/renderer/gpu_primitive/bvh/BVH_BUILD_SETTINGS_STRUCT.js +45 -0
  205. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +1115 -0
  206. package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +201 -0
  207. package/src/shade/renderer/gpu_primitive/bvh/check_bvh_structure.d.ts +88 -0
  208. package/src/shade/renderer/gpu_primitive/bvh/check_bvh_structure.d.ts.map +1 -0
  209. package/src/shade/renderer/gpu_primitive/bvh/check_bvh_structure.js +233 -0
  210. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_delta.d.ts +18 -0
  211. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_delta.d.ts.map +1 -0
  212. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_delta.js +38 -0
  213. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_determine_range.d.ts +16 -0
  214. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_determine_range.d.ts.map +1 -0
  215. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_determine_range.js +79 -0
  216. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_find_split.d.ts +16 -0
  217. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_find_split.d.ts.map +1 -0
  218. package/src/shade/renderer/gpu_primitive/bvh/chunk_bvh_find_split.js +49 -0
  219. package/src/shade/renderer/gpu_primitive/bvh/graph_bvh_build_lbvh.d.ts +43 -0
  220. package/src/shade/renderer/gpu_primitive/bvh/graph_bvh_build_lbvh.d.ts.map +1 -0
  221. package/src/shade/renderer/gpu_primitive/bvh/graph_bvh_build_lbvh.js +217 -0
  222. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.d.ts +24 -0
  223. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.d.ts.map +1 -0
  224. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.js +143 -0
  225. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.d.ts +17 -0
  226. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.d.ts.map +1 -0
  227. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.js +83 -0
  228. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.d.ts +24 -0
  229. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.d.ts.map +1 -0
  230. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.js +141 -0
  231. package/src/shade/renderer/gpu_primitive/bvh/verify_gpu_bvh_build.d.ts +31 -0
  232. package/src/shade/renderer/gpu_primitive/bvh/verify_gpu_bvh_build.d.ts.map +1 -0
  233. package/src/shade/renderer/gpu_primitive/bvh/verify_gpu_bvh_build.js +422 -0
  234. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/test_prefix_sum.d.ts.map +1 -1
  235. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/test_prefix_sum.js +1 -2
  236. package/src/shade/renderer/gpu_primitive/sort/SORT_PASS_SETTINGS_STRUCT.d.ts +9 -0
  237. package/src/shade/renderer/gpu_primitive/sort/SORT_PASS_SETTINGS_STRUCT.d.ts.map +1 -0
  238. package/src/shade/renderer/gpu_primitive/sort/SORT_PASS_SETTINGS_STRUCT.js +23 -0
  239. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_digit.d.ts +18 -0
  240. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_digit.d.ts.map +1 -0
  241. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_digit.js +32 -0
  242. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_rank.d.ts +27 -0
  243. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_rank.d.ts.map +1 -0
  244. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_rank.js +41 -0
  245. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_workgroup_scan.d.ts +25 -0
  246. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_workgroup_scan.d.ts.map +1 -0
  247. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_workgroup_scan.js +74 -0
  248. package/src/shade/renderer/gpu_primitive/sort/defs.d.ts +71 -0
  249. package/src/shade/renderer/gpu_primitive/sort/defs.d.ts.map +1 -0
  250. package/src/shade/renderer/gpu_primitive/sort/defs.js +79 -0
  251. package/src/shade/renderer/gpu_primitive/sort/graph_radix_sort.d.ts +37 -0
  252. package/src/shade/renderer/gpu_primitive/sort/graph_radix_sort.d.ts.map +1 -0
  253. package/src/shade/renderer/gpu_primitive/sort/graph_radix_sort.js +197 -0
  254. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.d.ts +16 -0
  255. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.d.ts.map +1 -0
  256. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.js +85 -0
  257. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.d.ts +15 -0
  258. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.d.ts.map +1 -0
  259. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.js +192 -0
  260. package/src/shade/renderer/gpu_primitive/sort/verify_radix_sort.d.ts +12 -0
  261. package/src/shade/renderer/gpu_primitive/sort/verify_radix_sort.d.ts.map +1 -0
  262. package/src/shade/renderer/gpu_primitive/sort/verify_radix_sort.js +188 -0
  263. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.d.ts +16 -4
  264. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.d.ts.map +1 -1
  265. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.js +235 -24
  266. package/src/shade/renderer/scene/GPUSceneContext.d.ts.map +1 -1
  267. package/src/shade/renderer/scene/GPUSceneContext.js +15 -3
  268. package/src/shade/renderer/scene/SkinnedMesh.d.ts +57 -11
  269. package/src/shade/renderer/scene/SkinnedMesh.d.ts.map +1 -1
  270. package/src/shade/renderer/scene/SkinnedMesh.js +111 -11
  271. package/src/shade/renderer/scene/optimization/geometry/internal/anchor_grow_topological.d.ts.map +1 -1
  272. package/src/shade/renderer/scene/optimization/geometry/internal/anchor_grow_topological.js +12 -3
  273. package/src/shade/renderer/scene/optimization/geometry/internal/match_validate_topology.js +3 -3
  274. package/src/shade/renderer/scene/optimization/geometry/internal/seed_correspondence_from_window.js +3 -3
  275. package/src/shade/renderer/scene/serialization/deserialize_scene.d.ts.map +1 -1
  276. package/src/shade/renderer/scene/serialization/deserialize_scene.js +0 -2
  277. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.d.ts +22 -0
  278. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.d.ts.map +1 -0
  279. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.js +145 -0
  280. package/src/shade/renderer/scene/verify_gpu_tlas_build.d.ts +18 -0
  281. package/src/shade/renderer/scene/verify_gpu_tlas_build.d.ts.map +1 -0
  282. package/src/shade/renderer/scene/verify_gpu_tlas_build.js +364 -0
  283. package/src/shade/renderer/shader/chunk/binary/chunk_split_by_3.d.ts +9 -1
  284. package/src/shade/renderer/shader/chunk/binary/chunk_split_by_3.d.ts.map +1 -1
  285. package/src/shade/renderer/shader/chunk/binary/chunk_split_by_3.js +29 -21
  286. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_v3_morton_encode.d.ts +22 -0
  287. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_v3_morton_encode.d.ts.map +1 -0
  288. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_v3_morton_encode.js +31 -0
  289. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_intersects_ray.d.ts.map +1 -1
  290. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_intersects_ray.js +87 -39
  291. package/src/shade/renderer/shader/chunk/geometry/format/chunk_geometry_read_triangle_bounds.d.ts +17 -0
  292. package/src/shade/renderer/shader/chunk/geometry/format/chunk_geometry_read_triangle_bounds.d.ts.map +1 -0
  293. package/src/shade/renderer/shader/chunk/geometry/format/chunk_geometry_read_triangle_bounds.js +40 -0
  294. package/src/shade/renderer/shader/chunk/geometry/meshlet/chunk_read_meshlet_triangle_bounds.d.ts +15 -0
  295. package/src/shade/renderer/shader/chunk/geometry/meshlet/chunk_read_meshlet_triangle_bounds.d.ts.map +1 -0
  296. package/src/shade/renderer/shader/chunk/geometry/meshlet/chunk_read_meshlet_triangle_bounds.js +52 -0
  297. package/src/shade/renderer/shader/chunk/geometry/ray/RAY_STRUCT.d.ts +9 -0
  298. package/src/shade/renderer/shader/chunk/geometry/ray/RAY_STRUCT.d.ts.map +1 -1
  299. package/src/shade/renderer/shader/chunk/geometry/ray/RAY_STRUCT.js +9 -0
  300. package/src/shade/renderer/shader/chunk/geometry/ray/chunk_ray_transform_m4.d.ts +20 -0
  301. package/src/shade/renderer/shader/chunk/geometry/ray/chunk_ray_transform_m4.d.ts.map +1 -1
  302. package/src/shade/renderer/shader/chunk/geometry/ray/chunk_ray_transform_m4.js +52 -34
  303. package/src/shade/renderer/texture/virtual/README.md +255 -276
  304. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.d.ts.map +1 -1
  305. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +66 -2
  306. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  307. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +83 -0
  308. package/src/view/interaction/InterfaceCommand.d.ts +8 -8
  309. package/src/view/interaction/InterfaceCommand.d.ts.map +1 -1
  310. package/src/view/interaction/InterfaceCommand.js +146 -146
  311. package/src/view/interaction/createInterfaceCommandButton.d.ts.map +1 -1
  312. package/src/view/interaction/createInterfaceCommandButton.js +115 -124
  313. package/src/engine/ecs/components/Timer.d.ts +0 -50
  314. package/src/engine/ecs/components/Timer.d.ts.map +0 -1
  315. package/src/engine/ecs/components/Timer.js +0 -80
  316. package/src/engine/ecs/evaluation/PointFitnessBinary.d.ts +0 -22
  317. package/src/engine/ecs/evaluation/PointFitnessBinary.d.ts.map +0 -1
  318. package/src/engine/ecs/evaluation/PointFitnessBinary.js +0 -33
  319. package/src/engine/ecs/evaluation/PointFitnessConstant.d.ts +0 -12
  320. package/src/engine/ecs/evaluation/PointFitnessConstant.d.ts.map +0 -1
  321. package/src/engine/ecs/evaluation/PointFitnessConstant.js +0 -27
  322. package/src/engine/ecs/evaluation/PointFitnessFunction.d.ts +0 -17
  323. package/src/engine/ecs/evaluation/PointFitnessFunction.d.ts.map +0 -1
  324. package/src/engine/ecs/evaluation/PointFitnessFunction.js +0 -20
  325. package/src/engine/ecs/evaluation/arithmetic/PointFitnessAdd.d.ts +0 -12
  326. package/src/engine/ecs/evaluation/arithmetic/PointFitnessAdd.d.ts.map +0 -1
  327. package/src/engine/ecs/evaluation/arithmetic/PointFitnessAdd.js +0 -27
  328. package/src/engine/ecs/evaluation/arithmetic/PointFitnessMultiply.d.ts +0 -12
  329. package/src/engine/ecs/evaluation/arithmetic/PointFitnessMultiply.d.ts.map +0 -1
  330. package/src/engine/ecs/evaluation/arithmetic/PointFitnessMultiply.js +0 -28
  331. package/src/engine/ecs/evaluation/complex/PointFitnessGaussianBlur.d.ts +0 -50
  332. package/src/engine/ecs/evaluation/complex/PointFitnessGaussianBlur.d.ts.map +0 -1
  333. package/src/engine/ecs/evaluation/complex/PointFitnessGaussianBlur.js +0 -176
  334. package/src/engine/ecs/evaluation/json/deserializeFromJSON_PointFitnessFunction.d.ts +0 -7
  335. package/src/engine/ecs/evaluation/json/deserializeFromJSON_PointFitnessFunction.d.ts.map +0 -1
  336. package/src/engine/ecs/evaluation/json/deserializeFromJSON_PointFitnessFunction.js +0 -68
  337. package/src/engine/ecs/evaluation/json/serializeToJSON_PointFitnessFunction.d.ts +0 -7
  338. package/src/engine/ecs/evaluation/json/serializeToJSON_PointFitnessFunction.d.ts.map +0 -1
  339. package/src/engine/ecs/evaluation/json/serializeToJSON_PointFitnessFunction.js +0 -89
  340. package/src/engine/ecs/evaluation/world/ReadTerrainLayerWeightPointFitness.d.ts +0 -17
  341. package/src/engine/ecs/evaluation/world/ReadTerrainLayerWeightPointFitness.d.ts.map +0 -1
  342. package/src/engine/ecs/evaluation/world/ReadTerrainLayerWeightPointFitness.js +0 -60
  343. package/src/engine/ecs/systems/TimerSystem.d.ts +0 -10
  344. package/src/engine/ecs/systems/TimerSystem.d.ts.map +0 -1
  345. package/src/engine/ecs/systems/TimerSystem.js +0 -86
  346. package/src/engine/graphics/FULL_SCREEN_QUAD_VERTEX_SHADER.d.ts +0 -13
  347. package/src/engine/graphics/FULL_SCREEN_QUAD_VERTEX_SHADER.d.ts.map +0 -1
  348. package/src/engine/graphics/FULL_SCREEN_QUAD_VERTEX_SHADER.js +0 -14
  349. package/src/engine/graphics/StandardFrameBuffers.d.ts +0 -7
  350. package/src/engine/graphics/StandardFrameBuffers.d.ts.map +0 -1
  351. package/src/engine/graphics/StandardFrameBuffers.js +0 -9
  352. package/src/engine/graphics/copy_transform_to_threejs_object.d.ts +0 -7
  353. package/src/engine/graphics/copy_transform_to_threejs_object.d.ts.map +0 -1
  354. package/src/engine/graphics/copy_transform_to_threejs_object.js +0 -12
  355. package/src/engine/graphics/ecs/light/binding/LightBinding.d.ts +0 -42
  356. package/src/engine/graphics/ecs/light/binding/LightBinding.d.ts.map +0 -1
  357. package/src/engine/graphics/ecs/light/binding/LightBinding.js +0 -133
  358. package/src/engine/graphics/ecs/light/binding/fp/FPLightBinding.d.ts +0 -25
  359. package/src/engine/graphics/ecs/light/binding/fp/FPLightBinding.d.ts.map +0 -1
  360. package/src/engine/graphics/ecs/light/binding/fp/FPLightBinding.js +0 -90
  361. package/src/engine/graphics/ecs/light/binding/three/applyRotation.d.ts +0 -7
  362. package/src/engine/graphics/ecs/light/binding/three/applyRotation.d.ts.map +0 -1
  363. package/src/engine/graphics/ecs/light/binding/three/applyRotation.js +0 -24
  364. package/src/engine/graphics/ecs/light/binding/three/threeEnsureLightObject.d.ts +0 -7
  365. package/src/engine/graphics/ecs/light/binding/three/threeEnsureLightObject.d.ts.map +0 -1
  366. package/src/engine/graphics/ecs/light/binding/three/threeEnsureLightObject.js +0 -12
  367. package/src/engine/graphics/ecs/light/binding/three/threeMakeLight.d.ts +0 -8
  368. package/src/engine/graphics/ecs/light/binding/three/threeMakeLight.d.ts.map +0 -1
  369. package/src/engine/graphics/ecs/light/binding/three/threeMakeLight.js +0 -41
  370. package/src/engine/graphics/ecs/light/shadow/LightShadow.d.ts +0 -4
  371. package/src/engine/graphics/ecs/light/shadow/LightShadow.d.ts.map +0 -1
  372. package/src/engine/graphics/ecs/light/shadow/LightShadow.js +0 -4
  373. package/src/engine/graphics/ecs/light/shadow/ShadowManager.d.ts +0 -27
  374. package/src/engine/graphics/ecs/light/shadow/ShadowManager.d.ts.map +0 -1
  375. package/src/engine/graphics/ecs/light/shadow/ShadowManager.js +0 -53
  376. package/src/engine/graphics/ecs/light/shadow/ShadowMap.d.ts +0 -8
  377. package/src/engine/graphics/ecs/light/shadow/ShadowMap.d.ts.map +0 -1
  378. package/src/engine/graphics/ecs/light/shadow/ShadowMap.js +0 -7
  379. package/src/engine/graphics/ecs/light/shadow/compute_view_frustum_aabb_in_space.d.ts +0 -14
  380. package/src/engine/graphics/ecs/light/shadow/compute_view_frustum_aabb_in_space.d.ts.map +0 -1
  381. package/src/engine/graphics/ecs/light/shadow/compute_view_frustum_aabb_in_space.js +0 -62
  382. package/src/engine/graphics/ecs/light/shadow/extend_shadow_camera_near_for_casters.d.ts +0 -30
  383. package/src/engine/graphics/ecs/light/shadow/extend_shadow_camera_near_for_casters.d.ts.map +0 -1
  384. package/src/engine/graphics/ecs/light/shadow/extend_shadow_camera_near_for_casters.js +0 -77
  385. package/src/engine/graphics/ecs/light/shadow/setShadowCameraDimensionsDiscrete.d.ts +0 -13
  386. package/src/engine/graphics/ecs/light/shadow/setShadowCameraDimensionsDiscrete.d.ts.map +0 -1
  387. package/src/engine/graphics/ecs/light/shadow/setShadowCameraDimensionsDiscrete.js +0 -43
  388. package/src/engine/graphics/ecs/light/three/light2type.d.ts +0 -8
  389. package/src/engine/graphics/ecs/light/three/light2type.d.ts.map +0 -1
  390. package/src/engine/graphics/ecs/light/three/light2type.js +0 -20
  391. package/src/engine/graphics/ecs/mesh/applyTransformToThreeObject.d.ts +0 -7
  392. package/src/engine/graphics/ecs/mesh/applyTransformToThreeObject.d.ts.map +0 -1
  393. package/src/engine/graphics/ecs/mesh/applyTransformToThreeObject.js +0 -13
  394. package/src/engine/graphics/particles/node-based/codegen/CodeContext.d.ts +0 -20
  395. package/src/engine/graphics/particles/node-based/codegen/CodeContext.d.ts.map +0 -1
  396. package/src/engine/graphics/particles/node-based/codegen/CodeContext.js +0 -47
  397. package/src/engine/graphics/particles/node-based/codegen/CodeGenerator.d.ts +0 -12
  398. package/src/engine/graphics/particles/node-based/codegen/CodeGenerator.d.ts.map +0 -1
  399. package/src/engine/graphics/particles/node-based/codegen/CodeGenerator.js +0 -19
  400. package/src/engine/graphics/particles/node-based/codegen/glsl/GLSLCodeGenerator.d.ts +0 -18
  401. package/src/engine/graphics/particles/node-based/codegen/glsl/GLSLCodeGenerator.d.ts.map +0 -1
  402. package/src/engine/graphics/particles/node-based/codegen/glsl/GLSLCodeGenerator.js +0 -249
  403. package/src/engine/graphics/particles/node-based/codegen/glsl/genAttributeInputName.d.ts +0 -7
  404. package/src/engine/graphics/particles/node-based/codegen/glsl/genAttributeInputName.d.ts.map +0 -1
  405. package/src/engine/graphics/particles/node-based/codegen/glsl/genAttributeInputName.js +0 -8
  406. package/src/engine/graphics/particles/node-based/codegen/glsl/genAttributeOutputName.d.ts +0 -7
  407. package/src/engine/graphics/particles/node-based/codegen/glsl/genAttributeOutputName.d.ts.map +0 -1
  408. package/src/engine/graphics/particles/node-based/codegen/glsl/genAttributeOutputName.js +0 -8
  409. package/src/engine/graphics/particles/node-based/codegen/glsl/getTypeByteSize.d.ts +0 -14
  410. package/src/engine/graphics/particles/node-based/codegen/glsl/getTypeByteSize.d.ts.map +0 -1
  411. package/src/engine/graphics/particles/node-based/codegen/glsl/getTypeByteSize.js +0 -20
  412. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/curl/glslm_curlNoise.d.ts +0 -3
  413. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/curl/glslm_curlNoise.d.ts.map +0 -1
  414. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/curl/glslm_curlNoise.js +0 -58
  415. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/curl/glslm_snoiseVector3.d.ts +0 -3
  416. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/curl/glslm_snoiseVector3.d.ts.map +0 -1
  417. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/curl/glslm_snoiseVector3.js +0 -44
  418. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_mod289_v3.d.ts +0 -3
  419. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_mod289_v3.d.ts.map +0 -1
  420. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_mod289_v3.js +0 -28
  421. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_mod289_v4.d.ts +0 -3
  422. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_mod289_v4.d.ts.map +0 -1
  423. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_mod289_v4.js +0 -28
  424. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_permute.d.ts +0 -3
  425. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_permute.d.ts.map +0 -1
  426. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_permute.js +0 -39
  427. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_snoise.d.ts +0 -3
  428. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_snoise.d.ts.map +0 -1
  429. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_snoise.js +0 -124
  430. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_tylorInvSqrt.d.ts +0 -3
  431. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_tylorInvSqrt.d.ts.map +0 -1
  432. package/src/engine/graphics/particles/node-based/codegen/glsl/modules/noise/simplex/3d/glslm_tylorInvSqrt.js +0 -30
  433. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModule.d.ts +0 -64
  434. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModule.d.ts.map +0 -1
  435. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModule.js +0 -131
  436. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModuleReference.d.ts +0 -32
  437. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModuleReference.d.ts.map +0 -1
  438. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModuleReference.js +0 -62
  439. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModuleRegistry.d.ts +0 -46
  440. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModuleRegistry.d.ts.map +0 -1
  441. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionModuleRegistry.js +0 -272
  442. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionParameterSpecification.d.ts +0 -28
  443. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionParameterSpecification.d.ts.map +0 -1
  444. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionParameterSpecification.js +0 -57
  445. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionSignature.d.ts +0 -32
  446. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionSignature.d.ts.map +0 -1
  447. package/src/engine/graphics/particles/node-based/codegen/modules/FunctionSignature.js +0 -55
  448. package/src/engine/graphics/particles/node-based/editor/ParticleSpecificationEditorView.d.ts +0 -34
  449. package/src/engine/graphics/particles/node-based/editor/ParticleSpecificationEditorView.d.ts.map +0 -1
  450. package/src/engine/graphics/particles/node-based/editor/ParticleSpecificationEditorView.js +0 -178
  451. package/src/engine/graphics/particles/node-based/nodes/FloatConstant.d.ts +0 -7
  452. package/src/engine/graphics/particles/node-based/nodes/FloatConstant.d.ts.map +0 -1
  453. package/src/engine/graphics/particles/node-based/nodes/FloatConstant.js +0 -31
  454. package/src/engine/graphics/particles/node-based/nodes/ParticleDataTypes.d.ts +0 -13
  455. package/src/engine/graphics/particles/node-based/nodes/ParticleDataTypes.d.ts.map +0 -1
  456. package/src/engine/graphics/particles/node-based/nodes/ParticleDataTypes.js +0 -13
  457. package/src/engine/graphics/particles/node-based/nodes/ReadFloatUniform.d.ts +0 -7
  458. package/src/engine/graphics/particles/node-based/nodes/ReadFloatUniform.d.ts.map +0 -1
  459. package/src/engine/graphics/particles/node-based/nodes/ReadFloatUniform.js +0 -26
  460. package/src/engine/graphics/particles/node-based/nodes/ReadPosition.d.ts +0 -4
  461. package/src/engine/graphics/particles/node-based/nodes/ReadPosition.d.ts.map +0 -1
  462. package/src/engine/graphics/particles/node-based/nodes/ReadPosition.js +0 -5
  463. package/src/engine/graphics/particles/node-based/nodes/ReadVelocity.d.ts +0 -6
  464. package/src/engine/graphics/particles/node-based/nodes/ReadVelocity.d.ts.map +0 -1
  465. package/src/engine/graphics/particles/node-based/nodes/ReadVelocity.js +0 -14
  466. package/src/engine/graphics/particles/node-based/nodes/ShaderNode.d.ts +0 -37
  467. package/src/engine/graphics/particles/node-based/nodes/ShaderNode.d.ts.map +0 -1
  468. package/src/engine/graphics/particles/node-based/nodes/ShaderNode.js +0 -73
  469. package/src/engine/graphics/particles/node-based/nodes/Vector3Constant.d.ts +0 -7
  470. package/src/engine/graphics/particles/node-based/nodes/Vector3Constant.d.ts.map +0 -1
  471. package/src/engine/graphics/particles/node-based/nodes/Vector3Constant.js +0 -30
  472. package/src/engine/graphics/particles/node-based/nodes/Vector3Merge.d.ts +0 -7
  473. package/src/engine/graphics/particles/node-based/nodes/Vector3Merge.d.ts.map +0 -1
  474. package/src/engine/graphics/particles/node-based/nodes/Vector3Merge.js +0 -30
  475. package/src/engine/graphics/particles/node-based/nodes/Vector3Split.d.ts +0 -7
  476. package/src/engine/graphics/particles/node-based/nodes/Vector3Split.d.ts.map +0 -1
  477. package/src/engine/graphics/particles/node-based/nodes/Vector3Split.js +0 -32
  478. package/src/engine/graphics/particles/node-based/nodes/WriteVelocity.d.ts +0 -6
  479. package/src/engine/graphics/particles/node-based/nodes/WriteVelocity.d.ts.map +0 -1
  480. package/src/engine/graphics/particles/node-based/nodes/WriteVelocity.js +0 -15
  481. package/src/engine/graphics/particles/node-based/nodes/attribute/ReadVector3Attribute.d.ts +0 -7
  482. package/src/engine/graphics/particles/node-based/nodes/attribute/ReadVector3Attribute.d.ts.map +0 -1
  483. package/src/engine/graphics/particles/node-based/nodes/attribute/ReadVector3Attribute.js +0 -27
  484. package/src/engine/graphics/particles/node-based/nodes/attribute/WriteVector3Attribute.d.ts +0 -7
  485. package/src/engine/graphics/particles/node-based/nodes/attribute/WriteVector3Attribute.d.ts.map +0 -1
  486. package/src/engine/graphics/particles/node-based/nodes/attribute/WriteVector3Attribute.js +0 -25
  487. package/src/engine/graphics/particles/node-based/nodes/math/AddFloatNode.d.ts +0 -7
  488. package/src/engine/graphics/particles/node-based/nodes/math/AddFloatNode.d.ts.map +0 -1
  489. package/src/engine/graphics/particles/node-based/nodes/math/AddFloatNode.js +0 -26
  490. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Add.d.ts +0 -7
  491. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Add.d.ts.map +0 -1
  492. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Add.js +0 -25
  493. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Divide.d.ts +0 -7
  494. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Divide.d.ts.map +0 -1
  495. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Divide.js +0 -25
  496. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Multiply.d.ts +0 -7
  497. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Multiply.d.ts.map +0 -1
  498. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Multiply.js +0 -25
  499. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Subtract.d.ts +0 -7
  500. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Subtract.d.ts.map +0 -1
  501. package/src/engine/graphics/particles/node-based/nodes/math/Vector3Subtract.js +0 -25
  502. package/src/engine/graphics/particles/node-based/nodes/noise/CurlNoiseNode.d.ts +0 -7
  503. package/src/engine/graphics/particles/node-based/nodes/noise/CurlNoiseNode.d.ts.map +0 -1
  504. package/src/engine/graphics/particles/node-based/nodes/noise/CurlNoiseNode.js +0 -39
  505. package/src/engine/graphics/particles/node-based/nodes/particle_data_type_component_count.d.ts +0 -39
  506. package/src/engine/graphics/particles/node-based/nodes/particle_data_type_component_count.d.ts.map +0 -1
  507. package/src/engine/graphics/particles/node-based/nodes/particle_data_type_component_count.js +0 -45
  508. package/src/engine/graphics/particles/node-based/particle/GeometryAttributes.d.ts +0 -9
  509. package/src/engine/graphics/particles/node-based/particle/GeometryAttributes.d.ts.map +0 -1
  510. package/src/engine/graphics/particles/node-based/particle/GeometryAttributes.js +0 -10
  511. package/src/engine/graphics/particles/node-based/particle/ParticleAttributeBinding.d.ts +0 -13
  512. package/src/engine/graphics/particles/node-based/particle/ParticleAttributeBinding.d.ts.map +0 -1
  513. package/src/engine/graphics/particles/node-based/particle/ParticleAttributeBinding.js +0 -14
  514. package/src/engine/graphics/particles/node-based/particle/ParticleAttributeSpecification.d.ts +0 -50
  515. package/src/engine/graphics/particles/node-based/particle/ParticleAttributeSpecification.d.ts.map +0 -1
  516. package/src/engine/graphics/particles/node-based/particle/ParticleAttributeSpecification.js +0 -47
  517. package/src/engine/graphics/particles/node-based/particle/ParticleSpecification.d.ts +0 -51
  518. package/src/engine/graphics/particles/node-based/particle/ParticleSpecification.d.ts.map +0 -1
  519. package/src/engine/graphics/particles/node-based/particle/ParticleSpecification.js +0 -93
  520. package/src/engine/graphics/particles/node-based/populateFunctionModuleRegistry.d.ts +0 -6
  521. package/src/engine/graphics/particles/node-based/populateFunctionModuleRegistry.d.ts.map +0 -1
  522. package/src/engine/graphics/particles/node-based/populateFunctionModuleRegistry.js +0 -23
  523. package/src/engine/graphics/particles/node-based/populateNodeRegistry.d.ts +0 -7
  524. package/src/engine/graphics/particles/node-based/populateNodeRegistry.d.ts.map +0 -1
  525. package/src/engine/graphics/particles/node-based/populateNodeRegistry.js +0 -48
  526. package/src/engine/graphics/particles/node-based/rendering/AbstractParticleRenderer.d.ts +0 -21
  527. package/src/engine/graphics/particles/node-based/rendering/AbstractParticleRenderer.d.ts.map +0 -1
  528. package/src/engine/graphics/particles/node-based/rendering/AbstractParticleRenderer.js +0 -35
  529. package/src/engine/graphics/particles/node-based/rendering/ParticleRenderCommand.d.ts +0 -20
  530. package/src/engine/graphics/particles/node-based/rendering/ParticleRenderCommand.d.ts.map +0 -1
  531. package/src/engine/graphics/particles/node-based/rendering/ParticleRenderCommand.js +0 -35
  532. package/src/engine/graphics/particles/node-based/rendering/ParticleRenderSpecification.d.ts +0 -14
  533. package/src/engine/graphics/particles/node-based/rendering/ParticleRenderSpecification.d.ts.map +0 -1
  534. package/src/engine/graphics/particles/node-based/rendering/ParticleRenderSpecification.js +0 -17
  535. package/src/engine/graphics/particles/node-based/rendering/billboard/ParticleBillboardShader.d.ts +0 -5
  536. package/src/engine/graphics/particles/node-based/rendering/billboard/ParticleBillboardShader.d.ts.map +0 -1
  537. package/src/engine/graphics/particles/node-based/rendering/billboard/ParticleBillboardShader.js +0 -150
  538. package/src/engine/graphics/particles/node-based/simulation/EmitterAttributeData.d.ts +0 -55
  539. package/src/engine/graphics/particles/node-based/simulation/EmitterAttributeData.d.ts.map +0 -1
  540. package/src/engine/graphics/particles/node-based/simulation/EmitterAttributeData.js +0 -105
  541. package/src/engine/graphics/particles/node-based/simulation/GLDataBuffer.d.ts +0 -49
  542. package/src/engine/graphics/particles/node-based/simulation/GLDataBuffer.d.ts.map +0 -1
  543. package/src/engine/graphics/particles/node-based/simulation/GLDataBuffer.js +0 -103
  544. package/src/engine/graphics/particles/node-based/simulation/GLSLParticleSimulator.d.ts +0 -4
  545. package/src/engine/graphics/particles/node-based/simulation/GLSLParticleSimulator.d.ts.map +0 -1
  546. package/src/engine/graphics/particles/node-based/simulation/GLSLParticleSimulator.js +0 -6
  547. package/src/engine/graphics/particles/node-based/simulation/GLSLSimulationShader.d.ts +0 -83
  548. package/src/engine/graphics/particles/node-based/simulation/GLSLSimulationShader.d.ts.map +0 -1
  549. package/src/engine/graphics/particles/node-based/simulation/GLSLSimulationShader.js +0 -188
  550. package/src/engine/graphics/particles/node-based/simulation/TransformFeedback.d.ts +0 -123
  551. package/src/engine/graphics/particles/node-based/simulation/TransformFeedback.d.ts.map +0 -1
  552. package/src/engine/graphics/particles/node-based/simulation/TransformFeedback.js +0 -415
  553. package/src/engine/graphics/render/buffer/FrameBufferManager.d.ts +0 -71
  554. package/src/engine/graphics/render/buffer/FrameBufferManager.d.ts.map +0 -1
  555. package/src/engine/graphics/render/buffer/FrameBufferManager.js +0 -229
  556. package/src/engine/graphics/render/buffer/simple-fx/taa/TemporalSupersamplingRenderPlugin.d.ts +0 -22
  557. package/src/engine/graphics/render/buffer/simple-fx/taa/TemporalSupersamplingRenderPlugin.d.ts.map +0 -1
  558. package/src/engine/graphics/render/buffer/simple-fx/taa/TemporalSupersamplingRenderPlugin.js +0 -84
  559. package/src/engine/graphics/render/forward_plus/LightRenderMetadata.d.ts +0 -33
  560. package/src/engine/graphics/render/forward_plus/LightRenderMetadata.d.ts.map +0 -1
  561. package/src/engine/graphics/render/forward_plus/LightRenderMetadata.js +0 -61
  562. package/src/engine/graphics/render/forward_plus/SPECIFICATION.md +0 -155
  563. package/src/engine/graphics/render/forward_plus/assign_cluster.d.ts +0 -29
  564. package/src/engine/graphics/render/forward_plus/assign_cluster.d.ts.map +0 -1
  565. package/src/engine/graphics/render/forward_plus/assign_cluster.js +0 -124
  566. package/src/engine/graphics/render/forward_plus/cluster/compute_light_data_hash.d.ts +0 -7
  567. package/src/engine/graphics/render/forward_plus/cluster/compute_light_data_hash.d.ts.map +0 -1
  568. package/src/engine/graphics/render/forward_plus/cluster/compute_light_data_hash.js +0 -17
  569. package/src/engine/graphics/render/forward_plus/cluster/compute_light_data_hash_0.d.ts +0 -7
  570. package/src/engine/graphics/render/forward_plus/cluster/compute_light_data_hash_0.d.ts.map +0 -1
  571. package/src/engine/graphics/render/forward_plus/cluster/compute_light_data_hash_0.js +0 -17
  572. package/src/engine/graphics/render/forward_plus/cluster/read_plane_pair.d.ts +0 -9
  573. package/src/engine/graphics/render/forward_plus/cluster/read_plane_pair.d.ts.map +0 -1
  574. package/src/engine/graphics/render/forward_plus/cluster/read_plane_pair.js +0 -29
  575. package/src/engine/graphics/render/forward_plus/cluster/write_cluster_planes.d.ts +0 -12
  576. package/src/engine/graphics/render/forward_plus/cluster/write_cluster_planes.d.ts.map +0 -1
  577. package/src/engine/graphics/render/forward_plus/cluster/write_cluster_planes.js +0 -43
  578. package/src/engine/graphics/render/forward_plus/computeFrustumCorners.d.ts +0 -8
  579. package/src/engine/graphics/render/forward_plus/computeFrustumCorners.d.ts.map +0 -1
  580. package/src/engine/graphics/render/forward_plus/computeFrustumCorners.js +0 -68
  581. package/src/engine/graphics/render/forward_plus/debug/createLayerSwitcher.d.ts +0 -4
  582. package/src/engine/graphics/render/forward_plus/debug/createLayerSwitcher.d.ts.map +0 -1
  583. package/src/engine/graphics/render/forward_plus/debug/createLayerSwitcher.js +0 -46
  584. package/src/engine/graphics/render/forward_plus/debug/createScreenGrid.d.ts +0 -14
  585. package/src/engine/graphics/render/forward_plus/debug/createScreenGrid.d.ts.map +0 -1
  586. package/src/engine/graphics/render/forward_plus/debug/createScreenGrid.js +0 -327
  587. package/src/engine/graphics/render/forward_plus/materials/FP_INJECTION_POINT_ACCUMULATION.d.ts +0 -2
  588. package/src/engine/graphics/render/forward_plus/materials/FP_INJECTION_POINT_ACCUMULATION.d.ts.map +0 -1
  589. package/src/engine/graphics/render/forward_plus/materials/FP_INJECTION_POINT_ACCUMULATION.js +0 -1
  590. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_ACCUMULATION.d.ts +0 -2
  591. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_ACCUMULATION.d.ts.map +0 -1
  592. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_ACCUMULATION.js +0 -40
  593. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_APPLY_DECALS.d.ts +0 -2
  594. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_APPLY_DECALS.d.ts.map +0 -1
  595. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_APPLY_DECALS.js +0 -74
  596. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_DECODE_PARS.d.ts +0 -2
  597. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_DECODE_PARS.d.ts.map +0 -1
  598. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_DECODE_PARS.js +0 -55
  599. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_LOAD_METADATA.d.ts +0 -2
  600. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_LOAD_METADATA.d.ts.map +0 -1
  601. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_LOAD_METADATA.js +0 -11
  602. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_PREAMBLE.d.ts +0 -2
  603. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_PREAMBLE.d.ts.map +0 -1
  604. package/src/engine/graphics/render/forward_plus/materials/FP_SHADER_CHUNK_PREAMBLE.js +0 -34
  605. package/src/engine/graphics/render/forward_plus/materials/fp_build_fragment_shader.d.ts +0 -7
  606. package/src/engine/graphics/render/forward_plus/materials/fp_build_fragment_shader.d.ts.map +0 -1
  607. package/src/engine/graphics/render/forward_plus/materials/fp_build_fragment_shader.js +0 -60
  608. package/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_lighting_shared.d.ts +0 -7
  609. package/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_lighting_shared.d.ts.map +0 -1
  610. package/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_lighting_shared.js +0 -38
  611. package/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_shader.d.ts +0 -7
  612. package/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_shader.d.ts.map +0 -1
  613. package/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_shader.js +0 -30
  614. package/src/engine/graphics/render/forward_plus/model/Decal.d.ts +0 -51
  615. package/src/engine/graphics/render/forward_plus/model/Decal.d.ts.map +0 -1
  616. package/src/engine/graphics/render/forward_plus/model/Decal.js +0 -133
  617. package/src/engine/graphics/render/forward_plus/plugin/forwardPlusMaterialMarker.d.ts +0 -16
  618. package/src/engine/graphics/render/forward_plus/plugin/forwardPlusMaterialMarker.d.ts.map +0 -1
  619. package/src/engine/graphics/render/forward_plus/plugin/forwardPlusMaterialMarker.js +0 -18
  620. package/src/engine/graphics/render/forward_plus/plugin/isLitMaterial.d.ts +0 -7
  621. package/src/engine/graphics/render/forward_plus/plugin/isLitMaterial.d.ts.map +0 -1
  622. package/src/engine/graphics/render/forward_plus/plugin/isLitMaterial.js +0 -8
  623. package/src/engine/graphics/render/forward_plus/query/analytical_biplanar_corner_mapping.d.ts +0 -10
  624. package/src/engine/graphics/render/forward_plus/query/analytical_biplanar_corner_mapping.d.ts.map +0 -1
  625. package/src/engine/graphics/render/forward_plus/query/analytical_biplanar_corner_mapping.js +0 -65
  626. package/src/engine/graphics/render/forward_plus/query/analytical_triplanar_corner_mapping.d.ts +0 -7
  627. package/src/engine/graphics/render/forward_plus/query/analytical_triplanar_corner_mapping.d.ts.map +0 -1
  628. package/src/engine/graphics/render/forward_plus/query/analytical_triplanar_corner_mapping.js +0 -8
  629. package/src/engine/graphics/render/forward_plus/query/cube_mapping.d.ts +0 -11
  630. package/src/engine/graphics/render/forward_plus/query/cube_mapping.d.ts.map +0 -1
  631. package/src/engine/graphics/render/forward_plus/query/cube_mapping.js +0 -38
  632. package/src/engine/graphics/render/forward_plus/query/detailed_sphere_frustum_intersection_test.d.ts +0 -21
  633. package/src/engine/graphics/render/forward_plus/query/detailed_sphere_frustum_intersection_test.d.ts.map +0 -1
  634. package/src/engine/graphics/render/forward_plus/query/detailed_sphere_frustum_intersection_test.js +0 -96
  635. package/src/engine/graphics/render/forward_plus/query/point_light_inside_volume.d.ts +0 -22
  636. package/src/engine/graphics/render/forward_plus/query/point_light_inside_volume.d.ts.map +0 -1
  637. package/src/engine/graphics/render/forward_plus/query/point_light_inside_volume.js +0 -44
  638. package/src/engine/graphics/render/forward_plus/query/query_bvh_frustum_from_texture.d.ts +0 -11
  639. package/src/engine/graphics/render/forward_plus/query/query_bvh_frustum_from_texture.d.ts.map +0 -1
  640. package/src/engine/graphics/render/forward_plus/query/query_bvh_frustum_from_texture.js +0 -169
  641. package/src/engine/graphics/render/forward_plus/query/spot_light_inside_volume.d.ts +0 -9
  642. package/src/engine/graphics/render/forward_plus/query/spot_light_inside_volume.d.ts.map +0 -1
  643. package/src/engine/graphics/render/forward_plus/query/spot_light_inside_volume.js +0 -33
  644. package/src/engine/graphics/render/forward_plus/read_frustum_corner.d.ts +0 -2
  645. package/src/engine/graphics/render/forward_plus/read_frustum_corner.d.ts.map +0 -1
  646. package/src/engine/graphics/render/forward_plus/read_frustum_corner.js +0 -2
  647. package/src/engine/graphics/render/forward_plus/sort_decal_data.d.ts +0 -9
  648. package/src/engine/graphics/render/forward_plus/sort_decal_data.d.ts.map +0 -1
  649. package/src/engine/graphics/render/forward_plus/sort_decal_data.js +0 -102
  650. package/src/engine/graphics/render/forward_plus/testClusterEquality.d.ts +0 -10
  651. package/src/engine/graphics/render/forward_plus/testClusterEquality.d.ts.map +0 -1
  652. package/src/engine/graphics/render/forward_plus/testClusterEquality.js +0 -22
  653. package/src/engine/graphics/render/frame_graph/webgl/WebGLRenderContext.d.ts +0 -19
  654. package/src/engine/graphics/render/frame_graph/webgl/WebGLRenderContext.d.ts.map +0 -1
  655. package/src/engine/graphics/render/frame_graph/webgl/WebGLRenderContext.js +0 -62
  656. package/src/engine/graphics/shaders/AlphaBlendShader.d.ts +0 -18
  657. package/src/engine/graphics/shaders/AlphaBlendShader.d.ts.map +0 -1
  658. package/src/engine/graphics/shaders/AlphaBlendShader.js +0 -42
  659. package/src/engine/graphics/shaders/ClearShader.d.ts +0 -8
  660. package/src/engine/graphics/shaders/ClearShader.d.ts.map +0 -1
  661. package/src/engine/graphics/shaders/ClearShader.js +0 -28
  662. package/src/engine/graphics/shaders/ScreenSpaceQuadShader.d.ts +0 -13
  663. package/src/engine/graphics/shaders/ScreenSpaceQuadShader.d.ts.map +0 -1
  664. package/src/engine/graphics/shaders/ScreenSpaceQuadShader.js +0 -38
  665. package/src/engine/graphics/shaders/ScreenSpaceShader.d.ts +0 -7
  666. package/src/engine/graphics/shaders/ScreenSpaceShader.d.ts.map +0 -1
  667. package/src/engine/graphics/shaders/ScreenSpaceShader.js +0 -102
  668. package/src/engine/graphics/shaders/chunks/glsl_octahedral_impostor.d.ts +0 -102
  669. package/src/engine/graphics/shaders/chunks/glsl_octahedral_impostor.d.ts.map +0 -1
  670. package/src/engine/graphics/shaders/chunks/glsl_octahedral_impostor.js +0 -277
  671. package/src/engine/graphics/shaders/chunks/glsl_voxel_impostor_splat.d.ts +0 -40
  672. package/src/engine/graphics/shaders/chunks/glsl_voxel_impostor_splat.d.ts.map +0 -1
  673. package/src/engine/graphics/shaders/chunks/glsl_voxel_impostor_splat.js +0 -57
  674. package/src/engine/graphics/shaders/glsl_gen_swizzled_read.d.ts +0 -8
  675. package/src/engine/graphics/shaders/glsl_gen_swizzled_read.d.ts.map +0 -1
  676. package/src/engine/graphics/shaders/glsl_gen_swizzled_read.js +0 -39
  677. package/src/engine/graphics/shadows/README.md +0 -6
  678. package/src/engine/graphics/shadows/vsm.glsl.d.ts +0 -3
  679. package/src/engine/graphics/shadows/vsm.glsl.d.ts.map +0 -1
  680. package/src/engine/graphics/shadows/vsm.glsl.js +0 -43
  681. package/src/engine/graphics/util/composeMatrix4.d.ts +0 -9
  682. package/src/engine/graphics/util/composeMatrix4.d.ts.map +0 -1
  683. package/src/engine/graphics/util/composeMatrix4.js +0 -13
  684. package/src/engine/graphics/util/threeUpdateMatrix.d.ts +0 -6
  685. package/src/engine/graphics/util/threeUpdateMatrix.d.ts.map +0 -1
  686. package/src/engine/graphics/util/threeUpdateMatrix.js +0 -14
  687. package/src/engine/graphics/util/threeUpdateTransform.d.ts +0 -6
  688. package/src/engine/graphics/util/threeUpdateTransform.d.ts.map +0 -1
  689. package/src/engine/graphics/util/threeUpdateTransform.js +0 -16
  690. package/src/engine/sound/ecs/SoundController.d.ts.map +0 -1
  691. package/src/engine/sound/ecs/SoundControllerSystem.d.ts +0 -23
  692. package/src/engine/sound/ecs/SoundControllerSystem.d.ts.map +0 -1
  693. package/src/engine/sound/ecs/SoundControllerSystem.js +0 -103
  694. package/src/engine/sound/ecs/emitter/SoundAttenuationFunction.d.ts.map +0 -1
  695. package/src/engine/sound/ecs/emitter/SoundEmitter.d.ts.map +0 -1
  696. package/src/engine/sound/ecs/emitter/SoundEmitterComponentContext.d.ts +0 -55
  697. package/src/engine/sound/ecs/emitter/SoundEmitterComponentContext.d.ts.map +0 -1
  698. package/src/engine/sound/ecs/emitter/SoundEmitterComponentContext.js +0 -265
  699. package/src/engine/sound/ecs/emitter/SoundEmitterFlags.d.ts.map +0 -1
  700. package/src/engine/sound/ecs/emitter/SoundEmitterSerializationAdapter.d.ts.map +0 -1
  701. package/src/engine/sound/ecs/emitter/SoundEmitterSystem.d.ts +0 -88
  702. package/src/engine/sound/ecs/emitter/SoundEmitterSystem.d.ts.map +0 -1
  703. package/src/engine/sound/ecs/emitter/SoundEmitterSystem.js +0 -159
  704. package/src/engine/sound/ecs/emitter/SoundPanningModelType.d.ts.map +0 -1
  705. package/src/engine/sound/ecs/emitter/SoundTrack.d.ts.map +0 -1
  706. package/src/engine/sound/ecs/emitter/SoundTrackFlags.d.ts.map +0 -1
  707. package/src/engine/sound/ecs/emitter/loadSoundTrackAsset.d.ts +0 -8
  708. package/src/engine/sound/ecs/emitter/loadSoundTrackAsset.d.ts.map +0 -1
  709. package/src/engine/sound/ecs/emitter/loadSoundTrackAsset.js +0 -41
  710. package/src/shade/playground/FramePerformanceMonitor.d.ts +0 -114
  711. package/src/shade/playground/FramePerformanceMonitor.d.ts.map +0 -1
  712. package/src/shade/playground/FramePerformanceMonitor.js +0 -468
  713. package/src/shade/playground/add_random_lights.d.ts +0 -8
  714. package/src/shade/playground/add_random_lights.d.ts.map +0 -1
  715. package/src/shade/playground/add_random_lights.js +0 -50
  716. package/src/shade/playground/animation_prototype.d.ts +0 -24
  717. package/src/shade/playground/animation_prototype.d.ts.map +0 -1
  718. package/src/shade/playground/animation_prototype.js +0 -508
  719. package/src/shade/playground/animation_prototype_skinning.d.ts +0 -31
  720. package/src/shade/playground/animation_prototype_skinning.d.ts.map +0 -1
  721. package/src/shade/playground/animation_prototype_skinning.js +0 -647
  722. package/src/shade/playground/animation_prototype_stress.d.ts +0 -35
  723. package/src/shade/playground/animation_prototype_stress.d.ts.map +0 -1
  724. package/src/shade/playground/animation_prototype_stress.js +0 -765
  725. package/src/shade/playground/enable_picker.d.ts +0 -8
  726. package/src/shade/playground/enable_picker.d.ts.map +0 -1
  727. package/src/shade/playground/enable_picker.js +0 -60
  728. package/src/shade/playground/gltf_skinned_prototype.d.ts +0 -27
  729. package/src/shade/playground/gltf_skinned_prototype.d.ts.map +0 -1
  730. package/src/shade/playground/gltf_skinned_prototype.js +0 -560
  731. package/src/shade/playground/gltf_skinned_stress_prototype.d.ts +0 -24
  732. package/src/shade/playground/gltf_skinned_stress_prototype.d.ts.map +0 -1
  733. package/src/shade/playground/gltf_skinned_stress_prototype.js +0 -387
  734. package/src/shade/playground/gltf_viewer.d.ts +0 -2
  735. package/src/shade/playground/gltf_viewer.d.ts.map +0 -1
  736. package/src/shade/playground/gltf_viewer.js +0 -283
  737. package/src/shade/playground/main.d.ts.map +0 -1
  738. package/src/shade/playground/main.js +0 -2727
  739. package/src/shade/playground/make_animated_instances.d.ts +0 -9
  740. package/src/shade/playground/make_animated_instances.d.ts.map +0 -1
  741. package/src/shade/playground/make_animated_instances.js +0 -118
  742. package/src/shade/playground/prototype_virtual_texture.d.ts +0 -2
  743. package/src/shade/playground/prototype_virtual_texture.d.ts.map +0 -1
  744. package/src/shade/playground/prototype_virtual_texture.js +0 -274
  745. package/src/shade/playground/prototype_volumetrics_01.d.ts +0 -2
  746. package/src/shade/playground/prototype_volumetrics_01.d.ts.map +0 -1
  747. package/src/shade/playground/prototype_volumetrics_01.js +0 -206
  748. package/src/shade/playground/sample.d.ts +0 -2
  749. package/src/shade/playground/sample.d.ts.map +0 -1
  750. package/src/shade/playground/sample.js +0 -234
  751. package/src/shade/playground/sample_load_gltf.d.ts +0 -9
  752. package/src/shade/playground/sample_load_gltf.d.ts.map +0 -1
  753. package/src/shade/playground/sample_load_gltf.js +0 -103
  754. package/src/shade/playground/spread_node3d_grid.d.ts +0 -7
  755. package/src/shade/playground/spread_node3d_grid.d.ts.map +0 -1
  756. package/src/shade/playground/spread_node3d_grid.js +0 -20
  757. package/src/shade/renderer/geometry/bvh/GPUGeometryBVH.d.ts +0 -24
  758. package/src/shade/renderer/geometry/bvh/GPUGeometryBVH.d.ts.map +0 -1
  759. package/src/shade/renderer/geometry/bvh/GPUGeometryBVH.js +0 -88
  760. package/src/shade/renderer/gpu_primitive/prefix_sum/v0/shader_exclusive_prefix_scan_add.d.ts +0 -3
  761. package/src/shade/renderer/gpu_primitive/prefix_sum/v0/shader_exclusive_prefix_scan_add.d.ts.map +0 -1
  762. package/src/shade/renderer/gpu_primitive/prefix_sum/v0/shader_exclusive_prefix_scan_add.js +0 -49
  763. package/src/shade/renderer/gpu_primitive/prefix_sum/v0/shader_exclusive_prefix_sum_scan.d.ts +0 -3
  764. package/src/shade/renderer/gpu_primitive/prefix_sum/v0/shader_exclusive_prefix_sum_scan.d.ts.map +0 -1
  765. package/src/shade/renderer/gpu_primitive/prefix_sum/v0/shader_exclusive_prefix_sum_scan.js +0 -93
  766. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/graph_prefix_scan_csdl.d.ts +0 -24
  767. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/graph_prefix_scan_csdl.d.ts.map +0 -1
  768. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/graph_prefix_scan_csdl.js +0 -119
  769. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/shader_one_sweep_prefix_scan.d.ts +0 -7
  770. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/shader_one_sweep_prefix_scan.d.ts.map +0 -1
  771. package/src/shade/renderer/gpu_primitive/prefix_sum/v1/shader_one_sweep_prefix_scan.js +0 -235
  772. package/src/shade/renderer/gpu_primitive/sort/NOTES.md +0 -5
  773. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_common.d.ts +0 -7
  774. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_common.d.ts.map +0 -1
  775. package/src/shade/renderer/gpu_primitive/sort/chunk_sort_common.js +0 -136
  776. package/src/shade/renderer/gpu_primitive/sort/ffx/chunk_safe_load_uint4_umax.d.ts +0 -18
  777. package/src/shade/renderer/gpu_primitive/sort/ffx/chunk_safe_load_uint4_umax.d.ts.map +0 -1
  778. package/src/shade/renderer/gpu_primitive/sort/ffx/chunk_safe_load_uint4_umax.js +0 -108
  779. package/src/shade/renderer/gpu_primitive/sort/ffx/defs.d.ts +0 -35
  780. package/src/shade/renderer/gpu_primitive/sort/ffx/defs.d.ts.map +0 -1
  781. package/src/shade/renderer/gpu_primitive/sort/ffx/defs.js +0 -42
  782. package/src/shade/renderer/gpu_primitive/sort/ffx/graph_radix_sort.d.ts +0 -13
  783. package/src/shade/renderer/gpu_primitive/sort/ffx/graph_radix_sort.d.ts.map +0 -1
  784. package/src/shade/renderer/gpu_primitive/sort/ffx/graph_radix_sort.js +0 -116
  785. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_bit_histogram.d.ts +0 -3
  786. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_bit_histogram.d.ts.map +0 -1
  787. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_bit_histogram.js +0 -120
  788. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_distribute_part_sum_int4.d.ts +0 -3
  789. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_distribute_part_sum_int4.d.ts.map +0 -1
  790. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_distribute_part_sum_int4.js +0 -76
  791. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scan_exclusive_partial.d.ts +0 -3
  792. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scan_exclusive_partial.d.ts.map +0 -1
  793. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scan_exclusive_partial.js +0 -125
  794. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scan_exclusive_uint4.d.ts +0 -3
  795. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scan_exclusive_uint4.d.ts.map +0 -1
  796. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scan_exclusive_uint4.js +0 -140
  797. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scatter_keys_and_values.d.ts +0 -3
  798. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scatter_keys_and_values.d.ts.map +0 -1
  799. package/src/shade/renderer/gpu_primitive/sort/ffx/shader_scatter_keys_and_values.js +0 -338
  800. package/src/shade/renderer/gpu_primitive/sort/gaussian_splatting/x.d.ts +0 -7
  801. package/src/shade/renderer/gpu_primitive/sort/gaussian_splatting/x.d.ts.map +0 -1
  802. package/src/shade/renderer/gpu_primitive/sort/gaussian_splatting/x.js +0 -261
  803. package/src/shade/renderer/gpu_primitive/sort/one_sweep/chunk_extract_digit.d.ts +0 -3
  804. package/src/shade/renderer/gpu_primitive/sort/one_sweep/chunk_extract_digit.d.ts.map +0 -1
  805. package/src/shade/renderer/gpu_primitive/sort/one_sweep/chunk_extract_digit.js +0 -14
  806. package/src/shade/renderer/gpu_primitive/sort/one_sweep/defs.d.ts +0 -25
  807. package/src/shade/renderer/gpu_primitive/sort/one_sweep/defs.d.ts.map +0 -1
  808. package/src/shade/renderer/gpu_primitive/sort/one_sweep/defs.js +0 -25
  809. package/src/shade/renderer/gpu_primitive/sort/one_sweep/graph_radix_sort.d.ts +0 -15
  810. package/src/shade/renderer/gpu_primitive/sort/one_sweep/graph_radix_sort.d.ts.map +0 -1
  811. package/src/shade/renderer/gpu_primitive/sort/one_sweep/graph_radix_sort.js +0 -192
  812. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_digit_binning_pass_pairs.d.ts +0 -5
  813. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_digit_binning_pass_pairs.d.ts.map +0 -1
  814. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_digit_binning_pass_pairs.js +0 -990
  815. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_global_histogram.d.ts +0 -5
  816. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_global_histogram.d.ts.map +0 -1
  817. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_global_histogram.js +0 -82
  818. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_init_sweep.d.ts +0 -3
  819. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_init_sweep.d.ts.map +0 -1
  820. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_init_sweep.js +0 -44
  821. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_scan.d.ts +0 -5
  822. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_scan.d.ts.map +0 -1
  823. package/src/shade/renderer/gpu_primitive/sort/one_sweep/shader_scan.js +0 -156
  824. package/src/shade/renderer/gpu_primitive/sort/one_sweep/test_radix_sort.d.ts +0 -6
  825. package/src/shade/renderer/gpu_primitive/sort/one_sweep/test_radix_sort.d.ts.map +0 -1
  826. package/src/shade/renderer/gpu_primitive/sort/one_sweep/test_radix_sort.js +0 -55
  827. package/src/shade/renderer/gpu_primitive/sort/one_sweep/verify_radix_sort_one_sweep.d.ts +0 -14
  828. package/src/shade/renderer/gpu_primitive/sort/one_sweep/verify_radix_sort_one_sweep.d.ts.map +0 -1
  829. package/src/shade/renderer/gpu_primitive/sort/one_sweep/verify_radix_sort_one_sweep.js +0 -110
  830. package/src/shade/renderer/gpu_primitive/sort/v4/graph_radix_sort.d.ts +0 -12
  831. package/src/shade/renderer/gpu_primitive/sort/v4/graph_radix_sort.d.ts.map +0 -1
  832. package/src/shade/renderer/gpu_primitive/sort/v4/graph_radix_sort.js +0 -74
  833. package/src/shade/renderer/gpu_primitive/sort/v4/shader_radix_sort.d.ts +0 -3
  834. package/src/shade/renderer/gpu_primitive/sort/v4/shader_radix_sort.d.ts.map +0 -1
  835. package/src/shade/renderer/gpu_primitive/sort/v4/shader_radix_sort.js +0 -125
  836. package/src/shade/renderer/scene/optimization/geometry/internal/bt_face_loop_helpers.d.ts +0 -32
  837. package/src/shade/renderer/scene/optimization/geometry/internal/bt_face_loop_helpers.d.ts.map +0 -1
  838. package/src/shade/renderer/scene/optimization/geometry/internal/bt_face_loop_helpers.js +0 -77
  839. package/src/shade/renderer/scene/optimization/geometry/internal/bt_find_edge_between.d.ts +0 -12
  840. package/src/shade/renderer/scene/optimization/geometry/internal/bt_find_edge_between.d.ts.map +0 -1
  841. package/src/shade/renderer/scene/optimization/geometry/internal/bt_find_edge_between.js +0 -35
  842. package/src/shade/renderer/scene/serialization/linear_to_pq.d.ts +0 -2
  843. package/src/shade/renderer/scene/serialization/linear_to_pq.d.ts.map +0 -1
  844. package/src/shade/renderer/scene/serialization/linear_to_pq.js +0 -14
  845. package/src/shade/renderer/scene/serialization/pq_to_linear.d.ts +0 -9
  846. package/src/shade/renderer/scene/serialization/pq_to_linear.d.ts.map +0 -1
  847. package/src/shade/renderer/scene/serialization/pq_to_linear.js +0 -30
  848. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_calculate_morton_code.d.ts +0 -7
  849. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_calculate_morton_code.d.ts.map +0 -1
  850. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_calculate_morton_code.js +0 -36
  851. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_get_morton_codes_from_unit_coord.d.ts +0 -7
  852. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_get_morton_codes_from_unit_coord.d.ts.map +0 -1
  853. package/src/shade/renderer/shader/chunk/bvh/morton/chunk_get_morton_codes_from_unit_coord.js +0 -54
  854. package/src/shade/renderer/shader/chunk/bvh/packed/NOTES.md +0 -19
  855. package/src/shade/renderer/shader/chunk/bvh/packed/PACKED_BVH_NODE_STRUCT.d.ts +0 -3
  856. package/src/shade/renderer/shader/chunk/bvh/packed/PACKED_BVH_NODE_STRUCT.d.ts.map +0 -1
  857. package/src/shade/renderer/shader/chunk/bvh/packed/PACKED_BVH_NODE_STRUCT.js +0 -9
  858. package/src/shade/renderer/shader/chunk/bvh/packed/PACKED_BVH_NODE_WORD_COUNT.d.ts +0 -6
  859. package/src/shade/renderer/shader/chunk/bvh/packed/PACKED_BVH_NODE_WORD_COUNT.d.ts.map +0 -1
  860. package/src/shade/renderer/shader/chunk/bvh/packed/PACKED_BVH_NODE_WORD_COUNT.js +0 -5
  861. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_make_f32_interval_f16_safe.d.ts +0 -8
  862. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_make_f32_interval_f16_safe.d.ts.map +0 -1
  863. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_make_f32_interval_f16_safe.js +0 -17
  864. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_pack_f32_interval_to_u32.d.ts +0 -3
  865. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_pack_f32_interval_to_u32.d.ts.map +0 -1
  866. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_pack_f32_interval_to_u32.js +0 -14
  867. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_from_unpacked_node.d.ts +0 -3
  868. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_from_unpacked_node.d.ts.map +0 -1
  869. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_from_unpacked_node.js +0 -27
  870. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_read_node.d.ts +0 -3
  871. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_read_node.d.ts.map +0 -1
  872. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_read_node.js +0 -31
  873. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_write_node.d.ts +0 -3
  874. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_write_node.d.ts.map +0 -1
  875. package/src/shade/renderer/shader/chunk/bvh/packed/chunk_packed_bvh_write_node.js +0 -25
  876. package/src/shade/renderer/shader/chunk/bvh/packed/shader_pack_blas.d.ts +0 -3
  877. package/src/shade/renderer/shader/chunk/bvh/packed/shader_pack_blas.d.ts.map +0 -1
  878. package/src/shade/renderer/shader/chunk/bvh/packed/shader_pack_blas.js +0 -42
  879. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundAttenuationFunction.d.ts +0 -0
  880. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundAttenuationFunction.js +0 -0
  881. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterFlags.d.ts +0 -0
  882. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterFlags.js +0 -0
  883. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationAdapter.d.ts +0 -0
  884. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationAdapter.js +0 -0
  885. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationUpgrader_0_1.d.ts +0 -0
  886. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationUpgrader_0_1.js +0 -0
  887. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationUpgrader_1_2.d.ts +0 -0
  888. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundEmitterSerializationUpgrader_1_2.js +0 -0
  889. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundPanningModelType.d.ts +0 -0
  890. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundPanningModelType.js +0 -0
  891. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundTrackFlags.d.ts +0 -0
  892. /package/src/engine/sound/{ecs/emitter → sopra/legacy}/SoundTrackFlags.js +0 -0
  893. /package/src/shade/playground/{main.d.ts → bvh_repro/main.d.ts} +0 -0
@@ -1,993 +1,999 @@
1
- # Sopra — Implementation Plan
2
-
3
- > Object-oriented, FMOD/Wwise-style sound engine for **meep**, built on the WebAudio API.
4
- > Status: implemented through **P5** (P0–P4 build-out + P5 spatial scaling to 100k emitters). Not yet
5
- > wired into the production engine config the legacy `SoundEmitter` path still ships; the native
6
- > `AudioEmitter`/`AudioEmitterSystem` is fully built and tested but registered only in tests, awaiting
7
- > the cut-over (register in `makeMirEngineConfig` + `AudioEventTrigger` + serialization wiring; see §9).
8
-
9
- This plan was produced from a full read of the current sound engine + a deep survey of the
10
- surrounding meep architecture (ECS core, asset system, serialization, precedents, game-side
11
- coupling) and an FMOD/Wwise WebAudio feasibility study. Every "verified" claim below was
12
- checked against source.
13
-
14
- **Source layout** (`engine/sound/sopra/`): `SopraEngine.js` (orchestrator) at the root;
15
- `definition/` = authored, immutable data (`EventDescription`, `BusDefinition`, `ParameterDefinition`,
16
- `SopraPanningModel`, `VoiceStealMode`, plus `clip/` and `effect/` families); `runtime/` = transient
17
- runtime + services (`EventInstance`, `Voice`, `VoicePool`, `BusGraph`, `VoiceManager`,
18
- `ParameterStore`, `SopraPlaybackContext`); `asset/` = buffer providers; `serialization/` = `sopraJSON`
19
- + registry (binary adapters stay co-located with their classes); `util/` = `buildAttenuationCurve`,
20
- `fadeOutAndStop`, `MockAudioContext`; `legacy/` = the SoundEmitter→sopra translator. Specs sit next to
21
- the code they cover.
22
-
23
- ---
24
-
25
- ## 0. Status
26
-
27
- - **P0 — Core skeleton + ser/de + bus inserts (no ECS): ✅ DONE** (26 tests green via
28
- `npx jest --config jest.conf.json engine/sound/sopra`).
29
- Delivered under `sopra/`: the finished clip hierarchy (`AbstractAudioClip`, `ContainerAudioClip`,
30
- `SampleAudioClip`, `SilenceAudioClip`, `SequenceContainerAudioClip`, `RandomContainerAudioClip`),
31
- `EventDescription`, `BusDefinition`, `ParameterDefinition` (3D attenuation reuses the existing
32
- `AnimationCurve`), the effect hierarchy
33
- (`AbstractAudioEffect`, `EqEffect`, `CompressorEffect`), runtime (`SopraEngine`, `BusGraph`,
34
- `Voice`, `VoicePool`, `EventInstance`, `ParameterStore`), `BufferProvider` +
35
- `AssetManagerBufferProvider`/`StubBufferProvider`, the click-safe `fadeOutAndStop` (with the
36
- Firefox fallback, D1), polymorphic binary adapters (co-located) + JSON dispatchers (`sopraJSON`) +
37
- `populateSopraSerializationRegistry`, and a `MockAudioContext` test double. `volume2dB`/`dB2Volume`
38
- are now exported. Specs: `sopraSerialization.spec.js`, `SopraEngine.spec.js`,
39
- `fadeOutAndStop.spec.js`.
40
- Deferred to **P3** (integration, not P0): registering the adapters in
41
- `GameBinarySerializationRegistry` and constructing `SopraEngine` in `makeMirEngineConfig`. P0 keeps
42
- the legacy engine fully untouched.
43
- - **P1 Timeline playback + containers + parameters: ✅ DONE** (sopra suite now 35 tests green).
44
- Delivered: a polymorphic `AbstractAudioClip.planTimeline(env, gainDb, pitchCents, offset)` on each
45
- clip (deterministic flatten of the clip graph to timed leaf plays, gain/pitch inheritance resolved
46
- once; no `instanceof` switching) + `collectSampleClips`, `SopraPlaybackContext` (seeded RNG + random-container
47
- avoid-repeat history network-deterministic per D5), Sequence/Random/Silence playback through
48
- `EventInstance`, per-trigger pitch/gain randomization on `SampleAudioClip`, and parameter→bus-volume
49
- automation via the reused `AnimationCurve`.
50
- NOTE: the pumping **lookahead Scheduler** moved to **P2**, where virtualization actually needs it
51
- (re-spawning virtual voices in-phase). P1 schedules a *finite* timeline eagerly with WebAudio's own
52
- sample-accurate future `start(when)` correct and simpler for bounded events.
53
- - **P2 Voice management + spatialization + cursor scheduling: DONE** (sopra suite now 43 tests
54
- green). Cursor-based playback (each frame `EventInstance.update(now, listener)` evaluates the
55
- timeline against the playhead, spawning leaves within a lookahead window only while audible);
56
- **virtualization** — out-of-range instances stop their voices, keep the cursor, and revive at the
57
- correct child + buffer-offset (D3); **3D spatialization** per-instance `attenuationGain panner
58
- bus` chain (D4), attenuation = `curve.evaluate(distance)`, with `buildAttenuationCurve` reproducing
59
- the legacy falloff from `interpolate_irradiance_*`; HRTF/EqualPower panner choice; and a
60
- **VoiceManager** (per-event `maxInstances` + steal oldest/quietest/none; defaults a no-op).
61
- NOTES: the lookahead scheduler is realised as the per-instance cursor evaluation (a global priority
62
- queue is a P4 optimization if profiling warrants); StereoPanner / "no-panner" modes deferred to P4.
63
- - **P3 ECS binding / strangler cut-over: P3.1–P3.6 DONE (P3.7 cleanup remains).** `SoundEngine` owns
64
- the sopra engine via `createSopra(bufferProvider)`; `SoundEmitterSystem(assetManager, soundEngine)`
65
- (required 2-arg ctorno switch, no `sopra === null` branch) creates it through an
66
- `AssetManagerBufferProvider`, reuses the single `SoundAssetLoader` (D7), and drives `sopra.setListener`
67
- + `sopra.update` from its tick. **sopra is now the single renderer:** every `SoundEmitter` routes
68
- through a per-entity `SoundEmitterComponentContext` translation record (legacy tracks sopra
69
- `EventInstance`s; live `emitter.volume × track.volume` instance gain; shared `transform.position`
70
- Vector3; live `track.volume` sets also plumb to the instance gain via `__soundRuntime`). The locked
71
- public API (`SoundEmitter`: tracks/channel/volume/flags/distanceMin/Max; `SoundTrack`:
72
- on.ended/flags/volume/time/url `duration` was dropped, never written) is preserved as
73
- deprecate-and-plumb; the `nodes` getters + `setVolumeOverTime` throw, while
74
- `distanceRolloff`/`buildNodes`/`endTrack` and the rest of the non-contract surface were removed
75
- outright. Channel volume = sopra bus volume (`get/setChannelVolume` proxy; legacy mix intact).
76
- `CombatEndMusicProcess.switchMusic` now crossfades via `fadeOutAllTracks(1)` + `tracks.add`. All 10
77
- `new SoundEmitterSystem(...)` call sites updated; the dead `SoundEmitterChannel` (singular) +
78
- `SoundTrackNodes` classes are deleted; full `engine/sound` suite green (62 tests).
79
- STILL PENDING (P3.7): single listener-feeder convergence and the remaining `@deprecated` JSDoc sweep.
80
- - **P4 build-out: STARTED.** Switch + Blend parameter-driven containers DONE
81
- (`SwitchContainerAudioClip` = discrete RTPC-keyed single-child pick; `BlendContainerAudioClip` =
82
- per-child parameter→gain curves, all audible layers play simultaneously). Parameter access threaded
83
- into the planTimeline `env` (`getParameter`, read once at trigger). Both fully serialized (JSON +
84
- binary + registry) and folded the dormant `material/` concepts (surface-switch / composition) into
85
- general primitives. v1 Blend is a **trigger-time snapshot**, not live re-blend as the parameter
86
- sweeps (follow-up below). **Reverb sends DONE:** `BusDefinition.sends` is now wired at build time
87
- (post-fader copy send gain target bus input), and `ReverbEffect` (`ConvolverNode` + a
88
- procedurally-generated decaying-noise IR no IR asset, synchronous build) is a new bus insert.
89
- **Mixer snapshots DONE:** `MixerSnapshot` (named per-bus target gains) + `SopraEngine.applySnapshot`
90
- (instant or click-safe ramped blend, via `BusGraph.rampVolume`) + `captureSnapshot`. **Emulated
91
- ducking DONE:** `DuckingRule` + `SopraEngine.addDucker`/`clearDuckers` — play-state sidechain (duck a
92
- target bus while any instance plays on a trigger bus; `setTargetAtTime` attack/release via
93
- `BusGraph.approachVolume`). **Native migration STARTED (additive-first):** native `AudioEmitter`
94
- component (holds a full `EventDescription` directly) + `AudioEmitterSystem` done at the engine level —
95
- shares the one sopra (idempotent `createSopra`), autoplay/stop/volume/position/listener, fully tested;
96
- coexists with the untouched legacy `SoundEmitter`. (The autoplay-every-emitter behavior here is
97
- **superseded by P5** spatial management — see below.) Remaining: `AudioEventTrigger` + game wiring
98
- (config/editor/serialization registries) + the `SoundEmitter`→`LegacySoundEmitter` rename & call-site
99
- flip (see §9). Sound banks were evaluated and DROPPED the ECS data model (components + dataset +
100
- AssetManager) already covers every bank role.
101
- Full `engine/sound` suite green.
102
- - **P5 spatial scaling to 100k emitters: ✅ DONE (P5.1–P5.6; see §15 for the full record).** A BVH
103
- broadphase + live/dormant split so the engine carries far more 3D emitters than can sound at once. A
104
- registered-but-dormant emitter costs only a BVH leaf (no instance, no nodes, no per-frame work); each
105
- tick the nearest in-range emitters up to a global voice **budget** (default 64) are promoted to
106
- live instances and the rest demoted. **P5.1** `SpatialAudioIndex` (BVH cull, reactive leaf refit via
107
- `position.onChanged`, allocation-free `queryAudible`). **P5.2** `LiveEmitterSet` (promote/budget/
108
- distance-priority stealing/rank-hysteresis). **P5.3** demotion policy (hard cut when culled out of
109
- range, click-safe fade on contention). **P5.4** continuous-clock phase reconstruction (`EventInstance.startTime`
110
- + looping-voice buffer-offset wrap). **P5.5** a cull throttle was added then **reverted** — the cull
111
- runs every tick (BVH is microseconds). **P5.6** `AudioEmitterSystem` wired to `LiveEmitterSet`:
112
- looping-3D autoplay spatially managed; 2D + finite-3D-one-shot direct; non-autoplay → inert;
113
- per-emitter `AudioEmitter.volume` carried through promote/demote. Two `.skip`-ped 100k stress tests
114
- prove only `budget` instances exist regardless of N and the BVH prunes the far field (per-frame
115
- `refresh + tick` 5 ms at 100k). Full `engine/sound` suite green (298, 2 skipped).
116
-
117
- ## 1. Recommendation in one paragraph
118
-
119
- Build sopra as an **ECS-agnostic runtime audio engine** (`SopraEngine` + its services) that is
120
- fully unit-testable with a mock `AudioContext`, then bind it to meep with a **thin ECS layer**, and
121
- keep the legacy `SoundEmitter` / `createSound` / channel surface alive as a **translating facade**
122
- during migration (the *strangler* pattern). There is always **exactly one renderer** (sopra);
123
- legacy classes become translators, never a second engine. The architecture grafts the best of three
124
- evaluated stances: the *ECS-agnostic, clock-injected core* (testability), the *clean component/system
125
- topology* (meep-idiomatic), and the *zero-churn facade* (ships v1 with no call-site / save / settings
126
- / editor edits).
127
-
128
- The **spine** is the thing meep lacks and FMOD/Wwise are built on: a **definition / instance split**.
129
- Today `SoundTrack` conflates the spec (`url`/`time`/`volume`/flags) with the live, single-use
130
- `AudioBufferSourceNode`. Sopra splits this into immutable **definitions** (the `*AudioClip` tree),
131
- transient pooled **instances/voices**, and engine-owned **services** (mixer bus tree, scheduler,
132
- parameter store, voice manager).
133
-
134
- ---
135
-
136
- ## 2. Engine philosophy this plan must respect (verified)
137
-
138
- **meep ECS** (`engine/ecs/`)
139
- - `System` subclasses declare `dependencies = [ClassA, ClassB]`; the engine auto-delivers
140
- `link(...componentsInDependencyOrder, entity)` / `unlink(...)` (arity is validated as
141
- `deps.length + 1`). `components_used = [ResourceAccessSpecification.from(Klass, access)]` is a
142
- scheduling hint that also auto-registers component columns.
143
- - `simulate(dt)` runs a **fixed-step lock-step pass** (`fixedUpdate(fixedStep)`) then **one
144
- variable pass** (`update(dt)`); `getFixedStepAlpha()` gives the sub-step remainder for
145
- interpolation. `fixedStepTick` only advances in the fixed pass.
146
- - Components are **plain serializable object instances** stored column-wise
147
- (`components[componentIndex][entityId]`). Runtime/WebAudio objects must NOT live on the
148
- component they live in a system-owned `this.data[entity]` context (the established
149
- `SoundEmitterComponentContext` discipline).
150
- - Singletons (the listener) are fetched via `ecd.getAnyComponent(Klass)`. Gameplay→sound uses the
151
- per-entity event bus (`addEntityEventListener` / `sendEvent`), as `SoundControllerSystem` does.
152
-
153
- **Conventions**: every persisted type has `toJSON`/`fromJSON` **and** a versioned
154
- `BinaryClassSerializationAdapter` registered by `typeName`; polymorphic trees recurse through
155
- `ObjectBasedClassSerializationAdapter` + the shared `objectAdapter` (the behavior-tree precedent);
156
- `Signal` for events, `List` for observable collections, `Vector1` for change-notifying scalars,
157
- flags as bitmask enums, `assert.*` (free in prod).
158
-
159
- **User memories (hard constraints)**
160
- - **Uniform control flow** — no `null = auto / value = override` sentinels.
161
- ⚠️ `AbstractAudioClip.parent` / `.channel` are exactly this antipattern; resolve inheritance
162
- **once** at voice instantiation (flatten), so the runtime tick never sees a sentinel.
163
- - **Reuse over micro-opt** — prefer tested primitives; lead with end-to-end measurement.
164
- - **Correctness first, no bandaids**; **black-box tests only** (assert observable outcomes — which
165
- buffer, which bus, what gain, what `when`, how many real voices never call counts);
166
- **no silent catch** (expected failure sentinel/defer; unexpected throw); asserts are free.
167
-
168
- ---
169
-
170
- ## 3. The current engine and its gaps
171
-
172
- | Layer | Current | FMOD/Wwise gap |
173
- |---|---|---|
174
- | Definition | `SoundEmitter` (List<SoundTrack>, channel, distance, attenuation enum, flags) | no event/instance split |
175
- | Playback | `SoundTrack` **conflates** spec + live single-use source + state | — |
176
- | Mixer | **flat** 3 channel `GainNode`s (effects 1.2 / music 0.1 / ambient) → master `Gain → DynamicsCompressor → destination` | no nestable buses, no inserts, no sends |
177
- | Spatial | per-emitter `PannerNode` (rolloff=0) + custom attenuation `GainNode` via `interpolate_irradiance_*`; BVH hearing-range cull | attenuation is a fixed 3-value enum, not a curve |
178
- | Voices | **plays every track of every in-range emitter**; only cull is BVH | no limits / priority / stealing / virtualization |
179
- | Params | none | no RTPC / automation |
180
- | Timing | `track.time += timeDelta` each frame; `Suspended` flag works around Chrome disconnected-source time-freeze | no lookahead scheduler; sequence/loop precision at mercy of frame rate |
181
- | Containers | `sopra/` stubs exist with **no** selection logic, scheduler, instance, or serialization | random / sequence / blend / switch are empty classes |
182
-
183
- Latent bug to *not* faithfully reproduce: `SoundEmitterSystem.update` line ~370 is
184
- `if (soundTrack.setFlag(...) && ...)` `setFlag` returns `undefined`, so the suspended-track-end
185
- branch is dead code and it sets `Suspended|Playing` on every track every frame. Port the **intent**
186
- (logical time advances while virtual; revive in phase), not the mechanism.
187
-
188
- ---
189
-
190
- ## 4. The sopra model three layers
191
-
192
- ### 4.1 Definition layer (immutable, serialized, `typeName` + adapter + `toJSON`/`fromJSON`)
193
-
194
- ```
195
- AbstractAudioClip (finish the stub: typeName, isAudioClip tag, .from(), compare/equals/hash/clone)
196
- ├── SampleAudioClip NEW leaf the only buffer-referencing clip: assetRef(path|alias),
197
- │ gainDb, pitch(cents), loop + loopStart/loopEnd, pitchRandom, gainRandom
198
- ├── SilenceAudioClip (stub) duration → pure schedule-cursor offset, emits no source
199
- ├── ContainerAudioClip (stub) children[]
200
- ├── SequenceContainerAudioClip plays children in order (scheduled, not onended-chained)
201
- ├── RandomContainerAudioClip (stub) avoid_repeating_last via recent-history ring + seededRandom
202
- ├── BlendContainerAudioClip [deferred] parameter-driven crossfade
203
- │ └── SwitchContainerAudioClip [deferred] discrete parameter selects child (subsumes material/)
204
- ```
205
-
206
- Plus three non-clip definition types:
207
- - **`EventDescription`** the triggerable unit: `label, rootClip, busId, maxInstances, priority,
208
- stealMode, virtualThresholdDb, is3D, attenuation: AnimationCurve, distanceMin/Max`.
209
- (FMOD `EventDescription` / Wwise Actor-Mixer object.)
210
- - **`BusDefinition`** `{ id, parentId, gainDb, effects[], sends[] }`. In v1, `effects[]` is **live**
211
- (an ordered chain of `AbstractAudioEffect` `EqEffect`/`CompressorEffect`, see D10); `sends[]` ships
212
- as a **data shape only** (reverb sends are P4) so adding them later is non-breaking.
213
- - **`AbstractAudioEffect`** (+ `EqEffect`, `CompressorEffect`) serialized bus-insert effects with a
214
- `build(ctx) { input, output }` contract (see D10).
215
- - **`AnimationCurve`** (reused from `engine/animation/curve/`, a cubic-Hermite keyframe curve)
216
- used for **both** parameter automation (RTPC) and 3D distance attenuation. `EventDescription.attenuation`
217
- is an `AnimationCurve`; since it has no `typeName`/binary adapter of its own, the event adapter
218
- serializes its keyframes inline. (Supersedes the originally-planned bespoke `AutomationCurve`.)
219
- - **`ParameterDefinition`** `{ name, range, default, scope }`.
220
-
221
- > **Uniform-flow fix:** `parent`/`channel`/`gain`/`pitch` inheritance is resolved **once** when an
222
- > `EventInstance` is created (top-down flatten). The runtime never branches on a `null`-means-inherit
223
- > sentinel.
224
-
225
- ### 4.2 Runtime layer (transient, pooled, **never serialized**)
226
-
227
- - **`EventInstance`** — one trigger: resolved position, parameter overrides, **schedule cursor**,
228
- active `Voice`s, one-shot|persistent lifetime. Owns **its** `PannerNode` + attenuation `GainNode`
229
- (see decision D4). Modeled on `ParticleEmitter.build()`, *not* the behavior-tree re-armed node
230
- (which cannot produce concurrent voices).
231
- - **`Voice`** — one throwaway `AudioBufferSourceNode` + a **pooled** trim `GainNode`. The source is
232
- the only unavoidable per-play allocation (it is single-use).
233
-
234
- ### 4.3 Services (single instances hung off `SoundEngine`, no ECS imports)
235
-
236
- - **`SopraEngine`** ECS-agnostic public API: `playEvent / playOneShot / createInstance / stop`,
237
- `bus(id)`, `setParameter / getParameter`, `setListener(pose)`, `update(now)`. Constructed with
238
- `(audioContext, destinationNode, bufferProvider)`; **reuses the existing `AudioContext` + master
239
- chain verbatim — never a second context.**
240
- - **`BusGraph`** — instantiates the `BusDefinition` tree into chained `GainNode`s; root
241
- `SoundEngine.destination`; **seeds the default `effects`/`music`/`ambient` buses with the exact
242
- legacy mix (Effects 1.2, Music 0.1)**; exposes `get/setChannelVolume` (linear, see D6).
243
- - **`Scheduler`**lookahead queue over `AudioContext.currentTime`; each `update(now)` drains events
244
- due before `now + lookahead` and calls `start(when, offset, duration)`. Replaces `time += timeDelta`
245
- and obsoletes the `Suspended` hack. (Reuse `BinaryHeap`.)
246
- - **`ParameterStore`** `Map<name, number>` + per-instance overrides + `onChanged` `Signal`s; samples
247
- bound `AnimationCurve`s `setTargetAtTime`.
248
- - **`VoiceManager`** active-voice registries keyed by `eventId` and `busId`; per-event
249
- `maxInstances` + per-bus limits + priority + steal-oldest/quietest (by post-attenuation gain);
250
- **virtualization** (stop+disconnect source, keep advancing the instance cursor, revive in phase).
251
- - **`VoicePool`** — `ObjectPoolFactory` of pre-wired trim-gain chains.
252
- - **`BufferProvider`** (interface) + **`AssetManagerBufferProvider`** `get(ref) Promise<AudioBuffer>`.
253
- Prod wraps `AssetManager.promise(resolveAlias(ref) || ref, 'audio')` (shared immutable buffer);
254
- a stub impl in tests is what makes the core ECS-free unit-testable.
255
- - **`fadeOutAndStop`** — the ONE click-safe fade primitive (see D1). No call site hand-rolls fades.
256
-
257
- ### 4.4 ECS binding (thin)
258
-
259
- - **`SopraEmitter`** (component) `+ Transform` plain serializable
260
- `{ eventId, paramOverrides, busOverride, volume: Vector1, flags }`. Runtime `EventInstance` lives
261
- in `system.data[entity]`.
262
- - **`SopraEmitterSystem`** `dependencies = [SopraEmitter, Transform]` — `link` → `createInstance`
263
- + `BVH.link`; `update(dt)` BVH cull around the listener (reuse `BVHQueryIntersectsSphere` +
264
- `bvh_query_user_data_generic` + `IncrementalDeltaSet`), push position/params, `engine.update(now)`;
265
- `unlink` → stop + release. Hosts `playOneShot(eventId, position, overrides)`.
266
- - **`SoundListener` / `SoundListenerSystem`****reused verbatim**; read via `getAnyComponent`,
267
- forwarded through `SopraEngine.setListener`.
268
- - BVH out-of-range = **virtual** (not disconnect); the `VoiceManager` decides actual realness under
269
- budget. This turns the Chrome `Suspended` hack into standard virtual-voice behavior.
270
-
271
- ---
272
-
273
- ## 5. Key design decisions (incl. the 5 critique revisions)
274
-
275
- - **D1 — `fadeOutAndStop` must NOT hard-depend on `cancelAndHoldAtTime`.** It is unimplemented in
276
- Firefox (and used nowhere in meep today). Feature-detect it; otherwise use the portable idiom:
277
- read `param.value`, `cancelScheduledValues(now)`, `setValueAtTime(currentValue, now)`, then ramp.
278
- Always ramp to an **epsilon, never 0** (exponential-ramp-to-0 throws / clicks), and schedule
279
- `stop()` just after the gain reaches ~epsilon. Test the no-`cancelAndHoldAtTime` path with a mock.
280
- - **D2 `playOneShot` owns a max-lifetime timeout (default 60s) AND releases on asset-load
281
- failure.** `createSound` today is belt-and-suspenders (`on.ended` **and** `Sequence[Delay(60),Die]`).
282
- A one-shot whose asset 404s must still release — the no-silent-catch sentinel path, not a hang.
283
- - **D3 Virtualization revive uses the instance logical cursor, not `t % dur`.** `t % dur` is only
284
- correct for a single looping leaf. For a `Sequence`/`Random`/one-shot, re-derive the active leaf(s)
285
- and their offsets from the cursor at `currentTime`, then `start(when, leafOffset)` each.
286
- - **D4 `PannerNode` + attenuation `GainNode` are per-`EventInstance` (shared by its leaf voices),
287
- not per-`Voice`.** This matches today's per-emitter sharing (`buildNodes` builds one chain; all
288
- tracks feed the single emitter volume node). Per-voice panners would multiply HRTF cost (the
289
- dominant spatialization cost) on multi-track emitters a regression for the dense scenes we care
290
- about. Only the source node + trim gain are per-voice. Panner-type choice (HRTF / equalpower /
291
- `StereoPanner` for 2D-UI / none) + distance/budget downgrade lives at the instance level.
292
- - **D5 Random/Switch selection is network-deterministic (decided).** Seed `seededRandom` from a
293
- replicated source (`entity id + fixedStepTick`), not `Math.random`. Because the seed depends on
294
- `fixedStepTick` (which only advances in the fixed pass), a selection decision must be made in
295
- `fixedUpdate(fixedStep)` **or** stamped with the `fixedStepTick` read once per frame the
296
- selection must never depend on the variable `update(dt)` cadence. Keep `setRandomSeed` on the
297
- container voice (the `WeightedRandomBehavior` precedent) so tests are reproducible.
298
- - **D6 Channel-volume facade stays linear end-to-end.** The settings sliders are `{min:0,max:1}`
299
- linear and `Effects` defaults to **1.2** (above max). Store/return the raw linear gain the slider
300
- expects; convert to dB only internally if a bus needs it. Preserve 1.2 / 0.1 exactly.
301
- - **D7 Single owner of the `SoundAssetLoader` registration.** `AssetManager.registerLoader` throws
302
- on duplicate type. The rewired `SoundEmitterSystem` already registers it; `SopraEngine` reuses it,
303
- does not re-register. Make construction order in `makeMirEngineConfig` explicit.
304
- - **D8 Clip-tree adapters model the behavior-tree spine, NOT the legacy `SoundEmitter` adapter.**
305
- The legacy adapter hand-rolls track ser/de inline and is **not** polymorphic. New container
306
- adapters extend `ObjectBasedClassSerializationAdapter` so children recurse by `typeName`.
307
- - **D9 Resolve the dormant overlaps.** `material/` (weighted `SoundMaterialComposition` + terrain
308
- splat detector) and `asset/SoundAssetPlaybackSpec` are dormant (zero usages/tests). Fold them into
309
- `Switch`/weighted containers in P4; do not maintain three parallel spec hierarchies.
310
- - **D10 Bus insert effects are live in v1 (per the scope decision).** Add an `AbstractAudioEffect`
311
- base mirroring `AbstractAudioClip` style (`typeName`, `toJSON`/`fromJSON`, adapter, `compare`/
312
- `equals`/`hash`/`clone`) with a `build(ctx) { input, output }` contract, plus two concrete
313
- effects for v1: `EqEffect` (`BiquadFilterNode`, automatable `frequency`/`Q`/`gain`) and
314
- `CompressorEffect` (`DynamicsCompressorNode`). `BusGraph` wires each bus as
315
- `input effect[0] → … → effect[n] → output`; an empty `effects[]` is a direct `input === output`.
316
- The existing **master** `DynamicsCompressor` (in `SoundEngine`) stays master-glue only buses must
317
- not double-compress by default. `WaveShaper`/occlusion-lowpass remain nice-to-have (P4+).
318
-
319
- ---
320
-
321
- ## 6. Reuse map (reuse-over-micro-opt)
322
-
323
- | Existing meep code | Used for |
324
- |---|---|
325
- | `sound/SoundEngine.js` (AudioContext, master `Gain→Compressor→destination`, suspend/resume) | injected into `SopraEngine`; never a 2nd context |
326
- | `sound/ecs/SoundListener*.js` | listener pose, reused **verbatim** |
327
- | `core/bvh2/bvh3/*` + `BVHQueryIntersectsSphere` + `IncrementalDeltaSet` | hearing-range cull = virtualization trigger |
328
- | `core/model/object/ObjectPoolFactory.js` | voice trim-gain pool; `ParticlePool`/`BitSet` if fixed-capacity budget wanted |
329
- | `core/math/random/seededRandom.js` (Mulberry32) + `weightedRandomFromArray.js` | deterministic random/weighted selection; avoid-repeat ring |
330
- | `Signal`, `Vector1` (onChanged), `List`, `combine_hash`/`computeHashFloat`/`computeStringHash` | params, gains, child collections, voice `onended`, clip hashing |
331
- | `engine/asset/*` (`AssetManager`, `SoundAssetLoader`, `loadSoundTrackAsset` alias path, `AssetPreloader`) | `AssetManagerBufferProvider`; preload via existing asset manifests (no bank concept) |
332
- | `engine/animation/curve/AnimationCurve.js` + `Keyframe.js` | the curve type for RTPC automation **and** 3D distance attenuation (`EventDescription.attenuation`) reused instead of a bespoke curve |
333
- | `core/math/physics/irradiance/interpolate_irradiance_*` | reference falloff shapes when authoring `AnimationCurve` attenuation presets (P2) |
334
- | `sound/volume2dB.js` + `dB2Volume.js` (**export them**) | dB↔linear at facade edges, gain randomization, ducking math |
335
- | `ecs/storage/binary/object/*` + `SequenceBehaviorSerializationAdapter` shape | polymorphic recursive clip-tree ser/de by `typeName` |
336
- | `intelligence/behavior/*` (`Behavior`, `CompositeBehavior`, `BehaviorSystem`) | definition node contract + system lifecycle shape (but split def/instance) |
337
- | `SoundEmitterComponentContext` discipline | runtime objects in `data[entity]`; `Suspended` plumbing `VoiceManager` virtualization |
338
-
339
- ---
340
-
341
- ## 7. Serialization plan
342
-
343
- - Each definition type gets a `typeName`, a `BinaryClassSerializationAdapter` at **version 0**, and a
344
- parallel `toJSON`/`fromJSON`. Container adapters extend `ObjectBasedClassSerializationAdapter`.
345
- - Register all in `GameBinarySerializationRegistry.initializeGameBinarySerializationRegistry`
346
- (`registerAdapters([...])`) **and** in `GameClassRegistry` — the single registration sites, called
347
- before `config.apply` in `GameBootstrap`.
348
- - Design byte layouts to be **upgrade-friendly from day one** (varint-counted, append-only fields),
349
- per the `SoundEmitterSerializationUpgrader_1_2` lesson (which just appends a default `volume`).
350
- - **The legacy `SoundEmitterSerializationAdapter` (v2) + upgraders `_0_1`/`_1_2` stay registered
351
- verbatim** — old saves deserialize into a `SoundEmitter` exactly as today; the facade translates at
352
- link time. No save-format migration in v1.
353
-
354
- ---
355
-
356
- ## 8. Migration / back-compat (the four surfaces)
357
-
358
- Invariant: always exactly one renderer (sopra); legacy classes are translators.
359
-
360
- 1. **`createSound`** (#1 fire-and-forget, ~7 high-traffic sites). Keep the signature
361
- `{position, url|track, positioned, channel, volume, timeout}` and self-destruct contract
362
- byte-for-byte. Reimplement the body to build an ad-hoc `EventDescription` (root =
363
- `SampleAudioClip`, `busId = channel`, `is3D = positioned`, `gainDb = volume2dB(volume)`) and call
364
- `playOneShot(desc, {position})`. **Preserve the full safety net** (D2). `GameSounds.js` named
365
- tracks become named `EventDescription`s keeping their export names.
366
- 2. **`SoundEmitter` + `fromJSON` + manual entity builds** (footsteps/voice/impacts, boss ambience,
367
- music stems, title music, achievements, dialogue). v1: keep the exact component shape & `fromJSON`;
368
- the rewired `SoundEmitterSystem.link` translates each `SoundTrack` `SampleAudioClip` + a
369
- persistent `EventInstance` on the channel-bus (attenuation enum → 3-point `AutomationCurve`).
370
- `tracks.on.added/removed` spawn/stop instances so `emitter.tracks.add/addAll` keep working.
371
- Direct AudioParam reaches get equivalents: `setVolumeOverTime`/`emitter.volume.onChanged` →
372
- live voice gain; `resetSoundEmitterTracks` (`t.time=0`) → `instance.seek(0)` **and** write
373
- `track.time` back from the live cursor so readers stay consistent; `hideEntityGracefully` 2.7s
374
- fade / `SoundEmitterVolumeBehavior` `instance.fadeOutAndStop`. `CombatEndMusicProcess` reads
375
- `track.nodes.volume.gain` directly expose a **temporary bridge GainNode** at
376
- `SoundTrack.nodes.volume` during cut-over, then migrate it to a `bus.fadeTo`/voice-crossfade
377
- primitive and drop the bridge in P4 (do not keep the bridge as steady state).
378
- 3. **`SoundController`** (UnitMaker only). Keep its v0 adapter frozen in v1. P4: provide
379
- `SopraEventTrigger` preserving the `{tracks, startEvent, stopEvent, loop, volume, channel}` rule
380
- shape; migrate UnitMaker last.
381
- 4. **Channels + settings UI** (must preserve). Seed default buses with the exact legacy mix; keep
382
- `get/setChannelVolume` on the (rewired) `SoundEmitterSystem` proxying to bus gain **in the linear
383
- domain** (D6). Sliders bind unchanged. **Editor** (`SoundEmitterController`, symbolic display,
384
- reset-tracks) keeps working because the component shape is preserved.
385
-
386
- ---
387
-
388
- ## 9. Phased plan
389
-
390
- > Each phase ships behind black-box `*.spec.js` run from `H:/git/moh` root. P0–P2 need **no ECS**.
391
-
392
- **P0 Core skeleton + ser/de + bus inserts (no ECS).**
393
- Finish clip classes; add `SampleAudioClip`, `EventDescription`, `BusDefinition`,
394
- `ParameterDefinition` (3D attenuation reuses the existing `AnimationCurve`), and the effect hierarchy
395
- `AbstractAudioEffect`/`EqEffect`/`CompressorEffect`
396
- (D10); `BusGraph` (default buses at legacy mix, wiring each bus's `input→effects→output` insert
397
- chain); `EventInstance` + `Voice` (pooled gain); `SopraEngine.playEvent/playOneShot/createInstance/
398
- stop/bus`; `BufferProvider` + stub; **export `volume2dB`/`dB2Volume`**; `fadeOutAndStop` **with the
399
- Firefox fallback (D1)**; one adapter per definition + effect type registered.
400
- *Exit:* a `SampleAudioClip` event plays to the right bus at the right gain, **through a bus insert
401
- chain (EQ→Compressor) wired in the correct order**, **and** every definition + effect round-trips
402
- equal (binary + JSON) in Jest with a mock `AudioContext`, no ECS. Firefox fade path tested. Legacy
403
- engine untouched and still active. **✅ DONE 26 tests green (see §0).**
404
-
405
- **P1 Timeline playback + containers + parameters. DONE sopra suite 35 tests green.**
406
- A polymorphic `AbstractAudioClip.planTimeline()` (overridden per clip) deterministically flattens the clip graph to timed leaf plays (gain/pitch
407
- inheritance resolved once); `Random` (seeded + avoid-repeat ring via `SopraPlaybackContext`) and
408
- `Sequence` (cumulative offsets; `Silence` = offset arithmetic); `ParameterStore` + `AnimationCurve`
409
- bound to bus volume (same curve type used for 3D attenuation); per-trigger pitch/gain randomization.
410
- D5 resolved (deterministic). The pumping lookahead `Scheduler` was MOVED TO P2 (it's only needed for
411
- virtualization / indefinite content); P1 schedules finite timelines eagerly via WebAudio future
412
- `start(when)`.
413
- *Exit (met):* sequence schedules children back-to-back with no gap; random avoids last N for a fixed
414
- seed; `setParameter` observably moves a live gain black-box, no ECS.
415
-
416
- **P2 — VoiceManager + spatialization + cursor scheduling. ✅ DONE — sopra suite 43 tests green.**
417
- Cursor-based per-instance scheduling realises the lookahead model (each frame
418
- `EventInstance.update(now, listener)` spawns leaves within a lookahead window)a global priority
419
- queue is deferred to P4 as an optimization. `VoiceManager` with per-event `maxInstances` + priority +
420
- steal (oldest/quietest/none). **Cursor-based virtualization** (D3): out-of-range instances stop their
421
- voices, advance the cursor, and revive at the correct child + buffer-offset. Per-`EventInstance`
422
- `PannerNode` (rolloff=0) + attenuation `GainNode` driven by `attenuation.evaluate(distance)` (D4);
423
- `buildAttenuationCurve` authors legacy-equivalent curves from `interpolate_irradiance_*`;
424
- HRTF/EqualPower panner choice (StereoPanner / no-panner deferred to P4). Per-bus limits also deferred
425
- to P4 (per-event covers the exit criteria).
426
- *Exit (met):* over-limit steals the correct victim; a virtualized sequence revives at the correct
427
- child+offset; attenuation matches the legacy curve at sample distances. Default limits are a no-op /
428
- stealing is opt-in.
429
- *Exit:* over-limit steals the correct victim; a virtualized **sequence** (not just a loop) revives at
430
- the correct child+offset; attenuation matches the legacy curve at sample distances. **Default limits
431
- are a no-op / stealing is opt-in** (so dense scenes can't regress before measurement).
432
-
433
- **P3 ECS binding + facade cut-over (zero call-site / save / settings / editor change).**
434
- `SopraEmitterSystem` (+ `playOneShot`); reuse `SoundListener*`; construct `SopraEngine` in
435
- `makeMirEngineConfig` from `sound.context/.destination`; **single loader owner (D7)**. Rewire
436
- `SoundEmitterSystem` to translate tracks→clips→persistent instances (same constructor); `createSound`
437
- `playOneShot` (D2); `GameSounds` as named events; `get/setChannelVolume` proxy (linear, D6);
438
- temporary `track.nodes.volume` bridge + `track.time` write-back. Legacy v2 adapter/upgraders +
439
- `SoundController`/`SoundListener` + editor untouched; remove old `SoundTrack` node-rendering once the
440
- regression suite is green.
441
- *Exit:* all ~25 call sites, saves, sliders, and the editor behave identically; sopra is the only
442
- renderer.
443
-
444
- **P4 Native migration + FMOD/Wwise build-out (post-strangler).**
445
-
446
- > **DONE — Switch/Blend containers.** `SwitchContainerAudioClip` (discrete RTPC-keyed: rounds+clamps a
447
- > parameter to a child index) and `BlendContainerAudioClip` (per-child parameter→linear-gain curves;
448
- > every child with gain > 0 plays simultaneously, scaled). Parameter access added to the planTimeline
449
- > `env.getParameter` (read once at trigger). Full JSON+binary serialization + registry; supersedes the
450
- > dormant `material/` subsystem's surface-switch / composition concepts. **Limitation:** Blend is a
451
- > trigger-time snapshot it does NOT re-blend live as the parameter later sweeps. **Live re-blend
452
- > follow-up:** have plays carry an optional `{gainParam, gainCurve}`, bind the spawned voice's trim
453
- > gain to the `ParameterStore` (needs an *unbind* on voice retire ParameterStore.bind currently has
454
- > no removal). Also still dormant: delete the `material/` files + `asset/SoundAssetPlaybackSpec` once
455
- > confirmed unreferenced.
456
-
457
- > **DONE Reverb / aux sends.** `BusGraph.build` now wires `BusDefinition.sends` (previously a
458
- > data-only shape): for each send it taps the source bus's output → a send-level `GainNode`
459
- > `targetBus.input` (post-fader copy; the dry path to the parent is unchanged). Throws on an unknown
460
- > send target. `ReverbEffect` is a new `ConvolverNode` insert whose impulse response is generated
461
- > procedurally at build (decaying noise; params `decaySeconds`/`decayPower`) so build stays
462
- > synchronous with no IR-asset dependency. A "reverb bus" = a bus with a `ReverbEffect` insert that
463
- > other buses send to. **Follow-ups:** live send-level control (sends are static at build); authored-IR
464
- > source from the BufferProvider (would make build async); pre-fader sends.
465
-
466
- **Native migration** (user naming: `AudioEmitter` [rename `SoundEmitter`→`LegacySoundEmitter`],
467
- `AudioEventTrigger` keep "sopra" internal-only). Decided **additive-first**: `AudioEmitter` holds a
468
- full `EventDescription`.
469
-
470
- > **DONE — additive foundation (engine-level, not yet wired into the game).** `AudioEmitter`
471
- > (`engine/sound/ecs/audio/`) holds a full `EventDescription` + a live `volume` (Vector1) + `autoplay`;
472
- > serializable (JSON). `AudioEmitterSystem` shares the single sopra (`SoundEngine.createSopra` made
473
- > idempotent) + self-registers the `SoundAssetLoader` if absent (standalone-capable, guarded), autoplays
474
- > on link with `oneShot` derived from `event.rootClip.loops()` (finite events self-release at end,
475
- > looping events persist), plumbs `volume`→instance gain + `transform.position`→instance, and ticks
476
- > sopra + listener. Coexists with the untouched legacy `SoundEmitter`. Black-box tested.
477
- > **RENAME DROPPED (owner change of plans):** no `SoundEmitter`→`LegacySoundEmitter` rename. Instead
478
- > the legacy stack `SoundEmitter`, `SoundEmitterSystem`, `SoundController`, `SoundControllerSystem`
479
- > is annotated `@deprecated` (pointing at `AudioEmitter`/`AudioEmitterSystem`/the forthcoming
480
- > `AudioEventTrigger`). The classes keep their names; callers migrate organically.
481
- > **NOT yet done (own pass):** `AudioEventTrigger` (replaces `SoundController`); wiring `AudioEmitter`
482
- > into `GameClassRegistry` / `GameBinarySerializationRegistry` / the editor (`AudioEmitterController`,
483
- > symbolic display) / `makeMirEngineConfig`; consolidating the sopra tick to one owner once both systems
484
- > run together; migrating call sites to `AudioEmitter`.
485
- >
486
- > **Post-review fixes landed (P4 review):** D1 — `oneShot` now derived from `event.rootClip.loops()`
487
- > (looping AudioEmitter events no longer die at the 60s lifetime backstop; added `loops()` to the clip
488
- > hierarchy). D2 (keystone) `BusGraph` now keeps an authoritative per-bus `nominal` gain; `getVolume`/
489
- > `getVolumeDb` read it, `setVolume`/`rampVolume` write it, and `approachVolume` (ducking) deliberately
490
- > does NOT so snapshot `captureSnapshot`, duck nominal-capture/restore, and settings read-back are
491
- > correct under automation (was: stale `gain.value`, which WebAudio never updates under ramps). IR2
492
- > `AudioEmitterSystem` self-registers the `SoundAssetLoader` (guarded). **Deferred (do at wiring time,
493
- > not band-aided):** IR1 single-owner sopra tick (the double-tick is idempotent-within-frame today and
494
- > only matters once both systems are in one config); D3 multi-ducker aggregation; IR4 production
495
- > serialization wiring; the remaining quick-fixes (D4–D9, C3).
496
-
497
- Flip call sites to `AudioEmitter`; `AudioEventTrigger` replaces `SoundController`; migrate
498
- `CombatEndMusicProcess` to bus/voice crossfade and drop the bridge; `AudioEmitterController` editor
499
- panel; `AudioEmitter` v0 adapter + save-translation path. Then the deferred features behind their own
500
- tests: **~~Snapshots/mixer-states~~ (DONE); ~~Blend/Switch containers~~ (DONE); additional bus insert
501
- effects (occlusion lowpass / `WaveShaper`) beyond the v1 EQ/Compressor; ~~reverb sends~~ (DONE);
502
- ~~emulated ducking~~ (DONE); scatterer; adaptive-music timeline.**
503
-
504
- > **DONE Emulated ducking.** `DuckingRule` definition (trigger/target bus, `duckDb`, `attack`,
505
- > `release`) + ser/de + registry. `SopraEngine.addDucker`/`clearDuckers`; evaluated each `update()`:
506
- > edge-triggered when the trigger bus has ≥1 live instance the target ducks (`setTargetAtTime` toward
507
- > nominal+duckDb over attack; nominal captured at engage), restoring over release when the trigger goes
508
- > quiet. **Play-state** sidechain (active-instance count, NOT signal level — WebAudio has no native
509
- > sidechain) via the new `BusGraph.approachVolume`. Trigger match is by direct `instance.busId` (child
510
- > buses not counted in v1). Follow-up: true signal-following duck via `AudioWorklet`.
511
-
512
- > **DONEMixer snapshots.** `MixerSnapshot` definition (id + per-bus target `gainDb`) with full
513
- > ser/de + registry. `SopraEngine.applySnapshot(snapshot, {duration})` snaps (duration 0
514
- > `setVolume`) or click-safely ramps (`BusGraph.rampVolume` shared `rampGain`) each listed bus to its
515
- > target the FMOD mix-state blend. `captureSnapshot(id, busIds)` reads the current mix (for
516
- > save/restore). Throws on an unknown bus.
517
-
518
- > **Sound banks DROPPED (not a feature).** A "bank" is FMOD/Wwise's catch-all for serialization +
519
- > load-unit + media package + by-id namespace, needed only because that middleware has no host data
520
- > model. meep already has every piece: `EventDescription`s are serializable definitions that live as
521
- > component / `EntityComponentDataset` data; load/unload is the scene/dataset boundary; media is the
522
- > `AssetManager`'s concern (cache + preload manifests). The only residual shared "play by id" events
523
- > — is at most a small `AudioEventLibrary` *component* (id→`EventDescription` map), and even that is
524
- > optional since `playEvent` already accepts a description object directly.
525
-
526
- ---
527
-
528
- ## 10. Testing strategy (black-box only)
529
-
530
- - **Layer 1 ECS-free core** (the payoff of the agnostic design): inject a mock `AudioContext`
531
- (records `createGain`/`createBufferSource`/`connect`/`start(when,offset,duration)`/gain
532
- automation as an observable graph + schedule), a stub `BufferProvider` (fixed-duration buffers),
533
- a deterministic clock, and a seeded RNG. Cover: definition round-trip; routing to the right bus at
534
- the right dB; sequence gapless scheduling (`when` = prev `when` + prev `duration`); random
535
- avoid-last-N **for a fixed seed (deterministic, D5)**; parameter moves gain; over-limit steals
536
- correct victim; **virtualized sequence revives at correct child+offset**; attenuation matches
537
- `interpolate_irradiance_*`; **a bus insert chain wires EQ→Compressor in order (empty `effects[]` is
538
- pass-through; the master compressor is not double-applied)**; `fadeOutAndStop` never ramps to 0,
539
- stops after ~epsilon, **and the no-`cancelAndHoldAtTime` fallback path**.
540
- - **Layer 2 ECS binding**: an entity with `SopraEmitter+Transform` plays a 3D event, attenuates
541
- with distance, goes virtual outside BVH range and revives on re-entry; sliders move bus gains.
542
- - **Layer 3 — back-compat regression** (the strangler safety net): `createSound` self-destructs on
543
- end **and** on the timeout/asset-failure path; `SoundEmitter.fromJSON` is audibly equivalent;
544
- `emitter.tracks.add` spawns a voice; `resetSoundEmitterTracks` restarts audibly; channel volume
545
- round-trips at **1.2 / 0.1** through the linear proxy; an old save deserializes via the frozen v2
546
- adapter and plays; single `SoundAssetLoader` registration when both engines are constructed.
547
-
548
- Determinism: seed `seededRandom` from the test (and, per D5, from a replicated source if
549
- network-deterministic audio is chosen).
550
-
551
- ---
552
-
553
- ## 11. Risks
554
-
555
- | Risk | Sev | Mitigation |
556
- |---|---|---|
557
- | Game code pokes `track.nodes.volume.gain` directly (CombatEndMusicProcess) | high | temporary bridge GainNode in P3; migrate to bus/voice crossfade in P4; regression test |
558
- | `cancelAndHoldAtTime` absent in Firefox | high | D1 feature-detect + portable fallback + test the fallback |
559
- | One-shot leak on asset 404 | high | D2 max-lifetime timeout + release-on-failure sentinel |
560
- | Per-voice HRTF cost regression on multi-track emitters | high | D4 panner per-instance, shared across leaf voices |
561
- | Voice stealing silences sounds the old "play everything" engine played | med | default limits a no-op; stealing opt-in; measure densest scene first |
562
- | Scheduler/virtualization regresses loop sync or clicks | med | one fade primitive; absolute `when` off `currentTime`; revive-phase tests |
563
- | New ser/de drifts from conventions | med | D8 model on object adapter; version 0 + day-1 upgrader plan; P0 round-trip specs |
564
- | Uniform-flow flatten changes resolved gain/bus vs legacy | med | resolve once at instance creation; assert resolved bus/gain/pitch in tests |
565
- | Duplicate `audio` loader registration crash | low | D7 single owner |
566
- | `track.time` becomes dead state, breaks readers | med | write `track.time` back from the live cursor each frame |
567
- | Scope creep (reverb/ducking/snapshots in v1) | med | hard v1 line = spine + facades; ship effects[]/sends[] data shape only |
568
-
569
- ---
570
-
571
- ## 12. Decisions
572
-
573
- **Locked by the owner:**
574
- 1. **Migration aggressiveness — Facade-first strangler.** One renderer always; `SoundEmitter`/
575
- `createSound`/channels become translators; sites migrate post-cut-over.
576
- 2. **v1 scope — Spine + bus inserts.** Spine (split + buses + scheduler + voice manager + params +
577
- random/sequence + spatialization + facades) **plus live EQ/Compressor insert effects on buses**
578
- (`BiquadFilterNode` / `DynamicsCompressorNode`). Reverb sends / ducking / snapshots remain
579
- P4 (banks dropped — see §9). (See D10 for the effect hierarchy and where it lands.)
580
- 3. **Random/Switch selectionNetwork-deterministic.** Seed `seededRandom` from a replicated source
581
- (entity id + `fixedStepTick`); selection is sampled where the fixed tick is stable (see D5).
582
-
583
- **Still the owner's call (defaults assumed; revisit any time):**
584
- 4. **`SoundEmitter` endgame** assume **keep as a frozen compat reader** through P3/P4; delete only
585
- after full migration + a tested save-translation path. Removing a frozen ~80-line adapter is low value.
586
- 5. **Voice budget defaults** assume **high/no-op defaults + opt-in stealing**, measured in the
587
- densest scene before enabling.
588
- 6. **Bus tree source of truth** — assume **both**: code-seeded legacy default (1.2/0.1) overridable by
589
- serialized scene data.
590
-
591
- ---
592
-
593
- ## 13. FMOD/Wwise concept → sopra coverage
594
-
595
- **v1 (core):** definition/instance split · nestable bus tree · **bus insert effects (EQ/Compressor)** ·
596
- lookahead scheduler · parameters/RTPC + automation curves · voice manager
597
- (limits/priority/stealing/virtualization) · random + sequence containers · custom 3D attenuation
598
- curves · one-shot vs persistent · pitch/gain randomization · gapless loop regions
599
- (`loopStart/loopEnd`).
600
-
601
- **P4 / deferred (important):** ~~banks~~ (DROPPED ECS data model covers it) ·
602
- ~~snapshots/mixer-states~~ **DONE** · ~~blend + switch containers (fold in `material/`)~~ **DONE** ·
603
- ~~reverb sends (`ConvolverNode`)~~ **DONE** · ~~emulated ducking~~ **DONE** · additional insert effects
604
- (occlusion lowpass / `WaveShaper`).
605
-
606
- **Nice-to-have:** scatterer/multi-spawn ambience · `AudioWorklet` (true sidechain + accurate meters)
607
- · `AnalyserNode` dev meters · `OfflineAudioContext` baking · adaptive-music timeline
608
- (transitions/stingers/sustain points).
609
-
610
- ---
611
-
612
- ## 14. P3 detailed integration sub-plan route `SoundEmitter` through sopra (real, not a veneer)
613
-
614
- Goal: the legacy `SoundEmitter` path genuinely renders through sopra. We do **not** keep 100%
615
- back-compat; instead we **deprecate-and-plumb** — the old public API stays as `@deprecated` members
616
- that translate into sopra (so call sites are unchanged), and is removed later. Where plumbing reaches
617
- private state, we rewrite the call site.
618
-
619
- ### 14.1 Architecture — strangler at the existing spine
620
- `SoundEmitterSystem` is already the seam: it owns `this.sopra` and ticks `sopra.setListener` +
621
- `sopra.update` each frame (currently inert). Translation happens **there, at link time** no new
622
- system. Per-entity runtime state lives in the (gutted) `SoundEmitterComponentContext`, repurposed from
623
- a WebAudio renderer into a **translation record**.
624
-
625
- - **link(emitter,transform,entity)** (sopra branch): translate the emitter to sopra
626
- `EventDescription`s and `playEvent` a persistent `EventInstance` per persistent/looping track; store
627
- them in a per-entity `Map<SoundTrack, EventInstance>`.
628
- - **tracks.on.added / on.removed** stay the hook: added → `playEvent` a new instance; removed →
629
- `instance.stop()`. Preserves the live-mutation idiom (`SoundController`, `CombatEndMusicProcess`,
630
- `injectMusic`, `StoryManager`).
631
- - **emitter.volume.onChanged** `instance.setGainDb(volume2dB(v))`; **setVolumeOverTime /
632
- fadeOutAllTracks** `instance.fadeToGainDb` / `instance.fadeOutAndStop`.
633
- - **update()**: push `transform.position` into each owned instance (sopra already does distance
634
- attenuation + panner + virtualization). The BVH stays as a cheap **far-cull pre-gate**.
635
- - **unlink** stop all the entity's instances.
636
- - **createSound** (+ ~9 one-shot sites) → `sopra.playOneShot`, via a kept `@deprecated` facade that
637
- still returns a destroyable `Entity` (so handle-dependent callers are unchanged) and wires
638
- `instance.onEnded builder.destroy`.
639
-
640
- **Component model:** `SoundEmitter`/`SoundTrack` are **kept as `@deprecated` data components**,
641
- translated at link time lowest churn (editor, serialization adapters, `GameClassRegistry`, the 8
642
- `fromJSON` sites all keep working). No `SopraEmitter` component in v1. **One persistent `SoundTrack` →
643
- one `EventInstance`** (both already own an independent playhead/gain/loop/ended-signal); the emitter
644
- maps to a *set* of instances reconciled via the existing `on.added/on.removed` signals.
645
-
646
- ### 14.2 Sopra gaps to fill first (engine-only, ECS-untouched)
647
- The engine is fire-and-forget today (content frozen at `#resolve`, no live gain/fade/seek; 2D voices
648
- route straight to the bus). Add the **live-control surface** without making content mutable:
649
- 1. **`EventInstance` `#instanceGain`** one always-on trim gain that *all* voices route through
650
- (uniform 2D/3D chain head: `instanceGain [panner if 3D] bus`). Parity with the legacy
651
- `nodes.volume`, not new cost. *(do first)*
652
- 2. **`EventInstance.setGainDb(db)` / `get gainDb`** composes with `#attenuationGain` (separate node)
653
- so user volume × attenuation multiply.
654
- 3. **`EventInstance.fadeToGainDb(target,dur,startAfter)` + public `fadeOutAndStop(dur,startAfter)`**
655
- refactor `fadeOutAndStop.js` into a shared `rampGain` helper; stop scheduled via an audio-clock
656
- deadline checked in `update()` (not `setTimeout`).
657
- 4. **`EventInstance.seek(seconds)` / `restart()`** — re-base `startedAt`, retire live voices so
658
- `#evaluate` respawns at the new offset; **must not** reseed `playbackContext` (preserve avoid-repeat
659
- determinism).
660
- 5. **`SopraEngine.crossfade(oldInstance, idOrDesc, opts)`** start new at epsilon, ramp up while
661
- `old.fadeOutAndStop`.
662
- 6. **Pure translators** in `sopra/legacy/`: `soundEmitterToEventDescriptions(emitter)` +
663
- `attenuationFunctionToCurve(fn,min,max)` (dispatch `SoundAttenuationFunction`
664
- `interpolate_irradiance_*` `buildAttenuationCurve`; `Attenuation` flag clear → flat-1 curve;
665
- `is3D ← Spatialization`; `SoundPanningModelType → SopraPanningModel`).
666
-
667
- **Gain composition rule (as built):** the translator bakes **NO** gain (`description.gainDb = 0`); the
668
- per-instance `#instanceGain` carries the full live product `volume2dB(emitter.volume × track.volume)`,
669
- re-applied on `emitter.volume.onChanged` and on a live `track.volume` set (via the track's
670
- `__soundRuntime` back-ref); the **bus** carries the channel mix (the legacy 1.2/0.1 already lives on
671
- sopra buses). Baking the product into the description *and* the instance gain would double-count — so
672
- the description carries none.
673
-
674
- ### 14.3 Phased sequence (bottom-up; the 47 sopra tests + 2 legacy specs stay green throughout)
675
- - **P3.2 live-control primitives** *(risk: med)*: `#instanceGain`, `setGainDb`, `fadeToGainDb` +
676
- public `fadeOutAndStop`, `seek`/`restart`, `SopraEngine.crossfade` + unit tests.
677
- - **P3.3 pure translators** *(low)*: `soundEmitterToEventDescriptions` + `attenuationFunctionToCurve`
678
- + unit tests (curves match `interpolate_irradiance_*` at sampled distances); not yet wired.
679
- - **P3.4 — one-shots** *(med)*: `GameSounds` → registered `EventDescription`s; `createSound` facade →
680
- `playOneShot` returning a destroyable Entity; legacy path kept when sopra unreachable.
681
- - **P3.5persistent-emitter spine** *(high)*: rewire `SoundEmitterSystem.link` + gut
682
- `SoundEmitterComponentContext` to a translation record; plumb volume/fade/stop; BVH far-cull kept;
683
- `get/setChannelVolume` bus proxies.
684
- - **P3.6 hard live-mutation sites** *(med)*: `CombatEndMusicProcess.switchMusic` `sopra.crossfade`;
685
- `resetSoundEmitterTracks` → `restart()`; verify `SoundController`/`injectMusic`/`StoryManager` via the
686
- add/remove plumb.
687
- - **P3.7listener convergence + cleanup** *(med)*: single sopra listener feeder; `@deprecated` JSDoc;
688
- delete dead WebAudio in the sopra branch.
689
-
690
- ### 14.4 Deletions vs retained-`@deprecated`
691
- - **Delete now (sopra branch):** `SoundEmitter.buildNodes`/`nodes`/`getTargetNode`/
692
- `writeAttenuationVolume`/panner-half of `updatePosition`; `SoundEmitterComponentContext` WebAudio
693
- body (connect/disconnect/suspend/resume); `SoundTrack.start`/`suspend`/`nodes`/`SoundTrackNodes`;
694
- the per-track time-advance + `endTrack` loop; `CombatEndMusicProcess` hand-rolled crossfade;
695
- `resetSoundEmitterTracks` `time=0` body.
696
- - **Retain `@deprecated`:** `SoundEmitter` + serializable fields, `SoundTrack`, `GameSounds` constants,
697
- `SoundEmitterChannel`/`channels` (editor dropdown), `get/setChannelVolume`, `fadeOutAllTracks`/
698
- `stopAllTracks`, `createSound`, `SoundController`/`System`, **both serialization adapters**
699
- (save-compat — never delete at cutover).
700
- - **Later (dedicated change):** remove the `sopra === null` legacy WebAudio fallback once prototypes
701
- are sound-free or sopra-capable; then retire `SoundEmitter` for a native sopra component.
702
-
703
- ### 14.5 Testing & risks
704
- Black-box only; existing 47 sopra tests + `SoundEmitterSystem.spec` + `SoundEmitterSerializationAdapter.spec`
705
- (both exercise the `sopra === null` path) stay green every phase. New coverage per phase via
706
- `MockAudioContext` + `StubBufferProvider`. **Not unit-testable** (verify in-browser): AudioContext
707
- bootstrap, autoplay-resume, audible result, dat.gui editor.
708
-
709
- Top risks: **save-compat** (keep both adapters round-tripping — high); **gain composition** double-fold
710
- (one rule, dB↔linear round-trip test — med); **createSound handle** (return a real Entity that stops
711
- its instance on destroy med); **`#instanceGain` topology change** perturbing the 47 tests (unity gain,
712
- re-run suite immediately — med); **`seek` reseeding RNG** (must not — med).
713
-
714
- ### 14.6 Open decisions (recommendations in **bold**)
715
- 1. Music crossfade ownership **keep `CombatEndMusicProcess` locating the music emitter + calling
716
- `sopra.crossfade`** (a `MusicDirector` is later scope).
717
- 2. Prototypes **keep the `sopra === null` branch** through P3 (strangler safety net); delete later.
718
- 3. `createSound` return **real `Entity`** (callers unchanged); revisit only if the UI-click path shows cost.
719
- 4. Per-track volume fold → **`description.gainDb` per instance + `#instanceGain` for live volume**.
720
- 5. `resetSoundEmitterTracks` **`restart()`** (not stop+replay — preserves RNG/loading).
721
- 6. `SoundController` → **keep the add/remove plumb** in v1 (single consumer: `UnitMaker`).
722
-
723
- ### 14.7 LOCKED API contract + no-switch refinement (owner-directed; supersedes the above where they differ)
724
-
725
- **No switch.** There is no `sopra === null` fallback. `SoundEmitterSystem` ALWAYS routes through sopra.
726
- Constructor becomes `SoundEmitterSystem(assetManager, soundEngine)` (context/destination derived from
727
- it; it always `createSopra`s). All 11 construction sites pass the `SoundEngine` (the 10 prototypes/
728
- `testEffect` pass `sound`/`engine.sound`; the unit spec passes a `MockAudioContext`-backed stub whose
729
- `createSopra` builds a real `SopraEngine` on the mock + a `StubBufferProvider`). The legacy WebAudio
730
- renderer is **deleted outright**, not guarded: per-track time-advance loop, `SoundTrackNodes`,
731
- connect/disconnect virtualization, the channel `GainNode`s, `buildNodes`. (The two background-added
732
- `SoundEmitterSystem.spec` tests cover that deleted loop and are replaced by sopra-routing tests.)
733
-
734
- **Preserved public API the whole contract. Everything else is removed or throws.**
735
-
736
- `SoundEmitter`:
737
-
738
- | member | plumbing into sopra |
739
- |---|---|
740
- | `tracks` (List) | add `playEvent` + map; remove `instance.stop()`; reconciled via `tracks.on.added/removed` |
741
- | `channel` (string) | `EventDescription.busId` (read at translation; `""` → `effects`) |
742
- | `volume` (Vector1) | `onChanged` → each owned `instance.setGainDb(volume2dB(emitter.volume × track.volume))` |
743
- | `flags` | `Spatialization` → `is3D`; `Attenuation` → attenuation curve (read at translation) |
744
- | `distanceMin` / `distanceMax` | `EventDescription.distanceMin/Max` |
745
-
746
- `SoundTrack`:
747
-
748
- | member | plumbing into sopra |
749
- |---|---|
750
- | `on.ended` (`{ended:Signal}`) | the track's `EventInstance.onEnded` → `on.ended.send1(track)` |
751
- | `flags` | `Loop` → `clip.loop`; `UsingAliasURL` → `clip.usingAlias`; `StartWhenReady` → autoplay |
752
- | `volume` (number) | NOT baked at translation; carried live in the owning instance gain (`emitter.volume × track.volume`), re-applied on a live set via `__soundRuntime` |
753
- | `time` (number) | written back from the instance playhead each frame; rewind via `restart()` (used by `resetSoundEmitterTracks`) |
754
- | `url` (string) | `SampleAudioClip.url` |
755
-
756
- **Node access fail loud, explicitly NOT public API** (as built). The property getters
757
- `SoundEmitter.nodes` and `SoundTrack.nodes`, plus `SoundTrack.setVolumeOverTime`, are throwing stubs
758
- (`get nodes() { throw new Error("... sopra owns the audio graph"); }`) these are the members external
759
- code could read. The internal builders that had **no external caller** (`buildNodes` / `getTargetNode` /
760
- `writeAttenuationVolume` / `start` / `suspend` / `initializeNodes`) and the `SoundTrackNodes` class were
761
- removed outright. Any straggler (e.g. `CombatEndMusicProcess` reaching `track.nodes.volume.gain`) breaks
762
- loudlyrewritten (it now uses `fadeOutAllTracks(1)` + `tracks.add`).
763
-
764
- **Kept as trivial `@deprecated` plumbing** (not in the contract, but behavioral with real callers, 1:1
765
- to instance ops): `SoundEmitter.fadeOutAllTracks` `instance.fadeOutAndStop` per owned instance;
766
- `stopAllTracks` owner `stopAll`. (Backs `hideEntityGracefully` / `AnimatedActions` unchanged.)
767
-
768
- **Kept for save-compat (data only, unchanged):** both serialization adapters + the component's
769
- serializable fields, including `isPositioned` (a thin `flags` wrapper retained for the editor +
770
- serialization). **Removed entirely** (callers migrated): the `SoundEmitterComponentContext` WebAudio
771
- body (gutted to a translation record), `endTrack`, `distanceRolloff`, the dead-but-unwritten
772
- `SoundTrack.duration` (dropped from the locked surface), and the rest of the non-contract surface.
773
-
774
- This also resolves §14.6 #2 (no `sopra === null` branch) and revises §14.4 (the node members move from
775
- "retained @deprecated" to "throwing stubs"; there is no legacy fallback to retain).
776
-
777
- ## 15. P5 — spatial scaling to 100k emitters (BVH broadphase + live/dormant split)
778
-
779
- **Goal.** Support ~100,000 registered 3D `AudioEmitter`s with per-frame work and WebAudio node count
780
- bounded by a small audible budget `K`, **not** by the registered count. The fix is to decouple a
781
- *registered emitter* (cheap data + one BVH leaf) from a *live `EventInstance`* (WebAudio nodes): only
782
- emitters whose own audible sphere reaches the listener — capped to `K` — are ever live.
783
-
784
- ### 15.1 Principle — broadphase / narrowphase (mirrors `PhysicsSystem`)
785
- - **Broadphase (new, cheap, ALL emitters):** a dynamic BVH of emitter audible-spheres; one point query
786
- at the listener returns the emitters that *can possibly* be heard.
787
- - **Narrowphase (existing, ≤K live):** the current `EventInstance` per-frame exact
788
- distance/attenuation + virtualization (`virtualThresholdDb`) decides which live instances actually
789
- hold a source `Voice`. Unchanged.
790
-
791
- ### 15.2 Three tiers
792
-
793
- | Tier | Count (island) | Cost each | Where |
794
- |---|---|---|---|
795
- | **Registered / dormant** | ~100k | 1 record + 1 BVH leaf — **no `EventInstance`, no nodes, no per-frame update** | `SpatialAudioIndex` |
796
- | **Live** | ≤ `K` (default **64**) | an `EventInstance` (panner chain), ticked each frame | sopra + the live set |
797
- | **Audible voice** | ≤ K, usually fewer | a `Voice` (source node) | existing virtualization |
798
-
799
- ### 15.3 Reuse (this layer is thin the BVH does the heavy lifting)
800
- - `core/bvh2/bvh3/BVH.js` + **`BvhClient`** (`link(tree,data)`/`unlink()`/`resize(x0..z1)`/`write_bounds`)
801
- one leaf per emitter; the same per-entity handle `RenderSystem`/`PhysicsSystem` already use.
802
- - `bvh_query_user_data_generic(out, 0, bvh, bvh.root, BVHQueryIntersectsSphere.from([lx,ly,lz, 0]))`
803
- the per-frame cull (point-in-leaf collect). `bvh_query_user_data_nearest_to_point` available if a
804
- nearest-N selector is preferred over collect-then-select.
805
- - Existing `EventInstance` virtualization / `seek` / `fadeOutAndStop`, the `VOICE_POOL`, and
806
- `SoundListener`+`Transform` all unchanged.
807
-
808
- ### 15.4 Key trick leaf AABB = the emitter's OWN audible region (no global radius owner-locked)
809
- Each emitter's leaf is sized to its audible sphere: `AABB = position ± event.distanceMax`. The cull is
810
- then a **point query at the listener**, returning exactly the emitters whose `distanceMax` reaches the
811
- listener honoring each emitter's own range, with no hardcoded global radius.
812
-
813
- ### 15.5 Reactive leaf maintenance (NO per-tick iteration — owner-locked)
814
- Everything in meep is dynamic; there is **no static/dynamic flag and no per-tick refit loop**. On
815
- `add`, the leaf subscribes to the emitter's `transform.position.onChanged` (the position channel of
816
- `Transform.subscribe`); the handler does `bvhClient.resize(position ± distanceMax)`. Unsubscribe on
817
- `remove`. Cost is proportional to *actual movement*, not emitter count: a forest of still trees refits
818
- zero leaves/frame; 100k handlers that never fire are just memory. (Live instances separately read the
819
- shared `position` Vector3 each frame for their pannerbut only ≤K of those exist.)
820
-
821
- ### 15.6 `SpatialAudioIndex` + `LiveEmitterSet` (two thin pieces) [as-built — the sketch's single class split in two]
822
- - **`SpatialAudioIndex`** (cull-only): owns the emitter `BVH` + records `{ entity, event, position,
823
- bvhClient, onMove }`.
824
- - `add(entity, event, position)` record + `bvhClient.link(bvh, entity)` (leaf user_data = the entity
825
- id, so queries return entity ids) + `resize(pos ± distanceMax)` + subscribe `position.onChanged`.
826
- - `remove(entity)` → unsubscribe + `bvhClient.unlink()` + drop the record.
827
- - `queryAudible(out, out_offset, listenerPosition)` writes candidate entity ids into the caller's
828
- reused buffer from `out_offset`, returns the match count (allocation-free; meep out-buffer convention).
829
- - **`LiveEmitterSet`** (the live/dormant lifecycle): wraps the index; owns the live map
830
- `{ entity EventInstance }` + per-emitter records `{ event, position, gainDb, playingSince }`; config
831
- `{ budget = 64, liveStickiness = 0.8, fadeOutSeconds = 0.15 }`.
832
- - `add(entity, event, position, gainDb)` register with the index + record (dormant).
833
- - `remove(entity)` → demote (hard cut) if live, then `index.remove`.
834
- - `setGainDb(entity, gainDb)` update the record + the live instance if any (survives re-promotion).
835
- - `refresh(listenerPosition)` cull + budget + diff, **every tick** (§15.7).
836
-
837
- Non-3D emitters (and finite-3D one-shots) skip the index entirely the ECS layer plays them directly
838
- (§15.13 P5.6); only persistent looping 3D emitters are spatially managed.
839
-
840
- ### 15.7 `LiveEmitterSet.refresh` every tick [as-built]
841
- 1. **Cull:** `queryAudible` point query candidate entity ids (reused buffer); an exact spherical
842
- refine then drops the conservative-cube corners (`distance > distanceMax`).
843
- 2. **Budget select:** rank in-range candidates by **effective distance**a live emitter competes as
844
- `distance × liveStickiness` so it does not flicker promote/demote at the cutoff (rank hysteresis) —
845
- and take the nearest `budget`. (The sketch's extra `event.priority` / attenuation-gain tie-breakers
846
- were not needed; pure distance + stickiness.)
847
- 3. **Diff** vs the current live set (`#live`) **demote** leavers, **promote** entrants; at budget a
848
- closer entrant steals the farthest live slot. The diff is re-derived from `#live` each call (so a
849
- denied or self-ended promotion is retried automatically), with demote-before-promote ordering.
850
- 4. `sopra.update(now)` the caller's, right after `refresh` ticks only the ≤budget live instances.
851
-
852
- **No throttle (§15.10):** this runs on every tick. The BVH query is microseconds and the diff is
853
- re-derived from `#live`, so there is nothing to gate; the scratch containers are reused so the per-tick
854
- diff is allocation-light apart from the rank entries.
855
-
856
- ### 15.8 Promotion / demotion + voice slots (allocated at go-live — owner-confirmed)
857
- `K` is the voice-slot pool, allocated at promotion (the only moment WebAudio cost is incurred).
858
- - **promote:** `sopra.playEvent(event, { position, oneShot, startTime })` where `startTime = playingSince`
859
- reconstructs the phase (§15.9 via the `EventInstance.startTime` option, not a post-hoc `seek`), then
860
- apply the emitter's `gainDb`, then store the instance.
861
- - **demote — two cases (owner-locked):**
862
- - **culled out of range** (the emitter left its own audible sphere → already ~0 gain / virtual):
863
- **hard cut** (`instance.stop()`). A fade is pointless/wrong it is already inaudible.
864
- - **contention / stolen** (a closer emitter evicts a still-in-range, audible live one): **fade**
865
- (`fadeOutAndStop`) to avoid a click.
866
- - **stealing = distance priority:** at budget, a closer entrant demotes (steals the slot of) the
867
- farthest live record.
868
-
869
- This sits *on top of*, not replacing, sopra's per-event `maxInstances` (polyphony among the live) and
870
- per-instance virtualization (source-voice gating). **Constraint (P5.2):** `budget` and `maxInstances`
871
- are independent composed caps. For content-equal ambience you want up to N copies of audible at once,
872
- that event's `maxInstances` must be N, else sopra gates the budget `stealMode None` denies the
873
- promote (emitter stays dormant, retried next refresh) and `stealMode Oldest` makes the two layers fight
874
- (churn). Set managed ambience `maxInstances` high (P5.6 wiring).
875
-
876
- ### 15.9 Phase reconstruction (continuous world clock owner-decided; supersedes the entity-id phaseOffset idea)
877
- Dormant emitters don't simulate playback, but the loop conceptually keeps running on the world clock.
878
- Record `playingSince` (audio clock) **once at registration**; on promotion the instance starts with
879
- logical `startTime = playingSince`, so its playhead is `now − playingSince` and the looping voice's
880
- buffer offset wraps into the loop region. A bird you approach is mid-song (continuity); a re-promotion
881
- after a cull resumes at **total elapsed since link** (the loop kept running while you were away).
882
- Deterministic from real time**no engine-invented random phase**. Decorrelation/variety is the content
883
- author's job (existing per-trigger pitch/gain randomization, RandomContainer), not an engine concern.
884
- (Rejected the earlier auto entity-id phaseOffset: opinionated/magic, and for sparse chirps the artifact
885
- is imperceptible; phase-lock matters mainly for tonal loops, which the author can vary.)
886
-
887
- ### 15.10 Churn control (promotion has real cost)
888
- - **Hysteresis (as-built):** rank hysteresis via `liveStickiness` a live emitter competes at
889
- `distance × liveStickiness` (default 0.8), so a newcomer must be clearly closer to steal its slot; this
890
- kills promote/demote flicker at the budget cutoff. (The sketch's leaf-AABB / `distanceMax + margin`
891
- approach was not needed range-edge churn is inaudible, gain ≈ 0 there; the audible flicker is at the
892
- *budget* cutoff, which the rank discount handles.) Demote-by-cull is at exactly `distanceMax` (the
893
- spherical refine).
894
- - **Cull every tick — NO throttling (owner-decided, P5.5 reverted).** `refresh()` runs the full cull
895
- on every `update` tick. The BVH point-query is O(log n + candidates) (microseconds; the 100k stress
896
- shows the whole per-frame `refresh + tick` is ~5 ms), and the promote/demote diff is re-derived from
897
- `#live` each call, so there is no reason to throttle. A throttle was briefly added and removed it was
898
- unnecessary complexity (and introduced a contention-fade gap edge). The diff is allocation-light: the
899
- scratch containers (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused
900
- across calls. (`IncrementalDeltaSet` was considered for the diff but does not fit: `#live` changes
901
- out-of-band instances self-end on asset failure, and promotions can be denied by polyphony caps — so
902
- the diff must be re-derived from `#live` each frame, which a maintained delta set would fight.)
903
- - **Pool the per-instance output chain** (instanceGain attenuationGain panner)**DEFERRED
904
- (measure-first).** Original rationale was the "1000 live panners / panner not torn down on virtualize"
905
- cost; the P5.1–P5.2 live/dormant model already bounds live panner chains to `budget` (≤64), and
906
- hysteresis minimises promote/demote frequency, so this is now a constant-factor micro-opt on ≤64
907
- chains churned only on genuine live-set changes. Per project guidance (reuse-over-micro-opt, measure
908
- first, treat constant-factor diffs as noise) this is held until profiling shows promote/demote
909
- node-churn is actually hot. AudioBufferSourceNodes are single-use and stay pooled via `VOICE_POOL`;
910
- only Gain/Panner nodes would be poolable.
911
-
912
- ### 15.11 What changes where
913
- - `SopraEngine` core: **unchanged**; its `update` is now O(K) (only live instances are active). No new
914
- public surface (`playEvent`/`stop`/`seek` exist).
915
- - **New `SpatialAudioIndex`** (BVH cull + records, thin over the BVH + queries) and **`LiveEmitterSet`**
916
- (the live/dormant lifecycle: budget + promote/demote/steal + per-emitter gain) layered on it.
917
- - `LiveEmitterSet.refresh` culls every tick (no throttling — §15.10), with reused scratch so the diff is
918
- allocation-light. The per-instance output-chain pool is **deferred** (measure-first — §15.10): not
919
- needed for the 100k target since live chains are already bounded to `budget`.
920
- - `AudioEmitterSystem` (DONE, P5.6): `link/unlink` → `liveSet.add/remove` for managed (looping 3D
921
- autoplay) emitters; `update` → `liveSet.refresh(listener)` + `sopra.update`. 2D + finite-3D-one-shot
922
- autoplay take the direct play path; non-autoplay is inert. No more unconditional autoplay-per-emitter.
923
- - `AudioEmitter`: **no** `dynamic` flag. `VoiceManager`: unchanged.
924
-
925
- ### 15.12 Scorecard (target, 100k emitters)
926
- - Per frame: 1 BVH point query (O(log n + candidates)) + top-K select + O(K) live ticks. **Independent
927
- of 100k.**
928
- - WebAudio: ≤K panner chains + ≤K voices.
929
- - Memory: 100k records + 100k BVH leaves (~a few MB, typed-array backed) + shared events/buffers. Leaf
930
- refits ∝ movement, not count.
931
-
932
- ### 15.13 Rollout (incremental, test-first; MockAudioContext + synthetic emitter field + movable mock listener)
933
- - **P5.1 — DONE** (meep `73f9becc1`). `SpatialAudioIndex` (`engine/sound/ecs/audio/SpatialAudioIndex.js`)
934
- cull only: BVH add/remove + reactive `position.onChanged` refit + point query (`BVHQueryIntersectsSphere`
935
- radius 0) candidate entity ids; leaf user_data = entity id; leaf AABB = `position ± distanceMax`;
936
- reused query object + result buffer. 12 black-box tests (in/out per-emitter range, reactive refit both
937
- ways, removal+detach, boundary-inclusive on-axis, repeated-query buffer reuse, 1000-emitter cull). No
938
- promotion yet.
939
- - **P5.2 — DONE** (meep `a39ecbbe6`). `LiveEmitterSet` (`engine/sound/ecs/audio/LiveEmitterSet.js`)
940
- composes the index: `refresh(listener)` promotes nearest-in-range up to `budget` (64), demotes the
941
- rest; distance-priority stealing; rank hysteresis via live-stickiness; exact spherical refine over the
942
- conservative cube; demote-frees-before-promote-fills; zombie-safe demote (delete-before-stop) + self-
943
- end cleanup. Hard cut on demote (fade-on-contention = P5.3). Documented budget↔maxInstances
944
- dependency + pinned by a test. 12 black-box tests.
945
- - **P5.3 — DONE** (meep `1e0a0deee`). Demotion policy in `LiveEmitterSet`: refresh builds an `inRange`
946
- set (exact distanceMax); a demoted live emitter still in range is faded (`fadeOutAndStop`,
947
- `fadeOutSeconds` default 0.15) audible, would click while one that left range is hard-stopped
948
- (already inaudible). `remove()` (unlink) hard-cuts. Slot frees immediately (delete-before-fade); the
949
- onEnded `=== instance` guard makes re-promote-during-fade safe. Tests: cut-on-cull, fade-on-contention,
950
- re-promote-during-fade no-clobber. Adversarial review CLEAN.
951
- - **P5.4 — DONE** (meep `ad96e3a00`). Continuous-clock phase reconstruction (see §15.9, owner-decided
952
- no random phaseOffset). `EventInstance` gained a logical `startTime` option (playhead = now − startTime)
953
- + a looping-voice buffer-offset wrap (also fixes long-dormant virtualization revival).
954
- `LiveEmitterSet` records `playingSince` once at add and promotes with it, so a re-promotion after a
955
- cull resumes at total elapsed since link. Zombie guard for a finite event whose play time already
956
- elapsed (self-stops synchronously during start). Tests: resume-at-elapsed + offset-wrap,
957
- continuity-across-cull, seek-wrap, finite-elapsed guard. Adversarial review CLEAN.
958
- - **P5.5 cull throttling ADDED then REVERTED** (added meep `8264bc328`, reverted later). A throttle
959
- (`cullIntervalSeconds`/`cullMoveThreshold`) was briefly added, then removed at the owner's direction:
960
- the BVH point-query is microseconds and the diff is re-derived from `#live` each call, so there is no
961
- reason not to cull on every `update` tick (100k stress: ~5 ms per `refresh + tick`). What remains from
962
- P5.5: `refresh()` culls every tick with an **allocation-light diff** — the scratch containers
963
- (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused across calls; the
964
- comparator is module-static. `IncrementalDeltaSet` was considered for the diff but does not fit (`#live`
965
- changes out-of-band: self-end on asset failure, denial by polyphony caps — the diff must re-derive from
966
- `#live`). The +9 throttle tests were removed. **Output-chain pool still DEFERRED (measure-first)**
967
- see §15.10 (live/dormant already bounds panners to budget; held until profiling shows churn is hot).
968
- - **P5.6 DONE** (meep `d4ba84cd4`). Wired `AudioEmitterSystem` to `LiveEmitterSet`. Routing fixed at
969
- link: **managed** = `autoplay && is3D && rootClip.loops()` → `liveSet.add(entity, event, position,
970
- volume2dB(volume))`, dormant until refresh; **direct** = any other autoplay (2D, or finite 3D one-shot)
971
- immediate `#play`; **inert** = non-autoplay. (Finite 3D one-shots are deliberately direct, not
972
- managed a dead one-shot would otherwise re-promote+self-stop every cull and occupy a budget slot:
973
- the P5.6 review's main RISK.) `update` resolves the `SoundListener`, then refreshes the live set BEFORE
974
- `sopra.update` (fresh promote spatializes same frame); a listener entity missing its `Transform` is
975
- tolerated as "no listener" (guard) rather than throwing every frame. Per-emitter gain: `AudioEmitter.volume`
976
- carried by `LiveEmitterSet` (`add` 4th arg + `setGainDb`), replayed on every promotion (survives
977
- demotere-promote). `instanceFor(entity)` exposes the active instance for either path; ctor takes
978
- `liveEmitterSetOptions`. **100k stress (both `.skip`):** `AudioEmitterSystem.spec` proves only `budget`
979
- hold a live instance (node count O(budget), not O(N)) + listener-walks-away frees all; `SpatialAudioIndex.spec`
980
- proves the cull returns only the in-range candidates (~200), not N per-frame work O(log N + k). 298
981
- engine/sound green (34 suites), 2 skipped. Adversarial workflow review (6 dims / 21 agents): no
982
- BUG-level defects; acted on the confirmed RISKs + doc fixes.
983
- - **Remaining (post-P5):** register `AudioEmitterSystem` in `makeMirEngineConfig` (production wiring; it
984
- is currently constructed only in tests) and the standing background follow-ups (P5.5 output-chain pool
985
- if profiling demands it; IR1/IR4; dormant-material cleanup).
986
-
987
- ### 15.14 Locked decisions (owner)
988
- - Budget **K = 64** (live instances; virtualization trims actual voices further).
989
- - **No global radius** cull by per-emitter `event.distanceMax` (leaf = `pos ± distanceMax`).
990
- - **No static/dynamic distinction** everything is dynamic; leaves refit reactively via
991
- `transform.position.onChanged`, never per-tick iteration.
992
- - **Demotion:** hard **cut** when culled out of range (already inaudible); **fade** only on contention
993
- (stealing a still-audible live instance).
1
+ # Sopra — Implementation Plan
2
+
3
+ > Object-oriented, FMOD/Wwise-style sound engine for **meep**, built on the WebAudio API.
4
+ > Status: implemented through **P5** (P0–P4 build-out + P5 spatial scaling to 100k emitters), and the
5
+ > cut-over is done on the meep side: `AudioEmitter`, `AudioEventTrigger` and their adapters are
6
+ > registered in `populateEngineSerializationRegistry`, the legacy `SoundEmitter`/`SoundController`
7
+ > behavioural path is deleted, and `convertLegacySoundComponents` turns old saves into native
8
+ > components. See `../SOUND_EMITTER_RETIREMENT_PLAN.md`. Remaining: the game repo still has to register
9
+ > `AudioEmitterSystem`/`AudioEventTriggerSystem` and port its own call sites.
10
+
11
+ This plan was produced from a full read of the current sound engine + a deep survey of the
12
+ surrounding meep architecture (ECS core, asset system, serialization, precedents, game-side
13
+ coupling) and an FMOD/Wwise ⇄ WebAudio feasibility study. Every "verified" claim below was
14
+ checked against source.
15
+
16
+ **Source layout** (`engine/sound/sopra/`): `SopraEngine.js` (orchestrator) at the root;
17
+ `definition/` = authored, immutable data (`EventDescription`, `BusDefinition`, `ParameterDefinition`,
18
+ `SopraPanningModel`, `VoiceStealMode`, plus `clip/` and `effect/` families); `runtime/` = transient
19
+ runtime + services (`EventInstance`, `Voice`, `VoicePool`, `BusGraph`, `VoiceManager`,
20
+ `ParameterStore`, `SopraPlaybackContext`); `asset/` = buffer providers; `serialization/` = `sopraJSON`
21
+ + registry (binary adapters stay co-located with their classes); `util/` = `buildAttenuationCurve`,
22
+ `fadeOutAndStop`, `MockAudioContext`; `legacy/` = the SoundEmitter→sopra translator. Specs sit next to
23
+ the code they cover.
24
+
25
+ ---
26
+
27
+ ## 0. Status
28
+
29
+ - **P0 Core skeleton + ser/de + bus inserts (no ECS): ✅ DONE** (26 tests green via
30
+ `npx jest --config jest.conf.json engine/sound/sopra`).
31
+ Delivered under `sopra/`: the finished clip hierarchy (`AbstractAudioClip`, `ContainerAudioClip`,
32
+ `SampleAudioClip`, `SilenceAudioClip`, `SequenceContainerAudioClip`, `RandomContainerAudioClip`),
33
+ `EventDescription`, `BusDefinition`, `ParameterDefinition` (3D attenuation reuses the existing
34
+ `AnimationCurve`), the effect hierarchy
35
+ (`AbstractAudioEffect`, `EqEffect`, `CompressorEffect`), runtime (`SopraEngine`, `BusGraph`,
36
+ `Voice`, `VoicePool`, `EventInstance`, `ParameterStore`), `BufferProvider` +
37
+ `AssetManagerBufferProvider`/`StubBufferProvider`, the click-safe `fadeOutAndStop` (with the
38
+ Firefox fallback, D1), polymorphic binary adapters (co-located) + JSON dispatchers (`sopraJSON`) +
39
+ `populateSopraSerializationRegistry`, and a `MockAudioContext` test double. `volume2dB`/`dB2Volume`
40
+ are now exported. Specs: `sopraSerialization.spec.js`, `SopraEngine.spec.js`,
41
+ `fadeOutAndStop.spec.js`.
42
+ Deferred to **P3** (integration, not P0): registering the adapters in
43
+ `GameBinarySerializationRegistry` and constructing `SopraEngine` in `makeMirEngineConfig`. P0 keeps
44
+ the legacy engine fully untouched.
45
+ - **P1 Timeline playback + containers + parameters: DONE** (sopra suite now 35 tests green).
46
+ Delivered: a polymorphic `AbstractAudioClip.planTimeline(env, gainDb, pitchCents, offset)` on each
47
+ clip (deterministic flatten of the clip graph to timed leaf plays, gain/pitch inheritance resolved
48
+ once; no `instanceof` switching) + `collectSampleClips`, `SopraPlaybackContext` (seeded RNG + random-container
49
+ avoid-repeat history network-deterministic per D5), Sequence/Random/Silence playback through
50
+ `EventInstance`, per-trigger pitch/gain randomization on `SampleAudioClip`, and parameter→bus-volume
51
+ automation via the reused `AnimationCurve`.
52
+ NOTE: the pumping **lookahead Scheduler** moved to **P2**, where virtualization actually needs it
53
+ (re-spawning virtual voices in-phase). P1 schedules a *finite* timeline eagerly with WebAudio's own
54
+ sample-accurate future `start(when)` correct and simpler for bounded events.
55
+ - **P2 Voice management + spatialization + cursor scheduling: DONE** (sopra suite now 43 tests
56
+ green). Cursor-based playback (each frame `EventInstance.update(now, listener)` evaluates the
57
+ timeline against the playhead, spawning leaves within a lookahead window only while audible);
58
+ **virtualization** out-of-range instances stop their voices, keep the cursor, and revive at the
59
+ correct child + buffer-offset (D3); **3D spatialization** — per-instance `attenuationGain panner
60
+ bus` chain (D4), attenuation = `curve.evaluate(distance)`, with `buildAttenuationCurve` reproducing
61
+ the legacy falloff from `interpolate_irradiance_*`; HRTF/EqualPower panner choice; and a
62
+ **VoiceManager** (per-event `maxInstances` + steal oldest/quietest/none; defaults a no-op).
63
+ NOTES: the lookahead scheduler is realised as the per-instance cursor evaluation (a global priority
64
+ queue is a P4 optimization if profiling warrants); StereoPanner / "no-panner" modes deferred to P4.
65
+ - **P3ECS binding / strangler cut-over: P3.1–P3.6 DONE (P3.7 cleanup remains).** `SoundEngine` owns
66
+ the sopra engine via `createSopra(bufferProvider)`; `SoundEmitterSystem(assetManager, soundEngine)`
67
+ (required 2-arg ctor no switch, no `sopra === null` branch) creates it through an
68
+ `AssetManagerBufferProvider`, reuses the single `SoundAssetLoader` (D7), and drives `sopra.setListener`
69
+ + `sopra.update` from its tick. **sopra is now the single renderer:** every `SoundEmitter` routes
70
+ through a per-entity `SoundEmitterComponentContext` translation record (legacy tracks sopra
71
+ `EventInstance`s; live `emitter.volume × track.volume` → instance gain; shared `transform.position`
72
+ Vector3; live `track.volume` sets also plumb to the instance gain via `__soundRuntime`). The locked
73
+ public API (`SoundEmitter`: tracks/channel/volume/flags/distanceMin/Max; `SoundTrack`:
74
+ on.ended/flags/volume/time/url — `duration` was dropped, never written) is preserved as
75
+ deprecate-and-plumb; the `nodes` getters + `setVolumeOverTime` throw, while
76
+ `distanceRolloff`/`buildNodes`/`endTrack` and the rest of the non-contract surface were removed
77
+ outright. Channel volume = sopra bus volume (`get/setChannelVolume` proxy; legacy mix intact).
78
+ `CombatEndMusicProcess.switchMusic` now crossfades via `fadeOutAllTracks(1)` + `tracks.add`. All 10
79
+ `new SoundEmitterSystem(...)` call sites updated; the dead `SoundEmitterChannel` (singular) +
80
+ `SoundTrackNodes` classes are deleted; full `engine/sound` suite green (62 tests).
81
+ STILL PENDING (P3.7): single listener-feeder convergence and the remaining `@deprecated` JSDoc sweep.
82
+ - **P4 build-out: STARTED.** Switch + Blend parameter-driven containers DONE
83
+ (`SwitchContainerAudioClip` = discrete RTPC-keyed single-child pick; `BlendContainerAudioClip` =
84
+ per-child parameter→gain curves, all audible layers play simultaneously). Parameter access threaded
85
+ into the planTimeline `env` (`getParameter`, read once at trigger). Both fully serialized (JSON +
86
+ binary + registry) and folded the dormant `material/` concepts (surface-switch / composition) into
87
+ general primitives. v1 Blend is a **trigger-time snapshot**, not live re-blend as the parameter
88
+ sweeps (follow-up below). **Reverb sends DONE:** `BusDefinition.sends` is now wired at build time
89
+ (post-fader copy send gain target bus input), and `ReverbEffect` (`ConvolverNode` + a
90
+ procedurally-generated decaying-noise IR — no IR asset, synchronous build) is a new bus insert.
91
+ **Mixer snapshots DONE:** `MixerSnapshot` (named per-bus target gains) + `SopraEngine.applySnapshot`
92
+ (instant or click-safe ramped blend, via `BusGraph.rampVolume`) + `captureSnapshot`. **Emulated
93
+ ducking DONE:** `DuckingRule` + `SopraEngine.addDucker`/`clearDuckers` play-state sidechain (duck a
94
+ target bus while any instance plays on a trigger bus; `setTargetAtTime` attack/release via
95
+ `BusGraph.approachVolume`). **Native migration STARTED (additive-first):** native `AudioEmitter`
96
+ component (holds a full `EventDescription` directly) + `AudioEmitterSystem` done at the engine level —
97
+ shares the one sopra (idempotent `createSopra`), autoplay/stop/volume/position/listener, fully tested;
98
+ coexists with the untouched legacy `SoundEmitter`. (The autoplay-every-emitter behavior here is
99
+ **superseded by P5** spatial managementsee below.) Remaining: `AudioEventTrigger` + game wiring
100
+ (config/editor/serialization registries) + the `SoundEmitter`→`LegacySoundEmitter` rename & call-site
101
+ flip (see §9). Sound banks were evaluated and DROPPED — the ECS data model (components + dataset +
102
+ AssetManager) already covers every bank role.
103
+ Full `engine/sound` suite green. **The remaining items here are now done** see the retirement plan:
104
+ `AudioEventTrigger` exists, serialization is wired, and the legacy component is a frozen reader under
105
+ `sopra/legacy/` rather than a live path (no rename was needed the behavioural half was deleted).
106
+ - **P5 spatial scaling to 100k emitters: ✅ DONE (P5.1–P5.6; see §15 for the full record).** A BVH
107
+ broadphase + live/dormant split so the engine carries far more 3D emitters than can sound at once. A
108
+ registered-but-dormant emitter costs only a BVH leaf (no instance, no nodes, no per-frame work); each
109
+ tick the nearest in-range emitters up to a global voice **budget** (default 64) are promoted to
110
+ live instances and the rest demoted. **P5.1** `SpatialAudioIndex` (BVH cull, reactive leaf refit via
111
+ `position.onChanged`, allocation-free `queryAudible`). **P5.2** `LiveEmitterSet` (promote/budget/
112
+ distance-priority stealing/rank-hysteresis). **P5.3** demotion policy (hard cut when culled out of
113
+ range, click-safe fade on contention). **P5.4** continuous-clock phase reconstruction (`EventInstance.startTime`
114
+ + looping-voice buffer-offset wrap). **P5.5** a cull throttle was added then **reverted** the cull
115
+ runs every tick (BVH is microseconds). **P5.6** `AudioEmitterSystem` wired to `LiveEmitterSet`:
116
+ looping-3D autoplay → spatially managed; 2D + finite-3D-one-shot → direct; non-autoplay → inert;
117
+ per-emitter `AudioEmitter.volume` carried through promote/demote. Two `.skip`-ped 100k stress tests
118
+ prove only `budget` instances exist regardless of N and the BVH prunes the far field (per-frame
119
+ `refresh + tick` 5 ms at 100k). Full `engine/sound` suite green (298, 2 skipped).
120
+
121
+ ## 1. Recommendation in one paragraph
122
+
123
+ Build sopra as an **ECS-agnostic runtime audio engine** (`SopraEngine` + its services) that is
124
+ fully unit-testable with a mock `AudioContext`, then bind it to meep with a **thin ECS layer**, and
125
+ keep the legacy `SoundEmitter` / `createSound` / channel surface alive as a **translating facade**
126
+ during migration (the *strangler* pattern). There is always **exactly one renderer** (sopra);
127
+ legacy classes become translators, never a second engine. The architecture grafts the best of three
128
+ evaluated stances: the *ECS-agnostic, clock-injected core* (testability), the *clean component/system
129
+ topology* (meep-idiomatic), and the *zero-churn facade* (ships v1 with no call-site / save / settings
130
+ / editor edits).
131
+
132
+ The **spine** is the thing meep lacks and FMOD/Wwise are built on: a **definition / instance split**.
133
+ Today `SoundTrack` conflates the spec (`url`/`time`/`volume`/flags) with the live, single-use
134
+ `AudioBufferSourceNode`. Sopra splits this into immutable **definitions** (the `*AudioClip` tree),
135
+ transient pooled **instances/voices**, and engine-owned **services** (mixer bus tree, scheduler,
136
+ parameter store, voice manager).
137
+
138
+ ---
139
+
140
+ ## 2. Engine philosophy this plan must respect (verified)
141
+
142
+ **meep ECS** (`engine/ecs/`)
143
+ - `System` subclasses declare `dependencies = [ClassA, ClassB]`; the engine auto-delivers
144
+ `link(...componentsInDependencyOrder, entity)` / `unlink(...)` (arity is validated as
145
+ `deps.length + 1`). `components_used = [ResourceAccessSpecification.from(Klass, access)]` is a
146
+ scheduling hint that also auto-registers component columns.
147
+ - `simulate(dt)` runs a **fixed-step lock-step pass** (`fixedUpdate(fixedStep)`) then **one
148
+ variable pass** (`update(dt)`); `getFixedStepAlpha()` gives the sub-step remainder for
149
+ interpolation. `fixedStepTick` only advances in the fixed pass.
150
+ - Components are **plain serializable object instances** stored column-wise
151
+ (`components[componentIndex][entityId]`). Runtime/WebAudio objects must NOT live on the
152
+ component — they live in a system-owned `this.data[entity]` context (the established
153
+ `SoundEmitterComponentContext` discipline).
154
+ - Singletons (the listener) are fetched via `ecd.getAnyComponent(Klass)`. Gameplay→sound uses the
155
+ per-entity event bus (`addEntityEventListener` / `sendEvent`), as `SoundControllerSystem` does.
156
+
157
+ **Conventions**: every persisted type has `toJSON`/`fromJSON` **and** a versioned
158
+ `BinaryClassSerializationAdapter` registered by `typeName`; polymorphic trees recurse through
159
+ `ObjectBasedClassSerializationAdapter` + the shared `objectAdapter` (the behavior-tree precedent);
160
+ `Signal` for events, `List` for observable collections, `Vector1` for change-notifying scalars,
161
+ flags as bitmask enums, `assert.*` (free in prod).
162
+
163
+ **User memories (hard constraints)**
164
+ - **Uniform control flow** — no `null = auto / value = override` sentinels.
165
+ ⚠️ `AbstractAudioClip.parent` / `.channel` are exactly this antipattern; resolve inheritance
166
+ **once** at voice instantiation (flatten), so the runtime tick never sees a sentinel.
167
+ - **Reuse over micro-opt** — prefer tested primitives; lead with end-to-end measurement.
168
+ - **Correctness first, no bandaids**; **black-box tests only** (assert observable outcomes — which
169
+ buffer, which bus, what gain, what `when`, how many real voices — never call counts);
170
+ **no silent catch** (expected failure sentinel/defer; unexpected → throw); asserts are free.
171
+
172
+ ---
173
+
174
+ ## 3. The current engine and its gaps
175
+
176
+ | Layer | Current | FMOD/Wwise gap |
177
+ |---|---|---|
178
+ | Definition | `SoundEmitter` (List<SoundTrack>, channel, distance, attenuation enum, flags) | no event/instance split |
179
+ | Playback | `SoundTrack` **conflates** spec + live single-use source + state | — |
180
+ | Mixer | **flat** 3 channel `GainNode`s (effects 1.2 / music 0.1 / ambient) master `Gain DynamicsCompressor destination` | no nestable buses, no inserts, no sends |
181
+ | Spatial | per-emitter `PannerNode` (rolloff=0) + custom attenuation `GainNode` via `interpolate_irradiance_*`; BVH hearing-range cull | attenuation is a fixed 3-value enum, not a curve |
182
+ | Voices | **plays every track of every in-range emitter**; only cull is BVH | no limits / priority / stealing / virtualization |
183
+ | Params | none | no RTPC / automation |
184
+ | Timing | `track.time += timeDelta` each frame; `Suspended` flag works around Chrome disconnected-source time-freeze | no lookahead scheduler; sequence/loop precision at mercy of frame rate |
185
+ | Containers | `sopra/` stubs exist with **no** selection logic, scheduler, instance, or serialization | random / sequence / blend / switch are empty classes |
186
+
187
+ Latent bug to *not* faithfully reproduce: `SoundEmitterSystem.update` line ~370 is
188
+ `if (soundTrack.setFlag(...) && ...)` — `setFlag` returns `undefined`, so the suspended-track-end
189
+ branch is dead code and it sets `Suspended|Playing` on every track every frame. Port the **intent**
190
+ (logical time advances while virtual; revive in phase), not the mechanism.
191
+
192
+ ---
193
+
194
+ ## 4. The sopra model — three layers
195
+
196
+ ### 4.1 Definition layer (immutable, serialized, `typeName` + adapter + `toJSON`/`fromJSON`)
197
+
198
+ ```
199
+ AbstractAudioClip (finish the stub: typeName, isAudioClip tag, .from(), compare/equals/hash/clone)
200
+ ├── SampleAudioClip NEW leaf the only buffer-referencing clip: assetRef(path|alias),
201
+ gainDb, pitch(cents), loop + loopStart/loopEnd, pitchRandom, gainRandom
202
+ ├── SilenceAudioClip (stub) duration → pure schedule-cursor offset, emits no source
203
+ ├── ContainerAudioClip (stub) children[]
204
+ │ ├── SequenceContainerAudioClip plays children in order (scheduled, not onended-chained)
205
+ │ ├── RandomContainerAudioClip (stub) avoid_repeating_last via recent-history ring + seededRandom
206
+ │ ├── BlendContainerAudioClip [deferred] parameter-driven crossfade
207
+ │ └── SwitchContainerAudioClip [deferred] discrete parameter selects child (subsumes material/)
208
+ ```
209
+
210
+ Plus three non-clip definition types:
211
+ - **`EventDescription`** the triggerable unit: `label, rootClip, busId, maxInstances, priority,
212
+ stealMode, virtualThresholdDb, is3D, attenuation: AnimationCurve, distanceMin/Max`.
213
+ (FMOD `EventDescription` / Wwise Actor-Mixer object.)
214
+ - **`BusDefinition`** — `{ id, parentId, gainDb, effects[], sends[] }`. In v1, `effects[]` is **live**
215
+ (an ordered chain of `AbstractAudioEffect` `EqEffect`/`CompressorEffect`, see D10); `sends[]` ships
216
+ as a **data shape only** (reverb sends are P4) so adding them later is non-breaking.
217
+ - **`AbstractAudioEffect`** (+ `EqEffect`, `CompressorEffect`) serialized bus-insert effects with a
218
+ `build(ctx) { input, output }` contract (see D10).
219
+ - **`AnimationCurve`** (reused from `engine/animation/curve/`, a cubic-Hermite keyframe curve)
220
+ used for **both** parameter automation (RTPC) and 3D distance attenuation. `EventDescription.attenuation`
221
+ is an `AnimationCurve`; since it has no `typeName`/binary adapter of its own, the event adapter
222
+ serializes its keyframes inline. (Supersedes the originally-planned bespoke `AutomationCurve`.)
223
+ - **`ParameterDefinition`** — `{ name, range, default, scope }`.
224
+
225
+ > **Uniform-flow fix:** `parent`/`channel`/`gain`/`pitch` inheritance is resolved **once** when an
226
+ > `EventInstance` is created (top-down flatten). The runtime never branches on a `null`-means-inherit
227
+ > sentinel.
228
+
229
+ ### 4.2 Runtime layer (transient, pooled, **never serialized**)
230
+
231
+ - **`EventInstance`** — one trigger: resolved position, parameter overrides, **schedule cursor**,
232
+ active `Voice`s, one-shot|persistent lifetime. Owns **its** `PannerNode` + attenuation `GainNode`
233
+ (see decision D4). Modeled on `ParticleEmitter.build()`, *not* the behavior-tree re-armed node
234
+ (which cannot produce concurrent voices).
235
+ - **`Voice`** — one throwaway `AudioBufferSourceNode` + a **pooled** trim `GainNode`. The source is
236
+ the only unavoidable per-play allocation (it is single-use).
237
+
238
+ ### 4.3 Services (single instances hung off `SoundEngine`, no ECS imports)
239
+
240
+ - **`SopraEngine`** — ECS-agnostic public API: `playEvent / playOneShot / createInstance / stop`,
241
+ `bus(id)`, `setParameter / getParameter`, `setListener(pose)`, `update(now)`. Constructed with
242
+ `(audioContext, destinationNode, bufferProvider)`; **reuses the existing `AudioContext` + master
243
+ chain verbatimnever a second context.**
244
+ - **`BusGraph`** instantiates the `BusDefinition` tree into chained `GainNode`s; root
245
+ `SoundEngine.destination`; **seeds the default `effects`/`music`/`ambient` buses with the exact
246
+ legacy mix (Effects 1.2, Music 0.1)**; exposes `get/setChannelVolume` (linear, see D6).
247
+ - **`Scheduler`** — lookahead queue over `AudioContext.currentTime`; each `update(now)` drains events
248
+ due before `now + lookahead` and calls `start(when, offset, duration)`. Replaces `time += timeDelta`
249
+ and obsoletes the `Suspended` hack. (Reuse `BinaryHeap`.)
250
+ - **`ParameterStore`** — `Map<name, number>` + per-instance overrides + `onChanged` `Signal`s; samples
251
+ bound `AnimationCurve`s `setTargetAtTime`.
252
+ - **`VoiceManager`** active-voice registries keyed by `eventId` and `busId`; per-event
253
+ `maxInstances` + per-bus limits + priority + steal-oldest/quietest (by post-attenuation gain);
254
+ **virtualization** (stop+disconnect source, keep advancing the instance cursor, revive in phase).
255
+ - **`VoicePool`** — `ObjectPoolFactory` of pre-wired trim-gain chains.
256
+ - **`BufferProvider`** (interface) + **`AssetManagerBufferProvider`** — `get(ref) → Promise<AudioBuffer>`.
257
+ Prod wraps `AssetManager.promise(resolveAlias(ref) || ref, 'audio')` (shared immutable buffer);
258
+ a stub impl in tests is what makes the core ECS-free unit-testable.
259
+ - **`fadeOutAndStop`** — the ONE click-safe fade primitive (see D1). No call site hand-rolls fades.
260
+
261
+ ### 4.4 ECS binding (thin)
262
+
263
+ - **`SopraEmitter`** (component) `+ Transform` plain serializable
264
+ `{ eventId, paramOverrides, busOverride, volume: Vector1, flags }`. Runtime `EventInstance` lives
265
+ in `system.data[entity]`.
266
+ - **`SopraEmitterSystem`** `dependencies = [SopraEmitter, Transform]` — `link` `createInstance`
267
+ + `BVH.link`; `update(dt)` → BVH cull around the listener (reuse `BVHQueryIntersectsSphere` +
268
+ `bvh_query_user_data_generic` + `IncrementalDeltaSet`), push position/params, `engine.update(now)`;
269
+ `unlink` stop + release. Hosts `playOneShot(eventId, position, overrides)`.
270
+ - **`SoundListener` / `SoundListenerSystem`** — **reused verbatim**; read via `getAnyComponent`,
271
+ forwarded through `SopraEngine.setListener`.
272
+ - BVH out-of-range = **virtual** (not disconnect); the `VoiceManager` decides actual realness under
273
+ budget. This turns the Chrome `Suspended` hack into standard virtual-voice behavior.
274
+
275
+ ---
276
+
277
+ ## 5. Key design decisions (incl. the 5 critique revisions)
278
+
279
+ - **D1 `fadeOutAndStop` must NOT hard-depend on `cancelAndHoldAtTime`.** It is unimplemented in
280
+ Firefox (and used nowhere in meep today). Feature-detect it; otherwise use the portable idiom:
281
+ read `param.value`, `cancelScheduledValues(now)`, `setValueAtTime(currentValue, now)`, then ramp.
282
+ Always ramp to an **epsilon, never 0** (exponential-ramp-to-0 throws / clicks), and schedule
283
+ `stop()` just after the gain reaches ~epsilon. Test the no-`cancelAndHoldAtTime` path with a mock.
284
+ - **D2 `playOneShot` owns a max-lifetime timeout (default 60s) AND releases on asset-load
285
+ failure.** `createSound` today is belt-and-suspenders (`on.ended` **and** `Sequence[Delay(60),Die]`).
286
+ A one-shot whose asset 404s must still release the no-silent-catch sentinel path, not a hang.
287
+ - **D3 Virtualization revive uses the instance logical cursor, not `t % dur`.** `t % dur` is only
288
+ correct for a single looping leaf. For a `Sequence`/`Random`/one-shot, re-derive the active leaf(s)
289
+ and their offsets from the cursor at `currentTime`, then `start(when, leafOffset)` each.
290
+ - **D4 `PannerNode` + attenuation `GainNode` are per-`EventInstance` (shared by its leaf voices),
291
+ not per-`Voice`.** This matches today's per-emitter sharing (`buildNodes` builds one chain; all
292
+ tracks feed the single emitter volume node). Per-voice panners would multiply HRTF cost (the
293
+ dominant spatialization cost) on multi-track emitters a regression for the dense scenes we care
294
+ about. Only the source node + trim gain are per-voice. Panner-type choice (HRTF / equalpower /
295
+ `StereoPanner` for 2D-UI / none) + distance/budget downgrade lives at the instance level.
296
+ - **D5 Random/Switch selection is network-deterministic (decided).** Seed `seededRandom` from a
297
+ replicated source (`entity id + fixedStepTick`), not `Math.random`. Because the seed depends on
298
+ `fixedStepTick` (which only advances in the fixed pass), a selection decision must be made in
299
+ `fixedUpdate(fixedStep)` **or** stamped with the `fixedStepTick` read once per frame — the
300
+ selection must never depend on the variable `update(dt)` cadence. Keep `setRandomSeed` on the
301
+ container voice (the `WeightedRandomBehavior` precedent) so tests are reproducible.
302
+ - **D6 Channel-volume facade stays linear end-to-end.** The settings sliders are `{min:0,max:1}`
303
+ linear and `Effects` defaults to **1.2** (above max). Store/return the raw linear gain the slider
304
+ expects; convert to dB only internally if a bus needs it. Preserve 1.2 / 0.1 exactly.
305
+ - **D7 Single owner of the `SoundAssetLoader` registration.** `AssetManager.registerLoader` throws
306
+ on duplicate type. The rewired `SoundEmitterSystem` already registers it; `SopraEngine` reuses it,
307
+ does not re-register. Make construction order in `makeMirEngineConfig` explicit.
308
+ - **D8 Clip-tree adapters model the behavior-tree spine, NOT the legacy `SoundEmitter` adapter.**
309
+ The legacy adapter hand-rolls track ser/de inline and is **not** polymorphic. New container
310
+ adapters extend `ObjectBasedClassSerializationAdapter` so children recurse by `typeName`.
311
+ - **D9 Resolve the dormant overlaps.** `material/` (weighted `SoundMaterialComposition` + terrain
312
+ splat detector) and `asset/SoundAssetPlaybackSpec` are dormant (zero usages/tests). Fold them into
313
+ `Switch`/weighted containers in P4; do not maintain three parallel spec hierarchies.
314
+ - **D10 — Bus insert effects are live in v1 (per the scope decision).** Add an `AbstractAudioEffect`
315
+ base mirroring `AbstractAudioClip` style (`typeName`, `toJSON`/`fromJSON`, adapter, `compare`/
316
+ `equals`/`hash`/`clone`) with a `build(ctx) { input, output }` contract, plus two concrete
317
+ effects for v1: `EqEffect` (`BiquadFilterNode`, automatable `frequency`/`Q`/`gain`) and
318
+ `CompressorEffect` (`DynamicsCompressorNode`). `BusGraph` wires each bus as
319
+ `input → effect[0] → … → effect[n] → output`; an empty `effects[]` is a direct `input === output`.
320
+ The existing **master** `DynamicsCompressor` (in `SoundEngine`) stays master-glue only — buses must
321
+ not double-compress by default. `WaveShaper`/occlusion-lowpass remain nice-to-have (P4+).
322
+
323
+ ---
324
+
325
+ ## 6. Reuse map (reuse-over-micro-opt)
326
+
327
+ | Existing meep code | Used for |
328
+ |---|---|
329
+ | `sound/SoundEngine.js` (AudioContext, master `Gain→Compressor→destination`, suspend/resume) | injected into `SopraEngine`; never a 2nd context |
330
+ | `sound/ecs/SoundListener*.js` | listener pose, reused **verbatim** |
331
+ | `core/bvh2/bvh3/*` + `BVHQueryIntersectsSphere` + `IncrementalDeltaSet` | hearing-range cull = virtualization trigger |
332
+ | `core/model/object/ObjectPoolFactory.js` | voice trim-gain pool; `ParticlePool`/`BitSet` if fixed-capacity budget wanted |
333
+ | `core/math/random/seededRandom.js` (Mulberry32) + `weightedRandomFromArray.js` | deterministic random/weighted selection; avoid-repeat ring |
334
+ | `Signal`, `Vector1` (onChanged), `List`, `combine_hash`/`computeHashFloat`/`computeStringHash` | params, gains, child collections, voice `onended`, clip hashing |
335
+ | `engine/asset/*` (`AssetManager`, `SoundAssetLoader`, `loadSoundTrackAsset` alias path, `AssetPreloader`) | `AssetManagerBufferProvider`; preload via existing asset manifests (no bank concept) |
336
+ | `engine/animation/curve/AnimationCurve.js` + `Keyframe.js` | the curve type for RTPC automation **and** 3D distance attenuation (`EventDescription.attenuation`) — reused instead of a bespoke curve |
337
+ | `core/math/physics/irradiance/interpolate_irradiance_*` | reference falloff shapes when authoring `AnimationCurve` attenuation presets (P2) |
338
+ | `sound/volume2dB.js` + `dB2Volume.js` (**export them**) | dB↔linear at facade edges, gain randomization, ducking math |
339
+ | `ecs/storage/binary/object/*` + `SequenceBehaviorSerializationAdapter` shape | polymorphic recursive clip-tree ser/de by `typeName` |
340
+ | `intelligence/behavior/*` (`Behavior`, `CompositeBehavior`, `BehaviorSystem`) | definition node contract + system lifecycle shape (but split def/instance) |
341
+ | `SoundEmitterComponentContext` discipline | runtime objects in `data[entity]`; `Suspended` plumbing → `VoiceManager` virtualization |
342
+
343
+ ---
344
+
345
+ ## 7. Serialization plan
346
+
347
+ - Each definition type gets a `typeName`, a `BinaryClassSerializationAdapter` at **version 0**, and a
348
+ parallel `toJSON`/`fromJSON`. Container adapters extend `ObjectBasedClassSerializationAdapter`.
349
+ - Register all in `GameBinarySerializationRegistry.initializeGameBinarySerializationRegistry`
350
+ (`registerAdapters([...])`) **and** in `GameClassRegistry` the single registration sites, called
351
+ before `config.apply` in `GameBootstrap`.
352
+ - Design byte layouts to be **upgrade-friendly from day one** (varint-counted, append-only fields),
353
+ per the `SoundEmitterSerializationUpgrader_1_2` lesson (which just appends a default `volume`).
354
+ - **The legacy `SoundEmitterSerializationAdapter` (v2) + upgraders `_0_1`/`_1_2` stay registered
355
+ verbatim** — old saves deserialize into a `SoundEmitter` exactly as today; the facade translates at
356
+ link time. No save-format migration in v1.
357
+
358
+ ---
359
+
360
+ ## 8. Migration / back-compat (the four surfaces)
361
+
362
+ Invariant: always exactly one renderer (sopra); legacy classes are translators.
363
+
364
+ 1. **`createSound`** (#1 fire-and-forget, ~7 high-traffic sites). Keep the signature
365
+ `{position, url|track, positioned, channel, volume, timeout}` and self-destruct contract
366
+ byte-for-byte. Reimplement the body to build an ad-hoc `EventDescription` (root =
367
+ `SampleAudioClip`, `busId = channel`, `is3D = positioned`, `gainDb = volume2dB(volume)`) and call
368
+ `playOneShot(desc, {position})`. **Preserve the full safety net** (D2). `GameSounds.js` named
369
+ tracks become named `EventDescription`s keeping their export names.
370
+ 2. **`SoundEmitter` + `fromJSON` + manual entity builds** (footsteps/voice/impacts, boss ambience,
371
+ music stems, title music, achievements, dialogue). v1: keep the exact component shape & `fromJSON`;
372
+ the rewired `SoundEmitterSystem.link` translates each `SoundTrack` → `SampleAudioClip` + a
373
+ persistent `EventInstance` on the channel-bus (attenuation enum 3-point `AutomationCurve`).
374
+ `tracks.on.added/removed` spawn/stop instances so `emitter.tracks.add/addAll` keep working.
375
+ Direct AudioParam reaches get equivalents: `setVolumeOverTime`/`emitter.volume.onChanged` →
376
+ live voice gain; `resetSoundEmitterTracks` (`t.time=0`) `instance.seek(0)` **and** write
377
+ `track.time` back from the live cursor so readers stay consistent; `hideEntityGracefully` 2.7s
378
+ fade / `SoundEmitterVolumeBehavior` `instance.fadeOutAndStop`. `CombatEndMusicProcess` reads
379
+ `track.nodes.volume.gain` directly expose a **temporary bridge GainNode** at
380
+ `SoundTrack.nodes.volume` during cut-over, then migrate it to a `bus.fadeTo`/voice-crossfade
381
+ primitive and drop the bridge in P4 (do not keep the bridge as steady state).
382
+ 3. **`SoundController`** (UnitMaker only). Keep its v0 adapter frozen in v1. P4: provide
383
+ `SopraEventTrigger` preserving the `{tracks, startEvent, stopEvent, loop, volume, channel}` rule
384
+ shape; migrate UnitMaker last.
385
+ 4. **Channels + settings UI** (must preserve). Seed default buses with the exact legacy mix; keep
386
+ `get/setChannelVolume` on the (rewired) `SoundEmitterSystem` proxying to bus gain **in the linear
387
+ domain** (D6). Sliders bind unchanged. **Editor** (`SoundEmitterController`, symbolic display,
388
+ reset-tracks) keeps working because the component shape is preserved.
389
+
390
+ ---
391
+
392
+ ## 9. Phased plan
393
+
394
+ > Each phase ships behind black-box `*.spec.js` run from `H:/git/moh` root. P0–P2 need **no ECS**.
395
+
396
+ **P0 Core skeleton + ser/de + bus inserts (no ECS).**
397
+ Finish clip classes; add `SampleAudioClip`, `EventDescription`, `BusDefinition`,
398
+ `ParameterDefinition` (3D attenuation reuses the existing `AnimationCurve`), and the effect hierarchy
399
+ `AbstractAudioEffect`/`EqEffect`/`CompressorEffect`
400
+ (D10); `BusGraph` (default buses at legacy mix, wiring each bus's `input→effects→output` insert
401
+ chain); `EventInstance` + `Voice` (pooled gain); `SopraEngine.playEvent/playOneShot/createInstance/
402
+ stop/bus`; `BufferProvider` + stub; **export `volume2dB`/`dB2Volume`**; `fadeOutAndStop` **with the
403
+ Firefox fallback (D1)**; one adapter per definition + effect type registered.
404
+ *Exit:* a `SampleAudioClip` event plays to the right bus at the right gain, **through a bus insert
405
+ chain (EQ→Compressor) wired in the correct order**, **and** every definition + effect round-trips
406
+ equal (binary + JSON) in Jest with a mock `AudioContext`, no ECS. Firefox fade path tested. Legacy
407
+ engine untouched and still active. **✅ DONE 26 tests green (see §0).**
408
+
409
+ **P1 Timeline playback + containers + parameters. DONE sopra suite 35 tests green.**
410
+ A polymorphic `AbstractAudioClip.planTimeline()` (overridden per clip) deterministically flattens the clip graph to timed leaf plays (gain/pitch
411
+ inheritance resolved once); `Random` (seeded + avoid-repeat ring via `SopraPlaybackContext`) and
412
+ `Sequence` (cumulative offsets; `Silence` = offset arithmetic); `ParameterStore` + `AnimationCurve`
413
+ bound to bus volume (same curve type used for 3D attenuation); per-trigger pitch/gain randomization.
414
+ D5 resolved (deterministic). The pumping lookahead `Scheduler` was MOVED TO P2 (it's only needed for
415
+ virtualization / indefinite content); P1 schedules finite timelines eagerly via WebAudio future
416
+ `start(when)`.
417
+ *Exit (met):* sequence schedules children back-to-back with no gap; random avoids last N for a fixed
418
+ seed; `setParameter` observably moves a live gainblack-box, no ECS.
419
+
420
+ **P2 — VoiceManager + spatialization + cursor scheduling. DONE sopra suite 43 tests green.**
421
+ Cursor-based per-instance scheduling realises the lookahead model (each frame
422
+ `EventInstance.update(now, listener)` spawns leaves within a lookahead window) — a global priority
423
+ queue is deferred to P4 as an optimization. `VoiceManager` with per-event `maxInstances` + priority +
424
+ steal (oldest/quietest/none). **Cursor-based virtualization** (D3): out-of-range instances stop their
425
+ voices, advance the cursor, and revive at the correct child + buffer-offset. Per-`EventInstance`
426
+ `PannerNode` (rolloff=0) + attenuation `GainNode` driven by `attenuation.evaluate(distance)` (D4);
427
+ `buildAttenuationCurve` authors legacy-equivalent curves from `interpolate_irradiance_*`;
428
+ HRTF/EqualPower panner choice (StereoPanner / no-panner deferred to P4). Per-bus limits also deferred
429
+ to P4 (per-event covers the exit criteria).
430
+ *Exit (met):* over-limit steals the correct victim; a virtualized sequence revives at the correct
431
+ child+offset; attenuation matches the legacy curve at sample distances. Default limits are a no-op /
432
+ stealing is opt-in.
433
+ *Exit:* over-limit steals the correct victim; a virtualized **sequence** (not just a loop) revives at
434
+ the correct child+offset; attenuation matches the legacy curve at sample distances. **Default limits
435
+ are a no-op / stealing is opt-in** (so dense scenes can't regress before measurement).
436
+
437
+ **P3 ECS binding + facade cut-over (zero call-site / save / settings / editor change).**
438
+ `SopraEmitterSystem` (+ `playOneShot`); reuse `SoundListener*`; construct `SopraEngine` in
439
+ `makeMirEngineConfig` from `sound.context/.destination`; **single loader owner (D7)**. Rewire
440
+ `SoundEmitterSystem` to translate tracks→clips→persistent instances (same constructor); `createSound`
441
+ `playOneShot` (D2); `GameSounds` as named events; `get/setChannelVolume` proxy (linear, D6);
442
+ temporary `track.nodes.volume` bridge + `track.time` write-back. Legacy v2 adapter/upgraders +
443
+ `SoundController`/`SoundListener` + editor untouched; remove old `SoundTrack` node-rendering once the
444
+ regression suite is green.
445
+ *Exit:* all ~25 call sites, saves, sliders, and the editor behave identically; sopra is the only
446
+ renderer.
447
+
448
+ **P4 Native migration + FMOD/Wwise build-out (post-strangler).**
449
+
450
+ > **DONE Switch/Blend containers.** `SwitchContainerAudioClip` (discrete RTPC-keyed: rounds+clamps a
451
+ > parameter to a child index) and `BlendContainerAudioClip` (per-child parameter→linear-gain curves;
452
+ > every child with gain > 0 plays simultaneously, scaled). Parameter access added to the planTimeline
453
+ > `env.getParameter` (read once at trigger). Full JSON+binary serialization + registry; supersedes the
454
+ > dormant `material/` subsystem's surface-switch / composition concepts. **Limitation:** Blend is a
455
+ > trigger-time snapshot — it does NOT re-blend live as the parameter later sweeps. **Live re-blend
456
+ > follow-up:** have plays carry an optional `{gainParam, gainCurve}`, bind the spawned voice's trim
457
+ > gain to the `ParameterStore` (needs an *unbind* on voice retire — ParameterStore.bind currently has
458
+ > no removal). Also still dormant: delete the `material/` files + `asset/SoundAssetPlaybackSpec` once
459
+ > confirmed unreferenced.
460
+
461
+ > **DONE Reverb / aux sends.** `BusGraph.build` now wires `BusDefinition.sends` (previously a
462
+ > data-only shape): for each send it taps the source bus's output a send-level `GainNode`
463
+ > `targetBus.input` (post-fader copy; the dry path to the parent is unchanged). Throws on an unknown
464
+ > send target. `ReverbEffect` is a new `ConvolverNode` insert whose impulse response is generated
465
+ > procedurally at build (decaying noise; params `decaySeconds`/`decayPower`) — so build stays
466
+ > synchronous with no IR-asset dependency. A "reverb bus" = a bus with a `ReverbEffect` insert that
467
+ > other buses send to. **Follow-ups:** live send-level control (sends are static at build); authored-IR
468
+ > source from the BufferProvider (would make build async); pre-fader sends.
469
+
470
+ **Native migration** (user naming: `AudioEmitter` [rename `SoundEmitter`→`LegacySoundEmitter`],
471
+ `AudioEventTrigger` keep "sopra" internal-only). Decided **additive-first**: `AudioEmitter` holds a
472
+ full `EventDescription`.
473
+
474
+ > **DONE additive foundation (engine-level, not yet wired into the game).** `AudioEmitter`
475
+ > (`engine/sound/ecs/audio/`) holds a full `EventDescription` + a live `volume` (Vector1) + `autoplay`;
476
+ > serializable (JSON). `AudioEmitterSystem` shares the single sopra (`SoundEngine.createSopra` made
477
+ > idempotent) + self-registers the `SoundAssetLoader` if absent (standalone-capable, guarded), autoplays
478
+ > on link with `oneShot` derived from `event.rootClip.loops()` (finite events self-release at end,
479
+ > looping events persist), plumbs `volume`→instance gain + `transform.position`→instance, and ticks
480
+ > sopra + listener. Coexists with the untouched legacy `SoundEmitter`. Black-box tested.
481
+ > **RENAME DROPPED (owner change of plans):** no `SoundEmitter`→`LegacySoundEmitter` rename. Instead
482
+ > the legacy stack — `SoundEmitter`, `SoundEmitterSystem`, `SoundController`, `SoundControllerSystem`
483
+ > is annotated `@deprecated` (pointing at `AudioEmitter`/`AudioEmitterSystem`/the forthcoming
484
+ > `AudioEventTrigger`). The classes keep their names; callers migrate organically.
485
+ > **NOT yet done (own pass):** `AudioEventTrigger` (replaces `SoundController`); wiring `AudioEmitter`
486
+ > into `GameClassRegistry` / `GameBinarySerializationRegistry` / the editor (`AudioEmitterController`,
487
+ > symbolic display) / `makeMirEngineConfig`; consolidating the sopra tick to one owner once both systems
488
+ > run together; migrating call sites to `AudioEmitter`.
489
+ >
490
+ > **Post-review fixes landed (P4 review):** D1 `oneShot` now derived from `event.rootClip.loops()`
491
+ > (looping AudioEmitter events no longer die at the 60s lifetime backstop; added `loops()` to the clip
492
+ > hierarchy). D2 (keystone) `BusGraph` now keeps an authoritative per-bus `nominal` gain; `getVolume`/
493
+ > `getVolumeDb` read it, `setVolume`/`rampVolume` write it, and `approachVolume` (ducking) deliberately
494
+ > does NOT so snapshot `captureSnapshot`, duck nominal-capture/restore, and settings read-back are
495
+ > correct under automation (was: stale `gain.value`, which WebAudio never updates under ramps). IR2 —
496
+ > `AudioEmitterSystem` self-registers the `SoundAssetLoader` (guarded). **Deferred (do at wiring time,
497
+ > not band-aided):** IR1 single-owner sopra tick (the double-tick is idempotent-within-frame today and
498
+ > only matters once both systems are in one config — **resolved**: the legacy system is gone and
499
+ > `AudioEmitterSystem` is the single tick owner); D3 multi-ducker aggregation; ~~IR4 production
500
+ > serialization wiring~~ (DONE); the remaining quick-fixes (D4–D9, C3).
501
+
502
+ ~~Flip call sites to `AudioEmitter`~~ (DONE); ~~`AudioEventTrigger` replaces `SoundController`~~
503
+ (DONE); ~~`AudioEmitter` v0 adapter + save-translation path~~ (DONE — registered, plus
504
+ `convertLegacySoundComponents`). Still open: migrate `CombatEndMusicProcess` to bus/voice crossfade and
505
+ drop the bridge (game repo); an `AudioEmitterController` editor property panel (the symbolic display
506
+ landed, the panel did not). Then the deferred features behind their own tests: **~~Snapshots/mixer-states~~ (DONE); ~~Blend/Switch containers~~ (DONE); additional bus insert
507
+ effects (occlusion lowpass / `WaveShaper`) beyond the v1 EQ/Compressor; ~~reverb sends~~ (DONE);
508
+ ~~emulated ducking~~ (DONE); scatterer; adaptive-music timeline.**
509
+
510
+ > **DONE Emulated ducking.** `DuckingRule` definition (trigger/target bus, `duckDb`, `attack`,
511
+ > `release`) + ser/de + registry. `SopraEngine.addDucker`/`clearDuckers`; evaluated each `update()`:
512
+ > edge-triggeredwhen the trigger bus has ≥1 live instance the target ducks (`setTargetAtTime` toward
513
+ > nominal+duckDb over attack; nominal captured at engage), restoring over release when the trigger goes
514
+ > quiet. **Play-state** sidechain (active-instance count, NOT signal level WebAudio has no native
515
+ > sidechain) via the new `BusGraph.approachVolume`. Trigger match is by direct `instance.busId` (child
516
+ > buses not counted in v1). Follow-up: true signal-following duck via `AudioWorklet`.
517
+
518
+ > **DONEMixer snapshots.** `MixerSnapshot` definition (id + per-bus target `gainDb`) with full
519
+ > ser/de + registry. `SopraEngine.applySnapshot(snapshot, {duration})` snaps (duration 0
520
+ > `setVolume`) or click-safely ramps (`BusGraph.rampVolume` shared `rampGain`) each listed bus to its
521
+ > target the FMOD mix-state blend. `captureSnapshot(id, busIds)` reads the current mix (for
522
+ > save/restore). Throws on an unknown bus.
523
+
524
+ > **Sound banks DROPPED (not a feature).** A "bank" is FMOD/Wwise's catch-all for serialization +
525
+ > load-unit + media package + by-id namespace, needed only because that middleware has no host data
526
+ > model. meep already has every piece: `EventDescription`s are serializable definitions that live as
527
+ > component / `EntityComponentDataset` data; load/unload is the scene/dataset boundary; media is the
528
+ > `AssetManager`'s concern (cache + preload manifests). The only residual — shared "play by id" events
529
+ > — is at most a small `AudioEventLibrary` *component* (id→`EventDescription` map), and even that is
530
+ > optional since `playEvent` already accepts a description object directly.
531
+
532
+ ---
533
+
534
+ ## 10. Testing strategy (black-box only)
535
+
536
+ - **Layer 1 — ECS-free core** (the payoff of the agnostic design): inject a mock `AudioContext`
537
+ (records `createGain`/`createBufferSource`/`connect`/`start(when,offset,duration)`/gain
538
+ automation as an observable graph + schedule), a stub `BufferProvider` (fixed-duration buffers),
539
+ a deterministic clock, and a seeded RNG. Cover: definition round-trip; routing to the right bus at
540
+ the right dB; sequence gapless scheduling (`when` = prev `when` + prev `duration`); random
541
+ avoid-last-N **for a fixed seed (deterministic, D5)**; parameter moves gain; over-limit steals
542
+ correct victim; **virtualized sequence revives at correct child+offset**; attenuation matches
543
+ `interpolate_irradiance_*`; **a bus insert chain wires EQ→Compressor in order (empty `effects[]` is
544
+ pass-through; the master compressor is not double-applied)**; `fadeOutAndStop` never ramps to 0,
545
+ stops after ~epsilon, **and the no-`cancelAndHoldAtTime` fallback path**.
546
+ - **Layer 2 ECS binding**: an entity with `SopraEmitter+Transform` plays a 3D event, attenuates
547
+ with distance, goes virtual outside BVH range and revives on re-entry; sliders move bus gains.
548
+ - **Layer 3 back-compat regression** (the strangler safety net): `createSound` self-destructs on
549
+ end **and** on the timeout/asset-failure path; `SoundEmitter.fromJSON` is audibly equivalent;
550
+ `emitter.tracks.add` spawns a voice; `resetSoundEmitterTracks` restarts audibly; channel volume
551
+ round-trips at **1.2 / 0.1** through the linear proxy; an old save deserializes via the frozen v2
552
+ adapter and plays; single `SoundAssetLoader` registration when both engines are constructed.
553
+
554
+ Determinism: seed `seededRandom` from the test (and, per D5, from a replicated source if
555
+ network-deterministic audio is chosen).
556
+
557
+ ---
558
+
559
+ ## 11. Risks
560
+
561
+ | Risk | Sev | Mitigation |
562
+ |---|---|---|
563
+ | Game code pokes `track.nodes.volume.gain` directly (CombatEndMusicProcess) | high | temporary bridge GainNode in P3; migrate to bus/voice crossfade in P4; regression test |
564
+ | `cancelAndHoldAtTime` absent in Firefox | high | D1 feature-detect + portable fallback + test the fallback |
565
+ | One-shot leak on asset 404 | high | D2 max-lifetime timeout + release-on-failure sentinel |
566
+ | Per-voice HRTF cost regression on multi-track emitters | high | D4 panner per-instance, shared across leaf voices |
567
+ | Voice stealing silences sounds the old "play everything" engine played | med | default limits a no-op; stealing opt-in; measure densest scene first |
568
+ | Scheduler/virtualization regresses loop sync or clicks | med | one fade primitive; absolute `when` off `currentTime`; revive-phase tests |
569
+ | New ser/de drifts from conventions | med | D8 model on object adapter; version 0 + day-1 upgrader plan; P0 round-trip specs |
570
+ | Uniform-flow flatten changes resolved gain/bus vs legacy | med | resolve once at instance creation; assert resolved bus/gain/pitch in tests |
571
+ | Duplicate `audio` loader registration crash | low | D7 single owner |
572
+ | `track.time` becomes dead state, breaks readers | med | write `track.time` back from the live cursor each frame |
573
+ | Scope creep (reverb/ducking/snapshots in v1) | med | hard v1 line = spine + facades; ship effects[]/sends[] data shape only |
574
+
575
+ ---
576
+
577
+ ## 12. Decisions
578
+
579
+ **Locked by the owner:**
580
+ 1. **Migration aggressivenessFacade-first strangler.** One renderer always; `SoundEmitter`/
581
+ `createSound`/channels become translators; sites migrate post-cut-over.
582
+ 2. **v1 scope — Spine + bus inserts.** Spine (split + buses + scheduler + voice manager + params +
583
+ random/sequence + spatialization + facades) **plus live EQ/Compressor insert effects on buses**
584
+ (`BiquadFilterNode` / `DynamicsCompressorNode`). Reverb sends / ducking / snapshots remain
585
+ P4 (banks dropped see §9). (See D10 for the effect hierarchy and where it lands.)
586
+ 3. **Random/Switch selectionNetwork-deterministic.** Seed `seededRandom` from a replicated source
587
+ (entity id + `fixedStepTick`); selection is sampled where the fixed tick is stable (see D5).
588
+
589
+ **Still the owner's call (defaults assumed; revisit any time):**
590
+ 4. **`SoundEmitter` endgame** — assume **keep as a frozen compat reader** through P3/P4; delete only
591
+ after full migration + a tested save-translation path. Removing a frozen ~80-line adapter is low value.
592
+ 5. **Voice budget defaults** — assume **high/no-op defaults + opt-in stealing**, measured in the
593
+ densest scene before enabling.
594
+ 6. **Bus tree source of truth** — assume **both**: code-seeded legacy default (1.2/0.1) overridable by
595
+ serialized scene data.
596
+
597
+ ---
598
+
599
+ ## 13. FMOD/Wwise concept → sopra coverage
600
+
601
+ **v1 (core):** definition/instance split · nestable bus tree · **bus insert effects (EQ/Compressor)** ·
602
+ lookahead scheduler · parameters/RTPC + automation curves · voice manager
603
+ (limits/priority/stealing/virtualization) · random + sequence containers · custom 3D attenuation
604
+ curves · one-shot vs persistent · pitch/gain randomization · gapless loop regions
605
+ (`loopStart/loopEnd`).
606
+
607
+ **P4 / deferred (important):** ~~banks~~ (DROPPED ECS data model covers it) ·
608
+ ~~snapshots/mixer-states~~ **DONE** · ~~blend + switch containers (fold in `material/`)~~ **DONE** ·
609
+ ~~reverb sends (`ConvolverNode`)~~ **DONE** · ~~emulated ducking~~ **DONE** · additional insert effects
610
+ (occlusion lowpass / `WaveShaper`).
611
+
612
+ **Nice-to-have:** scatterer/multi-spawn ambience · `AudioWorklet` (true sidechain + accurate meters)
613
+ · `AnalyserNode` dev meters · `OfflineAudioContext` baking · adaptive-music timeline
614
+ (transitions/stingers/sustain points).
615
+
616
+ ---
617
+
618
+ ## 14. P3 detailed integration sub-plan — route `SoundEmitter` through sopra (real, not a veneer)
619
+
620
+ Goal: the legacy `SoundEmitter` path genuinely renders through sopra. We do **not** keep 100%
621
+ back-compat; instead we **deprecate-and-plumb** the old public API stays as `@deprecated` members
622
+ that translate into sopra (so call sites are unchanged), and is removed later. Where plumbing reaches
623
+ private state, we rewrite the call site.
624
+
625
+ ### 14.1 Architecture strangler at the existing spine
626
+ `SoundEmitterSystem` is already the seam: it owns `this.sopra` and ticks `sopra.setListener` +
627
+ `sopra.update` each frame (currently inert). Translation happens **there, at link time** — no new
628
+ system. Per-entity runtime state lives in the (gutted) `SoundEmitterComponentContext`, repurposed from
629
+ a WebAudio renderer into a **translation record**.
630
+
631
+ - **link(emitter,transform,entity)** (sopra branch): translate the emitter to sopra
632
+ `EventDescription`s and `playEvent` a persistent `EventInstance` per persistent/looping track; store
633
+ them in a per-entity `Map<SoundTrack, EventInstance>`.
634
+ - **tracks.on.added / on.removed** stay the hook: added → `playEvent` a new instance; removed →
635
+ `instance.stop()`. Preserves the live-mutation idiom (`SoundController`, `CombatEndMusicProcess`,
636
+ `injectMusic`, `StoryManager`).
637
+ - **emitter.volume.onChanged** `instance.setGainDb(volume2dB(v))`; **setVolumeOverTime /
638
+ fadeOutAllTracks** → `instance.fadeToGainDb` / `instance.fadeOutAndStop`.
639
+ - **update()**: push `transform.position` into each owned instance (sopra already does distance
640
+ attenuation + panner + virtualization). The BVH stays as a cheap **far-cull pre-gate**.
641
+ - **unlink** stop all the entity's instances.
642
+ - **createSound** (+ ~9 one-shot sites) `sopra.playOneShot`, via a kept `@deprecated` facade that
643
+ still returns a destroyable `Entity` (so handle-dependent callers are unchanged) and wires
644
+ `instance.onEnded → builder.destroy`.
645
+
646
+ **Component model:** `SoundEmitter`/`SoundTrack` are **kept as `@deprecated` data components**,
647
+ translated at link time lowest churn (editor, serialization adapters, `GameClassRegistry`, the 8
648
+ `fromJSON` sites all keep working). No `SopraEmitter` component in v1. **One persistent `SoundTrack`
649
+ one `EventInstance`** (both already own an independent playhead/gain/loop/ended-signal); the emitter
650
+ maps to a *set* of instances reconciled via the existing `on.added/on.removed` signals.
651
+
652
+ ### 14.2 Sopra gaps to fill first (engine-only, ECS-untouched)
653
+ The engine is fire-and-forget today (content frozen at `#resolve`, no live gain/fade/seek; 2D voices
654
+ route straight to the bus). Add the **live-control surface** without making content mutable:
655
+ 1. **`EventInstance` `#instanceGain`** one always-on trim gain that *all* voices route through
656
+ (uniform 2D/3D chain head: `instanceGain [panner if 3D] → bus`). Parity with the legacy
657
+ `nodes.volume`, not new cost. *(do first)*
658
+ 2. **`EventInstance.setGainDb(db)` / `get gainDb`** composes with `#attenuationGain` (separate node)
659
+ so user volume × attenuation multiply.
660
+ 3. **`EventInstance.fadeToGainDb(target,dur,startAfter)` + public `fadeOutAndStop(dur,startAfter)`**
661
+ refactor `fadeOutAndStop.js` into a shared `rampGain` helper; stop scheduled via an audio-clock
662
+ deadline checked in `update()` (not `setTimeout`).
663
+ 4. **`EventInstance.seek(seconds)` / `restart()`** — re-base `startedAt`, retire live voices so
664
+ `#evaluate` respawns at the new offset; **must not** reseed `playbackContext` (preserve avoid-repeat
665
+ determinism).
666
+ 5. **`SopraEngine.crossfade(oldInstance, idOrDesc, opts)`** — start new at epsilon, ramp up while
667
+ `old.fadeOutAndStop`.
668
+ 6. **Pure translators** in `sopra/legacy/`: `soundEmitterToEventDescriptions(emitter)` +
669
+ `attenuationFunctionToCurve(fn,min,max)` (dispatch `SoundAttenuationFunction`
670
+ `interpolate_irradiance_*` `buildAttenuationCurve`; `Attenuation` flag clear flat-1 curve;
671
+ `is3D Spatialization`; `SoundPanningModelType SopraPanningModel`).
672
+
673
+ **Gain composition rule (as built):** the translator bakes **NO** gain (`description.gainDb = 0`); the
674
+ per-instance `#instanceGain` carries the full live product `volume2dB(emitter.volume × track.volume)`,
675
+ re-applied on `emitter.volume.onChanged` and on a live `track.volume` set (via the track's
676
+ `__soundRuntime` back-ref); the **bus** carries the channel mix (the legacy 1.2/0.1 already lives on
677
+ sopra buses). Baking the product into the description *and* the instance gain would double-count — so
678
+ the description carries none.
679
+
680
+ ### 14.3 Phased sequence (bottom-up; the 47 sopra tests + 2 legacy specs stay green throughout)
681
+ - **P3.2live-control primitives** *(risk: med)*: `#instanceGain`, `setGainDb`, `fadeToGainDb` +
682
+ public `fadeOutAndStop`, `seek`/`restart`, `SopraEngine.crossfade` + unit tests.
683
+ - **P3.3 — pure translators** *(low)*: `soundEmitterToEventDescriptions` + `attenuationFunctionToCurve`
684
+ + unit tests (curves match `interpolate_irradiance_*` at sampled distances); not yet wired.
685
+ - **P3.4 — one-shots** *(med)*: `GameSounds` → registered `EventDescription`s; `createSound` facade
686
+ `playOneShot` returning a destroyable Entity; legacy path kept when sopra unreachable.
687
+ - **P3.5persistent-emitter spine** *(high)*: rewire `SoundEmitterSystem.link` + gut
688
+ `SoundEmitterComponentContext` to a translation record; plumb volume/fade/stop; BVH far-cull kept;
689
+ `get/setChannelVolume` → bus proxies.
690
+ - **P3.6 hard live-mutation sites** *(med)*: `CombatEndMusicProcess.switchMusic` → `sopra.crossfade`;
691
+ `resetSoundEmitterTracks` `restart()`; verify `SoundController`/`injectMusic`/`StoryManager` via the
692
+ add/remove plumb.
693
+ - **P3.7 — listener convergence + cleanup** *(med)*: single sopra listener feeder; `@deprecated` JSDoc;
694
+ delete dead WebAudio in the sopra branch.
695
+
696
+ ### 14.4 Deletions vs retained-`@deprecated`
697
+ - **Delete now (sopra branch):** `SoundEmitter.buildNodes`/`nodes`/`getTargetNode`/
698
+ `writeAttenuationVolume`/panner-half of `updatePosition`; `SoundEmitterComponentContext` WebAudio
699
+ body (connect/disconnect/suspend/resume); `SoundTrack.start`/`suspend`/`nodes`/`SoundTrackNodes`;
700
+ the per-track time-advance + `endTrack` loop; `CombatEndMusicProcess` hand-rolled crossfade;
701
+ `resetSoundEmitterTracks` `time=0` body.
702
+ - **Retain `@deprecated`:** `SoundEmitter` + serializable fields, `SoundTrack`, `GameSounds` constants,
703
+ `SoundEmitterChannel`/`channels` (editor dropdown), `get/setChannelVolume`, `fadeOutAllTracks`/
704
+ `stopAllTracks`, `createSound`, `SoundController`/`System`, **both serialization adapters**
705
+ (save-compat never delete at cutover).
706
+ - **Later (dedicated change):** remove the `sopra === null` legacy WebAudio fallback once prototypes
707
+ are sound-free or sopra-capable; then retire `SoundEmitter` for a native sopra component.
708
+
709
+ ### 14.5 Testing & risks
710
+ Black-box only; existing 47 sopra tests + `SoundEmitterSystem.spec` + `SoundEmitterSerializationAdapter.spec`
711
+ (both exercise the `sopra === null` path) stay green every phase. New coverage per phase via
712
+ `MockAudioContext` + `StubBufferProvider`. **Not unit-testable** (verify in-browser): AudioContext
713
+ bootstrap, autoplay-resume, audible result, dat.gui editor.
714
+
715
+ Top risks: **save-compat** (keep both adapters round-tripping high); **gain composition** double-fold
716
+ (one rule, dB↔linear round-trip test — med); **createSound handle** (return a real Entity that stops
717
+ its instance on destroy med); **`#instanceGain` topology change** perturbing the 47 tests (unity gain,
718
+ re-run suite immediately med); **`seek` reseeding RNG** (must not med).
719
+
720
+ ### 14.6 Open decisions (recommendations in **bold**)
721
+ 1. Music crossfade ownership → **keep `CombatEndMusicProcess` locating the music emitter + calling
722
+ `sopra.crossfade`** (a `MusicDirector` is later scope).
723
+ 2. Prototypes **keep the `sopra === null` branch** through P3 (strangler safety net); delete later.
724
+ 3. `createSound` return → **real `Entity`** (callers unchanged); revisit only if the UI-click path shows cost.
725
+ 4. Per-track volume fold **`description.gainDb` per instance + `#instanceGain` for live volume**.
726
+ 5. `resetSoundEmitterTracks` → **`restart()`** (not stop+replay — preserves RNG/loading).
727
+ 6. `SoundController` **keep the add/remove plumb** in v1 (single consumer: `UnitMaker`).
728
+
729
+ ### 14.7 LOCKED API contract + no-switch refinement (owner-directed; supersedes the above where they differ)
730
+
731
+ **No switch.** There is no `sopra === null` fallback. `SoundEmitterSystem` ALWAYS routes through sopra.
732
+ Constructor becomes `SoundEmitterSystem(assetManager, soundEngine)` (context/destination derived from
733
+ it; it always `createSopra`s). All 11 construction sites pass the `SoundEngine` (the 10 prototypes/
734
+ `testEffect` pass `sound`/`engine.sound`; the unit spec passes a `MockAudioContext`-backed stub whose
735
+ `createSopra` builds a real `SopraEngine` on the mock + a `StubBufferProvider`). The legacy WebAudio
736
+ renderer is **deleted outright**, not guarded: per-track time-advance loop, `SoundTrackNodes`,
737
+ connect/disconnect virtualization, the channel `GainNode`s, `buildNodes`. (The two background-added
738
+ `SoundEmitterSystem.spec` tests cover that deleted loop and are replaced by sopra-routing tests.)
739
+
740
+ **Preserved public API the whole contract. Everything else is removed or throws.**
741
+
742
+ `SoundEmitter`:
743
+
744
+ | member | plumbing into sopra |
745
+ |---|---|
746
+ | `tracks` (List) | add → `playEvent` + map; remove → `instance.stop()`; reconciled via `tracks.on.added/removed` |
747
+ | `channel` (string) | `EventDescription.busId` (read at translation; `""` → `effects`) |
748
+ | `volume` (Vector1) | `onChanged` each owned `instance.setGainDb(volume2dB(emitter.volume × track.volume))` |
749
+ | `flags` | `Spatialization` → `is3D`; `Attenuation` → attenuation curve (read at translation) |
750
+ | `distanceMin` / `distanceMax` | `EventDescription.distanceMin/Max` |
751
+
752
+ `SoundTrack`:
753
+
754
+ | member | plumbing into sopra |
755
+ |---|---|
756
+ | `on.ended` (`{ended:Signal}`) | the track's `EventInstance.onEnded` `on.ended.send1(track)` |
757
+ | `flags` | `Loop` → `clip.loop`; `UsingAliasURL` `clip.usingAlias`; `StartWhenReady` autoplay |
758
+ | `volume` (number) | NOT baked at translation; carried live in the owning instance gain (`emitter.volume × track.volume`), re-applied on a live set via `__soundRuntime` |
759
+ | `time` (number) | written back from the instance playhead each frame; rewind via `restart()` (used by `resetSoundEmitterTracks`) |
760
+ | `url` (string) | `SampleAudioClip.url` |
761
+
762
+ **Node access fail loud, explicitly NOT public API** (as built). The property getters
763
+ `SoundEmitter.nodes` and `SoundTrack.nodes`, plus `SoundTrack.setVolumeOverTime`, are throwing stubs
764
+ (`get nodes() { throw new Error("... sopra owns the audio graph"); }`) these are the members external
765
+ code could read. The internal builders that had **no external caller** (`buildNodes` / `getTargetNode` /
766
+ `writeAttenuationVolume` / `start` / `suspend` / `initializeNodes`) and the `SoundTrackNodes` class were
767
+ removed outright. Any straggler (e.g. `CombatEndMusicProcess` reaching `track.nodes.volume.gain`) breaks
768
+ loudly rewritten (it now uses `fadeOutAllTracks(1)` + `tracks.add`).
769
+
770
+ **Kept as trivial `@deprecated` plumbing** (not in the contract, but behavioral with real callers, 1:1
771
+ to instance ops): `SoundEmitter.fadeOutAllTracks` `instance.fadeOutAndStop` per owned instance;
772
+ `stopAllTracks` owner `stopAll`. (Backs `hideEntityGracefully` / `AnimatedActions` unchanged.)
773
+
774
+ **Kept for save-compat (data only, unchanged):** both serialization adapters + the component's
775
+ serializable fields, including `isPositioned` (a thin `flags` wrapper retained for the editor +
776
+ serialization). **Removed entirely** (callers migrated): the `SoundEmitterComponentContext` WebAudio
777
+ body (gutted to a translation record), `endTrack`, `distanceRolloff`, the dead-but-unwritten
778
+ `SoundTrack.duration` (dropped from the locked surface), and the rest of the non-contract surface.
779
+
780
+ This also resolves §14.6 #2 (no `sopra === null` branch) and revises §14.4 (the node members move from
781
+ "retained @deprecated" to "throwing stubs"; there is no legacy fallback to retain).
782
+
783
+ ## 15. P5 — spatial scaling to 100k emitters (BVH broadphase + live/dormant split)
784
+
785
+ **Goal.** Support ~100,000 registered 3D `AudioEmitter`s with per-frame work and WebAudio node count
786
+ bounded by a small audible budget `K`, **not** by the registered count. The fix is to decouple a
787
+ *registered emitter* (cheap data + one BVH leaf) from a *live `EventInstance`* (WebAudio nodes): only
788
+ emitters whose own audible sphere reaches the listener — capped to `K` are ever live.
789
+
790
+ ### 15.1 Principle — broadphase / narrowphase (mirrors `PhysicsSystem`)
791
+ - **Broadphase (new, cheap, ALL emitters):** a dynamic BVH of emitter audible-spheres; one point query
792
+ at the listener returns the emitters that *can possibly* be heard.
793
+ - **Narrowphase (existing, ≤K live):** the current `EventInstance` per-frame exact
794
+ distance/attenuation + virtualization (`virtualThresholdDb`) decides which live instances actually
795
+ hold a source `Voice`. Unchanged.
796
+
797
+ ### 15.2 Three tiers
798
+
799
+ | Tier | Count (island) | Cost each | Where |
800
+ |---|---|---|---|
801
+ | **Registered / dormant** | ~100k | 1 record + 1 BVH leaf **no `EventInstance`, no nodes, no per-frame update** | `SpatialAudioIndex` |
802
+ | **Live** | ≤ `K` (default **64**) | an `EventInstance` (panner chain), ticked each frame | sopra + the live set |
803
+ | **Audible voice** | K, usually fewer | a `Voice` (source node) | existing virtualization |
804
+
805
+ ### 15.3 Reuse (this layer is thin the BVH does the heavy lifting)
806
+ - `core/bvh2/bvh3/BVH.js` + **`BvhClient`** (`link(tree,data)`/`unlink()`/`resize(x0..z1)`/`write_bounds`)
807
+ — one leaf per emitter; the same per-entity handle `RenderSystem`/`PhysicsSystem` already use.
808
+ - `bvh_query_user_data_generic(out, 0, bvh, bvh.root, BVHQueryIntersectsSphere.from([lx,ly,lz, 0]))`
809
+ the per-frame cull (point-in-leaf collect). `bvh_query_user_data_nearest_to_point` available if a
810
+ nearest-N selector is preferred over collect-then-select.
811
+ - Existing `EventInstance` virtualization / `seek` / `fadeOutAndStop`, the `VOICE_POOL`, and
812
+ `SoundListener`+`Transform` — all unchanged.
813
+
814
+ ### 15.4 Key trick leaf AABB = the emitter's OWN audible region (no global radius owner-locked)
815
+ Each emitter's leaf is sized to its audible sphere: `AABB = position ± event.distanceMax`. The cull is
816
+ then a **point query at the listener**, returning exactly the emitters whose `distanceMax` reaches the
817
+ listener honoring each emitter's own range, with no hardcoded global radius.
818
+
819
+ ### 15.5 Reactive leaf maintenance (NO per-tick iterationowner-locked)
820
+ Everything in meep is dynamic; there is **no static/dynamic flag and no per-tick refit loop**. On
821
+ `add`, the leaf subscribes to the emitter's `transform.position.onChanged` (the position channel of
822
+ `Transform.subscribe`); the handler does `bvhClient.resize(position ± distanceMax)`. Unsubscribe on
823
+ `remove`. Cost is proportional to *actual movement*, not emitter count: a forest of still trees refits
824
+ zero leaves/frame; 100k handlers that never fire are just memory. (Live instances separately read the
825
+ shared `position` Vector3 each frame for their panner but only ≤K of those exist.)
826
+
827
+ ### 15.6 `SpatialAudioIndex` + `LiveEmitterSet` (two thin pieces) [as-built the sketch's single class split in two]
828
+ - **`SpatialAudioIndex`** (cull-only): owns the emitter `BVH` + records `{ entity, event, position,
829
+ bvhClient, onMove }`.
830
+ - `add(entity, event, position)` record + `bvhClient.link(bvh, entity)` (leaf user_data = the entity
831
+ id, so queries return entity ids) + `resize(pos ± distanceMax)` + subscribe `position.onChanged`.
832
+ - `remove(entity)` unsubscribe + `bvhClient.unlink()` + drop the record.
833
+ - `queryAudible(out, out_offset, listenerPosition)` → writes candidate entity ids into the caller's
834
+ reused buffer from `out_offset`, returns the match count (allocation-free; meep out-buffer convention).
835
+ - **`LiveEmitterSet`** (the live/dormant lifecycle): wraps the index; owns the live map
836
+ `{ entity → EventInstance }` + per-emitter records `{ event, position, gainDb, playingSince }`; config
837
+ `{ budget = 64, liveStickiness = 0.8, fadeOutSeconds = 0.15 }`.
838
+ - `add(entity, event, position, gainDb)` register with the index + record (dormant).
839
+ - `remove(entity)` → demote (hard cut) if live, then `index.remove`.
840
+ - `setGainDb(entity, gainDb)` update the record + the live instance if any (survives re-promotion).
841
+ - `refresh(listenerPosition)` → cull + budget + diff, **every tick** (§15.7).
842
+
843
+ Non-3D emitters (and finite-3D one-shots) skip the index entirelythe ECS layer plays them directly
844
+ (§15.13 P5.6); only persistent looping 3D emitters are spatially managed.
845
+
846
+ ### 15.7 `LiveEmitterSet.refresh` every tick [as-built]
847
+ 1. **Cull:** `queryAudible` point query candidate entity ids (reused buffer); an exact spherical
848
+ refine then drops the conservative-cube corners (`distance > distanceMax`).
849
+ 2. **Budget select:** rank in-range candidates by **effective distance** a live emitter competes as
850
+ `distance × liveStickiness` so it does not flicker promote/demote at the cutoff (rank hysteresis) —
851
+ and take the nearest `budget`. (The sketch's extra `event.priority` / attenuation-gain tie-breakers
852
+ were not needed; pure distance + stickiness.)
853
+ 3. **Diff** vs the current live set (`#live`) **demote** leavers, **promote** entrants; at budget a
854
+ closer entrant steals the farthest live slot. The diff is re-derived from `#live` each call (so a
855
+ denied or self-ended promotion is retried automatically), with demote-before-promote ordering.
856
+ 4. `sopra.update(now)` the caller's, right after `refresh` ticks only the ≤budget live instances.
857
+
858
+ **No throttle (§15.10):** this runs on every tick. The BVH query is microseconds and the diff is
859
+ re-derived from `#live`, so there is nothing to gate; the scratch containers are reused so the per-tick
860
+ diff is allocation-light apart from the rank entries.
861
+
862
+ ### 15.8 Promotion / demotion + voice slots (allocated at go-live owner-confirmed)
863
+ `K` is the voice-slot pool, allocated at promotion (the only moment WebAudio cost is incurred).
864
+ - **promote:** `sopra.playEvent(event, { position, oneShot, startTime })` where `startTime = playingSince`
865
+ reconstructs the phase (§15.9 — via the `EventInstance.startTime` option, not a post-hoc `seek`), then
866
+ apply the emitter's `gainDb`, then store the instance.
867
+ - **demote — two cases (owner-locked):**
868
+ - **culled out of range** (the emitter left its own audible sphere → already ~0 gain / virtual):
869
+ **hard cut** (`instance.stop()`). A fade is pointless/wrong it is already inaudible.
870
+ - **contention / stolen** (a closer emitter evicts a still-in-range, audible live one): **fade**
871
+ (`fadeOutAndStop`) to avoid a click.
872
+ - **stealing = distance priority:** at budget, a closer entrant demotes (steals the slot of) the
873
+ farthest live record.
874
+
875
+ This sits *on top of*, not replacing, sopra's per-event `maxInstances` (polyphony among the live) and
876
+ per-instance virtualization (source-voice gating). **Constraint (P5.2):** `budget` and `maxInstances`
877
+ are independent composed caps. For content-equal ambience you want up to N copies of audible at once,
878
+ that event's `maxInstances` must be N, else sopra gates the budget `stealMode None` denies the
879
+ promote (emitter stays dormant, retried next refresh) and `stealMode Oldest` makes the two layers fight
880
+ (churn). Set managed ambience `maxInstances` high (P5.6 wiring).
881
+
882
+ ### 15.9 Phase reconstruction (continuous world clock owner-decided; supersedes the entity-id phaseOffset idea)
883
+ Dormant emitters don't simulate playback, but the loop conceptually keeps running on the world clock.
884
+ Record `playingSince` (audio clock) **once at registration**; on promotion the instance starts with
885
+ logical `startTime = playingSince`, so its playhead is `now − playingSince` and the looping voice's
886
+ buffer offset wraps into the loop region. A bird you approach is mid-song (continuity); a re-promotion
887
+ after a cull resumes at **total elapsed since link** (the loop kept running while you were away).
888
+ Deterministic from real time **no engine-invented random phase**. Decorrelation/variety is the content
889
+ author's job (existing per-trigger pitch/gain randomization, RandomContainer), not an engine concern.
890
+ (Rejected the earlier auto entity-id phaseOffset: opinionated/magic, and for sparse chirps the artifact
891
+ is imperceptible; phase-lock matters mainly for tonal loops, which the author can vary.)
892
+
893
+ ### 15.10 Churn control (promotion has real cost)
894
+ - **Hysteresis (as-built):** rank hysteresis via `liveStickiness` a live emitter competes at
895
+ `distance × liveStickiness` (default 0.8), so a newcomer must be clearly closer to steal its slot; this
896
+ kills promote/demote flicker at the budget cutoff. (The sketch's leaf-AABB / `distanceMax + margin`
897
+ approach was not needed range-edge churn is inaudible, gain 0 there; the audible flicker is at the
898
+ *budget* cutoff, which the rank discount handles.) Demote-by-cull is at exactly `distanceMax` (the
899
+ spherical refine).
900
+ - **Cull every tick NO throttling (owner-decided, P5.5 reverted).** `refresh()` runs the full cull
901
+ on every `update` tick. The BVH point-query is O(log n + candidates) (microseconds; the 100k stress
902
+ shows the whole per-frame `refresh + tick` is ~5 ms), and the promote/demote diff is re-derived from
903
+ `#live` each call, so there is no reason to throttle. A throttle was briefly added and removed it was
904
+ unnecessary complexity (and introduced a contention-fade gap edge). The diff is allocation-light: the
905
+ scratch containers (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused
906
+ across calls. (`IncrementalDeltaSet` was considered for the diff but does not fit: `#live` changes
907
+ out-of-band instances self-end on asset failure, and promotions can be denied by polyphony caps — so
908
+ the diff must be re-derived from `#live` each frame, which a maintained delta set would fight.)
909
+ - **Pool the per-instance output chain** (instanceGain attenuationGain panner) — **DEFERRED
910
+ (measure-first).** Original rationale was the "1000 live panners / panner not torn down on virtualize"
911
+ cost; the P5.1–P5.2 live/dormant model already bounds live panner chains to `budget` (≤64), and
912
+ hysteresis minimises promote/demote frequency, so this is now a constant-factor micro-opt on ≤64
913
+ chains churned only on genuine live-set changes. Per project guidance (reuse-over-micro-opt, measure
914
+ first, treat constant-factor diffs as noise) this is held until profiling shows promote/demote
915
+ node-churn is actually hot. AudioBufferSourceNodes are single-use and stay pooled via `VOICE_POOL`;
916
+ only Gain/Panner nodes would be poolable.
917
+
918
+ ### 15.11 What changes where
919
+ - `SopraEngine` core: **unchanged**; its `update` is now O(K) (only live instances are active). No new
920
+ public surface (`playEvent`/`stop`/`seek` exist).
921
+ - **New `SpatialAudioIndex`** (BVH cull + records, thin over the BVH + queries) and **`LiveEmitterSet`**
922
+ (the live/dormant lifecycle: budget + promote/demote/steal + per-emitter gain) layered on it.
923
+ - `LiveEmitterSet.refresh` culls every tick (no throttling — §15.10), with reused scratch so the diff is
924
+ allocation-light. The per-instance output-chain pool is **deferred** (measure-first — §15.10): not
925
+ needed for the 100k target since live chains are already bounded to `budget`.
926
+ - `AudioEmitterSystem` (DONE, P5.6): `link/unlink` `liveSet.add/remove` for managed (looping 3D
927
+ autoplay) emitters; `update` → `liveSet.refresh(listener)` + `sopra.update`. 2D + finite-3D-one-shot
928
+ autoplay take the direct play path; non-autoplay is inert. No more unconditional autoplay-per-emitter.
929
+ - `AudioEmitter`: **no** `dynamic` flag. `VoiceManager`: unchanged.
930
+
931
+ ### 15.12 Scorecard (target, 100k emitters)
932
+ - Per frame: 1 BVH point query (O(log n + candidates)) + top-K select + O(K) live ticks. **Independent
933
+ of 100k.**
934
+ - WebAudio: ≤K panner chains + ≤K voices.
935
+ - Memory: 100k records + 100k BVH leaves (~a few MB, typed-array backed) + shared events/buffers. Leaf
936
+ refits movement, not count.
937
+
938
+ ### 15.13 Rollout (incremental, test-first; MockAudioContext + synthetic emitter field + movable mock listener)
939
+ - **P5.1 — DONE** (meep `73f9becc1`). `SpatialAudioIndex` (`engine/sound/ecs/audio/SpatialAudioIndex.js`)
940
+ cull only: BVH add/remove + reactive `position.onChanged` refit + point query (`BVHQueryIntersectsSphere`
941
+ radius 0) → candidate entity ids; leaf user_data = entity id; leaf AABB = `position ± distanceMax`;
942
+ reused query object + result buffer. 12 black-box tests (in/out per-emitter range, reactive refit both
943
+ ways, removal+detach, boundary-inclusive on-axis, repeated-query buffer reuse, 1000-emitter cull). No
944
+ promotion yet.
945
+ - **P5.2 — DONE** (meep `a39ecbbe6`). `LiveEmitterSet` (`engine/sound/ecs/audio/LiveEmitterSet.js`)
946
+ composes the index: `refresh(listener)` promotes nearest-in-range up to `budget` (64), demotes the
947
+ rest; distance-priority stealing; rank hysteresis via live-stickiness; exact spherical refine over the
948
+ conservative cube; demote-frees-before-promote-fills; zombie-safe demote (delete-before-stop) + self-
949
+ end cleanup. Hard cut on demote (fade-on-contention = P5.3). Documented budget↔maxInstances
950
+ dependency + pinned by a test. 12 black-box tests.
951
+ - **P5.3 — DONE** (meep `1e0a0deee`). Demotion policy in `LiveEmitterSet`: refresh builds an `inRange`
952
+ set (exact distanceMax); a demoted live emitter still in range is faded (`fadeOutAndStop`,
953
+ `fadeOutSeconds` default 0.15) audible, would click while one that left range is hard-stopped
954
+ (already inaudible). `remove()` (unlink) hard-cuts. Slot frees immediately (delete-before-fade); the
955
+ onEnded `=== instance` guard makes re-promote-during-fade safe. Tests: cut-on-cull, fade-on-contention,
956
+ re-promote-during-fade no-clobber. Adversarial review CLEAN.
957
+ - **P5.4 DONE** (meep `ad96e3a00`). Continuous-clock phase reconstruction (see §15.9, owner-decided —
958
+ no random phaseOffset). `EventInstance` gained a logical `startTime` option (playhead = now startTime)
959
+ + a looping-voice buffer-offset wrap (also fixes long-dormant virtualization revival).
960
+ `LiveEmitterSet` records `playingSince` once at add and promotes with it, so a re-promotion after a
961
+ cull resumes at total elapsed since link. Zombie guard for a finite event whose play time already
962
+ elapsed (self-stops synchronously during start). Tests: resume-at-elapsed + offset-wrap,
963
+ continuity-across-cull, seek-wrap, finite-elapsed guard. Adversarial review CLEAN.
964
+ - **P5.5 cull throttling ADDED then REVERTED** (added meep `8264bc328`, reverted later). A throttle
965
+ (`cullIntervalSeconds`/`cullMoveThreshold`) was briefly added, then removed at the owner's direction:
966
+ the BVH point-query is microseconds and the diff is re-derived from `#live` each call, so there is no
967
+ reason not to cull on every `update` tick (100k stress: ~5 ms per `refresh + tick`). What remains from
968
+ P5.5: `refresh()` culls every tick with an **allocation-light diff** the scratch containers
969
+ (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused across calls; the
970
+ comparator is module-static. `IncrementalDeltaSet` was considered for the diff but does not fit (`#live`
971
+ changes out-of-band: self-end on asset failure, denial by polyphony caps the diff must re-derive from
972
+ `#live`). The +9 throttle tests were removed. **Output-chain pool still DEFERRED (measure-first)**
973
+ see §15.10 (live/dormant already bounds panners to budget; held until profiling shows churn is hot).
974
+ - **P5.6 DONE** (meep `d4ba84cd4`). Wired `AudioEmitterSystem` to `LiveEmitterSet`. Routing fixed at
975
+ link: **managed** = `autoplay && is3D && rootClip.loops()` `liveSet.add(entity, event, position,
976
+ volume2dB(volume))`, dormant until refresh; **direct** = any other autoplay (2D, or finite 3D one-shot)
977
+ immediate `#play`; **inert** = non-autoplay. (Finite 3D one-shots are deliberately direct, not
978
+ managed a dead one-shot would otherwise re-promote+self-stop every cull and occupy a budget slot:
979
+ the P5.6 review's main RISK.) `update` resolves the `SoundListener`, then refreshes the live set BEFORE
980
+ `sopra.update` (fresh promote spatializes same frame); a listener entity missing its `Transform` is
981
+ tolerated as "no listener" (guard) rather than throwing every frame. Per-emitter gain: `AudioEmitter.volume`
982
+ carried by `LiveEmitterSet` (`add` 4th arg + `setGainDb`), replayed on every promotion (survives
983
+ demote→re-promote). `instanceFor(entity)` exposes the active instance for either path; ctor takes
984
+ `liveEmitterSetOptions`. **100k stress (both `.skip`):** `AudioEmitterSystem.spec` proves only `budget`
985
+ hold a live instance (node count O(budget), not O(N)) + listener-walks-away frees all; `SpatialAudioIndex.spec`
986
+ proves the cull returns only the in-range candidates (~200), not N → per-frame work O(log N + k). 298
987
+ engine/sound green (34 suites), 2 skipped. Adversarial workflow review (6 dims / 21 agents): no
988
+ BUG-level defects; acted on the confirmed RISKs + doc fixes.
989
+ - **Remaining (post-P5):** register `AudioEmitterSystem` in `makeMirEngineConfig` (production wiring; it
990
+ is currently constructed only in tests) and the standing background follow-ups (P5.5 output-chain pool
991
+ if profiling demands it; IR1/IR4; dormant-material cleanup).
992
+
993
+ ### 15.14 Locked decisions (owner)
994
+ - Budget **K = 64** (live instances; virtualization trims actual voices further).
995
+ - **No global radius** — cull by per-emitter `event.distanceMax` (leaf = `pos ± distanceMax`).
996
+ - **No static/dynamic distinction** — everything is dynamic; leaves refit reactively via
997
+ `transform.position.onChanged`, never per-tick iteration.
998
+ - **Demotion:** hard **cut** when culled out of range (already inaudible); **fade** only on contention
999
+ (stealing a still-audible live instance).