@woosh/meep-engine 3.60.4 → 3.60.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (379) hide show
  1. package/CHANGELOG.md +2602 -0
  2. package/CONTRIBUTING.md +7 -0
  3. package/MIGRATION_2_TO_3.md +136 -0
  4. package/SECURITY.md +4 -0
  5. package/editor/EditorCameraNavigation.d.ts +2 -1
  6. package/editor/EditorCameraNavigation.d.ts.map +1 -1
  7. package/editor/EditorCameraNavigation.js +51 -57
  8. package/package.json +6 -2
  9. package/src/engine/EngineHarness.d.ts +3 -9
  10. package/src/engine/EngineHarness.d.ts.map +1 -1
  11. package/src/engine/EngineHarness.js +6 -8
  12. package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts +9 -6
  13. package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts.map +1 -1
  14. package/src/engine/graphics/camera/makeOrbitalCameraController.js +44 -71
  15. package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts +64 -0
  16. package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts.map +1 -0
  17. package/src/engine/graphics/camera/orbital/MomentumAxis.js +187 -0
  18. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts +86 -0
  19. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts.map +1 -0
  20. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.js +319 -0
  21. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts +34 -10
  22. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts.map +1 -1
  23. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js +116 -30
  24. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts +1 -7
  25. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts.map +1 -1
  26. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.js +1 -37
  27. package/src/engine/graphics/ecs/water/Water.d.ts +8 -0
  28. package/src/engine/graphics/ecs/water/Water.d.ts.map +1 -1
  29. package/src/engine/graphics/ecs/water/Water.js +8 -0
  30. package/src/engine/graphics3/WaterSystem.d.ts.map +1 -1
  31. package/src/engine/graphics3/WaterSystem.js +731 -734
  32. package/src/engine/graphics3/decal/DecalTileShader.d.ts +61 -0
  33. package/src/engine/graphics3/decal/DecalTileShader.d.ts.map +1 -0
  34. package/src/engine/graphics3/decal/DecalTileShader.js +138 -0
  35. package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts +10 -4
  36. package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts.map +1 -1
  37. package/src/engine/graphics3/decal/GPUDecalRenderer.js +149 -44
  38. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts +25 -0
  39. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts.map +1 -0
  40. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.js +55 -0
  41. package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts +22 -0
  42. package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts.map +1 -0
  43. package/src/engine/graphics3/decal/graph_gather_decal_tiles.js +70 -0
  44. package/src/engine/graphics3/decal/shader_decal_composite.d.ts +7 -5
  45. package/src/engine/graphics3/decal/shader_decal_composite.d.ts.map +1 -1
  46. package/src/engine/graphics3/decal/shader_decal_composite.js +48 -44
  47. package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts +12 -0
  48. package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts.map +1 -0
  49. package/src/engine/graphics3/decal/shader_decal_tile_copy.js +78 -0
  50. package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts +11 -0
  51. package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts.map +1 -0
  52. package/src/engine/graphics3/decal/shader_gather_decal_tiles.js +80 -0
  53. package/src/engine/input/devices/mouse/MouseEventButtons.d.ts +13 -0
  54. package/src/engine/input/devices/mouse/MouseEventButtons.d.ts.map +1 -0
  55. package/src/engine/input/devices/mouse/MouseEventButtons.js +23 -0
  56. package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts +4 -0
  57. package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts.map +1 -1
  58. package/src/engine/input/ecs/controllers/KeyboardCameraController.js +9 -45
  59. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  60. package/src/shade/playground/water_refraction/README.md +5 -6
  61. package/src/shade/playground/water_refraction/index.html +1 -1
  62. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  63. package/src/shade/renderer/Renderer.js +7 -5
  64. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts +40 -80
  65. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts.map +1 -1
  66. package/src/shade/renderer/camera/orbital/OrbitalCameraController.js +197 -147
  67. package/src/shade/renderer/fluid/GPUFluidCascade.d.ts +3 -3
  68. package/src/shade/renderer/fluid/GPUFluidCascadeStack.d.ts +1 -1
  69. package/src/shade/renderer/fluid/forcing/GPUFluidForcing.d.ts +6 -6
  70. package/src/shade/renderer/fluid/pressure/GPUFluidPressureSolve.d.ts +1 -1
  71. package/src/shade/renderer/fluid/query/GPUFluidQueries.d.ts +1 -1
  72. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts +2 -2
  73. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +2 -2
  74. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts +2 -1
  75. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts.map +1 -1
  76. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.js +18 -1
  77. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts +31 -0
  78. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts.map +1 -0
  79. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.js +343 -0
  80. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts +17 -0
  81. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts.map +1 -0
  82. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.js +29 -0
  83. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.d.ts.map +1 -1
  84. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.js +7 -13
  85. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.d.ts.map +1 -1
  86. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.js +1 -8
  87. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts +5 -5
  88. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts.map +1 -1
  89. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.js +10 -10
  90. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts +1 -4
  91. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts.map +1 -1
  92. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.js +1 -10
  93. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts +5 -15
  94. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts.map +1 -1
  95. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.js +6 -34
  96. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts.map +1 -1
  97. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.js +1 -7
  98. package/src/shade/renderer/hiz/HZBFootprint.d.ts +14 -0
  99. package/src/shade/renderer/hiz/HZBFootprint.d.ts.map +1 -0
  100. package/src/shade/renderer/hiz/HZBFootprint.js +16 -0
  101. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts +10 -0
  102. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts.map +1 -0
  103. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.js +11 -0
  104. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts +10 -0
  105. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts.map +1 -0
  106. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.js +11 -0
  107. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts +0 -7
  108. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  109. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +405 -350
  110. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts +12 -0
  111. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts.map +1 -0
  112. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.js +27 -0
  113. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts +12 -0
  114. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts.map +1 -0
  115. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.js +27 -0
  116. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts +14 -0
  117. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts.map +1 -0
  118. package/src/shade/renderer/hiz/hzb_footprint_texels.js +34 -0
  119. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts +0 -5
  120. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
  121. package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +37 -57
  122. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts +11 -0
  123. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts.map +1 -0
  124. package/src/shade/renderer/hiz/{shader_hzb_reduce_mip0_clip.js → shader_hzb_reduce_mip0.js} +120 -111
  125. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  126. package/src/shade/renderer/material/GPUMaterialManager.d.ts +7 -0
  127. package/src/shade/renderer/material/GPUMaterialManager.d.ts.map +1 -1
  128. package/src/shade/renderer/material/GPUMaterialManager.js +11 -0
  129. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts +12 -12
  130. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
  131. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +17 -118
  132. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts.map +1 -1
  133. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.js +7 -2
  134. package/src/shade/renderer/water/GPUWaterRenderer.d.ts +0 -6
  135. package/src/shade/renderer/water/GPUWaterRenderer.d.ts.map +1 -1
  136. package/src/shade/renderer/water/GPUWaterRenderer.js +12 -17
  137. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts +35 -0
  138. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts.map +1 -0
  139. package/src/shade/renderer/water/medium/graph_water_meshlets.js +89 -0
  140. package/src/shade/renderer/water/query/GPUWaterQueries.d.ts +1 -1
  141. package/src/shade/renderer/water/shader_water.d.ts.map +1 -1
  142. package/src/shade/renderer/water/shader_water.js +22 -13
  143. package/src/shade/renderer/water/shader_water_avboit.d.ts.map +1 -1
  144. package/src/shade/renderer/water/shader_water_avboit.js +7 -7
  145. package/src/shade/renderer/water/surface/graph_water_tessellation.d.ts +1 -1
  146. package/src/shade/renderer/water/surface/graph_water_tessellation.js +1 -1
  147. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +9 -1
  148. package/editor/REWORK_PLAN_2026_08.md +0 -692
  149. package/src/CODEC_LAYOUT_VERIFICATION.md +0 -419
  150. package/src/CORRECTNESS_HUNT_2026_07_24.md +0 -142
  151. package/src/DUPLICATION_AUDIT_2026_08_07/00_core_reuse_surface.md +0 -738
  152. package/src/DUPLICATION_AUDIT_2026_08_07/A_core_math_geom.md +0 -79
  153. package/src/DUPLICATION_AUDIT_2026_08_07/B_core_data.md +0 -93
  154. package/src/DUPLICATION_AUDIT_2026_08_07/C_gfx_render.md +0 -80
  155. package/src/DUPLICATION_AUDIT_2026_08_07/D_gfx_features.md +0 -164
  156. package/src/DUPLICATION_AUDIT_2026_08_07/E_physics.md +0 -69
  157. package/src/DUPLICATION_AUDIT_2026_08_07/F_sound.md +0 -110
  158. package/src/DUPLICATION_AUDIT_2026_08_07/G_net_save.md +0 -129
  159. package/src/DUPLICATION_AUDIT_2026_08_07/H_engine_misc.md +0 -72
  160. package/src/DUPLICATION_AUDIT_2026_08_07/I_generation_view.md +0 -74
  161. package/src/DUPLICATION_AUDIT_2026_08_07/K_crosscutting.md +0 -169
  162. package/src/DUPLICATION_AUDIT_2026_08_07.md +0 -254
  163. package/src/DUPLICATION_AUDIT_REMEDIATION.md +0 -250
  164. package/src/DUPLICATION_REVIEW_2026_10_24.md +0 -1072
  165. package/src/REVIEW_2026_08_06/adv-core.md +0 -511
  166. package/src/REVIEW_2026_08_06/adv-gfx.md +0 -480
  167. package/src/REVIEW_2026_08_06/adv-view.md +0 -446
  168. package/src/REVIEW_2026_08_06/api-core.md +0 -296
  169. package/src/REVIEW_2026_08_06/api-engine.md +0 -195
  170. package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +0 -507
  171. package/src/REVIEW_2026_08_06/core-binary.md +0 -348
  172. package/src/REVIEW_2026_08_06/core-collection.md +0 -376
  173. package/src/REVIEW_2026_08_06/core-math.md +0 -221
  174. package/src/REVIEW_2026_08_06/core-process.md +0 -266
  175. package/src/REVIEW_2026_08_06/core-science.md +0 -223
  176. package/src/REVIEW_2026_08_06/engine-ai-nav.md +0 -279
  177. package/src/REVIEW_2026_08_06/engine-animation.md +0 -167
  178. package/src/REVIEW_2026_08_06/engine-asset.md +0 -166
  179. package/src/REVIEW_2026_08_06/engine-ecs.md +0 -920
  180. package/src/REVIEW_2026_08_06/engine-input.md +0 -197
  181. package/src/REVIEW_2026_08_06/engine-misc.md +0 -248
  182. package/src/REVIEW_2026_08_06/engine-terrain.md +0 -52
  183. package/src/REVIEW_2026_08_06/generation.md +0 -176
  184. package/src/REVIEW_2026_08_06/geom-2d.md +0 -434
  185. package/src/REVIEW_2026_08_06/geom-3d.md +0 -117
  186. package/src/REVIEW_2026_08_06/gfx-gi.md +0 -143
  187. package/src/REVIEW_2026_08_06/gfx-render.md +0 -740
  188. package/src/REVIEW_2026_08_06/gfx-texture.md +0 -690
  189. package/src/REVIEW_2026_08_06/prior-criticals.md +0 -49
  190. package/src/REVIEW_2026_08_06/prior-highs.md +0 -138
  191. package/src/REVIEW_2026_08_06/test-gaps.md +0 -364
  192. package/src/REVIEW_2026_08_06/view.md +0 -667
  193. package/src/REVIEW_2026_08_06.md +0 -610
  194. package/src/core/binary/lz4/LZ4_HIGH_COMPRESSION_PLAN_2026_09_14.md +0 -542
  195. package/src/core/bvh2/bvh3/NOTES.md +0 -2
  196. package/src/core/bvh8/NOTES.md +0 -63
  197. package/src/core/cache/wtinylfu/benchmark/CacheWTinylfu.bench.md +0 -243
  198. package/src/core/color/COLOR_FIX_PLAN_2026_08_28.md +0 -417
  199. package/src/core/color/COLOR_REVIEW_2026_08_28.md +0 -779
  200. package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +0 -433
  201. package/src/core/geom/3d/atlas/ATLAS_PORT_PLAN.md +0 -486
  202. package/src/core/geom/3d/atlas/BFF_PLAN.md +0 -174
  203. package/src/core/geom/3d/atlas/REVIEW_02.md +0 -392
  204. package/src/core/geom/3d/gjk/GJK_REVIEW_NOTES.md +0 -146
  205. package/src/core/geom/3d/gjk/NOTES.md +0 -8
  206. package/src/core/geom/3d/topology/TOPO_MESH_RETIREMENT_PLAN.md +0 -135
  207. package/src/core/geom/GEOMETRY_REVIEW_002_API_ISSUES.md +0 -381
  208. package/src/core/geom/GEOMETRY_REVIEW_002_NAMING.md +0 -684
  209. package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +0 -554
  210. package/src/core/model/reactive/evaluation/PREDICATE_APPLICABILITY_PROPOSAL.md +0 -446
  211. package/src/engine/REVIEW_2026_07_03.md +0 -211
  212. package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +0 -815
  213. package/src/engine/control/first-person/DESIGN.md +0 -658
  214. package/src/engine/control/first-person/DESIGN_COLLISION.md +0 -382
  215. package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +0 -563
  216. package/src/engine/control/first-person/TODO.md +0 -160
  217. package/src/engine/ecs/ASYNC_SYSTEMS_PROPOSAL.md +0 -531
  218. package/src/engine/ecs/HIERARCHY_REVIEW.md +0 -717
  219. package/src/engine/ecs/async/WORKER_SYSTEM_PLAN.md +0 -262
  220. package/src/engine/ecs/ik/PLAN.md +0 -179
  221. package/src/engine/ecs/storage/binary/SERIALIZATION_COVERAGE.md +0 -272
  222. package/src/engine/ecs/storage/binary/object-v2/DESIGN.md +0 -199
  223. package/src/engine/ecs/transform/TRANSFORM64_MIGRATION_PLAN_2026_09_04.md +0 -521
  224. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +0 -463
  225. package/src/engine/graphics/ecs/camera/pp/NOTES.md +0 -48
  226. package/src/engine/graphics/render/frame_graph/CORRECTNESS_FIXES_2026_09_04.md +0 -659
  227. package/src/engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md +0 -235
  228. package/src/engine/graphics/sh3/path_tracer/REVIEW_001_ACTION_PLAN.md +0 -490
  229. package/src/engine/graphics/texture/sampler/compare/FLIP_IMPLEMENTATION_PLAN.md +0 -466
  230. package/src/engine/input/GAMEPAD_API_PROPOSAL.md +0 -717
  231. package/src/engine/input/ecs/ism/ISM_DESIGN.md +0 -1523
  232. package/src/engine/input/ecs/ism/ISM_IMPLEMENTATION_PLAN.md +0 -627
  233. package/src/engine/navigation/mesh/OVERLAPPING_SURFACES_2026_08_25.md +0 -661
  234. package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +0 -229
  235. package/src/engine/network/CONGESTION_CONTROL.md +0 -87
  236. package/src/engine/network/MESSAGE_TRANSPORT.md +0 -364
  237. package/src/engine/network/SMOOTHNESS_DESIGN.md +0 -400
  238. package/src/engine/physics/cloth/MEASUREMENTS.md +0 -5304
  239. package/src/engine/physics/cloth/PLAN.md +0 -3056
  240. package/src/engine/physics/fluid/GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md +0 -1382
  241. package/src/engine/physics/fluid/REVIEW_02_PLAN.md +0 -243
  242. package/src/engine/physics/fluid/solver/NOTES.md +0 -46
  243. package/src/engine/sound/SOUND_EMITTER_RETIREMENT_PLAN.md +0 -536
  244. package/src/engine/sound/simulation/IMPLEMENTATION_PLAN.md +0 -432
  245. package/src/engine/sound/simulation/PROBE_PLACEMENT.md +0 -250
  246. package/src/engine/sound/simulation/PROBE_TRANSFER_RESEARCH.md +0 -322
  247. package/src/engine/sound/simulation/REFLECTION_IR_PLAN.md +0 -261
  248. package/src/engine/sound/simulation/VISIBILITY_GRAPH_PLAN.md +0 -121
  249. package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +0 -999
  250. package/src/format/image/avif/DECISIONS.md +0 -545
  251. package/src/format/image/jpeg/NOTES.md +0 -3
  252. package/src/format/scene/gltf/MESHOPT_COMPRESSION_PLAN.md +0 -814
  253. package/src/generation/COORDINATES.md +0 -54
  254. package/src/generation/REVIEW_01_ACTION_PLAN.md +0 -628
  255. package/src/generation/foliage/ANNUAL_SHOOTS_BRIEF_2026_10_25.md +0 -220
  256. package/src/generation/foliage/CROWN_FILLING_BRIEF_2026_10_26.md +0 -172
  257. package/src/generation/foliage/CROWN_FULLNESS_BRIEF_2026_10_26.md +0 -86
  258. package/src/generation/foliage/FOLIAGE_DESIGN.md +0 -547
  259. package/src/generation/foliage/FOLIAGE_PLAN.md +0 -198
  260. package/src/generation/foliage/LEAF_ARCHITECTURE_BRIEF_2026_10_26.md +0 -158
  261. package/src/generation/foliage/LOD_REWORK_BRIEF_2026_10_27.md +0 -521
  262. package/src/generation/foliage/REAL_TIME_TREES_BRIEF_2026_10_27.md +0 -86
  263. package/src/generation/foliage/SELF_ORGANIZING_FRAMEWORK_BRIEF_2026_10_26.md +0 -347
  264. package/src/generation/foliage/SHOOT_SYSTEMS_ALL_SPECIES_BRIEF_2026_10_26.md +0 -248
  265. package/src/generation/foliage/SPEEDTREE_COMPARISON_2026_10_24.md +0 -694
  266. package/src/generation/foliage/TWO_FRONT_ENDS_BRIEF_2026_10_28.md +0 -552
  267. package/src/shade/RENDERER_CONTRACT.md +0 -697
  268. package/src/shade/SHADER_F16_PROPOSAL_2026_10_24.md +0 -263
  269. package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +0 -569
  270. package/src/shade/device/PASS_ENCODER_PROPOSAL_2026_08_31.md +0 -755
  271. package/src/shade/device/mock/EXTENSION_PROPOSAL_2026_08_22.md +0 -458
  272. package/src/shade/device/mock/IMPLEMENTATION_PLAN.md +0 -255
  273. package/src/shade/device/mock/REVIEW_2026_08_22.md +0 -527
  274. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +0 -894
  275. package/src/shade/device/timing/profile/SGPT_FORMAT.md +0 -345
  276. package/src/shade/renderer/NOTES.md +0 -78
  277. package/src/shade/renderer/PLAN.md +0 -8
  278. package/src/shade/renderer/animation/ADDITIVE_BLENDING_PLAN.md +0 -145
  279. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +0 -357
  280. package/src/shade/renderer/atmosphere/NOTES.md +0 -6
  281. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts +0 -76
  282. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts.map +0 -1
  283. package/src/shade/renderer/camera/orbital/MomentumAxis.js +0 -164
  284. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts +0 -45
  285. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts.map +0 -1
  286. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.js +0 -66
  287. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts +0 -98
  288. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts.map +0 -1
  289. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.js +0 -184
  290. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts +0 -175
  291. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts.map +0 -1
  292. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.js +0 -411
  293. package/src/shade/renderer/camera/orbital/README.md +0 -81
  294. package/src/shade/renderer/camera/orbital/index.d.ts +0 -6
  295. package/src/shade/renderer/camera/orbital/index.d.ts.map +0 -1
  296. package/src/shade/renderer/camera/orbital/index.js +0 -9
  297. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +0 -964
  298. package/src/shade/renderer/fluid/GPU_FLUID_OCCUPANCY_AND_SOTA_REVIEW_2026_10_26.md +0 -361
  299. package/src/shade/renderer/geometry/bvh/BLAS_REFIT_BATCH_PLAN.md +0 -619
  300. package/src/shade/renderer/geometry/meshlet/COMPACTION_PLAN_2026_09_16.md +0 -674
  301. package/src/shade/renderer/geometry/sdf/NOTES.md +0 -6
  302. package/src/shade/renderer/geometry/virtual/ATTRIBUTE_PLAN_2026_09_18.md +0 -540
  303. package/src/shade/renderer/geometry/virtual/MICRON_COMPARISON_2026_08_20.md +0 -305
  304. package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +0 -913
  305. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +0 -787
  306. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +0 -481
  307. package/src/shade/renderer/geometry/virtual/VK_LOD_CLUSTERS_COMPARISON_2026_08_20.md +0 -301
  308. package/src/shade/renderer/geometry/virtual/WEDGE_PLAN_2026_09_18.md +0 -448
  309. package/src/shade/renderer/geometry/virtual/runtime/CULL_PLAN_2026_09_16.md +0 -385
  310. package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +0 -656
  311. package/src/shade/renderer/geometry/virtual/runtime/REVIEW_LEDGER.md +0 -308
  312. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +0 -657
  313. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN_2026_09_14.md +0 -551
  314. package/src/shade/renderer/geometry/virtual/runtime/SHADOW_PLAN_2026_09_28.md +0 -529
  315. package/src/shade/renderer/global_illumination/lpv/NOTES.md +0 -3
  316. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_CLIPMAP_PROPOSAL_2026_10_27.md +0 -4306
  317. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md +0 -410
  318. package/src/shade/renderer/global_illumination/sharc/NOTES.md +0 -8
  319. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +0 -1369
  320. package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +0 -202
  321. package/src/shade/renderer/gpu_primitive/indirect/NOTES.md +0 -94
  322. package/src/shade/renderer/hiz/NOTES.md +0 -3
  323. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts +0 -8
  324. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +0 -1
  325. package/src/shade/renderer/light/cluster/NOTES.md +0 -11
  326. package/src/shade/renderer/light/cluster/PROPOSAL_ALGORITHM_2026_10_16.md +0 -524
  327. package/src/shade/renderer/light/cluster/PROPOSAL_HIERARCHICAL_2026_10_16.md +0 -1101
  328. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts +0 -24
  329. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts.map +0 -1
  330. package/src/shade/renderer/material/resident/shader_resident_texture_means.js +0 -120
  331. package/src/shade/renderer/particles/DESIGN.md +0 -1011
  332. package/src/shade/renderer/particles/EMITTER_GLOBAL_STATE_PLAN_2026_10_29.md +0 -280
  333. package/src/shade/renderer/particles/ORIENTATION_PLAN_2026_10_15.md +0 -453
  334. package/src/shade/renderer/path_tracer/PATH_TRACED_MEDIA_PLAN_2026_10_02.md +0 -520
  335. package/src/shade/renderer/postprocess/bloom/NOTES.md +0 -6
  336. package/src/shade/renderer/postprocess/fsr/NOTES.md +0 -4
  337. package/src/shade/renderer/postprocess/gtao/NOTES.md +0 -6
  338. package/src/shade/renderer/postprocess/nss/NOTES.md +0 -332
  339. package/src/shade/renderer/postprocess/perspective/NOTES.md +0 -3
  340. package/src/shade/renderer/postprocess/sscs/NOTES.md +0 -5
  341. package/src/shade/renderer/postprocess/ssr/NOTES.md +0 -14
  342. package/src/shade/renderer/postprocess/ssr/REPROJECTION_PROPOSAL.md +0 -286
  343. package/src/shade/renderer/postprocess/ssr/prefilter/NOTES.md +0 -39
  344. package/src/shade/renderer/postprocess/taa/NOTES.md +0 -11
  345. package/src/shade/renderer/postprocess/upscale-dither/NOTES.md +0 -78
  346. package/src/shade/renderer/postprocess/vrs/NOTES.md +0 -4
  347. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_LEDGER.md +0 -1218
  348. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_PLAN_2026_09_03.md +0 -600
  349. package/src/shade/renderer/restir/reservoir/NOTES.md +0 -6
  350. package/src/shade/renderer/shader/chunk/atmosphere/NOTES.md +0 -6
  351. package/src/shade/renderer/shader/chunk/brdf/FRESNEL_BRIEF_2026_10_16.md +0 -391
  352. package/src/shade/renderer/shader/chunk/color/tonemap/ACES_TONE_MAPPING_BRIEF_2026_10_17.md +0 -153
  353. package/src/shade/renderer/shader/chunk/color/tonemap/HDR_TONE_MAPPING_FORMS_BRIEF_2026_10_16.md +0 -322
  354. package/src/shade/renderer/shader/chunk/color/tonemap/SDR_TONE_MAPPING_BRIEF_2026_10_16.md +0 -247
  355. package/src/shade/renderer/shader/chunk/fsr/NOTES.md +0 -3
  356. package/src/shade/renderer/shader/chunk/fsr/spd/NOTES.md +0 -4
  357. package/src/shade/renderer/shader/chunk/geometry/ray/NOTES.md +0 -4
  358. package/src/shade/renderer/shader/chunk/material/MULTISCATTER.md +0 -159
  359. package/src/shade/renderer/shader/chunk/mboit/NOTES.md +0 -38
  360. package/src/shade/renderer/shader/chunk/spherical_harmonics/l1i/NOTES.md +0 -3
  361. package/src/shade/renderer/shader/chunk/spherical_harmonics/sampling/NOTES.md +0 -8
  362. package/src/shade/renderer/shader/compiler/SHADER_SWITCH_PROPOSAL_2026_10_16.md +0 -927
  363. package/src/shade/renderer/shadow/contact/FINDINGS.md +0 -413
  364. package/src/shade/renderer/shadow/map/csm/NOTES.md +0 -5
  365. package/src/shade/renderer/shadow/ray/NOTES.md +0 -31
  366. package/src/shade/renderer/shadow/sdf/NOTES.md +0 -9
  367. package/src/shade/renderer/shadow/sdf/brixelizer/NOTES.md +0 -5
  368. package/src/shade/renderer/shadow/sdf/cascade/NOTES.md +0 -9
  369. package/src/shade/renderer/shadow/vsm/NOTES.md +0 -12
  370. package/src/shade/renderer/shadow/vsm/PLAN.md +0 -442
  371. package/src/shade/renderer/shadow/vsm/REVIEW_LEDGER.md +0 -43
  372. package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +0 -2444
  373. package/src/shade/renderer/texture/mipmaps/NOTES.md +0 -3
  374. package/src/shade/renderer/volumetrics/NOTES.md +0 -1329
  375. package/src/shade/renderer/volumetrics/VOLUMETRIC_NOISE_PLAN_2026_10_15.md +0 -336
  376. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts +0 -23
  377. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts.map +0 -1
  378. package/src/shade/renderer/water/medium/GPUWaterBodies.js +0 -75
  379. package/src/shade/wgsl/NOTES.md +0 -4
@@ -1,999 +0,0 @@
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
- - **P3 — ECS 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 management — see 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 verbatim — never 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 gain — black-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-triggered — when 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
- > **DONE — Mixer 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 aggressiveness — Facade-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 selection — Network-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.2 — live-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.5 — persistent-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 iteration — owner-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 entirely — the 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).