@woosh/meep-engine 3.60.4 → 3.60.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) hide show
  1. package/CHANGELOG.md +2621 -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 +11 -0
  63. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  64. package/src/shade/renderer/Renderer.js +50 -6
  65. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts +40 -80
  66. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts.map +1 -1
  67. package/src/shade/renderer/camera/orbital/OrbitalCameraController.js +197 -147
  68. package/src/shade/renderer/fluid/GPUFluidCascade.d.ts +3 -3
  69. package/src/shade/renderer/fluid/GPUFluidCascadeStack.d.ts +1 -1
  70. package/src/shade/renderer/fluid/forcing/GPUFluidForcing.d.ts +6 -6
  71. package/src/shade/renderer/fluid/pressure/GPUFluidPressureSolve.d.ts +1 -1
  72. package/src/shade/renderer/fluid/query/GPUFluidQueries.d.ts +1 -1
  73. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts +28 -45
  74. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts.map +1 -1
  75. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +111 -119
  76. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts +22 -3
  77. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts.map +1 -1
  78. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.js +84 -3
  79. package/src/shade/renderer/geometry/virtual/proxy/vgeo_proxy_triangle_limit.d.ts +19 -0
  80. package/src/shade/renderer/geometry/virtual/proxy/vgeo_proxy_triangle_limit.d.ts.map +1 -0
  81. package/src/shade/renderer/geometry/virtual/proxy/vgeo_proxy_triangle_limit.js +25 -0
  82. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts +31 -0
  83. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts.map +1 -0
  84. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.js +343 -0
  85. package/src/shade/renderer/geometry/virtual/runtime/VirtualGeometry.d.ts +15 -0
  86. package/src/shade/renderer/geometry/virtual/runtime/VirtualGeometry.d.ts.map +1 -1
  87. package/src/shade/renderer/geometry/virtual/runtime/VirtualGeometry.js +38 -0
  88. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts +17 -0
  89. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts.map +1 -0
  90. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.js +29 -0
  91. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.d.ts.map +1 -1
  92. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.js +7 -13
  93. package/src/shade/renderer/geometry/virtual/sdf/VGeoSDFProxies.d.ts +28 -59
  94. package/src/shade/renderer/geometry/virtual/sdf/VGeoSDFProxies.d.ts.map +1 -1
  95. package/src/shade/renderer/geometry/virtual/sdf/VGeoSDFProxies.js +84 -789
  96. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.d.ts.map +1 -1
  97. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.js +1 -8
  98. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts +7 -6
  99. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts.map +1 -1
  100. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.js +12 -11
  101. package/src/shade/renderer/global_illumination/probe_clipmap/brick/mesh/chunk_mesh_brick_instance.d.ts +2 -2
  102. package/src/shade/renderer/global_illumination/probe_clipmap/brick/mesh/chunk_mesh_brick_instance.js +2 -2
  103. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts +1 -4
  104. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts.map +1 -1
  105. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.js +1 -10
  106. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts +5 -15
  107. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts.map +1 -1
  108. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.js +6 -34
  109. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts.map +1 -1
  110. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.js +1 -7
  111. package/src/shade/renderer/hiz/HZBFootprint.d.ts +14 -0
  112. package/src/shade/renderer/hiz/HZBFootprint.d.ts.map +1 -0
  113. package/src/shade/renderer/hiz/HZBFootprint.js +16 -0
  114. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts +10 -0
  115. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts.map +1 -0
  116. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.js +11 -0
  117. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts +10 -0
  118. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts.map +1 -0
  119. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.js +11 -0
  120. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts +0 -7
  121. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  122. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +405 -350
  123. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts +12 -0
  124. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts.map +1 -0
  125. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.js +27 -0
  126. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts +12 -0
  127. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts.map +1 -0
  128. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.js +27 -0
  129. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts +14 -0
  130. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts.map +1 -0
  131. package/src/shade/renderer/hiz/hzb_footprint_texels.js +34 -0
  132. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts +0 -5
  133. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
  134. package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +37 -57
  135. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts +11 -0
  136. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts.map +1 -0
  137. package/src/shade/renderer/hiz/{shader_hzb_reduce_mip0_clip.js → shader_hzb_reduce_mip0.js} +120 -111
  138. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  139. package/src/shade/renderer/material/GPUMaterialManager.d.ts +11 -1
  140. package/src/shade/renderer/material/GPUMaterialManager.d.ts.map +1 -1
  141. package/src/shade/renderer/material/GPUMaterialManager.js +16 -8
  142. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts +12 -12
  143. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
  144. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +17 -118
  145. package/src/shade/renderer/postprocess/gtao/GTAO.d.ts +7 -3
  146. package/src/shade/renderer/postprocess/gtao/GTAO.d.ts.map +1 -1
  147. package/src/shade/renderer/postprocess/gtao/GTAO.js +26 -13
  148. package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.d.ts +14 -0
  149. package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.d.ts.map +1 -1
  150. package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.js +196 -68
  151. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.d.ts.map +1 -1
  152. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.js +42 -28
  153. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.d.ts.map +1 -1
  154. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.js +429 -414
  155. package/src/shade/renderer/postprocess/gtao/shader_gtao_spatial_denoise.d.ts +16 -0
  156. package/src/shade/renderer/postprocess/gtao/shader_gtao_spatial_denoise.d.ts.map +1 -0
  157. package/src/shade/renderer/postprocess/gtao/shader_gtao_spatial_denoise.js +156 -0
  158. package/src/shade/renderer/scene/GPUSceneContext.d.ts +2 -2
  159. package/src/shade/renderer/scene/GPUSceneContext.js +5 -5
  160. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts.map +1 -1
  161. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.js +7 -2
  162. package/src/shade/renderer/water/GPUWaterRenderer.d.ts +0 -6
  163. package/src/shade/renderer/water/GPUWaterRenderer.d.ts.map +1 -1
  164. package/src/shade/renderer/water/GPUWaterRenderer.js +12 -17
  165. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts +35 -0
  166. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts.map +1 -0
  167. package/src/shade/renderer/water/medium/graph_water_meshlets.js +89 -0
  168. package/src/shade/renderer/water/query/GPUWaterQueries.d.ts +1 -1
  169. package/src/shade/renderer/water/shader_water.d.ts.map +1 -1
  170. package/src/shade/renderer/water/shader_water.js +22 -13
  171. package/src/shade/renderer/water/shader_water_avboit.d.ts.map +1 -1
  172. package/src/shade/renderer/water/shader_water_avboit.js +7 -7
  173. package/src/shade/renderer/water/surface/graph_water_tessellation.d.ts +1 -1
  174. package/src/shade/renderer/water/surface/graph_water_tessellation.js +1 -1
  175. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +9 -1
  176. package/editor/REWORK_PLAN_2026_08.md +0 -692
  177. package/src/CODEC_LAYOUT_VERIFICATION.md +0 -419
  178. package/src/CORRECTNESS_HUNT_2026_07_24.md +0 -142
  179. package/src/DUPLICATION_AUDIT_2026_08_07/00_core_reuse_surface.md +0 -738
  180. package/src/DUPLICATION_AUDIT_2026_08_07/A_core_math_geom.md +0 -79
  181. package/src/DUPLICATION_AUDIT_2026_08_07/B_core_data.md +0 -93
  182. package/src/DUPLICATION_AUDIT_2026_08_07/C_gfx_render.md +0 -80
  183. package/src/DUPLICATION_AUDIT_2026_08_07/D_gfx_features.md +0 -164
  184. package/src/DUPLICATION_AUDIT_2026_08_07/E_physics.md +0 -69
  185. package/src/DUPLICATION_AUDIT_2026_08_07/F_sound.md +0 -110
  186. package/src/DUPLICATION_AUDIT_2026_08_07/G_net_save.md +0 -129
  187. package/src/DUPLICATION_AUDIT_2026_08_07/H_engine_misc.md +0 -72
  188. package/src/DUPLICATION_AUDIT_2026_08_07/I_generation_view.md +0 -74
  189. package/src/DUPLICATION_AUDIT_2026_08_07/K_crosscutting.md +0 -169
  190. package/src/DUPLICATION_AUDIT_2026_08_07.md +0 -254
  191. package/src/DUPLICATION_AUDIT_REMEDIATION.md +0 -250
  192. package/src/DUPLICATION_REVIEW_2026_10_24.md +0 -1072
  193. package/src/REVIEW_2026_08_06/adv-core.md +0 -511
  194. package/src/REVIEW_2026_08_06/adv-gfx.md +0 -480
  195. package/src/REVIEW_2026_08_06/adv-view.md +0 -446
  196. package/src/REVIEW_2026_08_06/api-core.md +0 -296
  197. package/src/REVIEW_2026_08_06/api-engine.md +0 -195
  198. package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +0 -507
  199. package/src/REVIEW_2026_08_06/core-binary.md +0 -348
  200. package/src/REVIEW_2026_08_06/core-collection.md +0 -376
  201. package/src/REVIEW_2026_08_06/core-math.md +0 -221
  202. package/src/REVIEW_2026_08_06/core-process.md +0 -266
  203. package/src/REVIEW_2026_08_06/core-science.md +0 -223
  204. package/src/REVIEW_2026_08_06/engine-ai-nav.md +0 -279
  205. package/src/REVIEW_2026_08_06/engine-animation.md +0 -167
  206. package/src/REVIEW_2026_08_06/engine-asset.md +0 -166
  207. package/src/REVIEW_2026_08_06/engine-ecs.md +0 -920
  208. package/src/REVIEW_2026_08_06/engine-input.md +0 -197
  209. package/src/REVIEW_2026_08_06/engine-misc.md +0 -248
  210. package/src/REVIEW_2026_08_06/engine-terrain.md +0 -52
  211. package/src/REVIEW_2026_08_06/generation.md +0 -176
  212. package/src/REVIEW_2026_08_06/geom-2d.md +0 -434
  213. package/src/REVIEW_2026_08_06/geom-3d.md +0 -117
  214. package/src/REVIEW_2026_08_06/gfx-gi.md +0 -143
  215. package/src/REVIEW_2026_08_06/gfx-render.md +0 -740
  216. package/src/REVIEW_2026_08_06/gfx-texture.md +0 -690
  217. package/src/REVIEW_2026_08_06/prior-criticals.md +0 -49
  218. package/src/REVIEW_2026_08_06/prior-highs.md +0 -138
  219. package/src/REVIEW_2026_08_06/test-gaps.md +0 -364
  220. package/src/REVIEW_2026_08_06/view.md +0 -667
  221. package/src/REVIEW_2026_08_06.md +0 -610
  222. package/src/core/binary/lz4/LZ4_HIGH_COMPRESSION_PLAN_2026_09_14.md +0 -542
  223. package/src/core/bvh2/bvh3/NOTES.md +0 -2
  224. package/src/core/bvh8/NOTES.md +0 -63
  225. package/src/core/cache/wtinylfu/benchmark/CacheWTinylfu.bench.md +0 -243
  226. package/src/core/color/COLOR_FIX_PLAN_2026_08_28.md +0 -417
  227. package/src/core/color/COLOR_REVIEW_2026_08_28.md +0 -779
  228. package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +0 -433
  229. package/src/core/geom/3d/atlas/ATLAS_PORT_PLAN.md +0 -486
  230. package/src/core/geom/3d/atlas/BFF_PLAN.md +0 -174
  231. package/src/core/geom/3d/atlas/REVIEW_02.md +0 -392
  232. package/src/core/geom/3d/gjk/GJK_REVIEW_NOTES.md +0 -146
  233. package/src/core/geom/3d/gjk/NOTES.md +0 -8
  234. package/src/core/geom/3d/topology/TOPO_MESH_RETIREMENT_PLAN.md +0 -135
  235. package/src/core/geom/GEOMETRY_REVIEW_002_API_ISSUES.md +0 -381
  236. package/src/core/geom/GEOMETRY_REVIEW_002_NAMING.md +0 -684
  237. package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +0 -554
  238. package/src/core/model/reactive/evaluation/PREDICATE_APPLICABILITY_PROPOSAL.md +0 -446
  239. package/src/engine/REVIEW_2026_07_03.md +0 -211
  240. package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +0 -815
  241. package/src/engine/control/first-person/DESIGN.md +0 -658
  242. package/src/engine/control/first-person/DESIGN_COLLISION.md +0 -382
  243. package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +0 -563
  244. package/src/engine/control/first-person/TODO.md +0 -160
  245. package/src/engine/ecs/ASYNC_SYSTEMS_PROPOSAL.md +0 -531
  246. package/src/engine/ecs/HIERARCHY_REVIEW.md +0 -717
  247. package/src/engine/ecs/async/WORKER_SYSTEM_PLAN.md +0 -262
  248. package/src/engine/ecs/ik/PLAN.md +0 -179
  249. package/src/engine/ecs/storage/binary/SERIALIZATION_COVERAGE.md +0 -272
  250. package/src/engine/ecs/storage/binary/object-v2/DESIGN.md +0 -199
  251. package/src/engine/ecs/transform/TRANSFORM64_MIGRATION_PLAN_2026_09_04.md +0 -521
  252. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +0 -463
  253. package/src/engine/graphics/ecs/camera/pp/NOTES.md +0 -48
  254. package/src/engine/graphics/render/frame_graph/CORRECTNESS_FIXES_2026_09_04.md +0 -659
  255. package/src/engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md +0 -235
  256. package/src/engine/graphics/sh3/path_tracer/REVIEW_001_ACTION_PLAN.md +0 -490
  257. package/src/engine/graphics/texture/sampler/compare/FLIP_IMPLEMENTATION_PLAN.md +0 -466
  258. package/src/engine/input/GAMEPAD_API_PROPOSAL.md +0 -717
  259. package/src/engine/input/ecs/ism/ISM_DESIGN.md +0 -1523
  260. package/src/engine/input/ecs/ism/ISM_IMPLEMENTATION_PLAN.md +0 -627
  261. package/src/engine/navigation/mesh/OVERLAPPING_SURFACES_2026_08_25.md +0 -661
  262. package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +0 -229
  263. package/src/engine/network/CONGESTION_CONTROL.md +0 -87
  264. package/src/engine/network/MESSAGE_TRANSPORT.md +0 -364
  265. package/src/engine/network/SMOOTHNESS_DESIGN.md +0 -400
  266. package/src/engine/physics/cloth/MEASUREMENTS.md +0 -5304
  267. package/src/engine/physics/cloth/PLAN.md +0 -3056
  268. package/src/engine/physics/fluid/GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md +0 -1382
  269. package/src/engine/physics/fluid/REVIEW_02_PLAN.md +0 -243
  270. package/src/engine/physics/fluid/solver/NOTES.md +0 -46
  271. package/src/engine/sound/SOUND_EMITTER_RETIREMENT_PLAN.md +0 -536
  272. package/src/engine/sound/simulation/IMPLEMENTATION_PLAN.md +0 -432
  273. package/src/engine/sound/simulation/PROBE_PLACEMENT.md +0 -250
  274. package/src/engine/sound/simulation/PROBE_TRANSFER_RESEARCH.md +0 -322
  275. package/src/engine/sound/simulation/REFLECTION_IR_PLAN.md +0 -261
  276. package/src/engine/sound/simulation/VISIBILITY_GRAPH_PLAN.md +0 -121
  277. package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +0 -999
  278. package/src/format/image/avif/DECISIONS.md +0 -545
  279. package/src/format/image/jpeg/NOTES.md +0 -3
  280. package/src/format/scene/gltf/MESHOPT_COMPRESSION_PLAN.md +0 -814
  281. package/src/generation/COORDINATES.md +0 -54
  282. package/src/generation/REVIEW_01_ACTION_PLAN.md +0 -628
  283. package/src/generation/foliage/ANNUAL_SHOOTS_BRIEF_2026_10_25.md +0 -220
  284. package/src/generation/foliage/CROWN_FILLING_BRIEF_2026_10_26.md +0 -172
  285. package/src/generation/foliage/CROWN_FULLNESS_BRIEF_2026_10_26.md +0 -86
  286. package/src/generation/foliage/FOLIAGE_DESIGN.md +0 -547
  287. package/src/generation/foliage/FOLIAGE_PLAN.md +0 -198
  288. package/src/generation/foliage/LEAF_ARCHITECTURE_BRIEF_2026_10_26.md +0 -158
  289. package/src/generation/foliage/LOD_REWORK_BRIEF_2026_10_27.md +0 -521
  290. package/src/generation/foliage/REAL_TIME_TREES_BRIEF_2026_10_27.md +0 -86
  291. package/src/generation/foliage/SELF_ORGANIZING_FRAMEWORK_BRIEF_2026_10_26.md +0 -347
  292. package/src/generation/foliage/SHOOT_SYSTEMS_ALL_SPECIES_BRIEF_2026_10_26.md +0 -248
  293. package/src/generation/foliage/SPEEDTREE_COMPARISON_2026_10_24.md +0 -694
  294. package/src/generation/foliage/TWO_FRONT_ENDS_BRIEF_2026_10_28.md +0 -552
  295. package/src/shade/RENDERER_CONTRACT.md +0 -697
  296. package/src/shade/SHADER_F16_PROPOSAL_2026_10_24.md +0 -263
  297. package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +0 -569
  298. package/src/shade/device/PASS_ENCODER_PROPOSAL_2026_08_31.md +0 -755
  299. package/src/shade/device/mock/EXTENSION_PROPOSAL_2026_08_22.md +0 -458
  300. package/src/shade/device/mock/IMPLEMENTATION_PLAN.md +0 -255
  301. package/src/shade/device/mock/REVIEW_2026_08_22.md +0 -527
  302. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +0 -894
  303. package/src/shade/device/timing/profile/SGPT_FORMAT.md +0 -345
  304. package/src/shade/renderer/NOTES.md +0 -78
  305. package/src/shade/renderer/PLAN.md +0 -8
  306. package/src/shade/renderer/animation/ADDITIVE_BLENDING_PLAN.md +0 -145
  307. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +0 -357
  308. package/src/shade/renderer/atmosphere/NOTES.md +0 -6
  309. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts +0 -76
  310. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts.map +0 -1
  311. package/src/shade/renderer/camera/orbital/MomentumAxis.js +0 -164
  312. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts +0 -45
  313. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts.map +0 -1
  314. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.js +0 -66
  315. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts +0 -98
  316. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts.map +0 -1
  317. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.js +0 -184
  318. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts +0 -175
  319. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts.map +0 -1
  320. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.js +0 -411
  321. package/src/shade/renderer/camera/orbital/README.md +0 -81
  322. package/src/shade/renderer/camera/orbital/index.d.ts +0 -6
  323. package/src/shade/renderer/camera/orbital/index.d.ts.map +0 -1
  324. package/src/shade/renderer/camera/orbital/index.js +0 -9
  325. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +0 -964
  326. package/src/shade/renderer/fluid/GPU_FLUID_OCCUPANCY_AND_SOTA_REVIEW_2026_10_26.md +0 -361
  327. package/src/shade/renderer/geometry/bvh/BLAS_REFIT_BATCH_PLAN.md +0 -619
  328. package/src/shade/renderer/geometry/meshlet/COMPACTION_PLAN_2026_09_16.md +0 -674
  329. package/src/shade/renderer/geometry/sdf/NOTES.md +0 -6
  330. package/src/shade/renderer/geometry/virtual/ATTRIBUTE_PLAN_2026_09_18.md +0 -540
  331. package/src/shade/renderer/geometry/virtual/MICRON_COMPARISON_2026_08_20.md +0 -305
  332. package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +0 -913
  333. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +0 -787
  334. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +0 -481
  335. package/src/shade/renderer/geometry/virtual/VK_LOD_CLUSTERS_COMPARISON_2026_08_20.md +0 -301
  336. package/src/shade/renderer/geometry/virtual/WEDGE_PLAN_2026_09_18.md +0 -448
  337. package/src/shade/renderer/geometry/virtual/runtime/CULL_PLAN_2026_09_16.md +0 -385
  338. package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +0 -656
  339. package/src/shade/renderer/geometry/virtual/runtime/REVIEW_LEDGER.md +0 -308
  340. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +0 -657
  341. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN_2026_09_14.md +0 -551
  342. package/src/shade/renderer/geometry/virtual/runtime/SHADOW_PLAN_2026_09_28.md +0 -529
  343. package/src/shade/renderer/geometry/virtual/sdf/VGEO_SDF.d.ts +0 -47
  344. package/src/shade/renderer/geometry/virtual/sdf/VGEO_SDF.d.ts.map +0 -1
  345. package/src/shade/renderer/geometry/virtual/sdf/VGEO_SDF.js +0 -80
  346. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_collect.d.ts +0 -16
  347. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_collect.d.ts.map +0 -1
  348. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_collect.js +0 -67
  349. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_decode.d.ts +0 -17
  350. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_decode.d.ts.map +0 -1
  351. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_decode.js +0 -145
  352. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_traverse.d.ts +0 -12
  353. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_traverse.d.ts.map +0 -1
  354. package/src/shade/renderer/geometry/virtual/sdf/shader_vgeo_sdf_traverse.js +0 -105
  355. package/src/shade/renderer/global_illumination/lpv/NOTES.md +0 -3
  356. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_CLIPMAP_PROPOSAL_2026_10_27.md +0 -4306
  357. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md +0 -410
  358. package/src/shade/renderer/global_illumination/sharc/NOTES.md +0 -8
  359. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +0 -1369
  360. package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +0 -202
  361. package/src/shade/renderer/gpu_primitive/indirect/NOTES.md +0 -94
  362. package/src/shade/renderer/hiz/NOTES.md +0 -3
  363. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts +0 -8
  364. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +0 -1
  365. package/src/shade/renderer/light/cluster/NOTES.md +0 -11
  366. package/src/shade/renderer/light/cluster/PROPOSAL_ALGORITHM_2026_10_16.md +0 -524
  367. package/src/shade/renderer/light/cluster/PROPOSAL_HIERARCHICAL_2026_10_16.md +0 -1101
  368. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts +0 -24
  369. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts.map +0 -1
  370. package/src/shade/renderer/material/resident/shader_resident_texture_means.js +0 -120
  371. package/src/shade/renderer/particles/DESIGN.md +0 -1011
  372. package/src/shade/renderer/particles/EMITTER_GLOBAL_STATE_PLAN_2026_10_29.md +0 -280
  373. package/src/shade/renderer/particles/ORIENTATION_PLAN_2026_10_15.md +0 -453
  374. package/src/shade/renderer/path_tracer/PATH_TRACED_MEDIA_PLAN_2026_10_02.md +0 -520
  375. package/src/shade/renderer/postprocess/bloom/NOTES.md +0 -6
  376. package/src/shade/renderer/postprocess/fsr/NOTES.md +0 -4
  377. package/src/shade/renderer/postprocess/gtao/NOTES.md +0 -6
  378. package/src/shade/renderer/postprocess/nss/NOTES.md +0 -332
  379. package/src/shade/renderer/postprocess/perspective/NOTES.md +0 -3
  380. package/src/shade/renderer/postprocess/sscs/NOTES.md +0 -5
  381. package/src/shade/renderer/postprocess/ssr/NOTES.md +0 -14
  382. package/src/shade/renderer/postprocess/ssr/REPROJECTION_PROPOSAL.md +0 -286
  383. package/src/shade/renderer/postprocess/ssr/prefilter/NOTES.md +0 -39
  384. package/src/shade/renderer/postprocess/taa/NOTES.md +0 -11
  385. package/src/shade/renderer/postprocess/upscale-dither/NOTES.md +0 -78
  386. package/src/shade/renderer/postprocess/vrs/NOTES.md +0 -4
  387. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_LEDGER.md +0 -1218
  388. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_PLAN_2026_09_03.md +0 -600
  389. package/src/shade/renderer/restir/reservoir/NOTES.md +0 -6
  390. package/src/shade/renderer/shader/chunk/atmosphere/NOTES.md +0 -6
  391. package/src/shade/renderer/shader/chunk/brdf/FRESNEL_BRIEF_2026_10_16.md +0 -391
  392. package/src/shade/renderer/shader/chunk/color/tonemap/ACES_TONE_MAPPING_BRIEF_2026_10_17.md +0 -153
  393. package/src/shade/renderer/shader/chunk/color/tonemap/HDR_TONE_MAPPING_FORMS_BRIEF_2026_10_16.md +0 -322
  394. package/src/shade/renderer/shader/chunk/color/tonemap/SDR_TONE_MAPPING_BRIEF_2026_10_16.md +0 -247
  395. package/src/shade/renderer/shader/chunk/fsr/NOTES.md +0 -3
  396. package/src/shade/renderer/shader/chunk/fsr/spd/NOTES.md +0 -4
  397. package/src/shade/renderer/shader/chunk/geometry/ray/NOTES.md +0 -4
  398. package/src/shade/renderer/shader/chunk/material/MULTISCATTER.md +0 -159
  399. package/src/shade/renderer/shader/chunk/mboit/NOTES.md +0 -38
  400. package/src/shade/renderer/shader/chunk/spherical_harmonics/l1i/NOTES.md +0 -3
  401. package/src/shade/renderer/shader/chunk/spherical_harmonics/sampling/NOTES.md +0 -8
  402. package/src/shade/renderer/shader/compiler/SHADER_SWITCH_PROPOSAL_2026_10_16.md +0 -927
  403. package/src/shade/renderer/shadow/contact/FINDINGS.md +0 -413
  404. package/src/shade/renderer/shadow/map/csm/NOTES.md +0 -5
  405. package/src/shade/renderer/shadow/ray/NOTES.md +0 -31
  406. package/src/shade/renderer/shadow/sdf/NOTES.md +0 -9
  407. package/src/shade/renderer/shadow/sdf/brixelizer/NOTES.md +0 -5
  408. package/src/shade/renderer/shadow/sdf/cascade/NOTES.md +0 -9
  409. package/src/shade/renderer/shadow/vsm/NOTES.md +0 -12
  410. package/src/shade/renderer/shadow/vsm/PLAN.md +0 -442
  411. package/src/shade/renderer/shadow/vsm/REVIEW_LEDGER.md +0 -43
  412. package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +0 -2444
  413. package/src/shade/renderer/texture/mipmaps/NOTES.md +0 -3
  414. package/src/shade/renderer/volumetrics/NOTES.md +0 -1329
  415. package/src/shade/renderer/volumetrics/VOLUMETRIC_NOISE_PLAN_2026_10_15.md +0 -336
  416. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts +0 -23
  417. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts.map +0 -1
  418. package/src/shade/renderer/water/medium/GPUWaterBodies.js +0 -75
  419. package/src/shade/wgsl/NOTES.md +0 -4
@@ -1,1369 +0,0 @@
1
- # GPU BVH Builder — research findings and implementation plan
2
-
3
- > Status: **PHASE 1 LANDED (device-free tiers)** — 2026-08-26. The CSDLDF key-value radix sort and
4
- > the GPU TLAS build are in the tree, the GPU path is the default, and the three device-free
5
- > validation tiers are green (`npm test`: 13,590 passing, nothing skipped that was not already).
6
- > **Phase 1 is complete and gated.** The harnesses are green, and the consumer A/B has been run
7
- > through `src/shade/playground/sponza_path_trace/`: Sponza traced against a CPU-built and a
8
- > GPU-built tree, images verified equivalent by inspection. §4.6 carries the numbers.
9
- >
10
- > The A/B produced one result that changes what phase 2 is for, and §5.1 has been rewritten around
11
- > it: **at instance scale the GPU LBVH already beats the CPU pipeline on every axis** — SAH, depth,
12
- > traced frame time and build time — so phase 2's stated premise, that phase 1 ships a quality
13
- > regression, does not survive measurement. Deviations from this plan are recorded in place below,
14
- > each marked **[deviation]**.
15
- >
16
- > Landed as: `db960d9cb` (sort), `31599c3b9` (morton + emission chunks), `61727d3b8` (builder
17
- > shaders + recorder), `6b062dbcc` (TLAS integration), `7222f1b35` (format spec + verify harness).
18
- >
19
- > Original status: PLANNED — research complete, no code landed. 2026-08-26.
20
- > Revised same day after review: the sort is a deliverable built around **CSDLDF** (the
21
- > fallback-less OneSweep port was removed in `b6049214c`; history serves as reference);
22
- > **subgroups are a baseline engine requirement** and are used wherever they pay; validation
23
- > explicitly stands on all four device-free/on-device tiers, `SoftwareGPUDevice` and the WGSL
24
- > emulator included.
25
- >
26
- > Goal: move BVH construction to the GPU. First deliverable replaces the CPU-side build that
27
- > `GPUInstancesAccelerationStructure` (the TLAS) performs today; then treelet optimisation on the
28
- > GPU; then the geometry side (BLAS); and only after all of that, BVH8. Every phase leaves the
29
- > existing RT and culling consumers fully working.
30
- >
31
- > References studied: Microsoft's D3D12 Raytracing Fallback Layer and AMD's GPURT, plus the
32
- > construction literature (Karras 2012, Karras & Aila 2013, Apetrei 2014, PLOC/PLOC++/H-PLOC).
33
- > Both references and the literature survey are summarised in §3; meep's own inventory — which
34
- > turns out to already contain most of the algorithm — is §2.
35
-
36
- ---
37
-
38
- ## 1. Why, and why now
39
-
40
- `GPUInstancesAccelerationStructure.push_to_gpu()` currently does, on the CPU, on **every frame in
41
- which any instance moved**:
42
-
43
- 1. `ebvh_optimize_treelet(tree)` — a whole-tree treelet restructuring pass (Karras & Aila 2013,
44
- treelet size 7, exhaustive 2^7-subset SAH DP per treelet), mutating the dynamic tree;
45
- 2. `bvh_to_gpu_buffer(...)` — flatten `node_capacity` nodes from the 40-byte CPU format to the
46
- 32-byte GPU format into a fresh mapped staging buffer;
47
- 3. a full-buffer `copyBufferToBuffer` upload.
48
-
49
- This is a measured, known frame-budget problem. `TerrainSystem.spec.js` documents the incident
50
- (E19): *"the TLAS rebuilds — a whole-tree treelet optimisation and a fresh GPU buffer — whenever
51
- any instance it holds is told its bounds changed. A terrain standing still was doing all of that
52
- once a frame, which was a quarter of the frame."* The fix that spec pins (don't bump versions for
53
- unmoved nodes) treats the trigger; this project treats the cost. In any scene with continuous
54
- motion — animation, physics, particles-as-instances — the rebuild fires every frame by design, and
55
- the CPU pays O(N) treelet DP plus an O(capacity) upload each time.
56
-
57
- There is a second, structural motivation: **the CPU cannot see GPU-authority transforms.** Rows
58
- under `TransformAuthority.GPU` (GPU-side animation / hierarchy propagation) move on the GPU;
59
- `shader_refresh_all_mesh_bounds` recomputes their world bounds into the scene database — and the
60
- CPU-built TLAS never learns about any of it. A GPU-side build that reads instance bounds from the
61
- scene database gets correct bounds for both authorities from a single source of truth. The GPU
62
- build is not just faster; for GPU-animated content it is the only correct option.
63
-
64
- Context worth stating: the survey (§3.3) found that essentially every public WebGPU ray tracer
65
- still builds its BVH on the CPU and only traverses on the GPU — published CPU build times run to
66
- hundreds of milliseconds per million triangles, while driver-class GPU builders do the same work
67
- in low single-digit milliseconds. A WGSL GPU builder is near-unoccupied territory; meep has an
68
- unusually short path to it because the algorithms and the substrate primitives are already in the
69
- tree (§2).
70
-
71
- ---
72
-
73
- ## 2. Research: what meep already has
74
-
75
- The striking finding of this research: **meep already contains a complete, tested CPU
76
- implementation of exactly the algorithm family that both external references run on the GPU**, plus
77
- almost every GPU primitive the port needs. The builder is a port, not an invention.
78
-
79
- ### 2.1 The wire format — the compatibility contract
80
-
81
- Everything below is what "existing RT code stays compatible" means, verified against every consumer
82
- in the tree.
83
-
84
- **Node** — `src/shade/renderer/shader/chunk/bvh/BVH2_NODE_STRUCT.js`, 32 bytes, align 4:
85
-
86
- | bytes | field | notes |
87
- |---|---|---|
88
- | 0–23 | `bounds: array<f32, 6>` | `[min.x, min.y, min.z, max.x, max.y, max.z]` — block layout, not interleaved (`chunk_aabb3_intersects_ray` reads `bounds[0..2]` / `bounds[3..5]`) |
89
- | 24–27 | `child_1: u32` | `== 0xFFFFFFFF` (`BVH_NULL_NODE`) ⇔ **leaf** |
90
- | 28–31 | `child_2: u32` | internal: right-child node index; leaf: **payload** (user data) |
91
-
92
- There are no tag bits, no counts, no ranges. One primitive per leaf. Internal nodes always have two
93
- valid children — and the distance-ordered point queries (`chunk_point_query_blas_nearest`,
94
- `chunk_scene_point_query_nearest`) dereference **both** children's bounds before descending, so
95
- both must be real node indices with meaningful AABBs.
96
-
97
- **Buffer** — TLAS and the LPV tetrahedral lookup use `BVH_STRUCT`
98
- (`{root: u32, nodes: array<BVH2_NODE_STRUCT>}`): a `u32` root index at byte 0, nodes from byte 4.
99
- That is the entire meaning of the `+ 4` in every sizing expression. **BLAS has no header**: root is
100
- implicitly node 0 (asserted twice in `meshlet_batch_to_bvh.js`, relied on by
101
- `chunk_ray_query_blas_*` and by `shader_build_sdf.js`, which reads `blas_data[blas_address].bounds`
102
- as the geometry's bounds). BLAS trees live packed in one arena
103
- (`GPUGeometryBVHManager`); `blas_lookup[geometry] = address` is a **node index, not a byte
104
- offset**.
105
-
106
- **Leaf payloads**: TLAS leaf `child_2` = row in the scene database `meshes` table, consumed via
107
- `scene_read_mesh`. BLAS leaf `child_2` = `(meshlet_index << 8) | (triangle_index & 0xFF)` — ≤256
108
- triangles per meshlet, and this encoding is **serialized to disk** with geometry
109
- (`MeshletGeometrySerializationAdapter`), so it is a content-format contract, not just a runtime
110
- one.
111
-
112
- **Traversal** (`chunk_ray_query_nearest`, `chunk_ray_query_occluded`, BLAS variants, LPV lookup):
113
- stack-based, `BVH_TRAVERSAL_STACK_SIZE = 32`, descends `child_1`, pushes `child_2`, no ordered
114
- descent in the ray paths. Two facts with teeth:
115
-
116
- - The 32-entry stack is **shared between TLAS and BLAS** in one query — effective combined depth
117
- budget ≈ 31. The occlusion variant's `(pointer & 31) != 0` loop condition silently *truncates*
118
- traversal at stack overflow rather than erroring. Tree depth is therefore a real output-quality
119
- constraint on the builder, not a nicety (§7, R4).
120
- - `tlas.root` is read from the header word — the builder must write it (it will be constant 0, see
121
- §4.3, but it must be written).
122
-
123
- **Who consumes what** (all verified):
124
-
125
- - GPU, TLAS: path tracer (`graph_path_trace_scene`, `AccumulatingPathTracer`, `graph_restir_di`),
126
- lightmap baker (`GPULightMap`), LPV/probes (`graph_lpv_shade`, `GPUProbeAtlas`,
127
- `GPULightProbeVolumeRenderer`, `graph_generate_probe_locations`), brick4 GI bake, RTX shadows
128
- (`RTXShadows`, `graph_trace_rays`), SDF passes (`CascadedSceneSDF`, `graph_draw_sdf_shadows`,
129
- `shader_sdf_visualize`). All grab `scene_ctx.tlas.buffer` **at pass-execute time** as an external
130
- buffer — none of them track it as a frame-graph resource. A build that writes the same
131
- (or a replacement) buffer earlier in queue order is invisible to them.
132
- - CPU: `DirectionalLightShadowmap` runs `compute_tight_near_far_clipping_planes` against
133
- **`scene_ctx.tlas.bvh` — the CPU-side tree object** — per cascade per frame. The CPU dynamic tree
134
- must therefore stay alive regardless of where the GPU buffer comes from.
135
- - Culling: **none**. `graph_mesh_frustum_cull`, HZB filtering, and bucket classification are flat
136
- sweeps over the `meshes` table; `grep bvh src/shade/renderer/rasterize` is empty. "Culling stays
137
- compatible" is satisfied vacuously — and equally, no raster-side test will catch a TLAS
138
- regression; RT consumers are the only canaries.
139
-
140
- ### 2.2 CPU algorithms already in the tree (the semantic reference implementations)
141
-
142
- `src/core/bvh2/bvh3/`:
143
-
144
- - **`ebvh_build_hierarchy_radix.js` — Karras 2012 binary radix tree, complete and spec-tested.**
145
- `delta()` with the index tiebreak for duplicate morton codes
146
- (`ci === cj ? 32 + clz32(i ^ j) : clz32(ci ^ cj)`), `determineRange` (exponential probe + binary
147
- descent), `find_split` (binary search). The in-source comment pins a real trap: do **not**
148
- midpoint-split duplicate-code runs — it desynchronises from `determineRange` and produces a
149
- shared-node non-tree. This file is, line for line, the hierarchy-emission kernel of the GPU
150
- build; the GPU version is a transliteration with an index-layout change (§4.3).
151
- - **`ebvh_optimize_treelet.js` — Karras & Aila 2013 treelet restructuring**, treelet size 7,
152
- the exhaustive subset-DP with the paper's constants (`C_int = 1.2`, `C_prim = 1.0`), greedy
153
- largest-surface-area treelet formation. This is the same algorithm — same constants — that the
154
- DXR Fallback runs as its GPU `TreeletReorder` pass. Phase 2 is a port of semantics that already
155
- have a CPU oracle and a `.spec.js`.
156
- - `build_triangle_morton_codes.js` + `v3_morton_encode` (magic-bits): 30-bit morton, 10 bits/axis,
157
- `x | y<<1 | z<<2`. The WGSL side already has the matching expander:
158
- `shader/chunk/binary/chunk_split_by_3.js`.
159
- - `ebvh_update_hierarchy_bounds.js` / `refit_radix_tree` (inside the radix builder): sequential
160
- forms of the bottom-up refit.
161
- - `BVH.js`: 10-word AoS nodes (bounds ×6, parent, child_1, child_2/user_data, height),
162
- free-list allocation, `allocate_linear` bulk path, SAH-guided `insert_leaf` with
163
- `balance_rotate` (Kensler/Box2D-style SAH rotations) — the **dynamic** tree that
164
- `GPUInstancesAccelerationStructure` maintains incrementally and that CPU queries walk. It stays.
165
-
166
- The CPU→GPU flattening (`bvh2_compress.js` + `bvh_to_gpu_buffer.js`) is a pure 40→32-byte gather
167
- in slot order — CPU node index i **is** GPU node index i, free-pool holes are uploaded as garbage
168
- and never referenced, and the buffer is sized by `node_capacity`, not live count. Nothing anywhere
169
- depends on any particular node *ordering* — only on the format and the root convention. The GPU
170
- builder is free to choose its own layout (§4.3).
171
-
172
- ### 2.3 GPU compute infrastructure already in the tree
173
-
174
- - **Frame graph** (`engine/graphics/render/frame_graph/FrameGraph.js` + shade's
175
- `graph_*` recorder convention): one-shot record → `compile()` → `execute()`; passes run in
176
- insertion order; each `ComputeShader.dispatch` opens its own compute pass, so WebGPU's implicit
177
- inter-dispatch barriers do the synchronisation. `ShadeGPUCommandContext.create(graphics)` +
178
- `cmd.encodeGraph(graph)` + `cmd.finish()` is the standalone idiom
179
- (`test_radix_sort.js` is an 8-line template). Transient uniforms via
180
- `encoder.allocateTransientValueBuffer(STRUCT, value)`; pooled transient buffers via
181
- `BufferResourceDescriptor` (fresh buffers arrive zeroed; reused pool buffers honour the
182
- descriptor's clear range).
183
- - **Sort: none (deliberately).** Commit `b6049214c` ("deleted partial and dead code") removed all
184
- four sort variants — the OneSweep port included, because it had **no fallback path** (bare
185
- decoupled lookback assumes forward progress). The engine's sorting direction is now: **build
186
- around CSDLDF** — chained scan, decoupled lookback, *decoupled fallback*. The key-value radix
187
- sort is therefore a deliverable of this project (§4.2.1), not a reuse. The deleted OneSweep port
188
- remains reference material in history
189
- (`git show 370542c3f:src/shade/renderer/gpu_primitive/sort/one_sweep/...`) — in particular its
190
- digit-binning workgroup machinery and its two hard-won WebGPU lessons (the `subgroup_size`
191
- private-var propagation fix; manual unrolling for Tint's uniformity analysis).
192
- - **Prefix scan — the surviving, blessed pattern**
193
- (`gpu_primitive/prefix_sum/v1/graph_prefix_scan_csdldf.js` + `shader_prefix_scan_csdldf.js`,
194
- from the b0nes164 Decoupled-Fallback paper artifact): subgroup-first (shuffle/ballot,
195
- `subgroup_size` builtin, `MIN_SUBGROUP_SIZE = 4` handling, the
196
- `@diagnostic(off, subgroup_uniformity)` idiom), per-partition **split flag words** (two
197
- `atomic<u32>`s carrying READY/INCLUSIVE flags plus 16-bit value halves), bounded spin
198
- (`MAX_SPIN_COUNT = 4`, `LOCKED/UNLOCKED`) and then **fallback: the blocked workgroup reduces the
199
- missing predecessor tile itself** — progress guaranteed with no scheduling-fairness assumption.
200
- Inclusive-only, `{count: u32, elements: array<vec4u>}` layout (16-byte aligned), partition size
201
- 4096, indirect-dispatched. This shader is the in-repo template the sort's spine and protocol are
202
- built from.
203
- - **Indirect dispatch** (`gpu_primitive/indirect/IndirectComputeCommandBuildPass.js`):
204
- count-buffer → `STRUCT_DISPATCH_COMPUTE_COMMAND`, with `graph_build_indirect_dispatch_command`
205
- and `ComputeShader.dispatchIndirect`. Available; phase 1 does not need it (the instance count is
206
- CPU-known at encode time), the BLAS phase does.
207
- - **Atomic f32 AABB merge** (`shader/chunk/atomic/chunk_atomic_min_f32_storage.js` /
208
- `chunk_atomic_max_f32_storage.js`):
209
- CAS-spin `atomic_min_f32_storage` / `atomic_max_f32_storage` over `atomic<u32>` cells holding plain
210
- f32 bit patterns, plus `shader_meshlet_bounds_reduce.js` as the proven
211
- reduce-then-atomic-merge pattern — this is the scene-AABB pass, essentially verbatim, with one
212
- upgrade: that shader predates the subgroups baseline and reduces through workgroup memory only
213
- (its own comment concedes subgroup ops would be faster); the builder's variant puts
214
- `subgroupMin/Max` in front, workgroup memory only across subgroup boundaries. The same file
215
- also demonstrates the "same buffer bound as `array<atomic<u32>>` in one pass and as typed
216
- structs in another" idiom the refit pass uses.
217
- - **Scene database** (`GPUSceneContext` / `SCENE_DATABASE_SPEC`): the `meshes` table row already
218
- carries `bounding_box: array<f32,6>` (world-space) and is refreshed GPU-side by
219
- `shader_refresh_all_mesh_bounds` for GPU-authority content — the build's input, already resident.
220
- - **Shader authoring**: `CodeChunk` dependency DAG, `ShaderResourceSetDescriptor`,
221
- `WebGPUStruct/Array` with correct WGSL layout math, `ComputeShader.from({...}).dispatch(...)`.
222
- - **Validation tiers** (the standing rule: a vitest spec may not stand up a real GPU device):
223
- (a) tree-sitter WGSL parse + `validate_wgsl_source` (`src/shade/wgsl/`) in vitest;
224
- (b) **`ComputeShaderEmulator`** (`src/shade/wgsl/emulator/`) — compiles WGSL to JS and executes
225
- it point-wise; not a full implementation (no subgroups, no real barrier scheduling), but exactly
226
- right for executing *specific functions and chunks* against CPU oracles, and whole kernels when
227
- they are pure per-invocation functions;
228
- (c) **`SoftwareGPUDevice`** (`src/shade/device/mock/`) — a recording software `GPUDevice`
229
- (encoders, bind groups, buffers, mapping, timestamps, validation) that runs **none** of the
230
- shaders: dispatches land on `SoftwareGPUComputePassEncoder.dispatches` for assertion. The tier
231
- for host-side orchestration: encode the real frame graph against it and assert the dispatch
232
- sequence, bindings, buffer sizes/usages, and host-written bytes;
233
- (d) browser playground verify functions (FrameGraph + `graph_import_typed_buffer` +
234
- `graph_inspect_gpu_buffer` readback + CPU assertions, registered as lil-gui "review checks");
235
- `gpu_test.js` offers a standalone device bootstrap. GPU timing via `encoder.enable_profiling`
236
- with a `GPUFrameRecorder` (note: the `timer` argument `graph_compute_pass` forwards is silently
237
- dropped by `ComputeShader.dispatch` — use the profiler).
238
- - **Morton chunks** (`shader/chunk/bvh/morton/`): the fixed 10-bit `chunk_v3_morton_encode`,
239
- spec'd bit-exact against `v3_morton_encode_magicbits`, is what the build uses (§4.2) for exact
240
- CPU parity, alongside the 2D encode/decode pairs. A WGSL port of the DXR Fallback's
241
- *adaptive-bits* `CalculateMortonCodes.hlsli` also lived here (the repo has been absorbing
242
- reference #1 for a while) — `chunk_calculate_morton_code` plus its
243
- `chunk_get_morton_codes_from_unit_coord` dependency, which never passed WGSL validation
244
- (`if (bit)` on a `u32`; an f32/u32 `min` mix upstream). Both were unused and are now deleted (§8).
245
-
246
- ### 2.4 What does NOT exist yet (the actual gaps)
247
-
248
- 1. A **key-value radix sort** built around CSDLDF (§4.2.1) — the largest single new piece since
249
- commit `b6049214c` removed the fallback-less OneSweep port. A first-class `gpu_primitive/sort`
250
- deliverable in its own right, with the builder as its first consumer.
251
- 2. A WGSL **3D morton encode** chunk composing `chunk_split_by_3` (2D variants exist; 3D does not).
252
- 3. A **scene/leaf AABB reduction** pass (pattern exists in `shader_meshlet_bounds_reduce`; the
253
- general primitive does not).
254
- 4. The **hierarchy emission** kernel (CPU version exists; WGSL does not).
255
- 5. The **bottom-up refit** kernel (no GPU refit exists anywhere in the tree).
256
- 6. Orchestration: a `graph_bvh_build_*` recorder and the `GPUInstancesAccelerationStructure`
257
- integration, including a live-instance-rows input (§4.1).
258
- 7. The **treelet optimisation** kernel (phase 2; CPU version exists).
259
- 8. A BVH2→BVH8 **collapse** pass (phase 4; traversal-side `BVH8_INODE_STRUCT` +
260
- `chunk_bvh8_node_intersect` + `chunk_bvh8_ray_query_blas_occlusion` already exist as an
261
- unwired experiment).
262
-
263
- Scan (with the fallback discipline), reduction idiom, atomic-f32 min/max, indirect dispatch,
264
- struct/layout codegen, graph plumbing, readback, four validation tiers, CPU oracles: all exist.
265
- Phase 1's slice is the sort plus five small builder shaders (items 2–5 and an init pass) and one
266
- integration; items 7–8 belong to later phases.
267
-
268
- ---
269
-
270
- ## 3. Research: the references
271
-
272
- ### 3.1 Microsoft D3D12 Raytracing Fallback Layer (`D3D12RaytracingFallback/src`)
273
-
274
- The most directly relevant reference — same output shape (binary BVH, 32-byte nodes, software
275
- stack traversal with `TRAVERSAL_MAX_STACK_DEPTH 32`), and, remarkably, the same two core
276
- algorithms meep already has on CPU. ~5,400 LOC total for the GPU build path (~26 compute PSOs);
277
- the meep port is far smaller because sort, ingestion, and orchestration already exist.
278
-
279
- Pipeline (`GpuBvh2Builder::BuildBVH`), identical skeleton for TLAS and BLAS:
280
-
281
- 1. **Load elements** — per-geometry dispatches decoding VB/IB into a flat `Primitive[]` scratch
282
- (BLAS), or one dispatch loading instance descs, dereferencing each BLAS root box, transforming
283
- its 8 corners to world space, and pre-inverting the transform (TLAS).
284
- 2. **Scene AABB** — iterative 8:1 ping-pong reduction, O(log₈ n) dispatches.
285
- 3. **Morton codes** — 30-bit, 10 bits/axis, centroid quantised into the scene AABB (an
286
- adaptive-bits `SCALED_MORTON_CODES` variant exists but is dead code — meep carried an unused
287
- WGSL transcription of that same variant until §8 deleted it).
288
- 4. **Sort** — MiniEngine **bitonic** sort (indirect-dispatched), key=morton, payload=index. Not
289
- radix — O(n log²n); the CSDLDF radix sort this plan builds (§4.2.1) is strictly better here.
290
- 5. **Rearrange** — gather elements into sorted order (so leaves need no indirection), optionally
291
- persisting the inverse permutation in the AS for later refit-only updates.
292
- 6. **Hierarchy emission** — **Karras 2012**, cited by name in `BuildBVHSplits.hlsli`; one thread
293
- per internal node; duplicate keys broken by index (same tiebreak as meep's CPU builder). Node
294
- layout: internal nodes `[0, n-2]` (root = 0), leaves `[n-1, 2n-2]`. Children/parents written to
295
- a separate 12-byte `HierarchyNode` scratch, not the final format.
296
- 7. **Treelet reordering** (BLAS only, 0/1/3 passes by build flag) — **Karras & Aila 2013**, cited
297
- by name; treelet size 7, 128-subset SAH DP in groupshared memory, **one 32-thread group per
298
- treelet**, constants `Cbox = 1.2` / `Ctri = 1.0` (the very constants in
299
- `ebvh_optimize_treelet.js`). Three dispatches per pass: clear; find-treelets (bottom-up climb
300
- with `InterlockedAdd` arrival counters, emitting base-treelet roots once a subtree holds ≥
301
- `7·2^pass` primitives); reorder (form treelet by repeatedly expanding the largest-area leaf →
302
- subset DP → rewrite links → climb to the parent treelet within the same dispatch).
303
- 8. **AABB fitting** — single-dispatch bottom-up refit: one thread per leaf climbs parent links;
304
- `InterlockedAdd` on a per-node arrival counter; first arrival terminates, second proceeds with
305
- both children guaranteed written. Buffers declared `globallycoherent`. Also the entire
306
- refit-only update path (`PERFORM_UPDATE`): topology frozen, leaves written through the cached
307
- sort permutation, only this pass re-runs.
308
-
309
- Portability notes that shaped this plan: the builder uses **no wave intrinsics at all**
310
- (threadgroup size 64 chosen as "safest across vendors") — everything except the sort is portable
311
- compute; the load-bearing hazard is `globallycoherent` + relaxed-atomic handshakes for the refit
312
- and treelet climbs, which WGSL can only approximate (§7, R1); the leaf-collapse half of their
313
- treelet pass (multi-triangle leaves) was never finished (`MAX_TRIS_IN_LEAF 1`), so their shipped
314
- trees are 1-primitive-per-leaf — same as meep's format; their per-node layout differs (center/
315
- halfDim, leaf flag in the high bit) but nothing in their pipeline depends on it — the algorithms
316
- transfer to meep's min/max + `child_1`-sentinel format unchanged.
317
-
318
- ### 3.2 AMD GPURT (`GPUOpen-Drivers/gpurt`, dev branch, 2026-08 snapshot)
319
-
320
- The production reference — the shared BVH library under AMD's D3D12 and Vulkan RT drivers.
321
- Verified against source (a full snapshot sits in the session scratchpad). What it teaches, sorted
322
- by relevance to meep:
323
-
324
- **Build-mode architecture.** One shared 64-byte scratch-BVH2 node representation; three builders
325
- over it as a quality dial: `Linear` = **`BuildFastAgglomerativeLbvh.hlsl`** — an *Apetrei-style*
326
- single-pass LBVH (bottom-up walk from leaves; parent chosen by comparing adjacent-code deltas;
327
- first arrival does `InterlockedExchange` on a propagation flag and dies, second arrival owns the
328
- parent and fits its AABB inline — no separate refit dispatch), with **the identical duplicate-key
329
- index tiebreak meep's CPU builder uses** (`32 + clz(i ^ j)`); `PLOC` (TG = 256, radius ≤ 10,
330
- LDS-windowed nearest-neighbour merging, phased task queue); `HPLOC` (TG = 32 = one wave, the
331
- HPG 2024 algorithm of §3.3). Selection: fast-build and small builds → Linear; fast-trace → PLOC;
332
- HPLOC as the single-dispatch compromise. **No treelet restructuring exists in GPURT, and the
333
- older top-down/triangle-splitting builders have been removed from current branches** — the
334
- quality story is entirely "better sort order + PLOC-family clustering + wide-node encode".
335
-
336
- **Morton codes.** Default is **variable-bit** morton (axes sorted by extent; leading bits granted
337
- to dominant axes before regular interleave — the productionised form of the adaptive scheme the
338
- DXR Fallback left as dead code, and which meep's since-deleted `chunk_calculate_morton_code`
339
- transcribed), with
340
- optional surface-area "size bits" interleaved into 64-bit keys. 30-bit codes remain a supported
341
- end-to-end path. Degenerate/inactive primitives get sentinel codes sorting to the end; identical
342
- centroids quantise to 0.5.
343
-
344
- **Sorting.** 4-bit-digit LSD radix (8 passes for 30-bit keys) with a decoupled-lookback scan *or*
345
- barrier-based hierarchical scans as the portable fallback — and, notably, batched builds always
346
- use an LDS **merge sort** instead ("merge sort outperforms radix sort in most cases in the
347
- batched builder"; radix only wins in their parallel path above 50k primitives). Context for meep:
348
- their radix is a weaker configuration than the OneSweep/CSDLDF family (the successors to exactly
349
- this lookback-scan lineage); the datapoint says sort choice is not the quality lever — sort
350
- *order* (morton scheme) is.
351
-
352
- **Orchestration.** Two maintained execution strategies: a **batched multi-dispatch path** (one
353
- dispatch per phase, real barriers between phases) and **BuildParallel** (the whole build in one
354
- persistent-threads dispatch, phase-fenced by spin-loop "task loop" barriers on
355
- `globallycoherent` buffers). Their own structure is the portability verdict: everything
356
- single-dispatch leans on device-scope coherence plus inter-workgroup forward progress — neither
357
- guaranteed in WebGPU — while **the multi-dispatch path is the portable template**, which is
358
- precisely the shape meep's frame-graph pipeline takes. Small-build specialisations exist
359
- (whole-build-in-one-workgroup for ≤1024 primitives with an in-LDS sort; a 16-thread trivial
360
- builder for ≤16) — relevant later for per-geometry BLAS batching.
361
-
362
- **TLAS specifics.** Instance encode fetches the BLAS root bounds (RDNA4: a tighter KDOP-derived
363
- bound), transforms by the instance transform, writes scratch leaves, and atomically grows scene
364
- bounds using the monotonic sign-flip float→u32 mapping + `InterlockedMin/Max` (the cheaper
365
- alternative to meep's CAS chunk; both are portable WGSL). **Rebraid** runs before morton coding:
366
- instances are opened into up to `rebraidFactor` (4) leaves pointing at BLAS root children, budget
367
- distributed by a surface-area-difference priority — the production answer to the
368
- "huge overlapping instance" quality failure LBVH-family TLAS builds share (§3.3). Updates are
369
- pure refit via an update stack; topology is never touched — confirming the phase-3a shape.
370
-
371
- **Wide-node encode.** BVH2 → BVH4/BVH8 collapse by repeatedly opening the child with the largest
372
- surface area (`ApplyHeuristic`; 8-lane lane-groups cooperate per BVH8 node on RDNA4), producing
373
- 128-byte quantised 8-wide nodes (12-bit child bounds against a shared per-axis 8-bit exponent,
374
- children implicitly contiguous). Different final format from CWBVH but the same collapse
375
- philosophy and the same "BVH2 scratch is canonical, wide is an encode" architecture this plan
376
- adopts.
377
-
378
- **Float16/f32 box nodes, triangle pairing, OBBs, DGF** — BLAS-leaf compression machinery, out of
379
- scope until phase 3b+, noted as the direction wide-format work eventually goes.
380
-
381
- ### 3.3 Construction literature and WebGPU state of the art
382
-
383
- The field has converged, and the convergence is recent enough to matter:
384
-
385
- - **Karras 2012** (binary radix tree from sorted morton codes; one thread per internal node,
386
- range/split by longest-common-prefix searches) is the canonical GPU emission kernel — what the
387
- DXR Fallback ships and what meep's CPU builder implements. The duplicate-key index tiebreak is
388
- mandatory on real content (identical instance positions collide long before 2^30 cells fill).
389
- - **Apetrei 2014** ("Fast and Simple Agglomerative LBVH Construction") inverts it: one thread per
390
- leaf walks *upward*, finding each node's parent in O(1) from adjacent-code deltas; an
391
- `atomicExchange` handshake lets the second-arriving thread continue and fit the parent's AABB on
392
- the way — **hierarchy emission and refit fuse into one kernel**, no binary searches, same
393
- topology as Karras (different internal-node index assignment; arXiv:2402.00665 shows how to
394
- recover Karras indexing inside it if wanted). No spinning anywhere → no forward-progress
395
- assumption. This is the "speed of light" binary builder in AMD's terminology and the substrate
396
- H-PLOC is built on.
397
- - **PLOC (2018) → PLOC++ (2022, Intel) → H-PLOC (2024, AMD)**: parallel locally-ordered
398
- clustering — merge mutually-nearest neighbours within a search window of the morton-sorted
399
- cluster list, giving near-agglomerative SAH quality. PLOC++ fuses each iteration into one kernel
400
- (chunked shared-memory NN search, encoded-atomicMin pair propagation). **H-PLOC** composes
401
- Apetrei's upward walk with wave-cooperative PLOC merging of per-subtree cluster lists (threshold
402
- T = wave_size/2, search radius 8), emitting the whole BVH2 in a single kernel: PLOC-class
403
- quality at ~15% over raw-LBVH build cost. It is **the production default in AMD's stack** —
404
- GPURT, and RADV as of Mesa 26.0 (Jan 2026; measured ~1 ms/frame TLAS savings, 2–5% total fps in
405
- shipped titles). Measured TLAS scale: a BVH4 over **1M randomly-sized instances in 2.21 ms** on
406
- an RX 7900 XT (LBVH: 1.96 ms); ~0.25 ms extrapolated at 100k. GPU TLAS builds at meep's scale
407
- are cheap in absolute terms regardless of algorithm.
408
- - **Treelet restructuring (TRBVH 2013 / ATRBVH 2015) has been retired as a primary quality
409
- mechanism by the field.** The H-PLOC paper's head-to-head: ATRBVH's BVH2 phase is **8.2–13×
410
- slower than H-PLOC at indistinguishable SAH quality**, with structural waste (overlapping
411
- treelets repeatedly rebuilt during the ascent). Where more quality is wanted, the literature
412
- reaches for parallel reinsertion (PRBVH) offline, not treelets. This bears directly on phase 2
413
- (§5.1, AR2).
414
- - **How much quality is actually at stake** (Meister & Bittner, JCGT 2022, GPU, secondary/
415
- incoherent rays, normalized to LBVH = 1.00): PLOC 1.25, ATRBVH 1.39, binned SAH 1.43, SBVH 1.86
416
- — with per-scene extremes near 2× on mixed-primitive-size content. Coherent primary rays show
417
- much smaller spreads, and **widening the BVH compresses the gap** (at width 8: PLOC 1.21, SBVH
418
- 1.54 vs LBVH 0.98) — i.e. the eventual BVH8 phase partially launders binary-topology quality
419
- differences. LBVH's specific failure mode is *highly varying primitive sizes* (morton codes see
420
- centroids only); for heavily-overlapping or long-thin instances no bottom-up TLAS builder saves
421
- you — production drivers attack that with **rebraiding** (opening a BLAS root so one instance
422
- contributes several TLAS entries), a later, orthogonal lever worth remembering.
423
- - **Refit vs rebuild for TLAS**: NVIDIA's published best practice is to *rebuild* the TLAS per
424
- frame rather than refit ("the cost savings to refit likely aren't worth sacrificing quality of
425
- TLAS"); refit is for BLAS geometry that "bends but doesn't break". Matches this plan (§4, AR8,
426
- phase 3a).
427
- - **Wide BVH**: everyone builds binary and converts; nobody constructs BVH8 directly. The
428
- reference conversion is Ylitie/Karras/Laine 2017 — a bottom-up cost DP deciding each node's role
429
- (allowing SAH-decided multi-primitive leaves), then top-down emission of **CWBVH**: 80-byte
430
- 8-wide nodes with child boxes quantised to 8 bits/axis against a per-node origin/scale, 1.9–2.1×
431
- faster incoherent traversal at 35–60% of the memory. meep's dormant `BVH8_INODE_STRUCT` is
432
- 80 bytes — the experiment already targets this format, and CWBVH traversal in WGSL is proven in
433
- the wild (albedo / tinybvh-rs). H-PLOC's single-kernel converter carries a genuine
434
- forward-progress hazard (documented in the paper — resident consumers spin on resident
435
- producers); the boring per-level-dispatch collapse (~log₈N ≈ 5–7 launches) is formally safe and
436
- the right WebGPU answer.
437
- - **Sorting**: OneSweep (Adinets & Merrill 2022) is the single-pass state of the art and what
438
- H-PLOC's own pipeline uses — but its bare decoupled lookback spins across workgroups; b0nes164
439
- (whose port meep briefly carried before `b6049214c` removed it for exactly this) states it
440
- "tends to run on anything that is not mobile, a software rasterizer, or Apple", and the
441
- Linebender GPU-sorting survey reaches the same conclusion. The safe formulations are
442
- reduce-then-scan (`DeviceRadixSort`) and — the one meep has standardised on — **CSDLDF**, the
443
- decoupled-fallback protocol from b0nes164's Decoupled-Fallback paper, whose scan artifact is
444
- already ported in-tree. The engine direction is to build the sort around that protocol
445
- (§4.2.1). 32-bit keys are sufficient for TLAS at ≤1M instances; 64-bit keys (for dense BLAS
446
- quality) cost ~3× in sort time and, in WGSL (no u64), mean chained two-word sorts — deferred
447
- until phase 3b demands it.
448
- - **WebGPU state of the art: essentially every public WebGPU ray tracer builds its BVH on the CPU**
449
- (three-mesh-bvh — GPU traversal only; WebRTX; strahl — whose published *CPU* build times,
450
- 327–383 ms for 1M triangles, are the cautionary tale; gnikoloff/re-ovo path tracers; tinybvh's
451
- WGSL consumers). A WGSL GPU *builder* is near-unoccupied territory. Best porting bases found:
452
- MircoWerner/VkLBVH (GLSL Karras LBVH, transliterates to WGSL almost mechanically), NexusBVH
453
- (clean CUDA H-PLOC), and Nate Morrical's H-PLOC in Slang (Slang can emit WGSL), which notably
454
- expects an external radix sort and recommends OneSweep.
455
-
456
- ### 3.4 Synthesis — the choice
457
-
458
- **Phase 1 builds LBVH: morton → CSDLDF radix sort → Karras emission → arrival-counter refit.
459
- Phase 2 adds Karras & Aila treelet restructuring on top.** Reasons, in order of weight:
460
-
461
- 1. **Zero algorithm risk.** Both algorithms exist in-tree as tested CPU implementations
462
- (`ebvh_build_hierarchy_radix`, `ebvh_optimize_treelet`) whose semantics — including the
463
- duplicate-key tiebreak that is the classic LBVH correctness trap — the GPU kernels
464
- transliterate. The DXR Fallback is a complete worked example of the same two algorithms as GPU
465
- compute, in the same 32-byte-node, stack-traversal setting.
466
- 2. **Maximum reuse, one deliberate build.** Reduce idiom, atomic AABB merge, graph plumbing,
467
- scan-with-fallback, validation tiers: existing meep facilities. The one sizeable new primitive
468
- is the key-value radix sort — built around CSDLDF per the engine's sorting direction
469
- (§4.2.1), as a first-class `gpu_primitive` whose first consumer is the builder and whose
470
- spine/protocol come straight from the in-repo `shader_prefix_scan_csdldf`.
471
- 3. **Subgroups-first; portability through the fallback discipline, not through avoidance.**
472
- Subgroups are a baseline requirement of the engine — every kernel here uses them where they
473
- pay (wave pre-reduction before atomics in the bounds pass, ballot/prefix machinery in the
474
- sort's ranking, wave-aggregated allocation and subset evaluation in the treelet DP), following
475
- the csdldf shader's established idioms (`subgroup_size` builtin handling down to
476
- `MIN_SUBGROUP_SIZE = 4`, `@diagnostic(off, subgroup_uniformity)` where the analysis
477
- over-approximates). What keeps the pipeline safe on weakly-scheduled hardware is the CSDLDF
478
- fallback protocol — never spin without a bounded fallback — not renouncing wave ops. (The DXR
479
- Fallback's zero-wave-intrinsics posture was *its* portability answer for 2018 D3D12; it is not
480
- this engine's.)
481
- 4. **Karras emission + separate refit over the fused Apetrei kernel — for phase 1.** Apetrei's
482
- single fused kernel is objectively the leaner LBVH (§3.3) and the same topology. Karras wins
483
- the *first* deliverable on de-risking grounds: the emission kernel is a pure, atomic-free
484
- function of the sorted keys, which makes it (a) a line-for-line transliteration of the spec'd
485
- in-repo oracle and (b) executable on the WGSL emulator in vitest — the strongest device-free
486
- test this project can have, covering the classic LBVH correctness trap (duplicate-key
487
- handling). The refit kernel it requires is not overhead: refit-only rebuilds (DXR's update
488
- mode, phase 3a's skinned BLAS) need it as a standalone pass anyway. Apetrei is recorded as the
489
- recognised follow-up optimisation (AR11) and the substrate H-PLOC builds on.
490
- 5. **PLOC/H-PLOC deferred, seam kept swappable — with eyes open.** The PLOC family (which AMD
491
- ships; §3.2, §3.3) produces better trees than raw LBVH and H-PLOC does it at near-LBVH cost.
492
- It is deferred, not dismissed: it is a genuinely new algorithm with no in-repo oracle, a
493
- wave-*width*-dependent structure (its cluster threshold is defined in terms of the runtime
494
- subgroup size — a different thing from merely using wave ops, which are baseline here), and
495
- real WGSL-uniformity porting friction — while LBVH+treelets reaches today's CPU quality bar
496
- with two oracles in hand. The pipeline's seam — "sorted leaf
497
- array in, BVH2 topology out" — is exactly where an H-PLOC stage would replace emission + refit
498
- + treelets wholesale if the phase-2 gate argues for it (§6, AR2 carries the full argument and
499
- the numbers).
500
- 6. **BVH2 stays the canonical intermediate.** All references converge on this: build a binary
501
- scratch BVH, then encode wide/compressed formats from it. Phases 3–4 consume the same
502
- intermediate this phase produces.
503
-
504
- ---
505
-
506
- ## 4. Phase 1 — GPU TLAS build
507
-
508
- Replace steps 1–3 of `push_to_gpu()` with a GPU pipeline; keep the CPU dynamic tree for CPU
509
- queries; keep the CPU upload path as a fallback flag until phase 2 retires it.
510
-
511
- ### 4.1 Inputs
512
-
513
- - **`meshes` table** (scene database buffer) — world AABB per row, already GPU-resident and
514
- CPU-written each frame for CPU-authority rows (`GPUSceneContext.build()` runs, and submits,
515
- before `tlas.update()` — queue order guarantees visibility), GPU-written for GPU-authority rows.
516
- - **Live rows list** — `array<u32>` of occupied `meshes` rows. `GPUInstancesAccelerationStructure`
517
- already learns exactly this from `instance_add`/`instance_remove`; it keeps a compact
518
- `Uint32Array` mirror and re-uploads it (small: 4·N bytes) only when membership churns
519
- (`#membership_version`). Dead/reused rows never enter the build — no GPU-side liveness scheme is
520
- invented.
521
- - Uniforms: `element_count = N`.
522
-
523
- Input AABBs come from the scene database rather than from the CPU tree's leaves deliberately: it
524
- is the single source of truth, it is correct for GPU-authority instances (the CPU is not), and it
525
- removes the per-frame dependency of the GPU build on CPU-side bounds bookkeeping. One-frame
526
- staleness for GPU-authority bounds (refreshed later in the frame than `tlas.update()` runs) is
527
- accepted in phase 1 — today's CPU build has *unbounded* staleness for those rows, so this is
528
- strictly an improvement; the follow-up that moves TLAS encoding after the bounds-refresh point in
529
- the frame is noted in §8.
530
-
531
- ### 4.2 Passes
532
-
533
- New directory `src/shade/renderer/gpu_primitive/bvh/`, following the `graph_*`/`shader_*`/
534
- `chunk_*` conventions. The pipeline is one `graph_bvh_build_lbvh({graph, leaf_bounds,
535
- leaf_payload, element_count, nodes, root})` recorder plus a TLAS-specific extraction pass, so the
536
- BLAS phase reuses everything downstream of extraction.
537
-
538
- | # | pass | threads | what it does |
539
- |---|---|---|---|
540
- | 0 | `shader_bvh_extract_instance_leaves` | N | `row = rows[i]`; read `meshes[row].bounding_box`; write `leaf_bounds[i]` (6×f32) + `leaf_payload[i] = row`; merge into the 6-word scene-bounds cell via `subgroupMin/Max` pre-reduction → workgroup reduce → `chunk_atomic_min_f32_storage` / `chunk_atomic_max_f32_storage` (the `shader_meshlet_bounds_reduce` pattern, subgroup-accelerated — GPURT's sign-flip monotonic `atomicMin/Max` encoding is the known cheaper alternative if the CAS ever shows up in a profile). Scene bounds seeded ±inf by an init clear. |
541
- | 1 | `shader_bvh_morton` | N | centroid of `leaf_bounds[i]`, quantised 10 bits/axis into scene bounds, `morton = split_by_3(x) \| split_by_3(y)<<1 \| split_by_3(z)<<2` via a new `chunk_v3_morton_encode` mirroring `v3_morton_encode_magicbits` exactly; `keys[i] = morton`, `values[i] = i`. |
542
- | 2 | `graph_radix_sort` | — | the rebuilt CSDLDF-based sort (§4.2.1). Keys 32-bit (30 used), values = leaf indices. Stable, ascending. |
543
- | 3 | `shader_bvh_emit_hierarchy` | N | Transliteration of `ebvh_build_hierarchy_radix` with the DXR index layout: internal node k at node k (k ∈ [0, N-2], root = 0), leaf j at node (N-1)+j. Thread i writes leaf node (N-1)+i (`bounds = leaf_bounds[values[i]]`, `child_1 = BVH_NULL_NODE`, `child_2 = leaf_payload[values[i]]`); threads i < N-1 additionally run `determineRange`/`find_split` (`delta` with the `32 + countLeadingZeros(i^j)` duplicate tiebreak — `clz32` ⇔ `countLeadingZeros`) and write internal node i's children plus `parents[child] = i` into scratch. Pure per-thread function of the sorted keys: no barriers, no atomics, no subgroups. |
544
- | 4 | `shader_bvh_refit` | N | One thread per leaf climbs `parents[]`; `atomicAdd` on a per-internal-node arrival counter (transient buffer, arrives zeroed); first arrival terminates, second recomputes the parent's AABB from its children and continues. All cross-workgroup-communicated words — node bounds and counters — are accessed through `array<atomic<u32>>` bindings (atomicLoad/atomicStore, f32 via bitcast), per the `shader_meshlet_bounds_reduce` idiom, never as plain loads/stores. **[deviation]** the `root = 0` header word is written by pass 3 instead — emission already writes plain node words and precedes the refit, and nothing reads the header in between. |
545
-
546
- Edge cases handled at encode time on the CPU: `N == 0` → skip the graph, write `root =
547
- BVH_NULL_NODE`. Consumers already handle `root == BVH_NULL_NODE` (empty CPU tree uploads it today).
548
-
549
- **[deviation]** `N == 1` gets no special case at all. It falls out of the same four passes: the
550
- sort has nothing to reorder and returns its input, emission's tail `if` skips the internal node and
551
- writes leaf 0 at node 0 with `parents[0] = BVH_NULL_NODE`, and the refit's single thread reads that
552
- terminator and stops. The host-written 36 bytes the plan called for would have had to take the
553
- bounds from the CPU tree, whose copy for a GPU-authority row can be arbitrarily stale — the whole
554
- reason this build reads the `meshes` table. The one thing `N == 1` actually needed was a counters
555
- buffer WebGPU will accept, so it is sized `max(1, N-1)`.
556
-
557
- **[addition, 2026-10-16]** Up to one sort tile of leaves (1,024), these passes are one
558
- dispatch of one workgroup, with the same output word for word. See §4.7.
559
-
560
- ### 4.2.1 The sort — `gpu_primitive/sort/`, rebuilt around CSDLDF
561
-
562
- Commit `b6049214c` cleared the ground; this project re-founds the directory with one blessed
563
- implementation. It keeps the deleted port's interface for continuity —
564
- `graph_radix_sort({graph, keys, values, element_count}) → {keys, values}`, u32 keys + u32 values,
565
- ascending, stable, result landing back in the caller's buffers — and is a first-class primitive
566
- with its own spec and verify harness (the deleted `one_sweep.spec.js` /
567
- `verify_radix_sort_one_sweep.js` in history are the direct templates for both).
568
-
569
- **v1 — reduce-then-scan on the csdldf spine** (the shipping shape). LSD radix, 8-bit digits × 4
570
- passes; per digit pass three dispatches: (a) per-tile 256-bin histogram (subgroup-accelerated,
571
- workgroup atomics); (b) spine scan of the `tile × bin` counts, flattened column-major so one scan
572
- yields global digit offsets and per-tile offsets together — run through
573
- **`graph_prefix_scan_csdldf` as-is** (its inclusive-only output consumed exclusively by
574
- subtracting own count; its `{count, elements: vec4u}` 16-byte-aligned layout adopted for the
575
- spine); (c) rank-and-scatter, with the workgroup ranking machinery adapted from the deleted
576
- binning shader in history (subgroup multi-split, and its two pinned WebGPU lessons carried over).
577
- Every dispatch is bounded; the only spin anywhere is csdldf's own bounded-spin-then-fallback.
578
-
579
- **[deviation]** the scatter's ranking is not the deleted port's per-wave-histogram multi-split. That
580
- formulation needs `workgroup_size / subgroup_size * 256` words of workgroup memory, which at the
581
- minimum subgroup size of 4 is 16 KB — the entire budget, with nothing left to stage keys in — which
582
- is precisely why the deleted shader carried a whole second `WLT16` code path with bit-packed
583
- half-radix histograms. What ships instead sorts the tile in workgroup memory with eight stable
584
- binary splits (256 threads × 4 keys = a 1024-key tile, ~10.3 KB of workgroup memory at any subgroup
585
- size). That makes a key's rank its sorted position, gets contiguous per-bin device writes for free,
586
- and lets the tail tile's padding be identified by position alone. Subgroups carry the wide part of
587
- each split's scan; the barrier-carrying loop over per-subgroup partials runs a *constant* trip count
588
- rather than one derived from `subgroup_size`, so no barrier sits under a bound a uniformity analysis
589
- has to reason about. Ballot-based peer aggregation in the histogram pass is the recorded upgrade
590
- there; v1 uses workgroup atomics.
591
- Roughly twelve smallish dispatches per sort against the removed port's seven — at TLAS scale
592
- (≤1M keys) this is tens of microseconds of difference, and it is correct on every scheduler.
593
-
594
- **v2 — single-pass digit binning with the fallback protocol** (recorded upgrade, not scheduled).
595
- OneSweep's chained per-(tile, bin) lookback with the Decoupled-Fallback protocol generalised to
596
- histogram vectors: split flag words per bin following the csdldf READY/INCLUSIVE rules, fallback
597
- re-reducing the stuck predecessor tile's histogram from its keys. Pursued only if a measured
598
- consumer (realistically: BLAS-scale triangle sorts, phase 3b) shows the spine passes matter.
599
-
600
- The sort ships before the builder consumes it — its own verify harness green in the playground,
601
- its shaders through all four validation tiers — so builder bring-up debugging never has to ask
602
- "is it the sort?".
603
-
604
- ### 4.3 Output layout
605
-
606
- `[u32 root = 0][internal 0 .. N-2][leaf N-1 .. 2N-2]`, `2N-1` nodes exactly. This differs from the
607
- CPU path's slot-order-with-holes layout — legal, because (§2.1) nothing consumes ordering, only
608
- format + root word. Root is node 0 by Karras's construction (internal node 0 spans the whole
609
- range), which also natively satisfies the BLAS "root must be node 0, no header" convention when the
610
- same pipeline is pointed at geometry in phase 3.
611
-
612
- Buffers: the persistent output buffer keeps its `STORAGE | COPY_DST | COPY_SRC` usage and grows
613
- via `ensure_buffer_capacity` to `4 + 32·(2N-1)`; the staging copy disappears (the refit writes
614
- final bytes in place). Transients (graph-pooled): `leaf_bounds` 24N, `leaf_payload` 4N, keys/values
615
- 4N each (+ the sort's own internals), `parents` 4·(2N-1), `counters` 4·(N-1), scene bounds 24 B.
616
- For N = 100k: ~5–6 MB transient, pooled and reused across frames — versus today's freshly
617
- allocated ~6.4 MB staging buffer plus its full-buffer copy on every rebuild, and versus the CPU
618
- milliseconds that dominate the current cost.
619
-
620
- ### 4.4 Integration in `GPUInstancesAccelerationStructure`
621
-
622
- - `instance_add/remove/update` unchanged (CPU tree maintenance stays — `DirectionalLightShadowmap`
623
- and any future CPU query walk it).
624
- - `push_to_gpu()` becomes: ensure output capacity → refresh rows buffer if membership churned →
625
- `new FrameGraph()` → import rows + scene database + output → record extraction +
626
- `graph_bvh_build_lbvh` → `ShadeGPUCommandContext.create` / `encodeGraph` / `finish`. Same frame
627
- position (inside `GPUSceneContext.update`, after `build()`), same submit-per-change semantics,
628
- same version guard. **`ebvh_optimize_treelet` no longer runs on the CPU tree** — the tree stays
629
- a plain SAH-insertion/rotation tree, which is what its CPU consumers actually need.
630
- - ~~A module-level flag selects GPU vs legacy CPU upload~~ — **the legacy path is gone.** It
631
- existed for one A/B; that A/B has been run (§4.6), the GPU build won on every axis at instance
632
- scale, and the flag, the treelet call, the flatten and the whole-buffer upload went with it. It
633
- was `TLAS_BUILD.use_gpu` — a settable field rather than the `const USE_GPU_TLAS_BUILD` boolean
634
- the plan named, so the comparison could be a toggle in front of a running scene rather than an
635
- edit and a reload. Anyone needing the comparison again recovers ~40 lines from history at
636
- `23ab7bcd2`, the commit before the deletion. Subgroups are a baseline engine requirement, so there
637
- is no "no-subgroups device" tier to serve: the legacy path (~40 lines) is deleted outright at
638
- the phase-2 gate, flag and all.
639
-
640
- **[deviation]** `GPUSceneContext` needs two lines. The TLAS is constructed with `graphics`
641
- rather than `device` (the build has a frame graph to encode, which needs the buffer allocators), and
642
- `update()` is handed `scene_database_buffer` — the table the world AABBs live in, which the TLAS has
643
- no other way to reach. `GPUSceneContext`'s own API is unchanged, and consumers — every reader of
644
- `tlas.buffer`, and `DirectionalLightShadowmap`'s reader of `tlas.bvh` — need no changes.
645
-
646
- ### 4.5 Validation
647
-
648
- Four tiers, each doing the job only it can do:
649
-
650
- 1. **WGSL validity, vitest** (`bvh_lbvh.spec.js`, sort specs alongside): every new shader parses
651
- (tree-sitter) and passes `validate_wgsl_source` as assembled — subgroup-using shaders
652
- included; this is the tier that catches uniformity-adjacent assembly mistakes before a device
653
- ever sees them.
654
- 2. **`ComputeShaderEmulator`, vitest — functions and chunks first, whole kernels where pure.**
655
- The emulator is not a full device, and that is its value: it executes *specific WGSL
656
- functions* against CPU oracles. Chunk-level: `chunk_v3_morton_encode` vs `v3_morton_encode`;
657
- the emission kernel's `delta` / `determine_range` / `find_split` chunks vs their counterparts
658
- inside `ebvh_build_hierarchy_radix` — each as a directly-invoked function over randomized and
659
- adversarial inputs (duplicate-key runs — the historical failure mode — first among them).
660
- Kernel-level: `shader_bvh_morton` and `shader_bvh_emit_hierarchy` executed point-wise
661
- end-to-end (both are barrier- and atomic-free pure functions of their inputs, which the
662
- emulator schedules faithfully); full-tree topology compared against the CPU builder. The
663
- refit and sort-scatter kernels are atomics/subgroups-bound and are *not* forced through the
664
- emulator — their chunks that are pure (bounds merge math, digit extraction, rank arithmetic)
665
- still get chunk-level emulator coverage.
666
- 3. **`SoftwareGPUDevice`, vitest — the orchestration tier.** Encode the real thing against the
667
- recording device: `GPUInstancesAccelerationStructure.push_to_gpu()` and
668
- `graph_bvh_build_lbvh` run their actual FrameGraph against a `SoftwareGPUDevice`, and the spec
669
- asserts what was encoded — dispatch sequence and group counts per pass, bind groups resolving
670
- to the intended buffers, transient sizes/usages/clears, the rows-list upload happening only on
671
- membership churn, and the `N == 0` / `N == 1` short-circuits (graph skipped, root word written
672
- host-side — bytes the host wrote are assertable here; shader-written bytes are not, by the
673
- mock's own contract). This tier pins the integration and the graph wiring in CI, where neither
674
- the emulator (no host path) nor the browser (no CI) can.
675
- 4. **Browser playground + end-to-end** — **[not run]** this tier is the human's; meep is a
676
- library and the playground mounts in the host repo. Both harnesses are in place and registered.
677
- (`verify_gpu_bvh_build.js` and `verify_radix_sort.js`, registered under "review checks"): randomized instance sets (duplicate positions, degenerate
678
- AABBs, N ∈ {0, 1, 2, 3, powers of two ± 1, ~100k}); readback via `graph_inspect_gpu_buffer`;
679
- assert structural validity (every leaf reachable exactly once; both children valid on every
680
- internal node; parent AABB ⊇ children; root word correct), payload permutation correctness,
681
- **depth histogram** (§7, R4), and SAH cost vs the CPU oracle on the same input — same sorted
682
- order fed to both ⇒ topology must match node-for-node modulo the index mapping. Then the real
683
- consumers: path tracer + RTX shadows on existing playground scenes, CPU-built vs GPU-built A/B
684
- (the flag), image diff + `GPUTimer` numbers for build and trace.
685
-
686
- A `bvh_format.spec.js` locking the byte contract of §2.1 (today enforced only by the shaders
687
- themselves) is added alongside — it pins both the legacy compressor and the GPU layout to the same
688
- golden bytes.
689
-
690
- ### 4.6 Acceptance
691
-
692
- - All four validation tiers green (sort and builder both); traversal-consumer A/B images
693
- identical-or-better.
694
- - Depth histogram within the traversal stack budget on benchmark scenes — no silent traversal
695
- truncation (R4).
696
- - Build time for 100k instances well under the CPU path's. Calibration from the literature
697
- (§3.3): AMD measures a full quality-build TLAS over 1M instances at 2.2 ms on an RX 7900 XT,
698
- ~0.25 ms extrapolated at 100k; raw LBVH ~15% cheaper still. WebGPU overheads on top are exactly
699
- the novel data point this phase produces — measure with `GPUTimer`, don't assume.
700
-
701
- **Measured, 2026-08-26** — `verify_gpu_bvh_build` and `verify_gpu_tlas_build` on one machine, all
702
- 58 builder cases and all 8 TLAS scenes PASS. One machine, one browser, and a harness that makes a
703
- separate submission per case: treat these as an order-of-magnitude reading, not a benchmark.
704
-
705
- - **Topology parity is exact.** Every case's SAH matches the CPU oracle's to the last reported
706
- decimal, at every size and every shape — which is the node-for-node claim, since the SAH is
707
- summed over the same internal nodes. Including 100k coincident instances.
708
- - **Build time, 100k scattered instances: ~0.15 ms** of GPU time across all 19 dispatches (morton
709
- 0.003, sort ~0.082 over four digit passes, emission 0.026, refit 0.040). That sits where §3.3's
710
- extrapolation put a driver-class builder (~0.2 ms), which is the answer to "what do WebGPU
711
- overheads cost" — apparently not much. Two outliers in the log (a 0.24 ms first scan and a
712
- 0.58 ms readback copy in one case) do not recur across the other three digit passes of the same
713
- build and look like queue bubbles between the harness's per-case submissions; a real number for
714
- the frame budget has to come from the in-frame path, not from here.
715
- - **The duplicate-key tiebreak works at scale.** 100,003 instances at *identical* positions — one
716
- Morton code for the entire scene — build to depth 18, not depth 100,003. The depth histogram
717
- (`8:1 9:2 12:32 13:128 14:1536 18:98304`, summing to exactly 100,003) is the binary decomposition
718
- of the index range showing through, which is what `delta`'s `32 + clz32(i ^ j)` produces when no
719
- code carries information. This is the failure mode the whole emission kernel is careful about,
720
- now observed on a device.
721
- - **The ±inf seeding is confirmed.** The `far from origin` TLAS scene (700 instances in a 10-unit
722
- cube offset ~9000 units from the origin) builds to a normal depth spread of 8..16. A zeroed
723
- scene-bounds cell would have anchored the bounds at the origin, crushed every centroid into one
724
- Morton cell, and produced the flat single-depth histogram the coincident case shows.
725
- - **Depth.** 100k scattered instances build to **depth 22 of the 32-entry stack**, and 20k to
726
- depth 20. The stack is shared with the BLAS within one query and the occlusion path truncates
727
- silently past it, so this is a real output constraint — but see the A/B below, where the legacy
728
- CPU tree turns out to be the *deeper* of the two.
729
-
730
- **Consumer A/B, 2026-08-26** — `sponza_path_trace`, path tracer, same camera, same frame, toggling
731
- `TLAS_BUILD.use_gpu` under the running scene. Images verified equivalent by inspection. Frame times
732
- are medians of three runs; SAH is the sum of internal-node surface areas, which over a fixed leaf
733
- set is the term that differs between two trees.
734
-
735
- Sponza as it ships — **103 instances**:
736
-
737
- | | GPU LBVH | CPU pipeline (insertion + rotations + treelets) |
738
- |---|---|---|
739
- | SAH | 43,409 | 22,414 |
740
- | max depth | 13 | 20 |
741
- | traced frame | 69.5 ms | 70.6 ms |
742
-
743
- Sponza plus **20,000 scattered boxes** at sizes spanning two orders of magnitude — the shape that
744
- troubles a Morton order most, and enough instances that traversal cost is in the TLAS rather than
745
- in the per-mesh BLAS:
746
-
747
- | | GPU LBVH | CPU pipeline |
748
- |---|---|---|
749
- | SAH | 713,765 | 941,015 |
750
- | max depth | 20 | 27 |
751
- | traced frame | **132.6 ms** | 176.1 ms |
752
- | rebuild, wall clock | **21.7 ms** | 161.2 ms |
753
-
754
- Read together these say something the plan did not anticipate. At ~100 instances the GPU tree's SAH
755
- is 1.9× the CPU tree's and the traced frame times are identical — the regression AR1 predicted,
756
- and invisible, because a TLAS that small is not what traversal spends its time in. At 20k the
757
- ordering **reverses on every axis**: the GPU tree is 24% better by SAH, seven levels shallower, and
758
- 25% faster to trace.
759
-
760
- Why: the CPU tree is built by incremental SAH-guided insertion with local rotations. At a hundred
761
- instances that is close to optimal. At twenty thousand it is a greedy, order-dependent construction
762
- whose mistakes rotations only locally repair, while the LBVH's global Morton ordering does not
763
- degrade. The treelet pass on top buys 2.4% (22,954 → 22,414 over two passes on the 103-instance
764
- scene) — it repairs neighbourhoods, not a bad global shape.
765
-
766
- Note also the depth column: at 20k the *legacy* tree reaches depth 27 of a 32-entry stack shared
767
- with the BLAS, leaving five. R4's cliff is real, and the GPU build moves away from it rather than
768
- toward it.
769
- - Known, documented, temporary quality dip vs the treelet-optimised CPU tree (raw LBVH SAH cost is
770
- worse; phase 2 exists to close exactly this). The flag stays available until phase 2's gate.
771
-
772
- ### 4.7 The single-workgroup path for small builds — landed 2026-10-16
773
-
774
- **Why.** Over a few hundred leaves every one of the 19 dispatches is nearly empty, so a rebuild
775
- costs 19 times a dispatch's fixed overhead. On the RTX 4090 that is ~73 µs at any count up to a
776
- tile (below). A consumer that rebuilds per spawn or despawn pays it every time. The GPU fluid
777
- (`GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md` §10/§14) rebuilds a tree over effector and obstacle
778
- boxes every frame. The TLAS gets the path automatically for small scenes. The engine has no
779
- "typical" scene, but most scenes have few elements. So this is a fast path keyed on the runtime
780
- count, and the multi-pass build stays first-class above it.
781
-
782
- **What.** `graph_bvh_build_lbvh` records one dispatch of one workgroup,
783
- `shader_bvh_build_single_workgroup`, when `element_count <= SORT_TILE_SIZE` (256 × 4 = 1,024,
784
- `sort/defs.js`). Above that, the multi-pass recording is unchanged. The shader runs the four phases
785
- inside one workgroup, fenced by barriers:
786
-
787
- 1. **Morton.** Each of the 256 threads takes four leaves, striped. Each leaf's code goes into
788
- workgroup `keys` and its index into `values`.
789
- 2. **Sort.** A bitonic network in workgroup memory, `chunk_sort_bitonic_workgroup`, ordered by
790
- (code, leaf index). Seeding each value with its leaf index makes that a stable sort by code,
791
- which is exactly the radix sort's order, so the permutation matches it word for word. The
792
- network spans the next power of two of `n`, padded with the largest pair. It is
793
- `log2(P)(log2(P)+1)/2` steps of one barrier each: 10 at 16 leaves, 55 at 1,024. The scatter's
794
- own in-tile sort (stable binary splits, `chunk_sort_rank`) would fit in the memory too, but a
795
- 30-bit key is 30 rounds of a ~15-barrier workgroup scan at any count, about 450 barriers.
796
- 3. **Emission**, as `shader_bvh_emit_hierarchy` does it. The sorted keys and leaf order are
797
- published to the same transients the multi-pass build returns. Node words are stored
798
- atomically, because the refit reads them atomically.
799
- 4. **Refit** with the multi-pass climb, `chunk_bvh_refit_climb`, unchanged. Counters and node
800
- words stay storage atomics, as there. The arrival counters are a transient the shader zeroes
801
- itself, so the descriptor needs no clear. They are not workgroup atomics: a first arrival's
802
- bounds go out as storage stores, and nothing would order those against a shared-memory
803
- counter.
804
-
805
- Workgroup memory is `keys` + `values` + `parents` (2N−1) = 16,380 bytes, inside the default
806
- 16 KiB with four to spare. The parents are there rather than in storage because the climb reads
807
- them once per step. Every per-thread loop is bounded by the tile constants. Nothing waits on
808
- another workgroup, because there is none. No subgroups, and default limits only.
809
-
810
- **Reuse map.** Nothing is copied:
811
-
812
- | piece | from | how |
813
- |---|---|---|
814
- | `chunk_bvh_morton_code` | the body of `shader_bvh_morton` | extracted; `shader_bvh_morton` now calls it |
815
- | `chunk_bvh_internal_children` | range, split and child selects inline in `shader_bvh_emit_hierarchy` | extracted; emission now calls it |
816
- | `bvh_delta` / `bvh_determine_range` / `bvh_find_split` | as they were | read `keys` by name instead of through `ptr<storage, array<u32>>` |
817
- | `chunk_bvh_refit_climb` | as it was | reused unchanged; `parents` resolves to workgroup memory here |
818
- | `chunk_sort_bitonic_comparator`, `chunk_sort_bitonic_workgroup` | new, in `sort/` | pure index maths split from the barrier loop, as `chunk_sort_rank` is from the scatter |
819
-
820
- The by-name change follows the precedent the climb chunk set with `parents`, `counters` and
821
- `nodes`. A WGSL pointer type carries its address space and access mode. So one function taking
822
- `ptr<storage, array<u32>>` cannot be handed a workgroup array, nor a storage array its own shader
823
- writes. Emission's sorted codes are a read-only storage binding in one kernel and workgroup
824
- memory in the other.
825
-
826
- The brick4 bake's `chunk_sort_f32_array_32` is a bitonic network over a private 32-element array.
827
- It is per-thread and not reusable here.
828
-
829
- **Tests.**
830
-
831
- - **Tier 1:** the new shader assembles clean (`bvh_lbvh.spec.js`).
832
- - **Tier 2:** `chunk_bvh_morton_code.spec.js` checks the chunk against `v3_morton_encode_bounded`.
833
- `chunk_sort_bitonic_comparator.spec.js` checks the comparator maths. Every step is a perfect
834
- matching a stride apart inside one block, the last block ascends, and the network run through
835
- the chunk gives the stable order on duplicate-heavy keys. A direction mutant, a tiebreak mutant
836
- and a size mutant each turn it red.
837
- - **Tier 3:** `graph_bvh_build_lbvh.spec.js` checks the encoded dispatches. n = 1, 2, 1,023 and
838
- 1,024 give exactly one one-workgroup dispatch, red before the change, when 1 gave 3 and the rest
839
- 19. 1,025 gives 19. The TLAS spec now expects the single dispatch for small scenes and keeps the
840
- multi-pass expectations above a tile.
841
-
842
- **Device, RTX 4090 only (D3D12, headless Chrome 155), 2026-10-16.** All one-offs from the
843
- session scratchpad; nothing device-backed went into the repo.
844
-
845
- - **Equivalence: 300/300 cases bit-identical** to the multi-pass build. The single-workgroup
846
- path's node buffer, guard words around the tree included, its sorted keys and its leaf order all
847
- match the multi-pass build's output from the same input. Counts were 1–5, 7–9, 15–17, 31–33,
848
- 63–65, 127–129, 255–257, 511–513, 777, 1,000, 1,023 and 1,024. Shapes were scattered (two
849
- seeds), coincident (one Morton code for all), clustered (long duplicate runs), degenerate
850
- (zero-volume), mixed (half at the origin) and lattice (centroids on exact quantisation halves).
851
- Each ran in the TLAS layout, and scattered and coincident also in a headerless arena layout
852
- (node 0 at word 97, `BVH_NO_ROOT_WORD`). `check_bvh_structure` found nothing on any
853
- single-workgroup tree. Negative controls show the comparison is live: one leaf's payload +1, or
854
- one bound moved by 1e-3, is reported in every one of six cases.
855
- - **Harnesses:** `verify_radix_sort`, `verify_gpu_bvh_build` and `verify_gpu_tlas_build` through
856
- `playground/bvh_repro` give 118 PASS lines per run, with every builder case at n ≤ 1,024 now on
857
- the new path. The topology matches the CPU oracle node for node.
858
- - **Cost per build.** Medians over 48 blocks of 16 back-to-back builds, ABBA/BAAB across 24
859
- submissions. A constant ~85 µs ballast dispatch ran each round as the clock check, and it read
860
- 84–85 µs throughout:
861
-
862
- | n | single workgroup | multi-pass (19 dispatches) | multi-pass ÷ single |
863
- |---|---|---|---|
864
- | 16 | 7.3 µs | 73.5 µs | 10.0× |
865
- | 128 | 13.2 µs | 77.5 µs | 5.9× |
866
- | 1,024 | 53.2 µs | 83.8 µs | 1.6× |
867
-
868
- CPU-side recording and encoding was ~6 µs a build against ~60–100 µs. That figure is indicative
869
- only, read from a 100 µs timer over blocks of 16.
870
- - **Where the single workgroup's time goes.** Medians per build from deleted-workload twin
871
- pipelines swapped at `setPipeline`; nothing in the engine was edited:
872
-
873
- | n | dispatch + Morton | + sort | + emission | + refit = full |
874
- |---|---|---|---|---|
875
- | 128 | 1.7 µs | 4.8 µs | 8.2 µs | 13.2 µs |
876
- | 1,024 | 2.4 µs | 12.0 µs | 26.4 µs | 53.0 µs |
877
-
878
- At 1,024 the refit climb is half and emission a quarter. Emission's node stores are 2.4 µs of
879
- that; the rest is the range and split search. The kernel is latency-bound by construction:
880
- 256 threads are eight warps on one SM, each thread walks four leaves in turn, and the default
881
- limits fix both numbers. It still wins at the tile boundary, so the threshold stands as derived.
882
-
883
- **Observed during this work: R1 once, in the multi-pass path.** In one of 30 harness runs on this
884
- tree, `verify_gpu_tlas_build` failed `scattered 20000 after movement`: internal node 8195 did not
885
- contain child 8192. The parent's x-max was exactly 3.0 below the child's, and the movement grows
886
- every box by 3. So the parent was fitted from that child's bounds left by the previous build. That
887
- is a stale read in the cross-workgroup climb, the hazard R1 names. 20,000 leaves takes the
888
- multi-pass path, whose recording and kernels this change leaves as they were. None of these
889
- reproduced it:
890
-
891
- - the other 29 runs on this tree;
892
- - 28 runs on its base commit `b2fe0c44f`, alternated with them;
893
- - 1,000 forced rebuilds per tree at 20,000 leaves over reused node buffers, alternating two
894
- far-apart leaf sets, 200 of them one per submission and 800 eight per submission.
895
-
896
- It was reproduced and attributed the same day, and the climb now handles it: §4.8.
897
-
898
- ### 4.8 R1 measured, and the refit's repair — landed 2026-10-16
899
-
900
- **What happens.** The climb's second arrival at a node sometimes reads its sibling's box before the
901
- sibling's stores have reached it. It is not rare enough to ignore. A refit-only probe, which builds a
902
- tree once and then re-runs only the refit, 64 a submission, alternating two leaf sets of one
903
- topology, measured it. Each refit's internal bounds were compared word for word with the CPU's exact
904
- tree:
905
-
906
- | configuration, RTX 4090, 20,000 leaves | refits | stale |
907
- |---|---|---|
908
- | refit only, nothing else on the GPU | 32,000 | 14 |
909
- | refit only, a second device hammering the GPU | 16,000 | 9 |
910
- | harness-shaped full builds, nothing else | 1,000 builds | 0 |
911
- | harness-shaped full builds, second device | 1,000 builds | 2 |
912
-
913
- That is about one refit in 2,000. It accounts for the harness's single failure, and for the 1,000
914
- rebuilds that showed nothing. The node pair the harness reported, 8195 and 8192, recurs in the
915
- refit-only probe at the same seed.
916
-
917
- **What it is, and what it is not.** The pooled arrival counters were read back after every build and
918
- every refit, and every internal node read exactly 2, so the protocol ran as designed and no counter
919
- was stale. The stale node is always the union of its fresh child and its other child's previous
920
- value, with 1 to 4 of the 6 words stale, and the stale child is always internal: leaves come from
921
- emission, a dispatch earlier. A copy of the climb that records the thread of each arrival found the
922
- same in 17 of 17 events: the stale child's writer was the parent's first arrival, which then exits,
923
- and the reader, the second arrival, was always in a different workgroup, always an adjacent one. It
924
- is cross-workgroup visibility of the bound stores after the counter, exactly as AR3 named it.
925
-
926
- **Why no ordering fix.** WGSL has no device-scope fence. Its atomics are relaxed, and
927
- `storageBarrier` is workgroup-scoped and needs uniform control flow. Inside one dispatch the only
928
- formally coherent channel between workgroups is a single word's read-modify-write, and a box is six
929
- words. So the fix is the CSDLDF one: make the read that went wrong detectable, and finish the job
930
- where a dispatch boundary guarantees visibility.
931
-
932
- **The fix.**
933
-
934
- 1. Emission seeds every internal node's six bound words with `BVH_UNFITTED`, a quiet NaN no union
935
- produces (`BVH_REFIT_DEFERRED_LAYOUT.js`). Emission's dispatch ends before the refit's begins, so
936
- a refit read returns the store or the seed, never the previous build's box.
937
- 2. The climb's second arrival reads both children through a check (`chunk_bvh_refit_climb`). A seed
938
- word counts as the empty box's word (±inf, made at runtime from the seed, because WGSL will not
939
- fold an infinity), the node is fitted from what can be seen and the climb goes on, and the node is
940
- appended to a deferred list with its tree's node and parents offsets. The list's first three words
941
- are the repair's dispatch command, folded as the engine's builders fold one, and raised with
942
- `atomicMax` as entries are taken, so no prepare pass is needed.
943
- 3. `shader_bvh_refit_repair`, the build's 20th dispatch, is indirect over that command, so it is
944
- empty unless something was deferred. For each listed node it takes the union of the children,
945
- now visible, and grows the node and every ancestor with `atomic_min/max_f32_storage`
946
- (`chunk_bvh_refit_repair`).
947
-
948
- Why that is exact. Everything the climb fitted holds no more than the true box, because an
949
- unarrived word reads as empty. Every repair contribution holds no more than the true box either.
950
- For any leaf under a node, the lowest node on the path between them that fails to hold it must
951
- have read a seed for its path child, so it was listed, and its repair grows the whole path with a
952
- box that holds the leaf. Growth is a per-word min or max, which commute, so repairs that meet need no
953
- order.
954
-
955
- The single-workgroup build takes the same climb with the same seed. It repairs in-workgroup after one
956
- more barrier, which gives one workgroup what a dispatch boundary gives the multi-pass build. The
957
- per-frame skinned BLAS refit (`GPUBLASRefitBatch`) takes it too, with a seeding dispatch of its own
958
- (`shader_blas_refit_batch_seed`, one lane per node slot) and the same repair over every tree in the
959
- batch. The batch writes its leaf boxes in the same dispatch too, so the seed covers leaves as
960
- well. On the device every stale read classified so far was of an internal child, 6 of 6
961
- (`BLAS_REFIT_BATCH_PLAN.md` §3.7).
962
-
963
- The refit-only update of a built tree (`graph_bvh_refit_lbvh`, the GPU fluid's forcing tree above a
964
- sort tile) has no emission ahead of its climb, so its prepare pass (`shader_bvh_refit_prepare`), the
965
- dispatch before the climb, seeds each internal node as it rebuilds that node's children's parents. It
966
- binds a deferred list and records the same indirect repair: three dispatches. On the RTX 4090 at
967
- 20,000 leaves, refits alternating with a pre-R1 twin (no seed, no repair) in every submission: 0
968
- stale in 96,000 against the twin's 92 in 96,000, with the repair firing in 129. The seed moved the
969
- prepare from 2.99 to 3.62 µs (mean, 1 µs timer), the empty repair costs 2.64 µs, and the sum of
970
- passes went from 19.5 to 22.7 µs.
971
-
972
- R1's per-level-dispatch fallback was not built. It needs a dispatch per tree level. The 100,000-leaf
973
- tree is 22 levels deep, and the recorder would have to record the worst case, about 64, because the
974
- depth is only known on the device. At the measured 3.1 µs for an empty indirect dispatch that is
975
- 70–200 µs, against the ~8 µs below.
976
-
977
- **Measured, RTX 4090, on the device:**
978
-
979
- - **Stale rate, refit only, the real kernels before and after:** 23 stale refits in 48,000
980
- before, 0 in 48,000 after. The fallback fired 62 times in those 48,000, and every refit came out
981
- exact.
982
- - **The skinned batch, before and after:** eight UV spheres, about 45,000 leaves, two poses
983
- alternated over the meshlet data. Before, 11 stale refits in 48,000, at least 9 of them real
984
- holes, a child escaping its parent; one genuine stale node per bad tree. In the 32,000 that were
985
- classified, all 6 stale children were internal and none a leaf. After, 0 in 48,000. At that
986
- size the batch's own refit pass is unchanged (p10 about 38 µs either way); the seeding pass adds
987
- 10.2 µs at p10 (16–19 µs median) and the empty repair 3.1 µs.
988
- - **Equivalence:** the fix's trees equal the old recorder's, word for word, at 1, 2, 16, 1,000,
989
- 1,024, 1,025, 4,096, 20,000 and 100,000 leaves.
990
- - **Harness:** `bvh_repro` "Run all", three times: 118 PASS, 0 FAIL.
991
- - **Cost:** per-pass timestamps, medians of 320 builds per arm, builds alternating between the old
992
- recorder and the new in one page, with other sessions holding the GPU at about 50%:
993
-
994
- | | before | after |
995
- |---|---|---|
996
- | 100,000 leaves, sum of passes | 140.3 µs | 148.5 µs (+8.2, +5.8%) |
997
- | … emission | 25.6 µs | 27.7 µs |
998
- | … refit | 41.0 µs | 43.0 µs |
999
- | … repair, empty | — | 3.1 µs |
1000
- | 1,024 leaves, one workgroup | 50.2 µs | 54.3 µs (+4.1) |
1001
- | 128 leaves, one workgroup | 12.3 µs | 13.3 µs (+1.0) |
1002
-
1003
- The gaps between passes are not inside these spans; the extra pass adds one.
1004
-
1005
- **Specs.** `bvh_refit_late_stores.spec.js` runs emission, the refit and the repair on the emulator
1006
- with node cells whose stores reach other invocations only when the dispatch ends — a visibility the
1007
- memory model allows, and the one the device showed. It holds the tree exact for one late store, the
1008
- harness's case, and for none landing before the end. `shader_blas_refit_batch.spec.js` does the same
1009
- for a batch frame. On the old kernels the harness's case leaves the parent exactly 3.0 short, and the
1010
- batch leaves children outside their parents.
1011
-
1012
- ---
1013
-
1014
- ## 5. Phases 2–4
1015
-
1016
- ### 5.1 Phase 2 — tree quality optimisation on the GPU
1017
-
1018
- > **Checkpoint, 2026-08-26 — the premise did not survive measurement.** This phase was committed to
1019
- > on the expectation that phase 1 ships a quality regression which treelet restructuring then closes
1020
- > (AR1, AR2, R3). §4.6's A/B says otherwise: at instance scale the GPU LBVH is *better* than the
1021
- > CPU pipeline it replaced — 24% better SAH, seven levels shallower, 25% faster to trace — and at
1022
- > the small end where it is worse by SAH, the difference does not reach the frame. The gate this
1023
- > phase was to be judged against ("SAH cost within noise of the current CPU pipeline, and
1024
- > traversal-heavy consumers at parity or better frame time") is **already met, without treelets**.
1025
- >
1026
- > What follows is therefore no longer a committed plan. It is what phase 2 *would* be, kept because
1027
- > the reasoning is still sound where a case for it exists, and the decision belongs to whoever picks
1028
- > the project up next. The three ways forward, in the order the evidence supports them:
1029
- >
1030
- > 1. **Do nothing here and delete the legacy path.** — **taken.** The legacy CPU upload and
1031
- > `TLAS_BUILD` are gone; phase 3 is the next work.
1032
- > 2. **Take the quality work to the BLAS instead.** Everything above is a statement about a TLAS —
1033
- > thousands of leaves, and leaf *boxes* rather than leaf shape dominating. A BLAS is millions of
1034
- > triangles, where the literature's LBVH-vs-PLOC gap (§3.3) was measured and where it should
1035
- > actually bite. If PLOC/H-PLOC is worth building, that is where its case is.
1036
- > 3. **Build it anyway, for a workload not yet measured.** Content with far more instances, or with
1037
- > heavy overlap, could still find the LBVH's weak spot. That case has to be produced and measured
1038
- > first; `sponza_path_trace`'s box-scatter control is the tool for it.
1039
- >
1040
- > The seam holds whichever of the rest is picked up later: "sorted leaf array in, BVH2 topology out" is where any of this plugs
1041
- > in, and nothing about extract/morton/sort or the output contract has to move.
1042
-
1043
- The treelet port, in the DXR Fallback's GPU formulation (§3.1, step 7), appended to the phase-1
1044
- graph after refit:
1045
-
1046
- - **Pass A — clear**: zero treelet counters/list.
1047
- - **Pass B — find base treelets**: one thread per leaf climbs `parents[]` with arrival counters
1048
- (the refit trick again), accumulating subtree primitive counts; on crossing the
1049
- `min_prims_per_treelet` threshold, appends the node to the base-treelet list via an atomic
1050
- cursor and stops.
1051
- - **Pass C — reorder**: one **32-thread workgroup per base treelet**: form the 7-leaf treelet by
1052
- greedy largest-area expansion; evaluate all 128 subset AABBs/areas cooperatively; run the
1053
- subset-partition DP (`partition = (partition - delta) & s` enumeration — identical to the CPU
1054
- code); rewrite the ≤6 internal nodes' links and bounds; climb to the parent treelet via arrival
1055
- counters and repeat. Workgroup memory is the correctness layer (the DP must not assume a wave
1056
- width — subgroup size ranges 4–128 at runtime); subgroup reductions/broadcasts accelerate the
1057
- subset-area evaluation and argmin steps per the csdldf idioms. All cross-group traffic through
1058
- `array<atomic<u32>>`, as in refit.
1059
- - Iterations: quality knob, default 1 per build.
1060
-
1061
- Treelets were also the answer to the depth risk (§7, R4). That reading has changed too: the
1062
- measured depths put the GPU build *below* the legacy tree, so shortening chains is no longer a
1063
- reason to reach for them.
1064
-
1065
- ### 5.2 Phase 3 — BLAS on the GPU
1066
-
1067
- Two sub-phases, in value order:
1068
-
1069
- - **3a — skinned-clone BLAS refit (per frame). DONE.** Built as planned, refit-only, with the
1070
- phase-1 refit pass unchanged. Four deviations, all recorded here because the code is what won:
1071
-
1072
- - **The extraction pass writes node bounds directly, not a `leaf_bounds` array.** A refit has no
1073
- sort and no emission, so nothing consumes a separate array — the refit kernel (originally
1074
- `shader_blas_refit_leaf_bounds`, now the leaf half of `shader_blas_refit_batch`)
1075
- puts each triangle's box straight into the leaf node and the climb reads it there. It gets the
1076
- triangle id by reading the leaf's own `child_2` payload back, so no side table has to agree
1077
- with the tree about which leaf is which.
1078
- - **A clone gets its own *nodes*, but shares its source's *shape*.** Parents and leaf-node
1079
- indices are identical across every copy of a geometry — only bounds are per-instance — so
1080
- `GPUGeometryBVHManager` derives them once per source into two arenas of their own
1081
- (`bvh2_derive_topology`, which walks the compressed bytes from the root so the free list's
1082
- holes are never mistaken for leaves) and every copy points at that.
1083
- - **The BLAS map is keyed by geometry *id*, not by geometry.** A clone and its source share a
1084
- `MeshletGeometry` and collided on it. `obtain(owner, source)` now separates the identity a tree
1085
- is looked up by from the geometry it takes its shape from. This also fixed a live bug in the
1086
- `blas_lookup` table (`blas_lookup_indexing.spec.js`, commit `f1f247438`).
1087
- - **Both settings structs carry arena offsets.** `parents_offset` / `leaf_nodes_offset` join
1088
- `node_word_offset` for the same reason it exists: a build owns its arrays and passes 0, a refit
1089
- is a tenant in shared ones. Counters are sized by *node*, not by leaf — they are indexed by
1090
- node id, and a tree read off an arena numbers its nodes with holes in.
1091
-
1092
- - **[deviation] The refit is one batch per frame, not one per tree** (2026-09-02). The per-tree
1093
- recorder and its leaf kernel are gone. `GPUBLASRefitBatch` records `shader_blas_refit_batch`
1094
- once over every skinned clone, through a per-workgroup lookup and a residency version on the
1095
- BLAS manager, and the climb is shared with this builder's pass 4 as `chunk_bvh_refit_climb`.
1096
- Plan, adversarial review and commit ledger: `geometry/bvh/BLAS_REFIT_BATCH_PLAN.md`.
1097
-
1098
- Verified on an NVIDIA Lovelace device by `src/shade/playground/skinned_blas_refit/`, which holds
1099
- the refit's root box against the bounds-refresh chain's independently computed box over the same
1100
- deformed vertices — two paths sharing no code, and `min`/`max` do not round, so the bar is bit
1101
- equality. Result: bit-identical at every sample, moving 0.39–0.80 of its own span between poses,
1102
- and all 28 105 internal nodes exactly their own children's union, which is what says the interior
1103
- holds and not merely the root. Four clones, four distinct arena regions on a uniform stride.
1104
-
1105
- **Still open:** `shader_build_sdf` reads `blas_lookup[slot_index]` with an SDF atlas slot — a
1106
- third index space over a table that is now unambiguously indexed by geometry id.
1107
- - **3b — full BLAS build (import/procedural).** Same `graph_bvh_build_lbvh` with a triangle
1108
- extraction pass (`leaf_payload = (meshlet << 8) | tri`, computed at extraction). Root lands at
1109
- node 0 with no header — the exact BLAS convention — because Karras emission puts it there. The
1110
- current CPU builder shapes the tree as meshlet-subtrees-under-a-top-tree; **no consumer depends
1111
- on that internal shape** (only on root-at-0 + the leaf payload encoding), so the GPU build's
1112
- global-morton tree is format-legal and expected to trace better. Verify `shader_build_sdf`'s
1113
- root-bounds read and the serialization adapter round-trip (GPU-built BLAS read back once at
1114
- import time for the on-disk cache) before flipping. Batch small geometries through one graph;
1115
- use `graph_build_indirect_dispatch_command` where counts live GPU-side.
1116
-
1117
- ### 5.3 Phase 4 — BVH8
1118
-
1119
- Traversal-side artifacts already exist (`bvh8/webgpu/BVH8_INODE_STRUCT.js` — 80-byte compressed
1120
- wide node — and `chunk_bvh8_node_intersect`, `chunk_bvh8_ray_query_blas_occlusion`,
1121
- `chunk_ray_get_octant_inv4`, `chunk_sign_extend_s8x4`), currently unwired. The build side follows
1122
- the references' shared design: **BVH2 stays the construction intermediate; BVH8 is an encode.**
1123
-
1124
- - **Collapse pass(es)**: bottom-up cost DP over the BVH2 (arrival-counter climb again) deciding
1125
- each node's role in the wide tree per Ylitie et al. 2017 (SAH-driven collapse to ≤8 children,
1126
- which is also where SAH-decided multi-primitive leaves enter, if the leaf format ever grows
1127
- them), then emission of the 80-byte compressed nodes — meep's dormant `BVH8_INODE_STRUCT` is
1128
- already CWBVH-shaped, and CWBVH traversal in WGSL is proven in the wild (§3.3). Conversion runs
1129
- as **per-level dispatches** (~log₈N launches, formally-safe inter-dispatch ordering) rather than
1130
- H-PLOC's single-kernel polling converter, which the paper itself flags for its forward-progress
1131
- requirement — the one place this plan deliberately declines a single-kernel formulation.
1132
- Quantised-box packing needs no `shader-f16` device feature (`pack`-family builtins suffice).
1133
- GPURT's encoder (§3.2) confirms the collapse heuristic — repeatedly open the child with the
1134
- largest surface area until the slots fill — and demonstrates the cooperative refinement
1135
- (8-lane groups per wide node) available if the per-level formulation ever needs the speed.
1136
- - The JCGT data (§3.3) is the planning caveat worth repeating here: widening compresses the
1137
- quality differences between binary builders — so re-run the phase-2 quality debate's numbers on
1138
- BVH8 before spending further on binary-topology quality.
1139
- - Ships **additively**: BVH8 buffers alongside BVH2, consumers migrate per-shader (occlusion /
1140
- RTX shadows first — the existing chunk is the occlusion variant), format compatibility preserved
1141
- throughout; BVH2 bindings retire only where no consumer remains.
1142
-
1143
- ---
1144
-
1145
- ## 6. Adversarial review
1146
-
1147
- The plan above already absorbed several rounds of self-challenge; the surviving objections and
1148
- their dispositions, kept honest:
1149
-
1150
- **AR1 — "You're building LBVH in 2026; raw LBVH trees are noticeably worse than SAH trees, and
1151
- phase 1 ships a quality regression."** **Measured, and largely wrong** — see §4.6's A/B: the
1152
- regression appears only at ~100 instances, where it does not reach the frame, and reverses at 20k,
1153
- where the GPU tree beats the CPU pipeline on SAH, depth and traced frame time alike. The objection
1154
- was right that raw LBVH is worse than a *good* SAH tree; what it missed is that incremental
1155
- insertion at scale does not produce one. Original disposition, kept for the record:
1156
- the regression is temporary by design (phase 2 is the same PR series), bounded (TLAS-over-
1157
- instances is the most LBVH-tolerant tier — leaf count is small and leaf AABBs, not leaf shape,
1158
- dominate), measured (SAH + frame-time gates, not vibes), and reversible (flag). The alternative —
1159
- shipping treelets in the same deliverable — was rejected for review size, not difficulty; the
1160
- phases share every buffer.
1161
-
1162
- **AR2 — "The field retired treelets. AMD ships H-PLOC. You researched this and still plan a TRBVH
1163
- port — that's sentimentality about in-repo code, not engineering."** The strongest objection in
1164
- this review, and it gets the full numbers. Against the treelet plan: H-PLOC delivers ATRBVH-class
1165
- quality with its BVH2 phase **8.2–13× faster** than the treelet approach (same codebase, same
1166
- GPU, AMD's own measurements); RADV replaced PLOC with H-PLOC as the shipping default in Mesa 26.0;
1167
- treelets have a documented GPU-stability history (the DXR pass carries "TDRs on AMD" workaround
1168
- flags); overlapping treelets are rebuilt repeatedly during the ascent by construction. For the
1169
- treelet plan: the transliteration-with-oracle argument is not sentiment — `ebvh_optimize_treelet`
1170
- has a spec suite pinning the exact DP, the DXR Fallback is a complete worked GPU example of the
1171
- same pass, the DP's cooperation pattern is workgroup-shaped and indifferent to wave width
1172
- (whereas H-PLOC's cluster-list threshold is defined *in terms of* the wave size, which in WGSL is
1173
- a runtime value ranging 4–128 — dynamic `T = subgroup_size/2` handling, degenerate at the low
1174
- end, plus known uniformity-analysis friction; subgroups being the engine baseline makes wave ops
1175
- free to *use*, but it does not make wave-*width-dependent algorithm structure* free to port), and
1176
- at TLAS scale the 8–13× speed difference is ~0.5 ms vs ~0.06 ms — real but not decision-grade,
1177
- while implementation risk is. Both paths clear
1178
- the actual gate (SAH parity with today's CPU trees — the acceptance test is SAH-based either way,
1179
- so the oracle advantage matters for *debugging*, not for *acceptance*). Disposition: treelet port
1180
- stays the committed phase-2 path per the stated goal sequencing; **phase 2 opens with an explicit
1181
- decision checkpoint** re-weighing H-PLOC (porting bases exist: NexusBVH in CUDA, Morrical's Slang
1182
- implementation that can emit WGSL) now that phase 1 will have produced our own SAH/depth/frame
1183
- numbers on our own scenes; and the seam is pre-committed so that whichever loses can be replaced
1184
- without touching extract/morton/sort or the output contract. If meep had no in-repo treelet
1185
- implementation, H-PLOC would be the phase-2 recommendation outright.
1186
-
1187
- **AR3 — "Cross-workgroup relaxed-atomic handshakes (refit, treelet climbs) are formally racy
1188
- under the WGSL memory model — WGSL atomics are relaxed, there is no `globallycoherent`, no
1189
- device-scope barrier."** Correct, and this is the plan's most serious technical risk (§7, R1).
1190
- Disposition: (a) every production GPU builder ships this pattern (DXR: `InterlockedAdd` +
1191
- `globallycoherent`; GPURT likewise), and D3D's guarantees here are not materially stronger than
1192
- WGSL's; (b) the engine's line, drawn when `b6049214c` removed OneSweep, is precise and this
1193
- design respects it: what was rejected was **unbounded waiting on another workgroup's progress**,
1194
- not cross-workgroup communication through relaxed atomic words — csdldf itself communicates
1195
- exactly that way, with bounded spin and a fallback. The refit/treelet climbs sit on the accepted
1196
- side: arrival-counter handshakes have no spin at all (the losing thread exits; the winner's
1197
- inputs are already written), so they assume atomic-word *visibility*, never scheduling fairness;
1198
- (c) discipline: all cross-group-communicated state (bounds, counters, links during climbs) goes
1199
- through `array<atomic<u32>>` accesses only, never plain loads/stores gated by an atomic — the
1200
- strictest formulation available in WGSL; (d) the browser verify harness asserts structural
1201
- validity per platform, and a formally-clean fallback (iterative per-level dispatches —
1202
- inter-dispatch ordering is guaranteed by WebGPU) is sketched and cheap to add if a platform ever
1203
- misbehaves. Not hand-waved: named, bounded, fallback designed. **Update, 2026-10-16:** the RTX 4090
1204
- does misbehave, about one refit in 2,000, and (c) alone does not prevent it. The landed answer is
1205
- not the per-level fallback: emission seeds the bounds, the climb defers what it cannot see, and a
1206
- repair dispatch grows it (§4.8).
1207
-
1208
- **AR4 — "The 32-slot traversal stack is shared TLAS+BLAS and your LBVH can exceed the depth the
1209
- current balanced trees keep. Silent geometry drop-out is the failure mode."** The sharpest finding
1210
- of the consumer audit (§2.1). Worst-case Karras depth over 30-bit keys with index tiebreaks is
1211
- ~30 + log₂(duplicate-run), far beyond 31 in adversarial clustering; today's rotation-balanced
1212
- dynamic tree keeps ~1.44·log₂N. Disposition: depth histogram is a first-class assertion in the
1213
- verify harness and an A/B metric on real scenes (phase 1 gate includes "no traversal truncation on
1214
- benchmark scenes"); treelet restructuring (phase 2) actively shortens chains; and if real content
1215
- ever approaches the cliff, `BVH_TRAVERSAL_STACK_SIZE` is a single constant whose 32→64 cost will
1216
- be measured rather than feared. Note the cliff is *pre-existing* (deep TLAS+BLAS combinations can
1217
- overflow today); the builder makes it measurable for the first time.
1218
-
1219
- **AR5 — "Reading instance AABBs from the scene database couples the TLAS to renderer-internal
1220
- tables; the CPU tree's leaves were a clean input."** The coupling already exists — TLAS leaf
1221
- payloads *are* `meshes`-table rows, consumed by `scene_read_mesh` in every traversal; bounds from
1222
- the same row is less coupling, not more (one source of truth instead of two). And CPU-side bounds
1223
- are simply wrong for GPU-authority rows. The builder core stays decoupled regardless: extraction
1224
- is the only scene-aware pass; `graph_bvh_build_lbvh` itself takes flat `leaf_bounds`/
1225
- `leaf_payload` and serves BLAS unchanged.
1226
-
1227
- **AR6 — "Keeping the CPU BVH is duplicated state — kill it."** Rejected: `DirectionalLightShadowmap`
1228
- queries it per cascade per frame on the CPU, and the acoustic/probe systems use the same `BVH`
1229
- class independently. What *does* die: the per-frame CPU treelet pass over it (phase 1), the
1230
- full-tree flatten+upload (phase 1), and the legacy upload path plus its flag (phase 2). The CPU
1231
- tree reverts to what it structurally is — a cheap incremental index for CPU queries.
1232
-
1233
- **AR7 — "The pipeline's backbone is a sort with a forward-progress assumption (tilers, Apple)."**
1234
- This objection **won**, pre-emptively: the engine removed the fallback-less OneSweep port
1235
- (`b6049214c`) and set the direction — build around CSDLDF. The sort is now a deliverable of this
1236
- plan (§4.2.1) whose v1 has no unbounded spin anywhere except inside csdldf's own
1237
- bounded-spin-then-fallback protocol, making it correct on every scheduler b0nes164's warning
1238
- covers ("tends to run on anything that is not mobile, a software rasterizer, or Apple" — §3.3,
1239
- about the removed formulation). The cost is honest: three dispatches per digit instead of one,
1240
- tens of microseconds at TLAS scale, with the single-pass v2 recorded for the day a measurement
1241
- demands it. What remains of the original objection is only schedule risk — the sort is new
1242
- engineering on the critical path — carried as R2.
1243
-
1244
- **AR8 — "Per-frame full rebuild is wasteful; you should refit-update the GPU tree."** Rebuild-
1245
- always is the references' default for TLAS (cheap at instance counts; immune to the
1246
- quality decay that refit-only accumulates under motion) and is dramatically cheaper than what
1247
- ships today. Refit-only (topology frozen, extraction + refit passes alone — both already exist in
1248
- phase 1) is a knob this architecture gets almost for free, and phase 3a *is* that knob applied to
1249
- skinned BLAS, where it matters. Deliberately not a phase-1 deliverable: no measurement yet says
1250
- the rebuild is the bottleneck.
1251
-
1252
- **AR9 — "The emission kernel writes leaves and internals from one dispatch with divergent work
1253
- per thread (thread i does leaf i and maybe internal i) — split it for clarity?"** No: one dispatch
1254
- of N threads with a tail `if` is the canonical LBVH formulation (DXR ships it), avoids a second
1255
- pass over the sorted arrays, and the emulator tier tests it as a pure function either way.
1256
-
1257
- **AR10 — "New directory `gpu_primitive/bvh/` — is that the right home, or is this a scene
1258
- feature?"** `gpu_primitive/` is where engine-level, consumer-agnostic GPU algorithms live (sort,
1259
- prefix_sum, indirect, hash_table); the builder is exactly that (TLAS and BLAS both consume it).
1260
- Scene-specific extraction lives with the scene code. This mirrors how `graph_radix_sort` is a
1261
- primitive while its callers own their data semantics — the "first-class member, not tack-on"
1262
- requirement made structural.
1263
-
1264
- **AR11 — "Apetrei 2014 fuses emission and refit into one kernel with no searches and no spinning —
1265
- you're knowingly shipping the two-kernel 2012 formulation."** Yes, knowingly (§3.4-4). What the
1266
- fused kernel saves at TLAS scale is one small dispatch and some binary searches; what the split
1267
- formulation buys is the project's best test story — the emission kernel is a pure function, so the
1268
- vitest emulator tier executes the actual WGSL against the actual in-repo oracle, duplicate-key
1269
- runs and all — plus a standalone refit pass that phase 3a and refit-only updates require to exist
1270
- regardless. Apetrei changes neither topology nor the output contract, and is the natural
1271
- optimisation to fold in during phase-2 work — note that GPURT's shipping `Linear` builder *is*
1272
- the Apetrei formulation (§3.2), and it is H-PLOC's substrate, so effort spent there compounds
1273
- toward AR2's alternative rather than against it.
1274
-
1275
- ---
1276
-
1277
- ## 7. Risk register
1278
-
1279
- | # | risk | mitigation |
1280
- |---|---|---|
1281
- | R1 | WGSL relaxed-atomic cross-workgroup visibility (refit/treelet climbs) is beyond formal guarantees | **Realised and closed for the refit, 2026-10-16** (§4.8): about one refit in 2,000 at 20,000 leaves on the RTX 4090 read a sibling's box before it arrived, always across workgroups. Emission seeds the bounds with `BVH_UNFITTED`, the climb defers a node it cannot see whole, and an indirect repair grows it after the dispatch boundary. This is exact, with no waits, and costs +8 µs at 100,000 leaves. The same climb serves the single-workgroup build and the skinned BLAS batch, which seed and repair too. Treelet climbs, when they come, inherit the discipline: a read across an in-dispatch handoff must be able to see that it read too early. |
1282
- | R2 | The sort is new engineering on phase 1's critical path (the fallback-less port was removed; nothing shippable exists today) | v1 deliberately assembled from proven parts — `graph_prefix_scan_csdldf` reused as-is for the spine, ranking/scatter machinery adapted from the deleted port in history, interface kept; ships and verifies before the builder consumes it; single-pass v2 deferred behind a measurement. See AR7, §4.2.1. |
1283
- | R3 | ~~Phase-1 quality dip (raw LBVH vs treelet-optimised dynamic tree)~~ | **Retired.** Measured (§4.6): a dip at ~100 instances that does not reach the frame, and an improvement on every axis at 20k. See AR1. |
1284
- | R4 | Tree depth vs the shared 32-slot traversal stack; occlusion path truncates silently | Depth histogram in verify harness + phase gates; treelets shorten chains; `BVH_TRAVERSAL_STACK_SIZE` bump is a measured last resort. Pre-existing cliff, now instrumented — and **measured**: 22/32 at 100k scattered instances, 20/32 at 20k (§4.6). Not a hypothetical any more. See AR4. |
1285
- | R5 | GPU-authority bounds one frame stale at `tlas.update()` encode position | Accepted for phase 1 (strict improvement over CPU build's unbounded staleness); follow-up moves TLAS encoding after the bounds-refresh point in the frame. |
1286
- | R6 | Degenerate inputs: N ∈ {0, 1}, all-identical centroids, zero-extent scene bounds | Explicit encode-time special cases; duplicate-run handling is the transliterated tiebreak, tested at emulator tier; zero-extent axes quantise to 0 like the CPU builder (scale = 0). |
1287
- | R7 | `TerrainSystem.spec.js` and similar pin current TLAS *trigger* behaviour in comments/asserts | Trigger semantics (`#version` guard) are preserved verbatim in phase 1; only the work done per trigger changes. Spec expectations re-checked at phase-1 PR. |
1288
- | R8 | BLAS serialization round-trip (phase 3b) — GPU-built trees must reach the on-disk cache | Import-time readback path; serialized-BVH adoption is already format-first (bytes are adopted verbatim), so a format-identical GPU build slots in. |
1289
- | R9 | Subgroup size is a runtime value (4–128) across devices; wave-width-baked assumptions break silently | Subgroups are used everywhere they pay, but algorithm *structure* stays workgroup-shaped unless width-guarded — the csdldf idioms (`subgroup_size` builtin, `MIN_SUBGROUP_SIZE` handling, `@diagnostic(off, subgroup_uniformity)` only where the analysis over-approximates) are the house style; wave-width-dependent designs (H-PLOC's `T = wave/2`) get this called out at their decision checkpoint (AR2). |
1290
-
1291
- ---
1292
-
1293
- ## 8. Cleanup and hardening backlog (folded into the phases)
1294
-
1295
- Doing this as a first-class citizen also means leaving the neighbourhood cleaner:
1296
-
1297
- - ~~Delete the dead/fallback-less sort variants~~ — **done** (`b6049214c` removed all four,
1298
- OneSweep included). The re-founded sort (§4.2.1) carries a `warn_limited` element-count guard
1299
- from day one, like the scan.
1300
- - ~~Retire the deprecated pre-fallback scan pair once nothing references it:
1301
- `prefix_sum/v1/graph_prefix_scan_csdl.js` + `shader_one_sweep_prefix_scan.js`~~ — **done**; both
1302
- deleted. Nothing referenced either: the graph was the shader's only importer, and none of the
1303
- symbols the pair exported (`graph_prefix_scan_csdl`, `shader_one_sweep_prefix_scan`,
1304
- `ONE_SWEEP_UNIFORMS_STRUCT`, `PREFIX_SCAN_NUM_GROUP_THREADS` / `_NUM_ELEMENTS_PER_GROUP`) was
1305
- named anywhere outside the pair. Same no-fallback class the sort purge removed in `b6049214c` —
1306
- bare decoupled lookback, which assumes cross-workgroup forward progress. `graph_prefix_scan_csdldf`
1307
- / `shader_prefix_scan_csdldf` are the survivors and are what the sort's spine (§4.2.1) dispatches.
1308
- - ~~Supersede the buggy, unused adaptive-morton chunks with the validated fixed-bit
1309
- `chunk_v3_morton_encode`~~ — **done**: the fixed-bit chunk is spec'd bit-exact against
1310
- `v3_morton_encode_magicbits`, and `chunk_calculate_morton_code` /
1311
- `chunk_get_morton_codes_from_unit_coord` are deleted. Neither had an importer — the pair only
1312
- imported each other — and the one textual reference outside it was a sentence in
1313
- `chunk_v3_morton_encode`'s doc comment, reworded with the deletion. Note that `chunk_split_by_3`
1314
- turned out to be invalid WGSL too (`x | x << 16` does not parse — WGSL gives `<<` and `|` no
1315
- relative precedence) and was fixed, since the Morton chunk composes it.
1316
- - ~~Delete `GPUGeometryBVH.js`~~ — **done**. Its header claimed "Currently used"; nothing in the
1317
- tree named the class. It built a CPU per-geometry BVH over raw triangle indices
1318
- (`ebvh_build_for_geometry_morton`, then an optional treelet pass) with both of its tuning
1319
- constants pinned to `0` — so the treelet loop it carried was dead even to itself. The live BLAS
1320
- path is `meshlet_batch_to_bvh.js` building into `GPUGeometryBVHManager`'s arena, with
1321
- `GPUGeometryMeshletsBVH` as the per-geometry record.
1322
- - ~~Decide the fate of `shader/chunk/bvh/packed/`~~ — **deleted**, all nine files; nothing imported
1323
- any of them. *Superseded*: it is a second, competing compaction of BVH2 — a 20-byte node (3 words
1324
- of packed bounds + 2 child words) beside the live 32-byte one — while §5.3 takes compaction in a
1325
- different direction, keeping BVH2 as the construction intermediate and making BVH8 an encode into
1326
- the 80-byte `BVH8_INODE_STRUCT` that already exists with traversal chunks to match. *Broken*:
1327
- `NOTES.md`'s leaf convention ("MSB is set to 0 in `child_1`") is not merely unimplemented, it is
1328
- *inverted* relative to §2.1, where a leaf is `child_1 == 0xFFFFFFFF` — MSB set — while an internal
1329
- node's child index has it clear; `packed_bvh_from_unpacked_node` copies `child_1` through
1330
- untouched, so nothing reconciles the two. *Correction* to the note this replaces: `shader-f16` was
1331
- a red herring — the pair is `pack2x16float`/`unpack2x16float`, core builtins needing no device
1332
- feature (§5.3 says the same of the BVH8 quantised boxes), and both halves validated clean against
1333
- `validate_wgsl_source`, extension list empty, before deletion. **Carry forward into §5.3**: the
1334
- one piece of real logic there, `make_f32_interval_f16_safe`, was wrong and carried its own
1335
- `FIXME` — it widens an interval by an *absolute* `FLT_EPSILON_16` (2⁻¹⁰, the f16 ULP at 1.0), but
1336
- f16 spacing scales with magnitude, so near 1024 (ULP 1.0) the nudge rounds away entirely and the
1337
- bound clips *inward*, dropping geometry from traversal. BVH8's quantised boxes need the same
1338
- conservative outward rounding, and it has to be directed rounding per magnitude — which is what
1339
- the routine's own TODO was pointing at in the DXR Fallback's `CpuBVH2Builder.cpp`.
1340
- - ~~`prefix_sum/v1/test_prefix_sum.js` passes `element_count`/`timer` args that
1341
- `graph_prefix_scan_csdldf` does not accept~~ — **done** in the sort commit.
1342
- - Sort v2 (single-pass digit binning with the per-bin fallback protocol, §4.2.1) — recorded
1343
- upgrade, gated on a measurement from a real consumer.
1344
- - ~~Add `bvh_format.spec.js` locking the §2.1 byte contract~~ — **done**; it holds both producers
1345
- to the same golden bytes.
1346
- - Extend `ComputeShaderEmulator` with sequential atomics (optional; would lift the refit kernel
1347
- into the emulator tier). Its bit-counting builtins (`countLeadingZeros` and siblings) were added
1348
- in phase 1 — `delta` is written in terms of `countLeadingZeros`, so the emission chunks could not
1349
- be executed at all without them.
1350
- - `SoftwareGPUDevice` now accepts a `subgroups` requirement. It runs no WGSL, so the feature changes
1351
- nothing it emulates; refusing it left the orchestration of every subgroup-using pass untestable at
1352
- tier 3. The corollary for future specs: one that stands up a scene and calls
1353
- `GPUSceneContext.update()` must construct its device with `requiredFeatures: ['subgroups']`.
1354
- - `Renderer` requests `Subgroups` as *optional*, so a device without it starts and then throws at
1355
- the first subgroup dispatch. That predates this work (the csdldf scan has the same property) and
1356
- is consistent with subgroups being a stated baseline, but it is the one place where the baseline
1357
- is not enforced where the device is created.
1358
-
1359
- ---
1360
-
1361
- ## 9. Deliverable sequence (summary)
1362
-
1363
- | phase | delivers | new shaders | gate |
1364
- |---|---|---|---|
1365
- | 1 | CSDLDF key-value radix sort (`gpu_primitive/sort` re-founded) + GPU TLAS build (LBVH); CPU treelet+flatten+upload retired from the hot path | sort: histogram, spine (csdldf reused), scatter; builder: extract, morton, emit, refit (+ init) | 4-tier validation green for sort and builder; A/B images clean; build-time win measured; depth instrumented |
1366
- | 2 | GPU treelet optimisation; GPU path becomes default | clear, find-treelets, reorder | SAH + frame-time parity with today's CPU pipeline; legacy CPU upload path deleted |
1367
- | 3a | Skinned-clone BLAS refit (per-frame correct animated BLAS) | triangle-leaf extract (refit reused) | deformed-mesh RT correctness on animation scenes |
1368
- | 3b | Full GPU BLAS build (import + procedural) | (extraction variant only) | format + serialization round-trip; trace parity vs CPU-built BLAS |
1369
- | 4 | BVH8 encode + consumer migration, additive | cost/collapse, encode | traversal win measured per consumer before migration |