@woosh/meep-engine 2.168.0 → 2.169.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +2 -2
  2. package/build/bundle-worker-image-decoder.js +1 -1
  3. package/package.json +1 -1
  4. package/src/REVIEW_2026_08_06/adv-core.md +511 -0
  5. package/src/REVIEW_2026_08_06/adv-gfx.md +480 -0
  6. package/src/REVIEW_2026_08_06/adv-view.md +446 -0
  7. package/src/REVIEW_2026_08_06/api-core.md +296 -0
  8. package/src/REVIEW_2026_08_06/api-engine.md +195 -0
  9. package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +507 -0
  10. package/src/REVIEW_2026_08_06/core-binary.md +348 -0
  11. package/src/REVIEW_2026_08_06/core-collection.md +376 -0
  12. package/src/REVIEW_2026_08_06/core-math.md +221 -0
  13. package/src/REVIEW_2026_08_06/core-process.md +266 -0
  14. package/src/REVIEW_2026_08_06/core-science.md +223 -0
  15. package/src/REVIEW_2026_08_06/engine-ai-nav.md +279 -0
  16. package/src/REVIEW_2026_08_06/engine-animation.md +167 -0
  17. package/src/REVIEW_2026_08_06/engine-asset.md +166 -0
  18. package/src/REVIEW_2026_08_06/engine-ecs.md +920 -0
  19. package/src/REVIEW_2026_08_06/engine-input.md +197 -0
  20. package/src/REVIEW_2026_08_06/engine-misc.md +248 -0
  21. package/src/REVIEW_2026_08_06/engine-terrain.md +52 -0
  22. package/src/REVIEW_2026_08_06/generation.md +176 -0
  23. package/src/REVIEW_2026_08_06/geom-2d.md +434 -0
  24. package/src/REVIEW_2026_08_06/geom-3d.md +117 -0
  25. package/src/REVIEW_2026_08_06/gfx-gi.md +143 -0
  26. package/src/REVIEW_2026_08_06/gfx-render.md +740 -0
  27. package/src/REVIEW_2026_08_06/gfx-texture.md +690 -0
  28. package/src/REVIEW_2026_08_06/prior-criticals.md +49 -0
  29. package/src/REVIEW_2026_08_06/prior-highs.md +138 -0
  30. package/src/REVIEW_2026_08_06/test-gaps.md +364 -0
  31. package/src/REVIEW_2026_08_06/view.md +667 -0
  32. package/src/REVIEW_2026_08_06.md +571 -0
  33. package/src/core/binary/BinaryBuffer.d.ts.map +1 -1
  34. package/src/core/binary/BinaryBuffer.js +12 -1
  35. package/src/core/binary/BitImage2.d.ts +5 -1
  36. package/src/core/binary/BitImage2.d.ts.map +1 -1
  37. package/src/core/binary/BitImage2.js +5 -1
  38. package/src/core/bvh8/build/NodeProxy.d.ts.map +1 -1
  39. package/src/core/bvh8/build/NodeProxy.js +314 -308
  40. package/src/core/collection/list/List.d.ts +15 -0
  41. package/src/core/collection/list/List.d.ts.map +1 -1
  42. package/src/core/collection/list/List.js +34 -0
  43. package/src/core/collection/list/SortedListProjection.d.ts.map +1 -1
  44. package/src/core/collection/list/SortedListProjection.js +4 -1
  45. package/src/core/collection/map/BiMap.d.ts +25 -2
  46. package/src/core/collection/map/BiMap.d.ts.map +1 -1
  47. package/src/core/collection/map/BiMap.js +57 -3
  48. package/src/core/debug/matchers/IsAnything.d.ts.map +1 -1
  49. package/src/core/debug/matchers/IsAnything.js +5 -1
  50. package/src/core/events/signal/Signal.d.ts.map +1 -1
  51. package/src/core/events/signal/Signal.js +3 -1
  52. package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +433 -0
  53. package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts +31 -0
  54. package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts.map +1 -0
  55. package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.js +84 -0
  56. package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts +24 -2
  57. package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts.map +1 -1
  58. package/src/core/geom/2d/polygon/polygon2_signed_area.js +52 -34
  59. package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts +28 -0
  60. package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts.map +1 -0
  61. package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.js +86 -0
  62. package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts +40 -0
  63. package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts.map +1 -0
  64. package/src/core/geom/2d/polygon/polygon2_triangulate.js +1280 -0
  65. package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts +28 -0
  66. package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts.map +1 -0
  67. package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.js +77 -0
  68. package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts +6 -3
  69. package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts.map +1 -1
  70. package/src/core/geom/2d/triangle/tri2_rasterize_conservative.js +26 -13
  71. package/src/core/geom/2d/triangle/tri2_signed_area.d.ts +17 -1
  72. package/src/core/geom/2d/triangle/tri2_signed_area.d.ts.map +1 -1
  73. package/src/core/geom/2d/triangle/tri2_signed_area.js +35 -23
  74. package/src/core/geom/2d/v2_morton_encode.d.ts +20 -0
  75. package/src/core/geom/2d/v2_morton_encode.d.ts.map +1 -0
  76. package/src/core/geom/2d/v2_morton_encode.js +23 -0
  77. package/src/core/geom/3d/atlas/segment/atlas_merge_charts.d.ts.map +1 -1
  78. package/src/core/geom/3d/atlas/segment/atlas_merge_charts.js +4 -1
  79. package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts +27 -0
  80. package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts.map +1 -0
  81. package/src/core/geom/3d/polygon/polygon3_compute_normal.js +60 -0
  82. package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts +34 -0
  83. package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts.map +1 -0
  84. package/src/core/geom/3d/polygon/polygon3_triangulate.js +94 -0
  85. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts +8 -2
  86. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts.map +1 -1
  87. package/src/core/geom/3d/quaternion/quat3_multiply.js +8 -2
  88. package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -1
  89. package/src/core/geom/3d/shape/ConvexHullShape3D.js +32 -2
  90. package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts +23 -6
  91. package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts.map +1 -1
  92. package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.js +268 -124
  93. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts +17 -0
  94. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts.map +1 -0
  95. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.js +44 -0
  96. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts +20 -0
  97. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts.map +1 -0
  98. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.js +40 -0
  99. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts +19 -0
  100. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts.map +1 -0
  101. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.js +34 -0
  102. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts +22 -0
  103. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts.map +1 -0
  104. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.js +37 -0
  105. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts +87 -0
  106. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts.map +1 -0
  107. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.js +599 -0
  108. package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts +32 -0
  109. package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts.map +1 -1
  110. package/src/core/geom/3d/triangle/tri3_compute_signed_volume.js +66 -7
  111. package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts +22 -1
  112. package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts.map +1 -1
  113. package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.js +83 -38
  114. package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +554 -0
  115. package/src/core/geom/vec3/v3_cotangent.d.ts +6 -2
  116. package/src/core/geom/vec3/v3_cotangent.d.ts.map +1 -1
  117. package/src/core/geom/vec3/v3_cotangent.js +6 -2
  118. package/src/core/geom/vec3/v3_cross.d.ts +6 -2
  119. package/src/core/geom/vec3/v3_cross.d.ts.map +1 -1
  120. package/src/core/geom/vec3/v3_cross.js +6 -2
  121. package/src/core/geom/vec3/v3_multiply.d.ts +6 -2
  122. package/src/core/geom/vec3/v3_multiply.d.ts.map +1 -1
  123. package/src/core/geom/vec3/v3_multiply.js +6 -2
  124. package/src/core/geom/vec3/v3_subtract.d.ts +6 -2
  125. package/src/core/geom/vec3/v3_subtract.d.ts.map +1 -1
  126. package/src/core/geom/vec3/v3_subtract.js +6 -2
  127. package/src/core/localization/Localization.js +1 -1
  128. package/src/core/math/random/randomSeed.d.ts +13 -0
  129. package/src/core/math/random/randomSeed.d.ts.map +1 -0
  130. package/src/core/math/random/randomSeed.js +16 -0
  131. package/src/core/math/spline/spline3_hermite_apply_transform.d.ts +50 -0
  132. package/src/core/math/spline/spline3_hermite_apply_transform.d.ts.map +1 -0
  133. package/src/core/math/spline/spline3_hermite_apply_transform.js +107 -0
  134. package/src/core/math/spline/spline3_hermite_reverse.d.ts +34 -0
  135. package/src/core/math/spline/spline3_hermite_reverse.d.ts.map +1 -0
  136. package/src/core/math/spline/spline3_hermite_reverse.js +49 -0
  137. package/src/core/math/statistics/hammersley_sequence.d.ts +17 -4
  138. package/src/core/math/statistics/hammersley_sequence.d.ts.map +1 -1
  139. package/src/core/math/statistics/hammersley_sequence.js +25 -16
  140. package/src/core/process/Future.d.ts.map +1 -1
  141. package/src/core/process/Future.js +12 -0
  142. package/src/core/process/PromiseWatcher.d.ts.map +1 -1
  143. package/src/core/process/PromiseWatcher.js +9 -2
  144. package/src/core/process/executor/ConcurrentExecutor.d.ts.map +1 -1
  145. package/src/core/process/executor/ConcurrentExecutor.js +12 -2
  146. package/src/core/process/worker/WorkerProxy.d.ts.map +1 -1
  147. package/src/core/process/worker/WorkerProxy.js +5 -2
  148. package/src/core/process/worker/extractTransferables.d.ts.map +1 -1
  149. package/src/core/process/worker/extractTransferables.js +4 -1
  150. package/src/engine/Engine.d.ts +12 -12
  151. package/src/engine/Engine.d.ts.map +1 -1
  152. package/src/engine/Engine.js +149 -10
  153. package/src/engine/EngineHarness.d.ts.map +1 -1
  154. package/src/engine/EngineHarness.js +4 -1
  155. package/src/engine/asset/loaders/ArrayBufferLoader.d.ts.map +1 -1
  156. package/src/engine/asset/loaders/ArrayBufferLoader.js +7 -0
  157. package/src/engine/ecs/speaker/lines/sets/LineSetDescription.d.ts.map +1 -1
  158. package/src/engine/ecs/speaker/lines/sets/LineSetDescription.js +3 -1
  159. package/src/engine/ecs/terrain/ecs/TerrainSystem.d.ts.map +1 -1
  160. package/src/engine/ecs/terrain/ecs/TerrainSystem.js +4 -1
  161. package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts +29 -0
  162. package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts.map +1 -1
  163. package/src/engine/ecs/terrain/tiles/TerrainTileManager.js +84 -23
  164. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +446 -0
  165. package/src/engine/graphics/GraphicsEngine.d.ts +56 -7
  166. package/src/engine/graphics/GraphicsEngine.d.ts.map +1 -1
  167. package/src/engine/graphics/GraphicsEngine.js +196 -24
  168. package/src/engine/graphics/context/WebGLContextFailureReason.d.ts +9 -0
  169. package/src/engine/graphics/context/WebGLContextFailureReason.d.ts.map +1 -0
  170. package/src/engine/graphics/context/WebGLContextFailureReason.js +17 -0
  171. package/src/engine/graphics/context/WebGLContextMonitor.d.ts +106 -0
  172. package/src/engine/graphics/context/WebGLContextMonitor.d.ts.map +1 -0
  173. package/src/engine/graphics/context/WebGLContextMonitor.js +341 -0
  174. package/src/engine/graphics/context/WebGLContextState.d.ts +14 -0
  175. package/src/engine/graphics/context/WebGLContextState.d.ts.map +1 -0
  176. package/src/engine/graphics/context/WebGLContextState.js +24 -0
  177. package/src/engine/graphics/context/testWebGLContextLoss.d.ts +2 -0
  178. package/src/engine/graphics/context/testWebGLContextLoss.d.ts.map +1 -0
  179. package/src/engine/graphics/context/testWebGLContextLoss.js +696 -0
  180. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.d.ts.map +1 -1
  181. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.js +68 -32
  182. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts +14 -0
  183. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts.map +1 -0
  184. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.js +41 -0
  185. package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js +1 -1
  186. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.d.ts.map +1 -1
  187. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js +5 -0
  188. package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts +5 -1
  189. package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts.map +1 -1
  190. package/src/engine/graphics/ecs/trail2d/Trail2D.js +15 -0
  191. package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts +1 -1
  192. package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts.map +1 -1
  193. package/src/engine/graphics/ecs/trail2d/Trail2DSystem.js +2 -1
  194. package/src/engine/graphics/ecs/trail3d/Trail3D.d.ts +4 -1
  195. package/src/engine/graphics/ecs/trail3d/Trail3D.d.ts.map +1 -1
  196. package/src/engine/graphics/ecs/trail3d/Trail3D.js +488 -476
  197. package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts +1 -1
  198. package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts.map +1 -1
  199. package/src/engine/graphics/ecs/trail3d/Trail3DSystem.js +280 -253
  200. package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.d.ts.map +1 -1
  201. package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.js +3 -2
  202. package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.d.ts.map +1 -1
  203. package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.js +3 -1
  204. package/src/engine/graphics/geometry/computeMeshSurfaceArea.js +1 -1
  205. package/src/engine/graphics/load_and_set_cubemap_v0.d.ts +5 -0
  206. package/src/engine/graphics/load_and_set_cubemap_v0.d.ts.map +1 -1
  207. package/src/engine/graphics/load_and_set_cubemap_v0.js +26 -21
  208. package/src/engine/graphics/particles/particular/group/ParticleGroup.d.ts.map +1 -1
  209. package/src/engine/graphics/particles/particular/group/ParticleGroup.js +19 -8
  210. package/src/engine/graphics/particles/particular/group/optimizeCommandQueue.js +1 -1
  211. package/src/engine/graphics/texture/atlas/AtlasLookupTexture.d.ts.map +1 -1
  212. package/src/engine/graphics/texture/atlas/AtlasLookupTexture.js +10 -3
  213. package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
  214. package/src/engine/graphics/texture/atlas/TextureAtlas.js +4 -1
  215. package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.d.ts.map +1 -1
  216. package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.js +11 -0
  217. package/src/engine/graphics/texture/cubemap/load_environment_map.d.ts.map +1 -1
  218. package/src/engine/graphics/texture/cubemap/load_environment_map.js +5 -2
  219. package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.d.ts.map +1 -1
  220. package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.js +26 -18
  221. package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts +15 -2
  222. package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts.map +1 -1
  223. package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.js +29 -3
  224. package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts +11 -1
  225. package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts.map +1 -1
  226. package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.js +158 -171
  227. package/src/engine/graphics/texture/sampler/sampler2d_paint.js +1 -1
  228. package/src/engine/graphics/texture/sampler/util/bitSet2Sampler2D.js +2 -2
  229. package/src/engine/input/ecs/util/TopDownCameraControllerHelper.d.ts.map +1 -1
  230. package/src/engine/input/ecs/util/TopDownCameraControllerHelper.js +10 -3
  231. package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts +14 -0
  232. package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts.map +1 -0
  233. package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.js +38 -0
  234. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  235. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +433 -425
  236. package/src/engine/physics/constraint/solve_constraints.d.ts +4 -1
  237. package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -1
  238. package/src/engine/physics/constraint/solve_constraints.js +38 -13
  239. package/src/engine/physics/contact/ManifoldStore.d.ts +15 -3
  240. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
  241. package/src/engine/physics/contact/ManifoldStore.js +15 -3
  242. package/src/engine/physics/ecs/Joint.d.ts +7 -4
  243. package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
  244. package/src/engine/physics/ecs/Joint.js +7 -4
  245. package/src/engine/physics/ecs/PhysicsSystem.d.ts +14 -0
  246. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  247. package/src/engine/physics/ecs/PhysicsSystem.js +16 -0
  248. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  249. package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +3 -3
  250. package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts +15 -7
  251. package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts.map +1 -1
  252. package/src/engine/physics/narrowphase/convex_convex_manifold.js +33 -10
  253. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts +6 -2
  254. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -1
  255. package/src/engine/physics/narrowphase/refine_ray_concave.js +6 -2
  256. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts +6 -2
  257. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -1
  258. package/src/engine/physics/narrowphase/refine_ray_hit.js +6 -2
  259. package/src/engine/physics/queries/raycast.d.ts.map +1 -1
  260. package/src/engine/physics/queries/raycast.js +11 -4
  261. package/src/engine/save/storage/IndexedDBStorage.d.ts.map +1 -1
  262. package/src/engine/save/storage/IndexedDBStorage.js +21 -3
  263. package/src/engine/simulation/Ticker.d.ts +12 -0
  264. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  265. package/src/engine/simulation/Ticker.js +18 -0
  266. package/src/engine/sound/simulation/AcousticSimulator.d.ts.map +1 -1
  267. package/src/engine/sound/simulation/AcousticSimulator.js +9 -1
  268. package/src/engine/sound/simulation/core/VolumeField.d.ts +6 -2
  269. package/src/engine/sound/simulation/core/VolumeField.d.ts.map +1 -1
  270. package/src/engine/sound/simulation/core/VolumeField.js +6 -2
  271. package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts +6 -2
  272. package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts.map +1 -1
  273. package/src/engine/sound/simulation/probe/AcousticProbeField.js +6 -2
  274. package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts +6 -2
  275. package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts.map +1 -1
  276. package/src/engine/sound/simulation/probe/acoustic_probe_transfer.js +6 -2
  277. package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts +3 -1
  278. package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts.map +1 -1
  279. package/src/engine/sound/simulation/probe/bakeProbeReflectors.js +3 -1
  280. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  281. package/src/engine/ui/GUIEngine.js +8 -1
  282. package/src/generation/theme/TerrainTheme.d.ts.map +1 -1
  283. package/src/generation/theme/TerrainTheme.js +2 -1
  284. package/src/generation/theme/ThemeEngine.d.ts.map +1 -1
  285. package/src/generation/theme/ThemeEngine.js +6 -5
  286. package/src/view/ViewGroup.d.ts.map +1 -1
  287. package/src/view/ViewGroup.js +9 -7
  288. package/src/view/graphics/WebGLContextFailureView.d.ts +19 -0
  289. package/src/view/graphics/WebGLContextFailureView.d.ts.map +1 -0
  290. package/src/view/graphics/WebGLContextFailureView.js +76 -0
  291. package/src/view/minimap/Minimap.d.ts +8 -0
  292. package/src/view/minimap/Minimap.d.ts.map +1 -1
  293. package/src/view/minimap/Minimap.js +16 -3
  294. package/src/view/minimap/dom/MinimapMarkerView.d.ts.map +1 -1
  295. package/src/view/minimap/dom/MinimapMarkerView.js +3 -1
@@ -0,0 +1,740 @@
1
+ # Correctness review — `engine/graphics` (ecs, render, shaders, camera, root)
2
+
3
+ Scope: every non-spec, non-prototype source file under
4
+ `meep/src/engine/graphics/{ecs,render,shaders,camera}` plus the files directly in
5
+ `meep/src/engine/graphics/`. three.js r136.
6
+
7
+ ---
8
+
9
+ ### GFXR-1 `Camera.projectWorldPointOntoNearPlane` projects onto the RIGHT frustum plane
10
+ - **Severity**: MAJOR
11
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/camera/Camera.js:279`
12
+ - **Defect**: The method takes `scratch_frustum.planes[0]` as "the near plane". `frustum_from_camera`
13
+ fills the frustum via three's `Frustum.setFromProjectionMatrix`, whose plane order is
14
+ `[0]=right, [1]=left, [2]=bottom, [3]=top, [4]=far, [5]=near`. Index 0 is the RIGHT side plane.
15
+ The sibling file `ecs/camera/pp/PerfectPanner.js:9-10` states the correct convention
16
+ (`NEAR_PLANE_INDEX = 5; FAR_PLANE_INDEX = 4;`), so the codebase already knows the ordering.
17
+ - **Failure scenario**: Camera at the origin looking down `-Z`, 45° fov, near 0.1. Call
18
+ `camera.projectWorldPointOntoNearPlane(0, 0, -10, out)`. Expected `out ≈ (0, 0, -0.1)` — the point
19
+ pushed onto the near plane in front of the eye. Actual: the point is projected onto the right
20
+ side plane of the frustum, landing several units off to the camera's right at a depth of 10.
21
+ Any UI/marker/clamping code built on this public method places its output in the wrong place
22
+ entirely, and gets *worse* the further off-axis the input point is.
23
+ - **Fix**: `const near = scratch_frustum.planes[5];`
24
+ - **Confidence**: Confirmed
25
+
26
+ ---
27
+
28
+ ### GFXR-2 Orthographic aspect correction uses the wrong delta when the viewport narrows
29
+ - **Severity**: MAJOR
30
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/camera/set_camera_aspect_ratio.js:35-40`
31
+ - **Defect**: The widening branch (`aspectDelta > 0`) grows the horizontal extent by `h * aspectDelta`,
32
+ which is exactly right (`w' = w + h·aspect − w = h·aspect`). The narrowing branch grows the vertical
33
+ extent by `-h * aspectDelta`, which does **not** solve `w / h' = aspect`. The required delta is
34
+ `w/aspect − h`, i.e. `-h * aspectDelta / aspect`.
35
+ ```js
36
+ } else if (aspectDelta < 0) {
37
+ const d = -h * aspectDelta; // <-- missing the /aspect
38
+ c.top += d / 2;
39
+ c.bottom -= d / 2;
40
+ }
41
+ ```
42
+ - **Failure scenario**: `OrthographicCamera(-10, 10, 10, -10, …)` (square frustum) in a portrait
43
+ viewport, `set_camera_aspect_ratio(camera, 100, 200)`. Requested aspect 0.5; the code produces
44
+ `top=15, bottom=-15`, i.e. frustum aspect `20/30 = 0.667`. Everything rendered through that camera
45
+ is stretched vertically by 33%: circles become ellipses, a square tile is drawn as a rectangle.
46
+ Verified numerically. The existing spec (`set_camera_aspect_ratio.spec.js`) only exercises the
47
+ widening case, which is why this survives.
48
+ - **Fix**: `const d = -h * aspectDelta / aspect;` — or, uniformly for both branches, set
49
+ `h' = w / aspect` when `aspect < existingAspect` and `w' = h * aspect` otherwise.
50
+ - **Confidence**: Confirmed
51
+
52
+ ---
53
+
54
+ ### GFXR-3 `CameraSystem` rebuild leaks a THREE camera into the scene on every fov/projection change
55
+ - **Severity**: MAJOR
56
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/camera/CameraSystem.js:146-148`
57
+ - **Defect**: `rebuild()` replaces `camera.object` with a freshly built THREE camera but never removes
58
+ the previous one from `this.scene`. `link()` is the only place that does `this.scene.add(...)`, and
59
+ `unlink()` removes only the *current* `camera.object`. The new object gets re-added to the scene by
60
+ `GraphicsEngine.render` (`if (scene.children.indexOf(camera) < 0) scene.add(camera)`), so every
61
+ rebuild adds one more permanent child. `rebuild` is wired to `camera.fov.onChanged` and
62
+ `camera.projectionType.onChanged` (lines 172, 174).
63
+ - **Failure scenario**: A fov-zoom effect animates `Camera.fov` (a `Vector1`, observable precisely so
64
+ it can be animated) at 60 Hz. Each frame builds a new `THREE.PerspectiveCamera` and appends it to
65
+ `graphics.scene.children`, which is never pruned. After one minute `scene.children` holds ~3600 dead
66
+ cameras: unbounded memory growth, an O(n) `scene.children.indexOf(camera)` in `GraphicsEngine.render`
67
+ every frame, and an O(n) root walk in `NormalFrameBuffer.render`, which traverses `scene.children`
68
+ each frame. Frame time degrades continuously for as long as the zoom runs.
69
+ - **Fix**: In `rebuild()`, capture the old object, `this.scene.remove(old)` and
70
+ `this.scene.add(camera.object)` after building. Better still, do not rebuild for a fov change — set
71
+ `camera.object.fov` and call `updateProjectionMatrix()`; only `projectionType` genuinely needs a new
72
+ object.
73
+ - **Confidence**: Confirmed
74
+
75
+ ---
76
+
77
+ ### GFXR-4 `Trail2DSystem` seeds the ribbon at a different point from where the head is driven
78
+ - **Severity**: MAJOR
79
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/trail2d/Trail2DSystem.js:156-158` vs `:234-235`
80
+ - **Defect**: Build-time seeding adds the offset in world space, ignoring the entity's rotation and
81
+ scale:
82
+ ```js
83
+ const position_x = trail_offset.x + transform_position.x; // __build_trail
84
+ ```
85
+ while the per-frame head update carries the offset through the entity's full transform:
86
+ ```js
87
+ newPosition.copy(trail.offset);
88
+ newPosition.applyMatrix4(transform.matrix); // updateTrailEntity
89
+ ```
90
+ The sibling `Trail3DSystem` has exactly this invariant written down and enforced via a shared
91
+ `compute_head_position` helper (`ecs/trail3d/Trail3DSystem.js:34-53`), whose doc comment describes
92
+ the resulting artifact verbatim. `Trail2DSystem` was not given the same treatment.
93
+ - **Failure scenario**: A projectile entity with `Trail2D.offset = (0, 0, -1)` (trail emitted from the
94
+ tail) rotated 90° about Y so it flies along `+X`. `__build_trail` seeds every ribbon point at
95
+ `position + (0, 0, -1)`; the first `updateTrailEntity` puts the head at `position + (-1, 0, 0)`.
96
+ The first rendered frame contains a ~1.41-unit ribbon streak pointing in a direction that has
97
+ nothing to do with the projectile's travel — a visible flash at every spawn.
98
+ - **Fix**: Replace lines 156-158 with the same transform-carried computation used in
99
+ `updateTrailEntity` (ideally extract a shared `compute_head_position(result, trail, transform)` as
100
+ Trail3D does).
101
+ - **Confidence**: Confirmed
102
+
103
+ ---
104
+
105
+ ### GFXR-5 `loadCubeTexture` calls `ImageUtils.loadTextureCube`, removed from three long before r136
106
+ - **Severity**: MAJOR
107
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/loadCubeTexture.js:27`
108
+ - **Defect**: `const textureCube = ImageUtils.loadTextureCube(urls);` — in the vendored
109
+ `three@0.136.0`, `src/extras/ImageUtils.js` declares exactly one member, `static getDataURL(image)`.
110
+ There is no `loadTextureCube`.
111
+ - **Failure scenario**: Any caller of this exported (public-surface) helper —
112
+ `loadCubeTexture('assets/sky', 'png')` — throws `TypeError: ImageUtils.loadTextureCube is not a
113
+ function` on the first line that matters, so no cubemap is ever produced. The function cannot
114
+ succeed under any input.
115
+ - **Fix**: Delete the function, or reimplement it on `CubeTextureLoader`:
116
+ `new CubeTextureLoader().load(urls)` (which already returns a `CubeTexture` with
117
+ `CubeReflectionMapping`). Note the package already has a working path for this —
118
+ `texture/cubemap/load_environment_map.js`, used by `load_and_set_cubemap_v0.js`.
119
+ - **Confidence**: Confirmed
120
+
121
+ ---
122
+
123
+ ### GFXR-6 `Lines` writes `drawRange.count` in the wrong units (twice, two different wrong values)
124
+ - **Severity**: MINOR
125
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/Lines.js:61` and `:105`
126
+ - **Defect**: `geometry.drawRange.count` is a **vertex** count for a non-indexed geometry. The
127
+ geometry stores `capacity * 2` vertices (2 per line segment).
128
+ - `setCount(x)` writes `x * 6` — the *float* count of the position attribute.
129
+ - `add_line` then overwrites it with `this.count` — the *line* count.
130
+ Neither is `count * 2`. Separately, `ensure_capacity` → `build()` replaces `this.geometry` without
131
+ disposing the old `BufferGeometry`, and `dispose()` never disposes `this.mesh.material`.
132
+ - **Failure scenario**: `const l = new Lines(); l.add_line([0,0,0],[1,0,0],[1,1,1,1]);` and add
133
+ `l.mesh` to a scene. `drawRange.count === 1`; `LineSegments` needs vertex pairs, so zero segments
134
+ are drawn. Adding 10 lines draws 5. Had `add_line` not overwritten line 61's value, `x*6` would
135
+ exceed the allocated `x*2` vertices and WebGL would raise
136
+ `INVALID_OPERATION: attempt to access out of range vertices`.
137
+ - **Fix**: `this.geometry.drawRange.count = x * 2;` in `setCount`, and delete the redundant
138
+ assignment at line 105. Dispose the replaced geometry in `build()`.
139
+ - **Confidence**: Confirmed (the class also logs `'feature not fully implemented'`, so this is
140
+ acknowledged WIP — listed for completeness)
141
+
142
+ ---
143
+
144
+ ### GFXR-7 `OutlineRenderer` caches the ID-pass mesh with a frozen geometry reference
145
+ - **Severity**: MINOR
146
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/highlight/renderer/OutlineRenderer.js:96-117`
147
+ - **Defect**: `obtain_id_mesh` builds the silhouette proxy once per source `Object3D` and caches it in
148
+ a module-level `WeakMap`. On a cache hit it refreshes the skinning state (`bindMatrix`,
149
+ `bindMatrixInverse`, `skeleton`) but never re-reads `object.geometry`. The equivalent proxy builder
150
+ in the same package, `render/utils/ThreeBypassRenderer.js:101`, *does* re-assign
151
+ `mesh.geometry = geometry` on every rebuild — the divergence is the tell.
152
+ - **Failure scenario**: A highlighted `SGMesh` entity crosses an LOD threshold and
153
+ `SGMeshSystem`/`ShadedGeometrySystem` swaps the `Object3D`'s `geometry` for the next LOD. The
154
+ visible mesh changes silhouette; the outline keeps tracing the previous LOD's geometry, so the
155
+ highlight no longer hugs the object — offset/oversized outline until the object is unhighlighted.
156
+ - **Fix**: In `obtain_id_mesh`, after the cache lookup, add `mesh.geometry = object.geometry;`
157
+ unconditionally.
158
+ - **Confidence**: Likely
159
+
160
+ ---
161
+
162
+ ### GFXR-8 `Water.updateShaderForTerrain` leaks a full terrain heightmap texture per call
163
+ - **Severity**: MINOR
164
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/water/Water.js:138-152`
165
+ - **Defect**: A new `DataTexture` wrapping the whole terrain height sampler is allocated and assigned
166
+ to `shader.uniforms.tHeightTexture.value`; the texture previously held by that uniform is never
167
+ `.dispose()`d. `WaterSystem.link` pushes the component onto `updateQueue`, so this runs once per
168
+ link — and `link` reuses the existing `component.__shader` when the component is re-linked
169
+ (`WaterSystem.js:164-175`), so each re-link allocates another one.
170
+ - **Failure scenario**: Level reload cycles unlink and re-link the Water component. Each cycle uploads
171
+ a fresh `R32F` heightmap — for a 1024×1024 terrain that is 4 MB of GPU memory — and orphans the
172
+ previous one. Twenty reloads leak ~80 MB of VRAM that three.js will never reclaim, because nothing
173
+ holds a reference to call `dispose()` on.
174
+ - **Fix**: Capture `const previous = shader.uniforms.tHeightTexture.value;` before the assignment and
175
+ `previous?.dispose()` after — the same replace-then-release shape `GraphicsEngine#bake_environment`
176
+ already uses for the PMREM target.
177
+ - **Confidence**: Confirmed
178
+
179
+ ---
180
+
181
+ ### GFXR-9 `FrameGraph.exportToJson` conflates resource-node ids with resource-registry ids
182
+ - **Severity**: MINOR
183
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/frame_graph/FrameGraph.js:479-508`
184
+ - **Defect**: The two id spaces are independent — `_createResourceEntry` numbers entries by
185
+ `__resource_registry.length`, `_createResourceNode` numbers nodes by `__resource_nodes.length` —
186
+ and `clone_resource` adds nodes without adding entries, so they diverge. The export passes a
187
+ registry id where a node id is required:
188
+ ```js
189
+ const node = this.getResourceNode(entry.resource_id); // registry id -> node lookup
190
+ ...
191
+ if (pass.reads(entry.resource_id)) // registry id vs node ids in resource_reads
192
+ ```
193
+ `getResourceNode`'s only protection is an `assert.equal`, which is compiled out in production.
194
+ - **Failure scenario**: `FramePassBuilder.write()` calls `graph.clone_resource()` for any write to a
195
+ resource the pass did not create — the normal case as soon as two passes touch one resource. In a
196
+ three-pass graph where pass B writes pass A's target, node ids run ahead of registry ids and
197
+ `exportToJson()` labels resources with another resource's name and emits reader/writer lists that
198
+ point at the wrong passes. The rendered GraphViz/JSON diagram misrepresents the pipeline it exists
199
+ to debug.
200
+ - **Fix**: Track the owning node per entry (or resolve registry id → node by scanning
201
+ `__resource_nodes` for `resource_id === entry.resource_id`), and compare `pass.reads/writes` against
202
+ node ids, not registry ids.
203
+ - **Confidence**: Confirmed
204
+
205
+ ---
206
+
207
+ ### GFXR-10 `PathDisplayContext.__build` aborts the whole build on one unknown spec type
208
+ - **Severity**: MINOR
209
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/path/PathDisplaySystem.js:135-139`
210
+ - **Defect**: Inside the loop over `display.specs`, an unrecognised `spec.type` logs a warning and
211
+ `return`s out of the entire method — skipping the remaining specs, the `ParentEntity` attachment
212
+ loop, `__build_existing_entities()`, and the `PathDisplayEvents.BuildComplete` dispatch. The
213
+ neighbouring failure path in the same loop (a builder that throws, lines 141-146) correctly
214
+ `catch`es and continues.
215
+ - **Failure scenario**: A `PathDisplay` with two specs, `[Ribbon, <stale type from an old save>]`. The
216
+ Ribbon builder runs and pushes its entities into `__owned_entities`, then the second spec hits the
217
+ `return`. Those entities are never `build(ecd)`-ed into the dataset, so the path renders nothing at
218
+ all — and `BuildComplete` never fires, so anything awaiting that event waits forever.
219
+ - **Fix**: `continue;` instead of `return;`.
220
+ - **Confidence**: Confirmed
221
+
222
+ ---
223
+
224
+ ### GFXR-11 `ShadedGeometrySystem.shutdown` never removes its render layer
225
+ - **Severity**: CRITICAL
226
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js:254` (create) vs `:280-294` (shutdown)
227
+ - **Defect**: `startup` does `this.__render_layer = graphics.layers.create('shaded-geometry')` and installs a
228
+ `buildVisibleSet` closure that resolves a per-view context from `this.__view_contexts`. `shutdown`
229
+ tears down every view context but never calls `graphics.layers.remove(this.__render_layer)`.
230
+ `graphics.layers` lives on the `GraphicsEngine`, which outlives the `EntityManager`. Every peer
231
+ system does remove its layer — `MeshSystem.js:247`, `WaterSystem.js:73`, `Trail2DSystem.js:117`,
232
+ `Trail3DSystem.js:134`, `GizmoRenderingPlugin.js:18`.
233
+ - **Failure scenario**: Level teardown/reload. `EntityManager.shutdown()` runs
234
+ `ShadedGeometrySystem.shutdown`, emptying `__view_contexts`; the layer stays registered and visible.
235
+ (a) If any frame renders before the new dataset is up, `VisibilityComputer.__processLayer` calls the
236
+ stale `buildVisibleSet`, `this.__view_contexts.get(view)` is `undefined`, and
237
+ `ctx.collect(...)` throws `TypeError: Cannot read properties of undefined (reading 'collect')` —
238
+ which aborts the whole visibility build, so *every* layer stops rendering.
239
+ (b) Adding a fresh `ShadedGeometrySystem` for the next level makes
240
+ `graphics.layers.create('shaded-geometry')` throw
241
+ `RenderLayer named 'shaded-geometry' already exists` (`RenderLayerManager.js:117`) — a hard crash on
242
+ level reload. The stale `compute_depth_range` closure also pins the dead BVH.
243
+ - **Fix**: In `shutdown`, `graphics.layers.remove(this.__render_layer); this.__render_layer = null;`
244
+ - **Confidence**: Confirmed
245
+
246
+ ---
247
+
248
+ ### GFXR-12 `SGMesh.materialOverride` is applied after `link`, bypassing the Forward+ material transform
249
+ - **Severity**: MAJOR
250
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.js:205-207`, and the same defect at `aggregate/SGMesh.js:120-131`
251
+ - **Defect**: `ShadedGeometrySystem.link` exists precisely to close this hole; its own comment says the
252
+ material is forced "through the manager's transform chain, so that shader injection performed by the
253
+ lighting engine (Forward+ clustered lights, decals) cannot be skipped by callers that assign
254
+ `sg.material` directly" (`ShadedGeometrySystem.js:305-311`). In `SGMeshSystem`,
255
+ `entity_node.build(ecd)` (line 179) synchronously drives `ShadedGeometrySystem.link` for every child
256
+ `ShadedGeometry`, installing the transformed material — and *then* the traverse at 190-207 overwrites
257
+ it with the raw override:
258
+ ```js
259
+ if (material_override !== null) {
260
+ sg.material = material_override;
261
+ }
262
+ ```
263
+ `SGMesh.applyMaterialOverride()`, reached from the `set materialOverride` setter, does the same on an
264
+ already-linked hierarchy.
265
+ - **Failure scenario**: A unit is spawned with `SGMesh.fromURL('unit.gltf')` and a `materialOverride`
266
+ (team tint / ghost preview / damage flash) using a lit `MeshStandardMaterial`. Every part receives
267
+ the raw material, which never went through `MaterialManager.obtain`, so the Forward+
268
+ `onBeforeCompile` clustered-light injection is absent. The overridden unit renders with *no*
269
+ clustered point lights and no decals while every neighbouring unit in the same scene is lit
270
+ correctly — it looks flat/black at night. The `assert.ok(!isForwardPlusLightingSkipped(...))` guard
271
+ in `link` cannot catch it: it runs before the override, and asserts are stripped in production.
272
+ Reference counting is also skipped, so `MaterialManager` can neither dedupe nor reclaim the override.
273
+ - **Fix**: Route both sites through the manager
274
+ (`sg.material = materialManager.obtain(material_override).getValue()`), tracking/releasing the
275
+ reference the same way `ShadedGeometrySystem`'s `#material_references` does.
276
+ - **Confidence**: Confirmed (ordering verified; the lighting consequence follows from the contract
277
+ documented in `ShadedGeometrySystem.link`)
278
+
279
+ ---
280
+
281
+ ### GFXR-13 `BoneMapping.build` commits the WORST bone name match first
282
+ - **Severity**: MAJOR
283
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/mesh/skeleton/BoneMapping.js:13-15, 55, 61`
284
+ - **Defect**: `string_compute_similarity` is Jaro-Winkler and is documented "Higher value means string
285
+ are more similar". The comparator sorts **descending** (best first), and the consumption loop then
286
+ takes from the tail:
287
+ ```js
288
+ function byDistance(a, b) { return b.distance - a.distance; } // descending
289
+ matches.sort(byDistance);
290
+ ...
291
+ const match = matches.pop(); // takes the LEAST similar pair
292
+ ```
293
+ Because the inner loop immediately evicts every remaining entry sharing that `boneIndex` or
294
+ `inputIndex`, the worst pairing is locked in permanently — this is a greedy assignment run in
295
+ exactly reverse order.
296
+ - **Failure scenario**: Load a rigged glTF whose bones are `Hips, Spine, Chest, Neck, Head, …`.
297
+ `SkeletonUtils.findSkeletonBoneByType` builds the mapping on first use. The globally least-similar
298
+ (bone, name) pair — say (`HumanoidBoneType.Head`, `"toe-right"`) — is committed first and consumes
299
+ the `Head` slot. `Mesh.getBonePositionByType(result, HumanoidBoneType.Head)` then returns the world
300
+ position of the right toe, so anything anchored to a humanoid bone (weapon socket, head-mounted VFX,
301
+ nameplate) attaches to the wrong body part.
302
+ - **Fix**: Consume best-first — either flip the comparator to `a.distance - b.distance` and keep
303
+ `pop()`, or keep the sort and use `matches.shift()` (with the inner splice loop starting at 0).
304
+ - **Confidence**: Confirmed
305
+
306
+ ---
307
+
308
+ ### GFXR-14 `SGMeshAnimationControllerSystem.update` spins on one unloaded mesh and starves the rest of the queue
309
+ - **Severity**: MAJOR
310
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshAnimationControllerSystem.js:159-167` with `:57-68`, `:90-92`
311
+ - **Defect**: `update` pops up to 1000 entities per tick and calls `this.#process(entity)` without
312
+ awaiting. `#process` re-enqueues synchronously when the mesh has not loaded
313
+ (`if (mesh.node === null) { this.#enqueue(entity); return; }`), and `#enqueue` **pushes to the tail**
314
+ while `update` **pops from the tail**. The same entity is therefore popped and re-pushed
315
+ immediately, for all 1000 iterations.
316
+ ```js
317
+ const entity = this.#queue.pop();
318
+ this.#process(entity); // re-pushes to the same end it was popped from
319
+ ```
320
+ - **Failure scenario**: Three units spawn in the same frame; A and B have cached meshes, C's glTF is
321
+ still downloading. The queue is `[A, B, C]`. Frame 1: `pop()` yields C, C re-queues, `pop()` yields C
322
+ again — 1000 times. A and B are never reached. This repeats every frame until C's asset resolves, so
323
+ A and B never get `controller.bound` populated and stand completely frozen for the whole duration of
324
+ C's download, even though their own meshes were ready immediately. Also burns 1000 pop/push cycles
325
+ per frame doing nothing.
326
+ - **Fix**: Use FIFO — `this.#queue.shift()` in `update` (or push to the head in `#enqueue`) — and cap
327
+ the loop by the queue length captured *before* the loop, so re-queued entities are deferred to the
328
+ next tick rather than reprocessed within the same one.
329
+ - **Confidence**: Confirmed
330
+
331
+ ---
332
+
333
+ ### GFXR-15 HiZ occlusion query samples `n_max.y` as an x coordinate
334
+ - **Severity**: MAJOR
335
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/visibility/hiz/query/QueryShader.js:108`
336
+ - **Defect**: The four taps are meant to be the four corners of the projected AABB. The
337
+ bottom-right tap reads `vec2(n_max.y, n_min.y)` — `n_max.y` where `n_max.x` was meant:
338
+ ```glsl
339
+ float n1 = textureLod(utDepth, vec2(n_min.x, n_max.y), float(mip_to_use)).x;
340
+ float n2 = textureLod(utDepth, vec2(n_max.y, n_min.y), float(mip_to_use)).x; // n_max.x
341
+ ```
342
+ - **Failure scenario**: A wide, short object — a wall whose projected box spans x∈[0.1, 0.9],
343
+ y∈[0.40, 0.45]. `n2` samples at x = 0.45 instead of x = 0.9, so the depth in front of the wall's
344
+ right-hand half is never read. `n = max(n0..n3)` therefore under-reports the scene depth over that
345
+ region and `depth_delta = n - n_min.z` goes negative: the wall is classified occluded and culled
346
+ while plainly on screen. The error is silent, view-dependent, and worse the wider the object.
347
+ - **Fix**: `float n2 = textureLod(utDepth, vec2(n_max.x, n_min.y), float(mip_to_use)).x;`
348
+ - **Confidence**: Confirmed
349
+
350
+ ---
351
+
352
+ ### GFXR-16 `HierarchicalZBuffer.build_mips` publishes the mip chain on a property three.js never reads
353
+ - **Severity**: MINOR
354
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/visibility/hiz/buffer/HierarchicalZBuffer.js:168-169`
355
+ - **Defect**: `this.__input_buffer.mips = mips.map(m => m.texture);` — `THREE.Texture` in r136 has no
356
+ `mips` field; the array `WebGLTextures` consults when `generateMipmaps === false` is `mipmaps`.
357
+ The assignment creates an inert own-property, so the reduction targets are never bound as mip levels
358
+ of the texture that `BatchOcclusionQuery.execute` hands to the shader as `utDepth`.
359
+ - **Failure scenario**: The reduction pass fills every mip render target correctly, but `utDepth` still
360
+ has only level 0 (`minFilter: NearestFilter`, no uploaded chain), so every
361
+ `textureLod(utDepth, uv, float(mip_to_use))` clamps to level 0. A distant object whose AABB covers
362
+ one texel at `mip_to_use = 6` is tested with four full-resolution point samples instead of the
363
+ conservative max over its footprint: the hierarchical test degenerates into a 4-sample point test
364
+ and reports visible objects as occluded — geometry pops in and out at distance.
365
+ - **Fix**: Bind the reduction targets' textures as explicit `sampler2D` uniforms and index them in the
366
+ shader, or build a real `mipmaps` chain on `__input_buffer` that `WebGLTextures` will upload.
367
+ Renaming the property alone is not sufficient — `mipmaps` entries are image-like objects, not render
368
+ targets.
369
+ - **Confidence**: Likely (only in-repo consumer today is `hiz/prototypeHiZ.js`, so this is latent)
370
+
371
+ ---
372
+
373
+ ### GFXR-17 `SGMesh.copy()` does not copy `flags`, so `clone()` re-enables shadow casting
374
+ - **Severity**: MINOR
375
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js:82-88`
376
+ - **Defect**: `copy(other)` transfers `__url`, `__node`, `__initial_bounds` and `#material_override`
377
+ but not `flags` — the sole backing store for `castShadow`/`receiveShadow`/`Loaded`. `equals()`
378
+ (line 68) nonetheless compares `this.flags === other.flags`.
379
+ - **Failure scenario**: A prefab `SGMesh` with `castShadow = false` (a flat ground decal mesh, say).
380
+ `prefab.clone()` yields a component with `DEFAULT_FLAGS = CastShadow | ReceiveShadow`, so every
381
+ cloned instance casts a shadow the original explicitly disabled. Additionally
382
+ `prefab.equals(prefab.clone())` is `false`, defeating any equality/hash-keyed reuse of the component.
383
+ - **Fix**: Add `this.flags = other.flags & ~SGMeshFlags.Loaded;` to `copy()` (`Loaded` must be cleared
384
+ because `__node` is reset to `null`).
385
+ - **Confidence**: Confirmed
386
+
387
+ ---
388
+
389
+ ### GFXR-18 `SGMeshAnimationControllerSystem` has no `unlink`, so destroyed entities stay queued
390
+ - **Severity**: MINOR
391
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshAnimationControllerSystem.js:57-68` (no `unlink` anywhere in the class)
392
+ - **Defect**: `link` enqueues the entity when its mesh has not loaded. The class defines no `unlink`,
393
+ so nothing drains `#queue` when the entity is destroyed. `#process` then does
394
+ `ecd.getComponent(entity, SGMesh)` — which returns `undefined` for a removed entity
395
+ (`EntityComponentDataset` deletes the slot) — and dereferences `mesh.node`.
396
+ - **Failure scenario**: Spawn an entity with `SGMesh.fromURL(...)` + `SGMeshAnimationController`; the
397
+ asset is not yet resolved so `link` queues it. Destroy the entity the same frame (unit dies, preview
398
+ cancelled). Next tick, `update` pops it and `#process` throws
399
+ `TypeError: Cannot read properties of undefined (reading 'node')`. Because `update` calls the async
400
+ `#process` without awaiting, this surfaces as an unhandled promise rejection rather than a thrown
401
+ frame error — silent in production, and the diagnostics for the real problem are lost.
402
+ - **Fix**: Add `unlink(anim, mesh, entity)` that removes `entity` from `#queue`.
403
+ - **Confidence**: Confirmed
404
+
405
+ ---
406
+
407
+ ### GFXR-19 Ambient light with `castShadow` builds a shadow view, then crashes the frame
408
+ - **Severity**: MAJOR
409
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/light/binding/three/ThreeLightBinding.js:476-491`
410
+ - **Defect**: `__apply_castShadow` calls `__shadowmap_link()` whenever `castShadow` is true, with no check
411
+ that the underlying three light actually owns a `shadow`. `THREE.AmbientLight` does not — only
412
+ Directional/Spot/Point define one. Every *other* shadow site in the file guards for exactly this
413
+ (`applySettings:525` `if (shadow !== undefined)`, `__shadowmap_unlink:316`), and
414
+ `LightSystem.__visit_entity_to_update_shadow:210` excludes AMBIENT with the comment "only non-ambient
415
+ lights can cast shadow" — so this is a state the code already treats as reachable, not a can't-happen.
416
+ - **Failure scenario**: An entity carries `Light{type: AMBIENT, castShadow: true}` —
417
+ `Light.fromJSON` and `LightSerializationAdapter.serialize` both persist `castShadow` unconditionally
418
+ for every light type, so an editor toggle or a saved blueprint produces it. `link()` registers a
419
+ shadow `CameraView` with `graphics.views`. On the next `constructVisibleScene`,
420
+ `__shadowmap_prepare_view` does `const shadow = light.shadow;` (undefined) then
421
+ `const camera = shadow.camera;` → `TypeError`. That throw propagates out of
422
+ `CameraView.build_visibility` → `CameraViewManager.build_visibility` →
423
+ `GraphicsEngine.constructVisibleScene`, so the whole frame dies — every frame, permanently.
424
+ - **Fix**: `if (v && this.__shadow_map === null && light.shadow !== undefined) { … }`
425
+ - **Confidence**: Confirmed (crash mechanism verified against three r136; reachability requires the
426
+ ambient+castShadow authoring combination)
427
+
428
+ ---
429
+
430
+ ### GFXR-20 Shadow cameras have `matrixAutoUpdate = false`, so `LightShadow.updateMatrices` cannot refresh their pose
431
+ - **Severity**: MAJOR
432
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/light/binding/three/ThreeLightBinding.js:549`
433
+ - **Defect**: `applySettings` sets `shadow.camera.matrixAutoUpdate = false` for directional/spot shadows.
434
+ three r136's `LightShadow.updateMatrices` writes `shadowCamera.position` / calls `lookAt(...)` and then
435
+ relies on `shadowCamera.updateMatrixWorld()` to recompose — but `Object3D.updateMatrixWorld` only calls
436
+ `updateMatrix()` when `matrixAutoUpdate` is true, and `matrixWorldNeedsUpdate` is false at that point,
437
+ so neither `matrix` nor `matrixWorld`/`matrixWorldInverse` is updated. (`lookAt` itself calls
438
+ `updateWorldMatrix(true,false)`, which is equally inert, so even the look rotation is derived from the
439
+ stale world position.) The point-light branch two lines above documents precisely this requirement and
440
+ keeps `matrixAutoUpdate = true`; the directional/spot branch does the opposite. `LightSystem.js:325`
441
+ even has a comment asserting the opposite of what happens: *"refresh shadow camera world matrix from
442
+ the light's pose before we read matrixWorldInverse"* — `shadow.updateMatrices(light, 0)` there is a
443
+ no-op with respect to `matrixWorldInverse`.
444
+ - **Failure scenario**: The only place that ever bakes the pose is `__shadowmap_prepare_view`
445
+ (`camera.updateMatrix(); camera.updateMatrixWorld();`), fired from `preVisibilityBuild` — i.e. earlier
446
+ in the frame than `three_update_shadow_camera_extents`, which runs on
447
+ `visibilityConstructionEnded`. (a) First frame a light starts casting: `prepare_view` bakes the
448
+ constructor pose (origin, identity), so `compute_view_frustum_aabb_in_space` fits the ortho box in
449
+ **world** space instead of light space — a visibly wrong/missing shadow flash on level load.
450
+ (b) Steady state: a spot light carried by a moving character always renders and fits from the pose
451
+ captured on the previous frame, so its shadow trails the light while strafing.
452
+ - **Fix**: Leave `matrixAutoUpdate` at three's default `true` for all shadow cameras (three's own
453
+ `WebGLShadowMap` never disables it), or explicitly call `shadow.camera.updateMatrix();
454
+ shadow.camera.updateMatrixWorld();` after every `shadow.updateMatrices(light, i)`.
455
+ - **Confidence**: Confirmed (mechanism verified against `three/src/lights/LightShadow.js` and
456
+ `Object3D.updateMatrixWorld` in r136)
457
+
458
+ ---
459
+
460
+ ### GFXR-21 `three_update_shadow_camera_extents` rebuilds the main camera's projection matrix, discarding every projection modifier
461
+ - **Severity**: MAJOR
462
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/light/LightSystem.js:334-345`
463
+ - **Defect**: To fit the shadow box, the function truncates `camera.far`, calls
464
+ `camera.updateProjectionMatrix()`, reads the matrix, then restores `far` and calls
465
+ `updateProjectionMatrix()` again. The in-code claim that "callers downstream see no change" is wrong:
466
+ `updateProjectionMatrix()` regenerates the matrix from `fov/aspect/near/far`, restoring the *default*
467
+ projection — not whatever the matrix previously contained. `CameraView.set_from_camera:123` writes
468
+ modified matrices straight back into `camera.projectionMatrix.elements`, and it runs *earlier* in the
469
+ same frame (`GraphicsEngine.constructVisibleScene`: `set_from_camera` at line 707,
470
+ `visibilityConstructionEnded` — where this function is hooked — at line 710).
471
+ - **Failure scenario**: `TemporalSupersamplingRenderPlugin` is enabled and one shadow-casting
472
+ directional light has a finite `maxShadowDistance` below `camera.far` (e.g. 60 vs 1000), so
473
+ `needs_truncation` is true. Every frame the jitter written into `elements[8]/[9]` is wiped before the
474
+ scene is drawn, so TAA resamples the identical position forever: the image never converges and edge
475
+ aliasing is exactly as if TAA were off, while the plugin still pays its full cost. Setting
476
+ `maxShadowDistance = Infinity` makes TAA start working — a baffling coupling. Any other registered
477
+ `CameraView.projection_modifier` is destroyed the same way.
478
+ - **Fix**: Do not mutate the live camera. Build the truncated projection into a scratch `Matrix4` with
479
+ `makePerspective(...)` and multiply that by `camera.matrixWorldInverse`; failing that, save and restore
480
+ the 16 elements of `projectionMatrix` (and `projectionMatrixInverse`) verbatim.
481
+ - **Confidence**: Confirmed (mechanism and frame ordering both verified)
482
+
483
+ ---
484
+
485
+ ### GFXR-22 Binary light serializer drops POINT `distance` and DIRECTION `maxShadowDistance`
486
+ - **Severity**: MAJOR
487
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/light/LightSerializationAdapter.js:22-26` and `:41-45`
488
+ - **Defect**: `distance` is written and read only inside `if (type === LightType.SPOT)`, but the
489
+ component uses `distance` for POINT as well (`Light.js:51`, `LightBinding.scaled_distance`,
490
+ `ThreeLightBinding.__apply_distance`, `FPLightBinding.#updateDistance`). The JSON adapter gets it right
491
+ — `Light.toJSON` writes `distance` for `SPOT || POINT` (verified) — which pins this as an oversight
492
+ rather than a schema decision. `maxShadowDistance` (written by `toJSON` for DIRECTION) is absent from
493
+ the binary format entirely.
494
+ - **Failure scenario**: A torch entity `Light{type: POINT, distance: 20}` is saved through the binary ECS
495
+ serializer. On load, `deserialize` never reads `distance`, so the component keeps
496
+ `DEFAULT_DISTANCE = 1`; `__apply_distance` sets the three `PointLight.distance = 1` (or the Forward+
497
+ `radius = 1`). The torch illuminates a 1-unit sphere and the room around it is black — while the exact
498
+ same scene loaded from a JSON save looks correct.
499
+ - **Fix**: Write/read `distance` in a `SPOT || POINT` branch mirroring `Light.toJSON`, add
500
+ `maxShadowDistance` for DIRECTION, and bump `version` from 0 since the byte layout changes.
501
+ - **Confidence**: Confirmed
502
+
503
+ ---
504
+
505
+ ### GFXR-23 `LightSystem.shutdown` never detaches the three light cache
506
+ - **Severity**: MAJOR
507
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/light/LightSystem.js:97` (attach, in the constructor) vs `:162-171` (shutdown)
508
+ - **Defect**: The constructor does `this.__three_light_cache.attach(this.scene)`. `shutdown()` releases
509
+ the Forward+ plugin and detaches the `visibilityConstructionEnded` handler, but never calls the
510
+ `ThreeLightCache.detach()` that exists for exactly this purpose. Every three light the cache ever
511
+ created — including pooled, currently-unused ones — stays parented to `graphics.scene`, which outlives
512
+ the `EntityManager`.
513
+ - **Failure scenario**: The host app keeps one `GraphicsEngine` across level loads and rebuilds the
514
+ `EntityManager` (a new `LightSystem` each time). After each cycle the previous cycle's
515
+ `DirectionalLight`/`PointLight`/`SpotLight`/`AmbientLight` objects remain in the scene.
516
+ `WebGLRenderer.projectObject` pushes every visible `isLight` object into `WebGLLights` regardless of
517
+ intensity, so `NUM_DIR_LIGHTS`/`NUM_POINT_LIGHTS`/`NUM_SPOT_LIGHTS` grow one generation per load: every
518
+ material's program cache key changes so **all** materials recompile on each load (multi-second hitch),
519
+ and after a handful of cycles the fragment uniform budget is exceeded and materials fail to link —
520
+ black scene.
521
+ - **Fix**: Call `this.__three_light_cache.detach()` in `shutdown()`, and move the `attach(this.scene)`
522
+ from the constructor into `startup()` so a stopped-then-restarted system still works.
523
+ - **Confidence**: Confirmed (leak); the recompile/black-scene escalation depends on the host recycling
524
+ the `EntityManager`
525
+
526
+ ---
527
+
528
+ ### GFXR-24 Shadow texel snapping uses a grid whose spacing changes every frame
529
+ - **Severity**: MINOR
530
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/ecs/light/shadow/setShadowCameraDimensionsDiscrete.js:28-36`
531
+ - **Defect**: `rX = dX / shadow_map_width` where `dX`/`dY` are the light-space extents of the *current*
532
+ frame's view-frustum AABB, recomputed every frame by `three_update_shadow_camera_extents`. Snapping a
533
+ moving origin to a grid whose spacing also moves does not produce a stable world-space texel lattice —
534
+ the step the algorithm depends on ("Fix the overall size of the frustum based on the longest diagonal
535
+ … Don't allow the shadow frustum to change size as the camera rotates", `LightSystem.js:201`) is
536
+ documented but never implemented. Secondary: for negative `x0`, JS `%` returns a negative remainder, so
537
+ `x0_d = x0 - (x0 % rX) - rX/2` can land up to half a texel *inside* `x0`, clipping coverage off the
538
+ left/bottom edge (the `(w+1)/w` slack is added only at the far edge).
539
+ - **Failure scenario**: Slowly orbit the camera over a static scene lit by one shadow-casting sun. The
540
+ shadow map re-quantises onto a different lattice each frame, so shadow boundaries crawl and shimmer
541
+ texel by texel — exactly the artifact the discretisation exists to remove. With the camera positioned
542
+ so the fit box straddles the origin, up to half a texel of shadow is also missing along one edge.
543
+ - **Fix**: Derive a frame-invariant extent from the view frustum's longest diagonal at the shadow
544
+ distance and use it for `dX`/`dY`, then snap with `Math.floor(x / rX) * rX` instead of `%`.
545
+ - **Confidence**: Likely
546
+
547
+ ---
548
+
549
+ ### GFXR-25 Forward+ vertex-only lighting reads `gl_Position` before `<project_vertex>` writes it
550
+ - **Severity**: MAJOR
551
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/materials/fp_build_vertex_lighting_shared.js:9-13, 26`
552
+ - **Defect**: The froxel-lookup preamble is injected immediately after `void main() {`:
553
+ ```js
554
+ result = insert_after(result, 'void main() {', accumulation_pars);
555
+ ```
556
+ and `accumulation_pars` begins with
557
+ `vec3 fp_normalized_screen_position = ((gl_Position.xyz / gl_Position.w)+vec3(1.0))*0.5;`.
558
+ In a three.js vertex shader `gl_Position` is assigned much later, by `#include <project_vertex>`.
559
+ This is an unconditional read of a vertex-stage built-in output before it is written.
560
+ - **Failure scenario**: A lit material compiled with `defines.LIGHTING_VERTEX_ONLY === true` (the branch
561
+ `MaterialTransformer.js:63` selects) computes `clip_v` from an undefined value, so
562
+ `v3_cluster_position` indexes a random froxel. Out-of-range `texelFetch` returns 0 →
563
+ `fp_cluster_metadata.y == 0` → the mesh receives **zero** clustered light and renders unlit; in-range,
564
+ it is lit by an unrelated part of the screen and flickers per vertex as the garbage changes.
565
+ - **Fix**: Inject after `#include <project_vertex>` instead, or compute the clip position locally
566
+ (`projectionMatrix * modelViewMatrix * vec4(position, 1.0)`) rather than reading `gl_Position`.
567
+ - **Confidence**: Confirmed (the read-before-write is unconditional; no in-repo material currently sets
568
+ `LIGHTING_VERTEX_ONLY`, so this is public-surface breakage rather than a live regression)
569
+
570
+ ---
571
+
572
+ ### GFXR-26 `LightManager.dispose()` frees GPU objects but leaves every light still subscribed to the dead manager
573
+ - **Severity**: MAJOR
574
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/LightManager.js:1137-1142`
575
+ - **Defect**: `dispose()` disposes four textures and nothing else. Three subscription families survive:
576
+ `LightRenderMetadata.link` (`LightRenderMetadata.js:52`) binds `this.update` to
577
+ `light.position.onChanged` / `light.radius.onChanged` (removed only by `removeLight` → `unlink()`);
578
+ `__handle_visible_light_added` (`:596-597`) binds `__handle_light_dimensions_change` /
579
+ `__handle_light_appearance_change`; `__handle_visible_decal_added` (`:538-546`) binds to
580
+ `patch_uv.position.onChanged` / `patch_uv.size.onChanged` and stores an un-released
581
+ `Reference<AtlasPatch>`. `__decal_atlas` is never disposed either.
582
+ - **Failure scenario**: `ForwardPlusRenderingPlugin.shutdown()` disposes the manager while a scene with
583
+ FP point lights and decals is still loaded (plugin released before entities unlink, or an engine
584
+ reconfiguration). Every live `PointLight.position` signal still holds a handler bound to the dead
585
+ manager, keeping it, its BVH, its cluster-plane buffers and all atlas patches reachable — a full leak
586
+ per plugin cycle. Worse, moving any of those lights afterwards still runs
587
+ `LightRenderMetadata.update()`, writing bounds into the abandoned BVH; re-acquiring the plugin creates
588
+ a second `LightManager` while the first keeps mutating.
589
+ - **Fix**: In `dispose()`, iterate `#metadata_map` calling `data.unlink()` and clear it; drain
590
+ `__visible_lights`/`__visible_decals` through the existing `__handle_visible_*_removed` teardown so the
591
+ handlers and atlas references are released; dispose `__decal_atlas`.
592
+ - **Confidence**: Likely
593
+
594
+ ---
595
+
596
+ ### GFXR-27 Forward+ `SpotLight` is accepted by `addLight` and then silently never rendered
597
+ - **Severity**: MAJOR
598
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/LightManager.js:771-796`
599
+ - **Defect**: The visibility dispatch branches on `light.isDecal` then `light.isPointLight` and drops
600
+ everything else on the floor. `render/forward_plus/model/SpotLight.js` implements everything `addLight`
601
+ requires (`getAABB` via `cone_compute_bounding_box`, `onDimensionChanged`, `toArray`), so
602
+ `addLight(spotLight)` succeeds and inserts a BVH leaf — it is simply never binned.
603
+ `encode_light_type` would have thrown on it and `spot_light_inside_volume` exists, but neither is
604
+ reached.
605
+ - **Failure scenario**: A consumer builds a `SpotLight` from `forward_plus/model/SpotLight.js` and adds
606
+ it to the manager expecting a cone of light. Nothing appears and nothing is logged — the light silently
607
+ contributes zero for the lifetime of the scene. Companion trap: `SpotLight.prototype.ENCODED_SLOT_COUNT`
608
+ is never assigned and `toArray` returns 13 (not a multiple of 4), so naively adding the branch makes
609
+ `address += undefined` produce `NaN` and trips the "Expected light data size" throw at `:878`.
610
+ - **Fix**: Either reject unsupported light types loudly in `addLight`, or complete the spot path —
611
+ `SpotLight.prototype.ENCODED_SLOT_COUNT = 16` (pad `toArray` to a 4-slot boundary), a type code in
612
+ `encode_light_type`, an `isSpotLight` branch here using `spot_light_inside_volume`, and the matching
613
+ branch in `query_bvh_frustum_from_texture`.
614
+ - **Confidence**: Likely
615
+
616
+ ---
617
+
618
+ ### GFXR-28 `decal_filtering_enabled` setter never re-uploads, and leaves a mipmap-incomplete texture
619
+ - **Severity**: MINOR
620
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/LightManager.js:359-367` (constructor at `:221-223`)
621
+ - **Defect**: The setter flips `t.generateMipmaps` and stops — `texture.version` is never bumped, so
622
+ three does not re-upload and the setting has no effect until an unrelated atlas repack. When that
623
+ repack eventually happens, `generateMipmaps === false` while `minFilter` is still
624
+ `LinearMipMapLinearFilter`; on WebGL2 three's `setTextureParameters` applies `LINEAR_MIPMAP_LINEAR`
625
+ verbatim and `uploadTexture` skips `generateMipmap`.
626
+ - **Failure scenario**: A game follows the method's own JSDoc ("Please set this to false if you have a
627
+ lot of overlapping decals") and sets `lightManager.decal_filtering_enabled = false`. Nothing changes on
628
+ screen. Later a new decal texture is registered, the atlas repacks and re-uploads — and every decal now
629
+ samples a mipmap-incomplete texture, rendering solid black instead of its diffuse.
630
+ - **Fix**: `t.generateMipmaps = v; t.minFilter = v ? LinearMipMapLinearFilter : LinearFilter;
631
+ t.needsUpdate = true;`
632
+ - **Confidence**: Confirmed (missing re-upload); Likely (black-decal consequence)
633
+
634
+ ---
635
+
636
+ ### GFXR-29 Debug froxel grid uses a 3-channel stride on the RGBA cluster texture and overruns each axis
637
+ - **Severity**: MINOR
638
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/debug/createScreenGrid.js:47-51` and `:272-281`
639
+ - **Defect**: `const texel_address = texel_index * 3; // RGB format` — the cluster texture is RGBA:
640
+ `LightManager.__assign_lights_to_clusters` writes at `tile_index * 4` (`LightManager.js:970`) and
641
+ `LightManager.spec.js:23` asserts a 4-element image for a 1×1×1 grid. Stride 3 aliases into the wrong
642
+ texel for every cell but index 0. The loops additionally run `i < resolution.N + 1`, so
643
+ `resolution.x - 1 - i_x` reaches `-1` and the z loop reads a slice past the end.
644
+ - **Failure scenario**: Open the debug screen grid on a 12×6×4 grid. Cell (3,2,1)'s reported light count
645
+ is read from a completely different froxel, so both the heatmap and the click-tooltip light list
646
+ describe the wrong cells — the tool actively misleads anyone debugging the binning.
647
+ - **Fix**: Use `texel_index * 4` / `index * 4` in both places and drop the `+ 1` from all three loop
648
+ bounds.
649
+ - **Confidence**: Confirmed
650
+
651
+ ---
652
+
653
+ ### GFXR-30 Cluster-volume debug widget clones the 3D cluster texture every frame and never disposes it
654
+ - **Severity**: MINOR
655
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/debug/buildLightClusterWidget.js:56-64`
656
+ - **Defect**: `const t = lm.getTextureClusters().clone(); t.needsUpdate = true; m.uniforms.map.value = t;`
657
+ — a fresh `DataTexture3D` per `update()`, with the previous clone dropped without `.dispose()`. Its GL
658
+ texture object stays in `WebGLTextures`' property map for the widget's renderer until context loss.
659
+ - **Failure scenario**: `update()` is driven from the render loop. At 60 fps with the default
660
+ 32×16×16 RGBA16UI volume (~64 KB each) that is ~4 MB/s of leaked GPU memory; a couple of minutes of
661
+ debugging and the tab hits out-of-memory / context-lost.
662
+ - **Fix**: Clone once outside `update()` (the clone shares `image`, so only `needsUpdate = true` is
663
+ needed per frame), or `m.uniforms.map.value?.dispose()` before assigning the new clone.
664
+ - **Confidence**: Confirmed
665
+
666
+ ---
667
+
668
+ ### GFXR-31 `sort_decal_data` uses a moving pivot and sorts on slots that hold colour, not priority
669
+ - **Severity**: MINOR
670
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/graphics/render/forward_plus/sort_decal_data.js:20-24, 65-76`
671
+ - **Defect**: Two independent errors. (a) The comparator reads `data[address + 20]` as `draw_priority`
672
+ and `+ 21` as `id`, but `Decal.toArray` (`model/Decal.js:116-121`) has both of those writes commented
673
+ out and `array_copy(this.color, 0, destination, address + 20, 4)` occupies slots 20-23 — so it sorts
674
+ decals by their red and green channels. (b) The quicksort pivot is an *index*, not a captured value;
675
+ `lookup[pivotIndex]` is itself swapped during partitioning, destroying the scan sentinel. The correct
676
+ sibling `core/collection/array/array_sort_quick.js` captures the pivot value before the loop.
677
+ - **Failure scenario**: Re-enable the call site the `TODO fix decal sorting artifacts` block at
678
+ `LightManager.js:995-1003` currently guards (or call this exported function directly). When the pivot
679
+ entry is swapped to a higher-priority value, `i` walks past `right`, `compare_decals` starts reading
680
+ `undefined`, and the subsequent `lookup[j] = lookup[i]` stores `0` into a neighbouring froxel's lookup
681
+ run — that froxel's decal slot then points at light-data texel 0 and renders the wrong decal. This is
682
+ precisely the artifact the TODO describes.
683
+ - **Fix**: Capture the pivot record (`const pivot_encoded = lookup[pivotIndex];`) and compare values, not
684
+ indices; and restore the `draw_priority`/`id` writes in `Decal.toArray` (widening
685
+ `ENCODED_SLOT_COUNT` past 24) or read priority from a slot that is actually written.
686
+ - **Confidence**: Likely
687
+
688
+ ---
689
+
690
+ ## Verified clean / non-issues checked along the way
691
+
692
+ - `GraphicsEngine.prepareRenderPass` writes `visibleGroup.length = j` where `children.length` was
693
+ meant — harmless: `clearVisibleGroup()` truncates `children` to 0 first, so `children.length`
694
+ already equals `j`. No failure scenario, not reported.
695
+ - `OutlineRenderer.writeParameters` → `HashMap.forEach` passes `(value, key)`, which matches
696
+ `__write_parameters_for_definition(id, definition)`. Correct.
697
+ - `OutlineRenderPlugin` subscribe/unsubscribe pairs match (`Vector2.process` adds to `onChanged` with
698
+ the same handler + context that `shutdown` removes).
699
+ - `AmbientOcclusionPostProcessEffect` startup/shutdown binding pairs all match; all four GPU resources
700
+ are disposed.
701
+ - TAA jitter indices (`elements[8]`/`[9]`) and magnitude match the pinned spec; the `=` (rather than
702
+ `+=`) is deliberate, since `CameraView.set_from_camera` writes the jittered matrix back onto the
703
+ camera and `CameraSystem`'s `visibilityConstructionStarted` hook rebuilds it each frame.
704
+ - `Water.updateBounds` BVH box agrees with the plane mesh's rotated/offset world extent.
705
+ - `FrameBufferManager.computeRenderOrder` splice-while-iterating is correctly compensated
706
+ (`i--; openSetCount--`), as is `WaterSystem.processUpdateQueue`.
707
+ - `GizmoRenderingPlugin` overrides `RenderLayer.buildVisibleSet` (whose prototype version throws
708
+ `'deprecated'`) — but `VisibilityComputer.__processLayer` still calls it, so gizmos do render.
709
+ - `RenderLayer.js:59`'s comment claims near is `planes[4]` and far is `planes[5]` (they are the other
710
+ way round), but it documents `computeNearFarClippingPlanes`, which unconditionally throws
711
+ `'deprecated'`. Stale doc only.
712
+ - Forward+ cluster index arithmetic, depth-slice distribution, plane orientation, the near/far swap and
713
+ X-flip in `computeFrustumCorners`, texel packing and the light-count overflow/recursion path are all
714
+ internally consistent and agree with `cluster_binning.spec.js` /
715
+ `light_culling_no_false_exclusion.spec.js`.
716
+ - `query_bvh_frustum_from_texture.js:132` masks the light address with `& 0xFFFFFFF8` where the encoding
717
+ (`type | address << 2`) calls for `& 0xFFFFFFFC`. Currently harmless — every record length in use
718
+ (`PointLight` 8, `Decal` 24) is a multiple of 8 — so no failure scenario exists today. Noted, not filed.
719
+ - `LightBinding` / `FPLightBinding` / `LightContext` link-unlink subscription pairs are symmetric;
720
+ `ThreeLightCache.release` keys off `light2type(threeObject)` rather than the already-changed component
721
+ type, so type-change rebuilds return the light to the correct pool.
722
+ - `__shadowmap_unlink` disposes both `shadow.map` and `shadow.mapPass`;
723
+ `three_set_shadow_resolution` disposes before resizing; `compute_view_frustum_aabb_in_space`'s plane
724
+ ordering and `near = -z1 / far = -z0` are correct.
725
+ - `ShadedGeometrySystem.link/unlink`, `SGMeshSystem.process`, `SGMeshHighlightSystem` and
726
+ `IncrementalDeltaSet` were checked in detail and are balanced/correct.
727
+ - `FPDecalSystem.Context` correctly handles the async texture-load race (`__is_linked` and
728
+ `this.__fp_decal === fpDecal` re-checks) — the only defect there is the known `subscribe`/`unsubscribe`
729
+ typo.
730
+ - `SpriteSystemPE`, `AnimationControllerSystem`, `RenderableHighlightSystem`,
731
+ `ShadedGeometryHighlightSystem`, `Trail3DSystem` and `GizmoRenderingPlugin` have matching
732
+ subscribe/unsubscribe pairs.
733
+
734
+ ## Coverage note
735
+
736
+ Not exhaustively reviewed for lack of time: `ecs/path/tube/build/*` and `ecs/path/ribbon/`
737
+ (heavily spec-covered geometry builders), `ecs/animation/animator/*` (graph/blend-space state machine,
738
+ little graphics-correctness surface), and the GLSL bodies of `shaders/TerrainShader.js` /
739
+ `shaders/AmbientOcclusionShader.js` / `render/buffer/simple-fx/ao/SAOShader.js` (the JS-side uniform
740
+ wiring for those *was* reviewed and is clean).