@woosh/meep-engine 3.11.3 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (401) hide show
  1. package/README.md +14 -18
  2. package/editor/SelectionVisualizer.js +127 -127
  3. package/editor/entity_world_bounds.js +27 -27
  4. package/editor/particles/rebuildParticleEmitter.js +31 -31
  5. package/editor/process/SymbolicDisplayProcess.js +1 -1
  6. package/editor/process/symbolic/CameraSymbolicDisplay.js +146 -146
  7. package/editor/process/symbolic/LightSymbolicDisplay.js +172 -172
  8. package/editor/prototypeEditorShell.js +156 -156
  9. package/editor/selection/editor_pick.js +130 -130
  10. package/editor/selection/screen_rect_frustum.js +119 -119
  11. package/editor/tools/v2/TransformControls.js +971 -971
  12. package/package.json +1 -1
  13. package/src/engine/Engine.d.ts +3 -3
  14. package/src/engine/Engine.d.ts.map +1 -1
  15. package/src/engine/Engine.js +726 -726
  16. package/src/engine/EngineHarness.d.ts +1 -1
  17. package/src/engine/EngineHarness.js +590 -590
  18. package/src/engine/ecs/HIERARCHY_REVIEW.md +717 -717
  19. package/src/engine/ecs/terrain/ecs/BuildLightTexture.d.ts +1 -1
  20. package/src/engine/ecs/terrain/ecs/BuildLightTexture.js +27 -27
  21. package/src/engine/ecs/terrain/ecs/Terrain.d.ts +1 -5
  22. package/src/engine/ecs/terrain/ecs/Terrain.d.ts.map +1 -1
  23. package/src/engine/ecs/terrain/ecs/Terrain.js +790 -790
  24. package/src/engine/ecs/terrain/util/loadVisibleTerrainTiles.js +129 -129
  25. package/src/engine/graphics/ecs/camera/camera_find_active.d.ts +1 -1
  26. package/src/engine/graphics/ecs/camera/camera_find_active.js +28 -28
  27. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.d.ts +1 -1
  28. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js +428 -428
  29. package/src/engine/graphics/ecs/water/Water.d.ts +1 -1
  30. package/src/engine/graphics/ecs/water/Water.js +104 -104
  31. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.d.ts +1 -1
  32. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js +1 -1
  33. package/src/engine/graphics3/AnimationGraphSystem.d.ts +65 -0
  34. package/src/engine/graphics3/AnimationGraphSystem.d.ts.map +1 -0
  35. package/src/engine/graphics3/AnimationGraphSystem.js +329 -0
  36. package/src/engine/graphics3/AnimationGraphSystem3.d.ts +6 -61
  37. package/src/engine/graphics3/AnimationGraphSystem3.d.ts.map +1 -1
  38. package/src/engine/graphics3/AnimationGraphSystem3.js +6 -325
  39. package/src/engine/graphics3/AnimationSystem.d.ts +76 -0
  40. package/src/engine/graphics3/AnimationSystem.d.ts.map +1 -0
  41. package/src/engine/graphics3/AnimationSystem.js +352 -0
  42. package/src/engine/graphics3/AnimationSystem3.d.ts +6 -72
  43. package/src/engine/graphics3/AnimationSystem3.d.ts.map +1 -1
  44. package/src/engine/graphics3/AnimationSystem3.js +6 -348
  45. package/src/engine/graphics3/CameraSystem.d.ts +49 -0
  46. package/src/engine/graphics3/CameraSystem.d.ts.map +1 -0
  47. package/src/engine/graphics3/CameraSystem.js +170 -0
  48. package/src/engine/graphics3/CameraSystem3.d.ts +6 -45
  49. package/src/engine/graphics3/CameraSystem3.d.ts.map +1 -1
  50. package/src/engine/graphics3/CameraSystem3.js +6 -166
  51. package/src/engine/graphics3/DebugDrawSystem.d.ts +72 -0
  52. package/src/engine/graphics3/DebugDrawSystem.d.ts.map +1 -0
  53. package/src/engine/graphics3/DebugDrawSystem.js +205 -0
  54. package/src/engine/graphics3/DebugDrawSystem3.d.ts +6 -68
  55. package/src/engine/graphics3/DebugDrawSystem3.d.ts.map +1 -1
  56. package/src/engine/graphics3/DebugDrawSystem3.js +6 -201
  57. package/src/engine/graphics3/DecalSystem.d.ts +81 -0
  58. package/src/engine/graphics3/DecalSystem.d.ts.map +1 -0
  59. package/src/engine/graphics3/DecalSystem.js +441 -0
  60. package/src/engine/graphics3/DecalSystem3.d.ts +6 -77
  61. package/src/engine/graphics3/DecalSystem3.d.ts.map +1 -1
  62. package/src/engine/graphics3/DecalSystem3.js +6 -437
  63. package/src/engine/graphics3/FogOfWarSystem.d.ts +74 -0
  64. package/src/engine/graphics3/FogOfWarSystem.d.ts.map +1 -0
  65. package/src/engine/graphics3/FogOfWarSystem.js +265 -0
  66. package/src/engine/graphics3/FogOfWarSystem3.d.ts +6 -70
  67. package/src/engine/graphics3/FogOfWarSystem3.d.ts.map +1 -1
  68. package/src/engine/graphics3/FogOfWarSystem3.js +6 -261
  69. package/src/engine/graphics3/GraphicsEngine.d.ts +340 -0
  70. package/src/engine/graphics3/GraphicsEngine.d.ts.map +1 -0
  71. package/src/engine/graphics3/GraphicsEngine.js +790 -0
  72. package/src/engine/graphics3/GraphicsEngine3.d.ts +6 -336
  73. package/src/engine/graphics3/GraphicsEngine3.d.ts.map +1 -1
  74. package/src/engine/graphics3/GraphicsEngine3.js +6 -786
  75. package/src/engine/graphics3/HighlightSystem.d.ts +82 -0
  76. package/src/engine/graphics3/HighlightSystem.d.ts.map +1 -0
  77. package/src/engine/graphics3/HighlightSystem.js +241 -0
  78. package/src/engine/graphics3/HighlightSystem3.d.ts +6 -78
  79. package/src/engine/graphics3/HighlightSystem3.d.ts.map +1 -1
  80. package/src/engine/graphics3/HighlightSystem3.js +6 -237
  81. package/src/engine/graphics3/LightSystem.d.ts +40 -0
  82. package/src/engine/graphics3/LightSystem.d.ts.map +1 -0
  83. package/src/engine/graphics3/LightSystem.js +211 -0
  84. package/src/engine/graphics3/LightSystem3.d.ts +6 -36
  85. package/src/engine/graphics3/LightSystem3.d.ts.map +1 -1
  86. package/src/engine/graphics3/LightSystem3.js +6 -207
  87. package/src/engine/graphics3/MeshSystem.d.ts +106 -0
  88. package/src/engine/graphics3/MeshSystem.d.ts.map +1 -0
  89. package/src/engine/graphics3/MeshSystem.js +647 -0
  90. package/src/engine/graphics3/MeshSystem3.d.ts +6 -102
  91. package/src/engine/graphics3/MeshSystem3.d.ts.map +1 -1
  92. package/src/engine/graphics3/MeshSystem3.js +6 -642
  93. package/src/engine/graphics3/ParticipatingMedia.d.ts +2 -2
  94. package/src/engine/graphics3/ParticipatingMedia.js +203 -203
  95. package/src/engine/graphics3/ParticipatingMediaSystem.d.ts +87 -0
  96. package/src/engine/graphics3/ParticipatingMediaSystem.d.ts.map +1 -0
  97. package/src/engine/graphics3/ParticipatingMediaSystem.js +184 -0
  98. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +6 -83
  99. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -1
  100. package/src/engine/graphics3/ParticipatingMediaSystem3.js +6 -180
  101. package/src/engine/graphics3/ParticleEmitterSystem.d.ts +76 -0
  102. package/src/engine/graphics3/ParticleEmitterSystem.d.ts.map +1 -0
  103. package/src/engine/graphics3/ParticleEmitterSystem.js +289 -0
  104. package/src/engine/graphics3/ParticleEmitterSystem3.d.ts +6 -72
  105. package/src/engine/graphics3/ParticleEmitterSystem3.d.ts.map +1 -1
  106. package/src/engine/graphics3/ParticleEmitterSystem3.js +6 -285
  107. package/src/engine/graphics3/PathDisplaySystem.d.ts +70 -0
  108. package/src/engine/graphics3/PathDisplaySystem.d.ts.map +1 -0
  109. package/src/engine/graphics3/PathDisplaySystem.js +309 -0
  110. package/src/engine/graphics3/PathDisplaySystem3.d.ts +6 -66
  111. package/src/engine/graphics3/PathDisplaySystem3.d.ts.map +1 -1
  112. package/src/engine/graphics3/PathDisplaySystem3.js +6 -305
  113. package/src/engine/graphics3/Pickable.d.ts +1 -1
  114. package/src/engine/graphics3/Pickable.js +65 -65
  115. package/src/engine/graphics3/PickingQuery.d.ts +1 -1
  116. package/src/engine/graphics3/PickingQuery.js +48 -48
  117. package/src/engine/graphics3/PickingSystem.d.ts +67 -0
  118. package/src/engine/graphics3/PickingSystem.d.ts.map +1 -0
  119. package/src/engine/graphics3/PickingSystem.js +273 -0
  120. package/src/engine/graphics3/PickingSystem3.d.ts +6 -63
  121. package/src/engine/graphics3/PickingSystem3.d.ts.map +1 -1
  122. package/src/engine/graphics3/PickingSystem3.js +6 -269
  123. package/src/engine/graphics3/ShadedGeometrySystem.d.ts +47 -0
  124. package/src/engine/graphics3/ShadedGeometrySystem.d.ts.map +1 -0
  125. package/src/engine/graphics3/ShadedGeometrySystem.js +169 -0
  126. package/src/engine/graphics3/ShadedGeometrySystem3.d.ts +6 -43
  127. package/src/engine/graphics3/ShadedGeometrySystem3.d.ts.map +1 -1
  128. package/src/engine/graphics3/ShadedGeometrySystem3.js +6 -165
  129. package/src/engine/graphics3/TerrainSystem.d.ts +80 -0
  130. package/src/engine/graphics3/TerrainSystem.d.ts.map +1 -0
  131. package/src/engine/graphics3/TerrainSystem.js +566 -0
  132. package/src/engine/graphics3/TerrainSystem3.d.ts +6 -76
  133. package/src/engine/graphics3/TerrainSystem3.d.ts.map +1 -1
  134. package/src/engine/graphics3/TerrainSystem3.js +6 -562
  135. package/src/engine/graphics3/TooltipComponentSystem.d.ts +65 -0
  136. package/src/engine/graphics3/TooltipComponentSystem.d.ts.map +1 -0
  137. package/src/engine/graphics3/TooltipComponentSystem.js +328 -0
  138. package/src/engine/graphics3/TooltipComponentSystem3.d.ts +6 -61
  139. package/src/engine/graphics3/TooltipComponentSystem3.d.ts.map +1 -1
  140. package/src/engine/graphics3/TooltipComponentSystem3.js +6 -324
  141. package/src/engine/graphics3/Trail3DSystem.d.ts +73 -0
  142. package/src/engine/graphics3/Trail3DSystem.d.ts.map +1 -0
  143. package/src/engine/graphics3/Trail3DSystem.js +221 -0
  144. package/src/engine/graphics3/Trail3DSystem3.d.ts +6 -69
  145. package/src/engine/graphics3/Trail3DSystem3.d.ts.map +1 -1
  146. package/src/engine/graphics3/Trail3DSystem3.js +6 -217
  147. package/src/engine/graphics3/VolumetricLightMap.d.ts +2 -2
  148. package/src/engine/graphics3/VolumetricLightMap.js +2 -2
  149. package/src/engine/graphics3/VolumetricLightMapSystem.d.ts +98 -0
  150. package/src/engine/graphics3/VolumetricLightMapSystem.d.ts.map +1 -0
  151. package/src/engine/graphics3/VolumetricLightMapSystem.js +261 -0
  152. package/src/engine/graphics3/VolumetricLightMapSystem3.d.ts +6 -94
  153. package/src/engine/graphics3/VolumetricLightMapSystem3.d.ts.map +1 -1
  154. package/src/engine/graphics3/VolumetricLightMapSystem3.js +10 -261
  155. package/src/engine/graphics3/WaterSystem.d.ts +59 -0
  156. package/src/engine/graphics3/WaterSystem.d.ts.map +1 -0
  157. package/src/engine/graphics3/WaterSystem.js +293 -0
  158. package/src/engine/graphics3/WaterSystem3.d.ts +6 -55
  159. package/src/engine/graphics3/WaterSystem3.d.ts.map +1 -1
  160. package/src/engine/graphics3/WaterSystem3.js +6 -289
  161. package/src/engine/graphics3/animation/AnimationGraphController.d.ts +1 -1
  162. package/src/engine/graphics3/animation/AnimationGraphController.js +518 -518
  163. package/src/engine/graphics3/animation/ClipListPlayer.d.ts +1 -1
  164. package/src/engine/graphics3/animation/ClipListPlayer.js +263 -263
  165. package/src/engine/graphics3/animation/ClipPlayback.d.ts +2 -2
  166. package/src/engine/graphics3/animation/ClipPlayback.js +84 -84
  167. package/src/engine/graphics3/decal/graph_build_decal_clusters.d.ts.map +1 -1
  168. package/src/engine/graphics3/decal/graph_build_decal_clusters.js +6 -8
  169. package/src/engine/graphics3/decal/shader_cluster_assign_decals.d.ts +0 -7
  170. package/src/engine/graphics3/decal/shader_cluster_assign_decals.d.ts.map +1 -1
  171. package/src/engine/graphics3/decal/shader_cluster_assign_decals.js +1 -1
  172. package/src/engine/graphics3/decal/shader_cull_decals_frustum.d.ts +0 -4
  173. package/src/engine/graphics3/decal/shader_cull_decals_frustum.d.ts.map +1 -1
  174. package/src/engine/graphics3/decal/shader_cull_decals_frustum.js +1 -1
  175. package/src/engine/graphics3/highlight/pack_highlight_table.d.ts +2 -2
  176. package/src/engine/graphics3/highlight/pack_highlight_table.d.ts.map +1 -1
  177. package/src/engine/graphics3/highlight/pack_highlight_table.js +74 -74
  178. package/src/engine/graphics3/pose/query_entity_node_world_pose.d.ts +1 -1
  179. package/src/engine/graphics3/pose/query_entity_node_world_pose.js +101 -101
  180. package/src/engine/graphics3/preview/make_model_thumbnail.d.ts +3 -3
  181. package/src/engine/graphics3/preview/make_model_thumbnail.d.ts.map +1 -1
  182. package/src/engine/graphics3/preview/make_model_thumbnail.js +48 -48
  183. package/src/engine/graphics3/shade_camera_projection_ray.d.ts +1 -1
  184. package/src/engine/graphics3/shade_camera_projection_ray.js +77 -77
  185. package/src/engine/graphics3/shade_node_to_entity_composition.d.ts +1 -1
  186. package/src/engine/graphics3/shade_node_to_entity_composition.js +84 -84
  187. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.d.ts.map +1 -1
  188. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.js +0 -1
  189. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.d.ts.map +1 -1
  190. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.js +0 -1
  191. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.d.ts.map +1 -1
  192. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.js +133 -134
  193. package/src/shade/playground/skinned_blas_refit/verify_leaf_triangles.js +202 -202
  194. package/src/shade/playground/skinned_blas_refit/verify_traversal_reachability.js +249 -249
  195. package/src/shade/renderer/STATIC_GRAPHICS_ENGINE_ASSETS.js +1 -1
  196. package/src/shade/renderer/animation/pose/PosePlayback.d.ts +2 -1
  197. package/src/shade/renderer/animation/pose/PosePlayback.d.ts.map +1 -1
  198. package/src/shade/renderer/animation/pose/PosePlayback.js +2 -1
  199. package/src/shade/renderer/animation/shader_animation_apply.d.ts.map +1 -1
  200. package/src/shade/renderer/animation/shader_animation_apply.js +128 -129
  201. package/src/shade/renderer/animation/shader_animation_pose_resolve.d.ts.map +1 -1
  202. package/src/shade/renderer/animation/shader_animation_pose_resolve.js +176 -177
  203. package/src/shade/renderer/animation/shader_animation_tick.d.ts.map +1 -1
  204. package/src/shade/renderer/animation/shader_animation_tick.js +125 -126
  205. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_geometry_sphere_derive.d.ts.map +1 -1
  206. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_geometry_sphere_derive.js +98 -99
  207. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_reduce.d.ts.map +1 -1
  208. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_reduce.js +212 -213
  209. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.d.ts.map +1 -1
  210. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.js +120 -121
  211. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_skinned_mesh_bounds_refresh.d.ts.map +1 -1
  212. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_skinned_mesh_bounds_refresh.js +75 -76
  213. package/src/shade/renderer/animation/skinning/shader_meshlet_apply_skinning.d.ts.map +1 -1
  214. package/src/shade/renderer/animation/skinning/shader_meshlet_apply_skinning.js +371 -372
  215. package/src/shade/renderer/animation/skinning/shader_skin_matrix_prep.d.ts.map +1 -1
  216. package/src/shade/renderer/animation/skinning/shader_skin_matrix_prep.js +104 -105
  217. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.d.ts.map +1 -1
  218. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.js +0 -1
  219. package/src/shade/renderer/buffer/graph_inspect_gpu_buffer.js +136 -136
  220. package/src/shade/renderer/buffer/table/GPUDatabase.d.ts.map +1 -1
  221. package/src/shade/renderer/buffer/table/GPUDatabase.js +2 -3
  222. package/src/shade/renderer/buffer/table/single/GPUSingleTypeTable.js +319 -319
  223. package/src/shade/renderer/buffer/util/shader_buffer_copy_storage_to_storage.d.ts.map +1 -1
  224. package/src/shade/renderer/buffer/util/shader_buffer_copy_storage_to_storage.js +41 -42
  225. package/src/shade/renderer/buffer/util/shader_buffer_intra_copy_regions.d.ts.map +1 -1
  226. package/src/shade/renderer/buffer/util/shader_buffer_intra_copy_regions.js +83 -84
  227. package/src/shade/renderer/dynamic/shader_dynamic_mesh.d.ts.map +1 -1
  228. package/src/shade/renderer/dynamic/shader_dynamic_mesh.js +177 -170
  229. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +918 -918
  230. package/src/shade/renderer/geometry/bvh/record_blas_refit.js +4 -4
  231. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.d.ts.map +1 -1
  232. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.js +121 -122
  233. package/src/shade/renderer/geometry/meshlet/GPUMeshletManager.js +774 -774
  234. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.d.ts +1 -1
  235. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.d.ts.map +1 -1
  236. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.js +69 -70
  237. package/src/shade/renderer/global_illumination/lpv/placement/graph_generate_probe_locations.js +367 -367
  238. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_lvp_atlas_to_sh.js +184 -184
  239. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +1115 -1115
  240. package/src/shade/renderer/gpu_primitive/bvh/graph_bvh_build_lbvh.js +217 -217
  241. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.d.ts.map +1 -1
  242. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.js +142 -143
  243. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.d.ts.map +1 -1
  244. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.js +0 -1
  245. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.d.ts.map +1 -1
  246. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.js +140 -141
  247. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.d.ts.map +1 -1
  248. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.js +0 -1
  249. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.d.ts.map +1 -1
  250. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.js +0 -1
  251. package/src/shade/renderer/light/cluster/cull/graph_cull_lights.d.ts.map +1 -1
  252. package/src/shade/renderer/light/cluster/cull/graph_cull_lights.js +191 -198
  253. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_frustum.d.ts +0 -6
  254. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_frustum.d.ts.map +1 -1
  255. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_frustum.js +120 -120
  256. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_hzb.d.ts +0 -4
  257. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_hzb.d.ts.map +1 -1
  258. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_hzb.js +112 -112
  259. package/src/shade/renderer/light/cluster/graph_assign_clsuters.d.ts.map +1 -1
  260. package/src/shade/renderer/light/cluster/graph_assign_clsuters.js +4 -7
  261. package/src/shade/renderer/light/cluster/shader_cluster_assign_lights.d.ts +0 -6
  262. package/src/shade/renderer/light/cluster/shader_cluster_assign_lights.d.ts.map +1 -1
  263. package/src/shade/renderer/light/cluster/shader_cluster_assign_lights.js +1 -1
  264. package/src/shade/renderer/lightmap/bake/raster/shader_lightmap_to_viz_buffer.js +222 -222
  265. package/src/shade/renderer/lightmap/bake/tile/shader_lightmap_tile_composit.js +81 -81
  266. package/src/shade/renderer/material/GPUMaterialContext.js +380 -380
  267. package/src/shade/renderer/material/standard/fragment_gbuffer_vt.js +169 -169
  268. package/src/shade/renderer/object_property/shader_object_property_process.d.ts.map +1 -1
  269. package/src/shade/renderer/object_property/shader_object_property_process.js +83 -84
  270. package/src/shade/renderer/object_property/shader_refresh_all_mesh_bounds.d.ts.map +1 -1
  271. package/src/shade/renderer/object_property/shader_refresh_all_mesh_bounds.js +57 -58
  272. package/src/shade/renderer/particles/prototypeParticleSystem.js +2 -3
  273. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  274. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +117 -118
  275. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  276. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +77 -79
  277. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  278. package/src/shade/renderer/particles/shaders/shader_particle_render.js +190 -183
  279. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  280. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +99 -100
  281. package/src/shade/renderer/particles/sort/shader_particle_sort.d.ts.map +1 -1
  282. package/src/shade/renderer/particles/sort/shader_particle_sort.js +158 -161
  283. package/src/shade/renderer/path_tracer/graph_path_trace_scene.js +184 -184
  284. package/src/shade/renderer/postprocess/denoise/graph_atorus_denoise_luma.js +119 -119
  285. package/src/shade/renderer/postprocess/dof/raymarch/graph_postprocess_dof.js +3 -4
  286. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch_compute.d.ts.map +1 -1
  287. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch_compute.js +266 -267
  288. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_slice_compute.d.ts.map +1 -1
  289. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_slice_compute.js +270 -271
  290. package/src/shade/renderer/postprocess/nss/shader_nss_concat.d.ts.map +1 -1
  291. package/src/shade/renderer/postprocess/nss/shader_nss_concat.js +68 -69
  292. package/src/shade/renderer/postprocess/nss/shader_nss_conv_layer.d.ts.map +1 -1
  293. package/src/shade/renderer/postprocess/nss/shader_nss_conv_layer.js +225 -226
  294. package/src/shade/renderer/postprocess/nss/shader_nss_extract_feedback.d.ts.map +1 -1
  295. package/src/shade/renderer/postprocess/nss/shader_nss_extract_feedback.js +49 -50
  296. package/src/shade/renderer/postprocess/nss/shader_nss_postprocess.d.ts.map +1 -1
  297. package/src/shade/renderer/postprocess/nss/shader_nss_postprocess.js +291 -292
  298. package/src/shade/renderer/postprocess/nss/shader_nss_preprocess.d.ts.map +1 -1
  299. package/src/shade/renderer/postprocess/nss/shader_nss_preprocess.js +230 -231
  300. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +16 -2
  301. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +1 -1
  302. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.js +12 -12
  303. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.js +2 -2
  304. package/src/shade/renderer/rasterize/bucket/shader_count_meshlets_by_material.d.ts.map +1 -1
  305. package/src/shade/renderer/rasterize/bucket/shader_count_meshlets_by_material.js +68 -69
  306. package/src/shade/renderer/rasterize/bucket/shader_make_meshlet_indirect_draw_commands_by_material.d.ts.map +1 -1
  307. package/src/shade/renderer/rasterize/bucket/shader_make_meshlet_indirect_draw_commands_by_material.js +59 -60
  308. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.d.ts.map +1 -1
  309. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.js +0 -1
  310. package/src/shade/renderer/rasterize/bucket/shader_sort_meshlets_by_material.d.ts.map +1 -1
  311. package/src/shade/renderer/rasterize/bucket/shader_sort_meshlets_by_material.js +64 -65
  312. package/src/shade/renderer/rasterize/compute/graph_mesh_frustum_cull.d.ts.map +1 -1
  313. package/src/shade/renderer/rasterize/compute/graph_mesh_frustum_cull.js +168 -169
  314. package/src/shade/renderer/rasterize/compute/graph_mesh_sphere_cull.d.ts.map +1 -1
  315. package/src/shade/renderer/rasterize/compute/graph_mesh_sphere_cull.js +160 -161
  316. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.d.ts.map +1 -1
  317. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.js +109 -110
  318. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.d.ts.map +1 -1
  319. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.js +115 -116
  320. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_classify_bucket.d.ts.map +1 -1
  321. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_classify_bucket.js +139 -140
  322. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_count_by_rasterization_bucket.d.ts.map +1 -1
  323. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_count_by_rasterization_bucket.js +91 -92
  324. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_rasterization_bucket_sort.d.ts.map +1 -1
  325. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_rasterization_bucket_sort.js +88 -89
  326. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_count_by_rasterization_bucket.d.ts.map +1 -1
  327. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_count_by_rasterization_bucket.js +90 -91
  328. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.d.ts.map +1 -1
  329. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.js +65 -66
  330. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_rasterization_bucket_sort.d.ts.map +1 -1
  331. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_rasterization_bucket_sort.js +89 -90
  332. package/src/shade/renderer/rasterize/expand/graph_mesh_filter_one_way.d.ts.map +1 -1
  333. package/src/shade/renderer/rasterize/expand/graph_mesh_filter_one_way.js +169 -170
  334. package/src/shade/renderer/rasterize/expand/mesh/shader_mesh_expand_to_meshlets_prefix.d.ts.map +1 -1
  335. package/src/shade/renderer/rasterize/expand/mesh/shader_mesh_expand_to_meshlets_prefix.js +89 -90
  336. package/src/shade/renderer/rasterize/expand/mesh/shader_meshes_to_meshlet_counts.d.ts.map +1 -1
  337. package/src/shade/renderer/rasterize/expand/mesh/shader_meshes_to_meshlet_counts.js +61 -62
  338. package/src/shade/renderer/rasterize/expand/pass1/filter_meshlets.2-way.compute.d.ts.map +1 -1
  339. package/src/shade/renderer/rasterize/expand/pass1/filter_meshlets.2-way.compute.js +153 -154
  340. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_alpha_tested_pass_descriptor.js +144 -144
  341. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_pass_descriptor.js +88 -88
  342. package/src/shade/renderer/rasterize/native/oit/shader_oit_generate_moments.js +217 -217
  343. package/src/shade/renderer/rasterize/native/oit/shader_oit_resolve_moments.js +179 -179
  344. package/src/shade/renderer/rasterize/native/oit/shader_oit_resolve_moments_ibl.js +178 -178
  345. package/src/shade/renderer/rasterize/native/viz/viz_rasterization_alpha_tested_pass_descriptor.js +194 -194
  346. package/src/shade/renderer/rasterize/native/viz/viz_rasterization_opaque_pass_descriptor.js +76 -76
  347. package/src/shade/renderer/restir/di/shader_restir_di_denoise.d.ts.map +1 -1
  348. package/src/shade/renderer/restir/di/shader_restir_di_denoise.js +136 -137
  349. package/src/shade/renderer/restir/di/shader_restir_di_resample.d.ts.map +1 -1
  350. package/src/shade/renderer/restir/di/shader_restir_di_resample.js +129 -130
  351. package/src/shade/renderer/restir/di/shader_restir_di_resolve.d.ts.map +1 -1
  352. package/src/shade/renderer/restir/di/shader_restir_di_resolve.js +99 -100
  353. package/src/shade/renderer/restir/di/shader_restir_di_spatial.d.ts.map +1 -1
  354. package/src/shade/renderer/restir/di/shader_restir_di_spatial.js +116 -117
  355. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.js +383 -383
  356. package/src/shade/renderer/scene/hierarchy/graph_scene_update_transform_hierarchy_naive.js +123 -123
  357. package/src/shade/renderer/scene/hierarchy/shader_nodes_record_input_positions.d.ts.map +1 -1
  358. package/src/shade/renderer/scene/hierarchy/shader_nodes_record_input_positions.js +64 -65
  359. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy.d.ts.map +1 -1
  360. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy.js +372 -373
  361. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy_naive.d.ts.map +1 -1
  362. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy_naive.js +111 -112
  363. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.d.ts.map +1 -1
  364. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.js +0 -1
  365. package/src/shade/renderer/shader/ComputeShader.d.ts +24 -3
  366. package/src/shade/renderer/shader/ComputeShader.d.ts.map +1 -1
  367. package/src/shade/renderer/shader/ComputeShader.js +350 -296
  368. package/src/shade/renderer/shader/ImageShader.js +217 -217
  369. package/src/shade/renderer/shader/ShaderDescriptor.d.ts +5 -0
  370. package/src/shade/renderer/shader/ShaderDescriptor.d.ts.map +1 -1
  371. package/src/shade/renderer/shader/ShaderDescriptor.js +20 -0
  372. package/src/shade/renderer/shader/util/compute_infer_workgroup_size_from_source.d.ts +47 -0
  373. package/src/shade/renderer/shader/util/compute_infer_workgroup_size_from_source.d.ts.map +1 -0
  374. package/src/shade/renderer/shader/util/compute_infer_workgroup_size_from_source.js +448 -0
  375. package/src/shade/renderer/shadow/map/csm/graph_csm_setup_cascades.js +3 -3
  376. package/src/shade/renderer/shadow/ray/staged/gen/graph_gen_rays.js +3 -3
  377. package/src/shade/renderer/shadow/ray/staged/trace/graph_trace_rays.js +2 -2
  378. package/src/shade/renderer/shadow/sdf/cascade/CascadedSceneSDF.js +4 -4
  379. package/src/shade/renderer/texture/virtual/build_shader_vt_feedback.d.ts.map +1 -1
  380. package/src/shade/renderer/texture/virtual/build_shader_vt_feedback.js +231 -232
  381. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +1 -1
  382. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  383. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +12 -15
  384. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts +3 -2
  385. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  386. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +3 -2
  387. package/src/shade/renderer/volumetrics/taa/shader_volumetrics_taa.d.ts +0 -2
  388. package/src/shade/renderer/volumetrics/taa/shader_volumetrics_taa.d.ts.map +1 -1
  389. package/src/shade/renderer/volumetrics/taa/shader_volumetrics_taa.js +17 -6
  390. package/src/shade/renderer/water/shader_water.js +246 -246
  391. package/src/shade/vitest.setup.mjs +1 -1
  392. package/src/shade/wgsl/emulator/ComputeShaderEmulator.js +291 -291
  393. package/src/view/minimap/Minimap.d.ts +2 -2
  394. package/src/view/minimap/Minimap.d.ts.map +1 -1
  395. package/src/view/minimap/Minimap.js +225 -225
  396. package/src/view/minimap/dom/MinimapCameraView.d.ts +3 -3
  397. package/src/view/minimap/dom/MinimapCameraView.d.ts.map +1 -1
  398. package/src/view/minimap/dom/MinimapCameraView.js +206 -206
  399. package/src/shade/renderer/shadow/map/csm/shader/shader_setup_cascades.d.ts +0 -3
  400. package/src/shade/renderer/shadow/map/csm/shader/shader_setup_cascades.d.ts.map +0 -1
  401. package/src/shade/renderer/shadow/map/csm/shader/shader_setup_cascades.js +0 -20
@@ -1,1115 +1,1115 @@
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. `TerrainSystem3.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** (`animation/skinning/bounds_refresh/chunk_aabb_atomic_f32.js`):
208
- CAS-spin `aabb_atomic_min_f32` / `aabb_atomic_max_f32` over `atomic<u32>` cells holding plain
209
- f32 bit patterns, plus `shader_meshlet_bounds_reduce.js` as the proven
210
- reduce-then-atomic-merge pattern — this is the scene-AABB pass, essentially verbatim, with one
211
- upgrade: that shader predates the subgroups baseline and reduces through workgroup memory only
212
- (its own comment concedes subgroup ops would be faster); the builder's variant puts
213
- `subgroupMin/Max` in front, workgroup memory only across subgroup boundaries. The same file
214
- also demonstrates the "same buffer bound as `array<atomic<u32>>` in one pass and as typed
215
- structs in another" idiom the refit pass uses.
216
- - **Scene database** (`GPUSceneContext` / `SCENE_DATABASE_SPEC`): the `meshes` table row already
217
- carries `bounding_box: array<f32,6>` (world-space) and is refreshed GPU-side by
218
- `shader_refresh_all_mesh_bounds` for GPU-authority content — the build's input, already resident.
219
- - **Shader authoring**: `CodeChunk` dependency DAG, `ShaderResourceSetDescriptor`,
220
- `WebGPUStruct/Array` with correct WGSL layout math, `ComputeShader.from({...}).dispatch(...)`.
221
- - **Validation tiers** (the standing rule: a vitest spec may not stand up a real GPU device):
222
- (a) tree-sitter WGSL parse + `validate_wgsl_source` (`src/shade/wgsl/`) in vitest;
223
- (b) **`ComputeShaderEmulator`** (`src/shade/wgsl/emulator/`) — compiles WGSL to JS and executes
224
- it point-wise; not a full implementation (no subgroups, no real barrier scheduling), but exactly
225
- right for executing *specific functions and chunks* against CPU oracles, and whole kernels when
226
- they are pure per-invocation functions;
227
- (c) **`SoftwareGPUDevice`** (`src/shade/device/mock/`) — a recording software `GPUDevice`
228
- (encoders, bind groups, buffers, mapping, timestamps, validation) that runs **none** of the
229
- shaders: dispatches land on `SoftwareGPUComputePassEncoder.dispatches` for assertion. The tier
230
- for host-side orchestration: encode the real frame graph against it and assert the dispatch
231
- sequence, bindings, buffer sizes/usages, and host-written bytes;
232
- (d) browser playground verify functions (FrameGraph + `graph_import_typed_buffer` +
233
- `graph_inspect_gpu_buffer` readback + CPU assertions, registered as lil-gui "review checks");
234
- `gpu_test.js` offers a standalone device bootstrap. GPU timing via `GPUTimer` +
235
- `encoder.enable_debug_timers` (note: the `timer` argument `graph_compute_pass` forwards is
236
- silently dropped by `ComputeShader.dispatch` — use the debug-timers path).
237
- - **Morton chunks** (`shader/chunk/bvh/morton/`): the fixed 10-bit `chunk_v3_morton_encode`,
238
- spec'd bit-exact against `v3_morton_encode_magicbits`, is what the build uses (§4.2) for exact
239
- CPU parity, alongside the 2D encode/decode pairs. A WGSL port of the DXR Fallback's
240
- *adaptive-bits* `CalculateMortonCodes.hlsli` also lived here (the repo has been absorbing
241
- reference #1 for a while) — `chunk_calculate_morton_code` plus its
242
- `chunk_get_morton_codes_from_unit_coord` dependency, which never passed WGSL validation
243
- (`if (bit)` on a `u32`; an f32/u32 `min` mix upstream). Both were unused and are now deleted (§8).
244
-
245
- ### 2.4 What does NOT exist yet (the actual gaps)
246
-
247
- 1. A **key-value radix sort** built around CSDLDF (§4.2.1) — the largest single new piece since
248
- commit `b6049214c` removed the fallback-less OneSweep port. A first-class `gpu_primitive/sort`
249
- deliverable in its own right, with the builder as its first consumer.
250
- 2. A WGSL **3D morton encode** chunk composing `chunk_split_by_3` (2D variants exist; 3D does not).
251
- 3. A **scene/leaf AABB reduction** pass (pattern exists in `shader_meshlet_bounds_reduce`; the
252
- general primitive does not).
253
- 4. The **hierarchy emission** kernel (CPU version exists; WGSL does not).
254
- 5. The **bottom-up refit** kernel (no GPU refit exists anywhere in the tree).
255
- 6. Orchestration: a `graph_bvh_build_*` recorder and the `GPUInstancesAccelerationStructure`
256
- integration, including a live-instance-rows input (§4.1).
257
- 7. The **treelet optimisation** kernel (phase 2; CPU version exists).
258
- 8. A BVH2→BVH8 **collapse** pass (phase 4; traversal-side `BVH8_INODE_STRUCT` +
259
- `chunk_bvh8_node_intersect` + `chunk_bvh8_ray_query_blas_occlusion` already exist as an
260
- unwired experiment).
261
-
262
- Scan (with the fallback discipline), reduction idiom, atomic-f32 min/max, indirect dispatch,
263
- struct/layout codegen, graph plumbing, readback, four validation tiers, CPU oracles: all exist.
264
- Phase 1's slice is the sort plus five small builder shaders (items 2–5 and an init pass) and one
265
- integration; items 7–8 belong to later phases.
266
-
267
- ---
268
-
269
- ## 3. Research: the references
270
-
271
- ### 3.1 Microsoft D3D12 Raytracing Fallback Layer (`D3D12RaytracingFallback/src`)
272
-
273
- The most directly relevant reference — same output shape (binary BVH, 32-byte nodes, software
274
- stack traversal with `TRAVERSAL_MAX_STACK_DEPTH 32`), and, remarkably, the same two core
275
- algorithms meep already has on CPU. ~5,400 LOC total for the GPU build path (~26 compute PSOs);
276
- the meep port is far smaller because sort, ingestion, and orchestration already exist.
277
-
278
- Pipeline (`GpuBvh2Builder::BuildBVH`), identical skeleton for TLAS and BLAS:
279
-
280
- 1. **Load elements** — per-geometry dispatches decoding VB/IB into a flat `Primitive[]` scratch
281
- (BLAS), or one dispatch loading instance descs, dereferencing each BLAS root box, transforming
282
- its 8 corners to world space, and pre-inverting the transform (TLAS).
283
- 2. **Scene AABB** — iterative 8:1 ping-pong reduction, O(log₈ n) dispatches.
284
- 3. **Morton codes** — 30-bit, 10 bits/axis, centroid quantised into the scene AABB (an
285
- adaptive-bits `SCALED_MORTON_CODES` variant exists but is dead code — meep carried an unused
286
- WGSL transcription of that same variant until §8 deleted it).
287
- 4. **Sort** — MiniEngine **bitonic** sort (indirect-dispatched), key=morton, payload=index. Not
288
- radix — O(n log²n); the CSDLDF radix sort this plan builds (§4.2.1) is strictly better here.
289
- 5. **Rearrange** — gather elements into sorted order (so leaves need no indirection), optionally
290
- persisting the inverse permutation in the AS for later refit-only updates.
291
- 6. **Hierarchy emission** — **Karras 2012**, cited by name in `BuildBVHSplits.hlsli`; one thread
292
- per internal node; duplicate keys broken by index (same tiebreak as meep's CPU builder). Node
293
- layout: internal nodes `[0, n-2]` (root = 0), leaves `[n-1, 2n-2]`. Children/parents written to
294
- a separate 12-byte `HierarchyNode` scratch, not the final format.
295
- 7. **Treelet reordering** (BLAS only, 0/1/3 passes by build flag) — **Karras & Aila 2013**, cited
296
- by name; treelet size 7, 128-subset SAH DP in groupshared memory, **one 32-thread group per
297
- treelet**, constants `Cbox = 1.2` / `Ctri = 1.0` (the very constants in
298
- `ebvh_optimize_treelet.js`). Three dispatches per pass: clear; find-treelets (bottom-up climb
299
- with `InterlockedAdd` arrival counters, emitting base-treelet roots once a subtree holds ≥
300
- `7·2^pass` primitives); reorder (form treelet by repeatedly expanding the largest-area leaf →
301
- subset DP → rewrite links → climb to the parent treelet within the same dispatch).
302
- 8. **AABB fitting** — single-dispatch bottom-up refit: one thread per leaf climbs parent links;
303
- `InterlockedAdd` on a per-node arrival counter; first arrival terminates, second proceeds with
304
- both children guaranteed written. Buffers declared `globallycoherent`. Also the entire
305
- refit-only update path (`PERFORM_UPDATE`): topology frozen, leaves written through the cached
306
- sort permutation, only this pass re-runs.
307
-
308
- Portability notes that shaped this plan: the builder uses **no wave intrinsics at all**
309
- (threadgroup size 64 chosen as "safest across vendors") — everything except the sort is portable
310
- compute; the load-bearing hazard is `globallycoherent` + relaxed-atomic handshakes for the refit
311
- and treelet climbs, which WGSL can only approximate (§7, R1); the leaf-collapse half of their
312
- treelet pass (multi-triangle leaves) was never finished (`MAX_TRIS_IN_LEAF 1`), so their shipped
313
- trees are 1-primitive-per-leaf — same as meep's format; their per-node layout differs (center/
314
- halfDim, leaf flag in the high bit) but nothing in their pipeline depends on it — the algorithms
315
- transfer to meep's min/max + `child_1`-sentinel format unchanged.
316
-
317
- ### 3.2 AMD GPURT (`GPUOpen-Drivers/gpurt`, dev branch, 2026-08 snapshot)
318
-
319
- The production reference — the shared BVH library under AMD's D3D12 and Vulkan RT drivers.
320
- Verified against source (a full snapshot sits in the session scratchpad). What it teaches, sorted
321
- by relevance to meep:
322
-
323
- **Build-mode architecture.** One shared 64-byte scratch-BVH2 node representation; three builders
324
- over it as a quality dial: `Linear` = **`BuildFastAgglomerativeLbvh.hlsl`** — an *Apetrei-style*
325
- single-pass LBVH (bottom-up walk from leaves; parent chosen by comparing adjacent-code deltas;
326
- first arrival does `InterlockedExchange` on a propagation flag and dies, second arrival owns the
327
- parent and fits its AABB inline — no separate refit dispatch), with **the identical duplicate-key
328
- index tiebreak meep's CPU builder uses** (`32 + clz(i ^ j)`); `PLOC` (TG = 256, radius ≤ 10,
329
- LDS-windowed nearest-neighbour merging, phased task queue); `HPLOC` (TG = 32 = one wave, the
330
- HPG 2024 algorithm of §3.3). Selection: fast-build and small builds → Linear; fast-trace → PLOC;
331
- HPLOC as the single-dispatch compromise. **No treelet restructuring exists in GPURT, and the
332
- older top-down/triangle-splitting builders have been removed from current branches** — the
333
- quality story is entirely "better sort order + PLOC-family clustering + wide-node encode".
334
-
335
- **Morton codes.** Default is **variable-bit** morton (axes sorted by extent; leading bits granted
336
- to dominant axes before regular interleave — the productionised form of the adaptive scheme the
337
- DXR Fallback left as dead code, and which meep's since-deleted `chunk_calculate_morton_code`
338
- transcribed), with
339
- optional surface-area "size bits" interleaved into 64-bit keys. 30-bit codes remain a supported
340
- end-to-end path. Degenerate/inactive primitives get sentinel codes sorting to the end; identical
341
- centroids quantise to 0.5.
342
-
343
- **Sorting.** 4-bit-digit LSD radix (8 passes for 30-bit keys) with a decoupled-lookback scan *or*
344
- barrier-based hierarchical scans as the portable fallback — and, notably, batched builds always
345
- use an LDS **merge sort** instead ("merge sort outperforms radix sort in most cases in the
346
- batched builder"; radix only wins in their parallel path above 50k primitives). Context for meep:
347
- their radix is a weaker configuration than the OneSweep/CSDLDF family (the successors to exactly
348
- this lookback-scan lineage); the datapoint says sort choice is not the quality lever — sort
349
- *order* (morton scheme) is.
350
-
351
- **Orchestration.** Two maintained execution strategies: a **batched multi-dispatch path** (one
352
- dispatch per phase, real barriers between phases) and **BuildParallel** (the whole build in one
353
- persistent-threads dispatch, phase-fenced by spin-loop "task loop" barriers on
354
- `globallycoherent` buffers). Their own structure is the portability verdict: everything
355
- single-dispatch leans on device-scope coherence plus inter-workgroup forward progress — neither
356
- guaranteed in WebGPU — while **the multi-dispatch path is the portable template**, which is
357
- precisely the shape meep's frame-graph pipeline takes. Small-build specialisations exist
358
- (whole-build-in-one-workgroup for ≤1024 primitives with an in-LDS sort; a 16-thread trivial
359
- builder for ≤16) — relevant later for per-geometry BLAS batching.
360
-
361
- **TLAS specifics.** Instance encode fetches the BLAS root bounds (RDNA4: a tighter KDOP-derived
362
- bound), transforms by the instance transform, writes scratch leaves, and atomically grows scene
363
- bounds using the monotonic sign-flip float→u32 mapping + `InterlockedMin/Max` (the cheaper
364
- alternative to meep's CAS chunk; both are portable WGSL). **Rebraid** runs before morton coding:
365
- instances are opened into up to `rebraidFactor` (4) leaves pointing at BLAS root children, budget
366
- distributed by a surface-area-difference priority — the production answer to the
367
- "huge overlapping instance" quality failure LBVH-family TLAS builds share (§3.3). Updates are
368
- pure refit via an update stack; topology is never touched — confirming the phase-3a shape.
369
-
370
- **Wide-node encode.** BVH2 → BVH4/BVH8 collapse by repeatedly opening the child with the largest
371
- surface area (`ApplyHeuristic`; 8-lane lane-groups cooperate per BVH8 node on RDNA4), producing
372
- 128-byte quantised 8-wide nodes (12-bit child bounds against a shared per-axis 8-bit exponent,
373
- children implicitly contiguous). Different final format from CWBVH but the same collapse
374
- philosophy and the same "BVH2 scratch is canonical, wide is an encode" architecture this plan
375
- adopts.
376
-
377
- **Float16/f32 box nodes, triangle pairing, OBBs, DGF** — BLAS-leaf compression machinery, out of
378
- scope until phase 3b+, noted as the direction wide-format work eventually goes.
379
-
380
- ### 3.3 Construction literature and WebGPU state of the art
381
-
382
- The field has converged, and the convergence is recent enough to matter:
383
-
384
- - **Karras 2012** (binary radix tree from sorted morton codes; one thread per internal node,
385
- range/split by longest-common-prefix searches) is the canonical GPU emission kernel — what the
386
- DXR Fallback ships and what meep's CPU builder implements. The duplicate-key index tiebreak is
387
- mandatory on real content (identical instance positions collide long before 2^30 cells fill).
388
- - **Apetrei 2014** ("Fast and Simple Agglomerative LBVH Construction") inverts it: one thread per
389
- leaf walks *upward*, finding each node's parent in O(1) from adjacent-code deltas; an
390
- `atomicExchange` handshake lets the second-arriving thread continue and fit the parent's AABB on
391
- the way — **hierarchy emission and refit fuse into one kernel**, no binary searches, same
392
- topology as Karras (different internal-node index assignment; arXiv:2402.00665 shows how to
393
- recover Karras indexing inside it if wanted). No spinning anywhere → no forward-progress
394
- assumption. This is the "speed of light" binary builder in AMD's terminology and the substrate
395
- H-PLOC is built on.
396
- - **PLOC (2018) → PLOC++ (2022, Intel) → H-PLOC (2024, AMD)**: parallel locally-ordered
397
- clustering — merge mutually-nearest neighbours within a search window of the morton-sorted
398
- cluster list, giving near-agglomerative SAH quality. PLOC++ fuses each iteration into one kernel
399
- (chunked shared-memory NN search, encoded-atomicMin pair propagation). **H-PLOC** composes
400
- Apetrei's upward walk with wave-cooperative PLOC merging of per-subtree cluster lists (threshold
401
- T = wave_size/2, search radius 8), emitting the whole BVH2 in a single kernel: PLOC-class
402
- quality at ~15% over raw-LBVH build cost. It is **the production default in AMD's stack** —
403
- GPURT, and RADV as of Mesa 26.0 (Jan 2026; measured ~1 ms/frame TLAS savings, 2–5% total fps in
404
- shipped titles). Measured TLAS scale: a BVH4 over **1M randomly-sized instances in 2.21 ms** on
405
- an RX 7900 XT (LBVH: 1.96 ms); ~0.25 ms extrapolated at 100k. GPU TLAS builds at meep's scale
406
- are cheap in absolute terms regardless of algorithm.
407
- - **Treelet restructuring (TRBVH 2013 / ATRBVH 2015) has been retired as a primary quality
408
- mechanism by the field.** The H-PLOC paper's head-to-head: ATRBVH's BVH2 phase is **8.2–13×
409
- slower than H-PLOC at indistinguishable SAH quality**, with structural waste (overlapping
410
- treelets repeatedly rebuilt during the ascent). Where more quality is wanted, the literature
411
- reaches for parallel reinsertion (PRBVH) offline, not treelets. This bears directly on phase 2
412
- (§5.1, AR2).
413
- - **How much quality is actually at stake** (Meister & Bittner, JCGT 2022, GPU, secondary/
414
- incoherent rays, normalized to LBVH = 1.00): PLOC 1.25, ATRBVH 1.39, binned SAH 1.43, SBVH 1.86
415
- — with per-scene extremes near 2× on mixed-primitive-size content. Coherent primary rays show
416
- much smaller spreads, and **widening the BVH compresses the gap** (at width 8: PLOC 1.21, SBVH
417
- 1.54 vs LBVH 0.98) — i.e. the eventual BVH8 phase partially launders binary-topology quality
418
- differences. LBVH's specific failure mode is *highly varying primitive sizes* (morton codes see
419
- centroids only); for heavily-overlapping or long-thin instances no bottom-up TLAS builder saves
420
- you — production drivers attack that with **rebraiding** (opening a BLAS root so one instance
421
- contributes several TLAS entries), a later, orthogonal lever worth remembering.
422
- - **Refit vs rebuild for TLAS**: NVIDIA's published best practice is to *rebuild* the TLAS per
423
- frame rather than refit ("the cost savings to refit likely aren't worth sacrificing quality of
424
- TLAS"); refit is for BLAS geometry that "bends but doesn't break". Matches this plan (§4, AR8,
425
- phase 3a).
426
- - **Wide BVH**: everyone builds binary and converts; nobody constructs BVH8 directly. The
427
- reference conversion is Ylitie/Karras/Laine 2017 — a bottom-up cost DP deciding each node's role
428
- (allowing SAH-decided multi-primitive leaves), then top-down emission of **CWBVH**: 80-byte
429
- 8-wide nodes with child boxes quantised to 8 bits/axis against a per-node origin/scale, 1.9–2.1×
430
- faster incoherent traversal at 35–60% of the memory. meep's dormant `BVH8_INODE_STRUCT` is
431
- 80 bytes — the experiment already targets this format, and CWBVH traversal in WGSL is proven in
432
- the wild (albedo / tinybvh-rs). H-PLOC's single-kernel converter carries a genuine
433
- forward-progress hazard (documented in the paper — resident consumers spin on resident
434
- producers); the boring per-level-dispatch collapse (~log₈N ≈ 5–7 launches) is formally safe and
435
- the right WebGPU answer.
436
- - **Sorting**: OneSweep (Adinets & Merrill 2022) is the single-pass state of the art and what
437
- H-PLOC's own pipeline uses — but its bare decoupled lookback spins across workgroups; b0nes164
438
- (whose port meep briefly carried before `b6049214c` removed it for exactly this) states it
439
- "tends to run on anything that is not mobile, a software rasterizer, or Apple", and the
440
- Linebender GPU-sorting survey reaches the same conclusion. The safe formulations are
441
- reduce-then-scan (`DeviceRadixSort`) and — the one meep has standardised on — **CSDLDF**, the
442
- decoupled-fallback protocol from b0nes164's Decoupled-Fallback paper, whose scan artifact is
443
- already ported in-tree. The engine direction is to build the sort around that protocol
444
- (§4.2.1). 32-bit keys are sufficient for TLAS at ≤1M instances; 64-bit keys (for dense BLAS
445
- quality) cost ~3× in sort time and, in WGSL (no u64), mean chained two-word sorts — deferred
446
- until phase 3b demands it.
447
- - **WebGPU state of the art: essentially every public WebGPU ray tracer builds its BVH on the CPU**
448
- (three-mesh-bvh — GPU traversal only; WebRTX; strahl — whose published *CPU* build times,
449
- 327–383 ms for 1M triangles, are the cautionary tale; gnikoloff/re-ovo path tracers; tinybvh's
450
- WGSL consumers). A WGSL GPU *builder* is near-unoccupied territory. Best porting bases found:
451
- MircoWerner/VkLBVH (GLSL Karras LBVH, transliterates to WGSL almost mechanically), NexusBVH
452
- (clean CUDA H-PLOC), and Nate Morrical's H-PLOC in Slang (Slang can emit WGSL), which notably
453
- expects an external radix sort and recommends OneSweep.
454
-
455
- ### 3.4 Synthesis — the choice
456
-
457
- **Phase 1 builds LBVH: morton → CSDLDF radix sort → Karras emission → arrival-counter refit.
458
- Phase 2 adds Karras & Aila treelet restructuring on top.** Reasons, in order of weight:
459
-
460
- 1. **Zero algorithm risk.** Both algorithms exist in-tree as tested CPU implementations
461
- (`ebvh_build_hierarchy_radix`, `ebvh_optimize_treelet`) whose semantics — including the
462
- duplicate-key tiebreak that is the classic LBVH correctness trap — the GPU kernels
463
- transliterate. The DXR Fallback is a complete worked example of the same two algorithms as GPU
464
- compute, in the same 32-byte-node, stack-traversal setting.
465
- 2. **Maximum reuse, one deliberate build.** Reduce idiom, atomic AABB merge, graph plumbing,
466
- scan-with-fallback, validation tiers: existing meep facilities. The one sizeable new primitive
467
- is the key-value radix sort — built around CSDLDF per the engine's sorting direction
468
- (§4.2.1), as a first-class `gpu_primitive` whose first consumer is the builder and whose
469
- spine/protocol come straight from the in-repo `shader_prefix_scan_csdldf`.
470
- 3. **Subgroups-first; portability through the fallback discipline, not through avoidance.**
471
- Subgroups are a baseline requirement of the engine — every kernel here uses them where they
472
- pay (wave pre-reduction before atomics in the bounds pass, ballot/prefix machinery in the
473
- sort's ranking, wave-aggregated allocation and subset evaluation in the treelet DP), following
474
- the csdldf shader's established idioms (`subgroup_size` builtin handling down to
475
- `MIN_SUBGROUP_SIZE = 4`, `@diagnostic(off, subgroup_uniformity)` where the analysis
476
- over-approximates). What keeps the pipeline safe on weakly-scheduled hardware is the CSDLDF
477
- fallback protocol — never spin without a bounded fallback — not renouncing wave ops. (The DXR
478
- Fallback's zero-wave-intrinsics posture was *its* portability answer for 2018 D3D12; it is not
479
- this engine's.)
480
- 4. **Karras emission + separate refit over the fused Apetrei kernel — for phase 1.** Apetrei's
481
- single fused kernel is objectively the leaner LBVH (§3.3) and the same topology. Karras wins
482
- the *first* deliverable on de-risking grounds: the emission kernel is a pure, atomic-free
483
- function of the sorted keys, which makes it (a) a line-for-line transliteration of the spec'd
484
- in-repo oracle and (b) executable on the WGSL emulator in vitest — the strongest device-free
485
- test this project can have, covering the classic LBVH correctness trap (duplicate-key
486
- handling). The refit kernel it requires is not overhead: refit-only rebuilds (DXR's update
487
- mode, phase 3a's skinned BLAS) need it as a standalone pass anyway. Apetrei is recorded as the
488
- recognised follow-up optimisation (AR11) and the substrate H-PLOC builds on.
489
- 5. **PLOC/H-PLOC deferred, seam kept swappable — with eyes open.** The PLOC family (which AMD
490
- ships; §3.2, §3.3) produces better trees than raw LBVH and H-PLOC does it at near-LBVH cost.
491
- It is deferred, not dismissed: it is a genuinely new algorithm with no in-repo oracle, a
492
- wave-*width*-dependent structure (its cluster threshold is defined in terms of the runtime
493
- subgroup size — a different thing from merely using wave ops, which are baseline here), and
494
- real WGSL-uniformity porting friction — while LBVH+treelets reaches today's CPU quality bar
495
- with two oracles in hand. The pipeline's seam — "sorted leaf
496
- array in, BVH2 topology out" — is exactly where an H-PLOC stage would replace emission + refit
497
- + treelets wholesale if the phase-2 gate argues for it (§6, AR2 carries the full argument and
498
- the numbers).
499
- 6. **BVH2 stays the canonical intermediate.** All references converge on this: build a binary
500
- scratch BVH, then encode wide/compressed formats from it. Phases 3–4 consume the same
501
- intermediate this phase produces.
502
-
503
- ---
504
-
505
- ## 4. Phase 1 — GPU TLAS build
506
-
507
- Replace steps 1–3 of `push_to_gpu()` with a GPU pipeline; keep the CPU dynamic tree for CPU
508
- queries; keep the CPU upload path as a fallback flag until phase 2 retires it.
509
-
510
- ### 4.1 Inputs
511
-
512
- - **`meshes` table** (scene database buffer) — world AABB per row, already GPU-resident and
513
- CPU-written each frame for CPU-authority rows (`GPUSceneContext.build()` runs, and submits,
514
- before `tlas.update()` — queue order guarantees visibility), GPU-written for GPU-authority rows.
515
- - **Live rows list** — `array<u32>` of occupied `meshes` rows. `GPUInstancesAccelerationStructure`
516
- already learns exactly this from `instance_add`/`instance_remove`; it keeps a compact
517
- `Uint32Array` mirror and re-uploads it (small: 4·N bytes) only when membership churns
518
- (`#membership_version`). Dead/reused rows never enter the build — no GPU-side liveness scheme is
519
- invented.
520
- - Uniforms: `element_count = N`.
521
-
522
- Input AABBs come from the scene database rather than from the CPU tree's leaves deliberately: it
523
- is the single source of truth, it is correct for GPU-authority instances (the CPU is not), and it
524
- removes the per-frame dependency of the GPU build on CPU-side bounds bookkeeping. One-frame
525
- staleness for GPU-authority bounds (refreshed later in the frame than `tlas.update()` runs) is
526
- accepted in phase 1 — today's CPU build has *unbounded* staleness for those rows, so this is
527
- strictly an improvement; the follow-up that moves TLAS encoding after the bounds-refresh point in
528
- the frame is noted in §8.
529
-
530
- ### 4.2 Passes
531
-
532
- New directory `src/shade/renderer/gpu_primitive/bvh/`, following the `graph_*`/`shader_*`/
533
- `chunk_*` conventions. The pipeline is one `graph_bvh_build_lbvh({graph, leaf_bounds,
534
- leaf_payload, element_count, nodes, root})` recorder plus a TLAS-specific extraction pass, so the
535
- BLAS phase reuses everything downstream of extraction.
536
-
537
- | # | pass | threads | what it does |
538
- |---|---|---|---|
539
- | 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_aabb_atomic_f32` (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. |
540
- | 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`. |
541
- | 2 | `graph_radix_sort` | — | the rebuilt CSDLDF-based sort (§4.2.1). Keys 32-bit (30 used), values = leaf indices. Stable, ascending. |
542
- | 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. |
543
- | 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. |
544
-
545
- Edge cases handled at encode time on the CPU: `N == 0` → skip the graph, write `root =
546
- BVH_NULL_NODE`. Consumers already handle `root == BVH_NULL_NODE` (empty CPU tree uploads it today).
547
-
548
- **[deviation]** `N == 1` gets no special case at all. It falls out of the same four passes: the
549
- sort has nothing to reorder and returns its input, emission's tail `if` skips the internal node and
550
- writes leaf 0 at node 0 with `parents[0] = BVH_NULL_NODE`, and the refit's single thread reads that
551
- terminator and stops. The host-written 36 bytes the plan called for would have had to take the
552
- bounds from the CPU tree, whose copy for a GPU-authority row can be arbitrarily stale — the whole
553
- reason this build reads the `meshes` table. The one thing `N == 1` actually needed was a counters
554
- buffer WebGPU will accept, so it is sized `max(1, N-1)`.
555
-
556
- ### 4.2.1 The sort — `gpu_primitive/sort/`, rebuilt around CSDLDF
557
-
558
- Commit `b6049214c` cleared the ground; this project re-founds the directory with one blessed
559
- implementation. It keeps the deleted port's interface for continuity —
560
- `graph_radix_sort({graph, keys, values, element_count}) → {keys, values}`, u32 keys + u32 values,
561
- ascending, stable, result landing back in the caller's buffers — and is a first-class primitive
562
- with its own spec and verify harness (the deleted `one_sweep.spec.js` /
563
- `verify_radix_sort_one_sweep.js` in history are the direct templates for both).
564
-
565
- **v1 — reduce-then-scan on the csdldf spine** (the shipping shape). LSD radix, 8-bit digits × 4
566
- passes; per digit pass three dispatches: (a) per-tile 256-bin histogram (subgroup-accelerated,
567
- workgroup atomics); (b) spine scan of the `tile × bin` counts, flattened column-major so one scan
568
- yields global digit offsets and per-tile offsets together — run through
569
- **`graph_prefix_scan_csdldf` as-is** (its inclusive-only output consumed exclusively by
570
- subtracting own count; its `{count, elements: vec4u}` 16-byte-aligned layout adopted for the
571
- spine); (c) rank-and-scatter, with the workgroup ranking machinery adapted from the deleted
572
- binning shader in history (subgroup multi-split, and its two pinned WebGPU lessons carried over).
573
- Every dispatch is bounded; the only spin anywhere is csdldf's own bounded-spin-then-fallback.
574
-
575
- **[deviation]** the scatter's ranking is not the deleted port's per-wave-histogram multi-split. That
576
- formulation needs `workgroup_size / subgroup_size * 256` words of workgroup memory, which at the
577
- minimum subgroup size of 4 is 16 KB — the entire budget, with nothing left to stage keys in — which
578
- is precisely why the deleted shader carried a whole second `WLT16` code path with bit-packed
579
- half-radix histograms. What ships instead sorts the tile in workgroup memory with eight stable
580
- binary splits (256 threads × 4 keys = a 1024-key tile, ~10.3 KB of workgroup memory at any subgroup
581
- size). That makes a key's rank its sorted position, gets contiguous per-bin device writes for free,
582
- and lets the tail tile's padding be identified by position alone. Subgroups carry the wide part of
583
- each split's scan; the barrier-carrying loop over per-subgroup partials runs a *constant* trip count
584
- rather than one derived from `subgroup_size`, so no barrier sits under a bound a uniformity analysis
585
- has to reason about. Ballot-based peer aggregation in the histogram pass is the recorded upgrade
586
- there; v1 uses workgroup atomics.
587
- Roughly twelve smallish dispatches per sort against the removed port's seven — at TLAS scale
588
- (≤1M keys) this is tens of microseconds of difference, and it is correct on every scheduler.
589
-
590
- **v2 — single-pass digit binning with the fallback protocol** (recorded upgrade, not scheduled).
591
- OneSweep's chained per-(tile, bin) lookback with the Decoupled-Fallback protocol generalised to
592
- histogram vectors: split flag words per bin following the csdldf READY/INCLUSIVE rules, fallback
593
- re-reducing the stuck predecessor tile's histogram from its keys. Pursued only if a measured
594
- consumer (realistically: BLAS-scale triangle sorts, phase 3b) shows the spine passes matter.
595
-
596
- The sort ships before the builder consumes it — its own verify harness green in the playground,
597
- its shaders through all four validation tiers — so builder bring-up debugging never has to ask
598
- "is it the sort?".
599
-
600
- ### 4.3 Output layout
601
-
602
- `[u32 root = 0][internal 0 .. N-2][leaf N-1 .. 2N-2]`, `2N-1` nodes exactly. This differs from the
603
- CPU path's slot-order-with-holes layout — legal, because (§2.1) nothing consumes ordering, only
604
- format + root word. Root is node 0 by Karras's construction (internal node 0 spans the whole
605
- range), which also natively satisfies the BLAS "root must be node 0, no header" convention when the
606
- same pipeline is pointed at geometry in phase 3.
607
-
608
- Buffers: the persistent output buffer keeps its `STORAGE | COPY_DST | COPY_SRC` usage and grows
609
- via `ensure_buffer_capacity` to `4 + 32·(2N-1)`; the staging copy disappears (the refit writes
610
- final bytes in place). Transients (graph-pooled): `leaf_bounds` 24N, `leaf_payload` 4N, keys/values
611
- 4N each (+ the sort's own internals), `parents` 4·(2N-1), `counters` 4·(N-1), scene bounds 24 B.
612
- For N = 100k: ~5–6 MB transient, pooled and reused across frames — versus today's freshly
613
- allocated ~6.4 MB staging buffer plus its full-buffer copy on every rebuild, and versus the CPU
614
- milliseconds that dominate the current cost.
615
-
616
- ### 4.4 Integration in `GPUInstancesAccelerationStructure`
617
-
618
- - `instance_add/remove/update` unchanged (CPU tree maintenance stays — `DirectionalLightShadowmap`
619
- and any future CPU query walk it).
620
- - `push_to_gpu()` becomes: ensure output capacity → refresh rows buffer if membership churned →
621
- `new FrameGraph()` → import rows + scene database + output → record extraction +
622
- `graph_bvh_build_lbvh` → `ShadeGPUCommandContext.create` / `encodeGraph` / `finish`. Same frame
623
- position (inside `GPUSceneContext.update`, after `build()`), same submit-per-change semantics,
624
- same version guard. **`ebvh_optimize_treelet` no longer runs on the CPU tree** — the tree stays
625
- a plain SAH-insertion/rotation tree, which is what its CPU consumers actually need.
626
- - ~~A module-level flag selects GPU vs legacy CPU upload~~ — **the legacy path is gone.** It
627
- existed for one A/B; that A/B has been run (§4.6), the GPU build won on every axis at instance
628
- scale, and the flag, the treelet call, the flatten and the whole-buffer upload went with it. It
629
- was `TLAS_BUILD.use_gpu` — a settable field rather than the `const USE_GPU_TLAS_BUILD` boolean
630
- the plan named, so the comparison could be a toggle in front of a running scene rather than an
631
- edit and a reload. Anyone needing the comparison again recovers ~40 lines from history at
632
- `23ab7bcd2`, the commit before the deletion. Subgroups are a baseline engine requirement, so there
633
- is no "no-subgroups device" tier to serve: the legacy path (~40 lines) is deleted outright at
634
- the phase-2 gate, flag and all.
635
-
636
- **[deviation]** `GPUSceneContext` needs two lines. The TLAS is constructed with `graphics`
637
- rather than `device` (the build has a frame graph to encode, which needs the buffer allocators), and
638
- `update()` is handed `scene_database_buffer` — the table the world AABBs live in, which the TLAS has
639
- no other way to reach. `GPUSceneContext`'s own API is unchanged, and consumers — every reader of
640
- `tlas.buffer`, and `DirectionalLightShadowmap`'s reader of `tlas.bvh` — need no changes.
641
-
642
- ### 4.5 Validation
643
-
644
- Four tiers, each doing the job only it can do:
645
-
646
- 1. **WGSL validity, vitest** (`bvh_lbvh.spec.js`, sort specs alongside): every new shader parses
647
- (tree-sitter) and passes `validate_wgsl_source` as assembled — subgroup-using shaders
648
- included; this is the tier that catches uniformity-adjacent assembly mistakes before a device
649
- ever sees them.
650
- 2. **`ComputeShaderEmulator`, vitest — functions and chunks first, whole kernels where pure.**
651
- The emulator is not a full device, and that is its value: it executes *specific WGSL
652
- functions* against CPU oracles. Chunk-level: `chunk_v3_morton_encode` vs `v3_morton_encode`;
653
- the emission kernel's `delta` / `determine_range` / `find_split` chunks vs their counterparts
654
- inside `ebvh_build_hierarchy_radix` — each as a directly-invoked function over randomized and
655
- adversarial inputs (duplicate-key runs — the historical failure mode — first among them).
656
- Kernel-level: `shader_bvh_morton` and `shader_bvh_emit_hierarchy` executed point-wise
657
- end-to-end (both are barrier- and atomic-free pure functions of their inputs, which the
658
- emulator schedules faithfully); full-tree topology compared against the CPU builder. The
659
- refit and sort-scatter kernels are atomics/subgroups-bound and are *not* forced through the
660
- emulator — their chunks that are pure (bounds merge math, digit extraction, rank arithmetic)
661
- still get chunk-level emulator coverage.
662
- 3. **`SoftwareGPUDevice`, vitest — the orchestration tier.** Encode the real thing against the
663
- recording device: `GPUInstancesAccelerationStructure.push_to_gpu()` and
664
- `graph_bvh_build_lbvh` run their actual FrameGraph against a `SoftwareGPUDevice`, and the spec
665
- asserts what was encoded — dispatch sequence and group counts per pass, bind groups resolving
666
- to the intended buffers, transient sizes/usages/clears, the rows-list upload happening only on
667
- membership churn, and the `N == 0` / `N == 1` short-circuits (graph skipped, root word written
668
- host-side — bytes the host wrote are assertable here; shader-written bytes are not, by the
669
- mock's own contract). This tier pins the integration and the graph wiring in CI, where neither
670
- the emulator (no host path) nor the browser (no CI) can.
671
- 4. **Browser playground + end-to-end** — **[not run]** this tier is the human's; meep is a
672
- library and the playground mounts in the host repo. Both harnesses are in place and registered.
673
- (`verify_gpu_bvh_build.js` and `verify_radix_sort.js`, registered under "review checks"): randomized instance sets (duplicate positions, degenerate
674
- AABBs, N ∈ {0, 1, 2, 3, powers of two ± 1, ~100k}); readback via `graph_inspect_gpu_buffer`;
675
- assert structural validity (every leaf reachable exactly once; both children valid on every
676
- internal node; parent AABB ⊇ children; root word correct), payload permutation correctness,
677
- **depth histogram** (§7, R4), and SAH cost vs the CPU oracle on the same input — same sorted
678
- order fed to both ⇒ topology must match node-for-node modulo the index mapping. Then the real
679
- consumers: path tracer + RTX shadows on existing playground scenes, CPU-built vs GPU-built A/B
680
- (the flag), image diff + `GPUTimer` numbers for build and trace.
681
-
682
- A `bvh_format.spec.js` locking the byte contract of §2.1 (today enforced only by the shaders
683
- themselves) is added alongside — it pins both the legacy compressor and the GPU layout to the same
684
- golden bytes.
685
-
686
- ### 4.6 Acceptance
687
-
688
- - All four validation tiers green (sort and builder both); traversal-consumer A/B images
689
- identical-or-better.
690
- - Depth histogram within the traversal stack budget on benchmark scenes — no silent traversal
691
- truncation (R4).
692
- - Build time for 100k instances well under the CPU path's. Calibration from the literature
693
- (§3.3): AMD measures a full quality-build TLAS over 1M instances at 2.2 ms on an RX 7900 XT,
694
- ~0.25 ms extrapolated at 100k; raw LBVH ~15% cheaper still. WebGPU overheads on top are exactly
695
- the novel data point this phase produces — measure with `GPUTimer`, don't assume.
696
-
697
- **Measured, 2026-08-26** — `verify_gpu_bvh_build` and `verify_gpu_tlas_build` on one machine, all
698
- 58 builder cases and all 8 TLAS scenes PASS. One machine, one browser, and a harness that makes a
699
- separate submission per case: treat these as an order-of-magnitude reading, not a benchmark.
700
-
701
- - **Topology parity is exact.** Every case's SAH matches the CPU oracle's to the last reported
702
- decimal, at every size and every shape — which is the node-for-node claim, since the SAH is
703
- summed over the same internal nodes. Including 100k coincident instances.
704
- - **Build time, 100k scattered instances: ~0.15 ms** of GPU time across all 19 dispatches (morton
705
- 0.003, sort ~0.082 over four digit passes, emission 0.026, refit 0.040). That sits where §3.3's
706
- extrapolation put a driver-class builder (~0.2 ms), which is the answer to "what do WebGPU
707
- overheads cost" — apparently not much. Two outliers in the log (a 0.24 ms first scan and a
708
- 0.58 ms readback copy in one case) do not recur across the other three digit passes of the same
709
- build and look like queue bubbles between the harness's per-case submissions; a real number for
710
- the frame budget has to come from the in-frame path, not from here.
711
- - **The duplicate-key tiebreak works at scale.** 100,003 instances at *identical* positions — one
712
- Morton code for the entire scene — build to depth 18, not depth 100,003. The depth histogram
713
- (`8:1 9:2 12:32 13:128 14:1536 18:98304`, summing to exactly 100,003) is the binary decomposition
714
- of the index range showing through, which is what `delta`'s `32 + clz32(i ^ j)` produces when no
715
- code carries information. This is the failure mode the whole emission kernel is careful about,
716
- now observed on a device.
717
- - **The ±inf seeding is confirmed.** The `far from origin` TLAS scene (700 instances in a 10-unit
718
- cube offset ~9000 units from the origin) builds to a normal depth spread of 8..16. A zeroed
719
- scene-bounds cell would have anchored the bounds at the origin, crushed every centroid into one
720
- Morton cell, and produced the flat single-depth histogram the coincident case shows.
721
- - **Depth.** 100k scattered instances build to **depth 22 of the 32-entry stack**, and 20k to
722
- depth 20. The stack is shared with the BLAS within one query and the occlusion path truncates
723
- silently past it, so this is a real output constraint — but see the A/B below, where the legacy
724
- CPU tree turns out to be the *deeper* of the two.
725
-
726
- **Consumer A/B, 2026-08-26** — `sponza_path_trace`, path tracer, same camera, same frame, toggling
727
- `TLAS_BUILD.use_gpu` under the running scene. Images verified equivalent by inspection. Frame times
728
- are medians of three runs; SAH is the sum of internal-node surface areas, which over a fixed leaf
729
- set is the term that differs between two trees.
730
-
731
- Sponza as it ships — **103 instances**:
732
-
733
- | | GPU LBVH | CPU pipeline (insertion + rotations + treelets) |
734
- |---|---|---|
735
- | SAH | 43,409 | 22,414 |
736
- | max depth | 13 | 20 |
737
- | traced frame | 69.5 ms | 70.6 ms |
738
-
739
- Sponza plus **20,000 scattered boxes** at sizes spanning two orders of magnitude — the shape that
740
- troubles a Morton order most, and enough instances that traversal cost is in the TLAS rather than
741
- in the per-mesh BLAS:
742
-
743
- | | GPU LBVH | CPU pipeline |
744
- |---|---|---|
745
- | SAH | 713,765 | 941,015 |
746
- | max depth | 20 | 27 |
747
- | traced frame | **132.6 ms** | 176.1 ms |
748
- | rebuild, wall clock | **21.7 ms** | 161.2 ms |
749
-
750
- Read together these say something the plan did not anticipate. At ~100 instances the GPU tree's SAH
751
- is 1.9× the CPU tree's and the traced frame times are identical — the regression AR1 predicted,
752
- and invisible, because a TLAS that small is not what traversal spends its time in. At 20k the
753
- ordering **reverses on every axis**: the GPU tree is 24% better by SAH, seven levels shallower, and
754
- 25% faster to trace.
755
-
756
- Why: the CPU tree is built by incremental SAH-guided insertion with local rotations. At a hundred
757
- instances that is close to optimal. At twenty thousand it is a greedy, order-dependent construction
758
- whose mistakes rotations only locally repair, while the LBVH's global Morton ordering does not
759
- degrade. The treelet pass on top buys 2.4% (22,954 → 22,414 over two passes on the 103-instance
760
- scene) — it repairs neighbourhoods, not a bad global shape.
761
-
762
- Note also the depth column: at 20k the *legacy* tree reaches depth 27 of a 32-entry stack shared
763
- with the BLAS, leaving five. R4's cliff is real, and the GPU build moves away from it rather than
764
- toward it.
765
- - Known, documented, temporary quality dip vs the treelet-optimised CPU tree (raw LBVH SAH cost is
766
- worse; phase 2 exists to close exactly this). The flag stays available until phase 2's gate.
767
-
768
- ---
769
-
770
- ## 5. Phases 2–4
771
-
772
- ### 5.1 Phase 2 — tree quality optimisation on the GPU
773
-
774
- > **Checkpoint, 2026-08-26 — the premise did not survive measurement.** This phase was committed to
775
- > on the expectation that phase 1 ships a quality regression which treelet restructuring then closes
776
- > (AR1, AR2, R3). §4.6's A/B says otherwise: at instance scale the GPU LBVH is *better* than the
777
- > CPU pipeline it replaced — 24% better SAH, seven levels shallower, 25% faster to trace — and at
778
- > the small end where it is worse by SAH, the difference does not reach the frame. The gate this
779
- > phase was to be judged against ("SAH cost within noise of the current CPU pipeline, and
780
- > traversal-heavy consumers at parity or better frame time") is **already met, without treelets**.
781
- >
782
- > What follows is therefore no longer a committed plan. It is what phase 2 *would* be, kept because
783
- > the reasoning is still sound where a case for it exists, and the decision belongs to whoever picks
784
- > the project up next. The three ways forward, in the order the evidence supports them:
785
- >
786
- > 1. **Do nothing here and delete the legacy path.** — **taken.** The legacy CPU upload and
787
- > `TLAS_BUILD` are gone; phase 3 is the next work.
788
- > 2. **Take the quality work to the BLAS instead.** Everything above is a statement about a TLAS —
789
- > thousands of leaves, and leaf *boxes* rather than leaf shape dominating. A BLAS is millions of
790
- > triangles, where the literature's LBVH-vs-PLOC gap (§3.3) was measured and where it should
791
- > actually bite. If PLOC/H-PLOC is worth building, that is where its case is.
792
- > 3. **Build it anyway, for a workload not yet measured.** Content with far more instances, or with
793
- > heavy overlap, could still find the LBVH's weak spot. That case has to be produced and measured
794
- > first; `sponza_path_trace`'s box-scatter control is the tool for it.
795
- >
796
- > The seam holds whichever of the rest is picked up later: "sorted leaf array in, BVH2 topology out" is where any of this plugs
797
- > in, and nothing about extract/morton/sort or the output contract has to move.
798
-
799
- The treelet port, in the DXR Fallback's GPU formulation (§3.1, step 7), appended to the phase-1
800
- graph after refit:
801
-
802
- - **Pass A — clear**: zero treelet counters/list.
803
- - **Pass B — find base treelets**: one thread per leaf climbs `parents[]` with arrival counters
804
- (the refit trick again), accumulating subtree primitive counts; on crossing the
805
- `min_prims_per_treelet` threshold, appends the node to the base-treelet list via an atomic
806
- cursor and stops.
807
- - **Pass C — reorder**: one **32-thread workgroup per base treelet**: form the 7-leaf treelet by
808
- greedy largest-area expansion; evaluate all 128 subset AABBs/areas cooperatively; run the
809
- subset-partition DP (`partition = (partition - delta) & s` enumeration — identical to the CPU
810
- code); rewrite the ≤6 internal nodes' links and bounds; climb to the parent treelet via arrival
811
- counters and repeat. Workgroup memory is the correctness layer (the DP must not assume a wave
812
- width — subgroup size ranges 4–128 at runtime); subgroup reductions/broadcasts accelerate the
813
- subset-area evaluation and argmin steps per the csdldf idioms. All cross-group traffic through
814
- `array<atomic<u32>>`, as in refit.
815
- - Iterations: quality knob, default 1 per build.
816
-
817
- Treelets were also the answer to the depth risk (§7, R4). That reading has changed too: the
818
- measured depths put the GPU build *below* the legacy tree, so shortening chains is no longer a
819
- reason to reach for them.
820
-
821
- ### 5.2 Phase 3 — BLAS on the GPU
822
-
823
- Two sub-phases, in value order:
824
-
825
- - **3a — skinned-clone BLAS refit (per frame). DONE.** Built as planned, refit-only, with the
826
- phase-1 refit pass unchanged. Four deviations, all recorded here because the code is what won:
827
-
828
- - **The extraction pass writes node bounds directly, not a `leaf_bounds` array.** A refit has no
829
- sort and no emission, so nothing consumes a separate array — `shader_blas_refit_leaf_bounds`
830
- puts each triangle's box straight into the leaf node and the climb reads it there. It gets the
831
- triangle id by reading the leaf's own `child_2` payload back, so no side table has to agree
832
- with the tree about which leaf is which.
833
- - **A clone gets its own *nodes*, but shares its source's *shape*.** Parents and leaf-node
834
- indices are identical across every copy of a geometry — only bounds are per-instance — so
835
- `GPUGeometryBVHManager` derives them once per source into two arenas of their own
836
- (`bvh2_derive_topology`, which walks the compressed bytes from the root so the free list's
837
- holes are never mistaken for leaves) and every copy points at that.
838
- - **The BLAS map is keyed by geometry *id*, not by geometry.** A clone and its source share a
839
- `MeshletGeometry` and collided on it. `obtain(owner, source)` now separates the identity a tree
840
- is looked up by from the geometry it takes its shape from. This also fixed a live bug in the
841
- `blas_lookup` table (`blas_lookup_indexing.spec.js`, commit `f1f247438`).
842
- - **Both settings structs carry arena offsets.** `parents_offset` / `leaf_nodes_offset` join
843
- `node_word_offset` for the same reason it exists: a build owns its arrays and passes 0, a refit
844
- is a tenant in shared ones. Counters are sized by *node*, not by leaf — they are indexed by
845
- node id, and a tree read off an arena numbers its nodes with holes in.
846
-
847
- Verified on an NVIDIA Lovelace device by `src/shade/playground/skinned_blas_refit/`, which holds
848
- the refit's root box against the bounds-refresh chain's independently computed box over the same
849
- deformed vertices — two paths sharing no code, and `min`/`max` do not round, so the bar is bit
850
- equality. Result: bit-identical at every sample, moving 0.39–0.80 of its own span between poses,
851
- and all 28 105 internal nodes exactly their own children's union, which is what says the interior
852
- holds and not merely the root. Four clones, four distinct arena regions on a uniform stride.
853
-
854
- **Still open:** `shader_build_sdf` reads `blas_lookup[slot_index]` with an SDF atlas slot — a
855
- third index space over a table that is now unambiguously indexed by geometry id.
856
- - **3b — full BLAS build (import/procedural).** Same `graph_bvh_build_lbvh` with a triangle
857
- extraction pass (`leaf_payload = (meshlet << 8) | tri`, computed at extraction). Root lands at
858
- node 0 with no header — the exact BLAS convention — because Karras emission puts it there. The
859
- current CPU builder shapes the tree as meshlet-subtrees-under-a-top-tree; **no consumer depends
860
- on that internal shape** (only on root-at-0 + the leaf payload encoding), so the GPU build's
861
- global-morton tree is format-legal and expected to trace better. Verify `shader_build_sdf`'s
862
- root-bounds read and the serialization adapter round-trip (GPU-built BLAS read back once at
863
- import time for the on-disk cache) before flipping. Batch small geometries through one graph;
864
- use `graph_build_indirect_dispatch_command` where counts live GPU-side.
865
-
866
- ### 5.3 Phase 4 — BVH8
867
-
868
- Traversal-side artifacts already exist (`bvh8/webgpu/BVH8_INODE_STRUCT.js` — 80-byte compressed
869
- wide node — and `chunk_bvh8_node_intersect`, `chunk_bvh8_ray_query_blas_occlusion`,
870
- `chunk_ray_get_octant_inv4`, `chunk_sign_extend_s8x4`), currently unwired. The build side follows
871
- the references' shared design: **BVH2 stays the construction intermediate; BVH8 is an encode.**
872
-
873
- - **Collapse pass(es)**: bottom-up cost DP over the BVH2 (arrival-counter climb again) deciding
874
- each node's role in the wide tree per Ylitie et al. 2017 (SAH-driven collapse to ≤8 children,
875
- which is also where SAH-decided multi-primitive leaves enter, if the leaf format ever grows
876
- them), then emission of the 80-byte compressed nodes — meep's dormant `BVH8_INODE_STRUCT` is
877
- already CWBVH-shaped, and CWBVH traversal in WGSL is proven in the wild (§3.3). Conversion runs
878
- as **per-level dispatches** (~log₈N launches, formally-safe inter-dispatch ordering) rather than
879
- H-PLOC's single-kernel polling converter, which the paper itself flags for its forward-progress
880
- requirement — the one place this plan deliberately declines a single-kernel formulation.
881
- Quantised-box packing needs no `shader-f16` device feature (`pack`-family builtins suffice).
882
- GPURT's encoder (§3.2) confirms the collapse heuristic — repeatedly open the child with the
883
- largest surface area until the slots fill — and demonstrates the cooperative refinement
884
- (8-lane groups per wide node) available if the per-level formulation ever needs the speed.
885
- - The JCGT data (§3.3) is the planning caveat worth repeating here: widening compresses the
886
- quality differences between binary builders — so re-run the phase-2 quality debate's numbers on
887
- BVH8 before spending further on binary-topology quality.
888
- - Ships **additively**: BVH8 buffers alongside BVH2, consumers migrate per-shader (occlusion /
889
- RTX shadows first — the existing chunk is the occlusion variant), format compatibility preserved
890
- throughout; BVH2 bindings retire only where no consumer remains.
891
-
892
- ---
893
-
894
- ## 6. Adversarial review
895
-
896
- The plan above already absorbed several rounds of self-challenge; the surviving objections and
897
- their dispositions, kept honest:
898
-
899
- **AR1 — "You're building LBVH in 2026; raw LBVH trees are noticeably worse than SAH trees, and
900
- phase 1 ships a quality regression."** **Measured, and largely wrong** — see §4.6's A/B: the
901
- regression appears only at ~100 instances, where it does not reach the frame, and reverses at 20k,
902
- where the GPU tree beats the CPU pipeline on SAH, depth and traced frame time alike. The objection
903
- was right that raw LBVH is worse than a *good* SAH tree; what it missed is that incremental
904
- insertion at scale does not produce one. Original disposition, kept for the record:
905
- the regression is temporary by design (phase 2 is the same PR series), bounded (TLAS-over-
906
- instances is the most LBVH-tolerant tier — leaf count is small and leaf AABBs, not leaf shape,
907
- dominate), measured (SAH + frame-time gates, not vibes), and reversible (flag). The alternative —
908
- shipping treelets in the same deliverable — was rejected for review size, not difficulty; the
909
- phases share every buffer.
910
-
911
- **AR2 — "The field retired treelets. AMD ships H-PLOC. You researched this and still plan a TRBVH
912
- port — that's sentimentality about in-repo code, not engineering."** The strongest objection in
913
- this review, and it gets the full numbers. Against the treelet plan: H-PLOC delivers ATRBVH-class
914
- quality with its BVH2 phase **8.2–13× faster** than the treelet approach (same codebase, same
915
- GPU, AMD's own measurements); RADV replaced PLOC with H-PLOC as the shipping default in Mesa 26.0;
916
- treelets have a documented GPU-stability history (the DXR pass carries "TDRs on AMD" workaround
917
- flags); overlapping treelets are rebuilt repeatedly during the ascent by construction. For the
918
- treelet plan: the transliteration-with-oracle argument is not sentiment — `ebvh_optimize_treelet`
919
- has a spec suite pinning the exact DP, the DXR Fallback is a complete worked GPU example of the
920
- same pass, the DP's cooperation pattern is workgroup-shaped and indifferent to wave width
921
- (whereas H-PLOC's cluster-list threshold is defined *in terms of* the wave size, which in WGSL is
922
- a runtime value ranging 4–128 — dynamic `T = subgroup_size/2` handling, degenerate at the low
923
- end, plus known uniformity-analysis friction; subgroups being the engine baseline makes wave ops
924
- free to *use*, but it does not make wave-*width-dependent algorithm structure* free to port), and
925
- at TLAS scale the 8–13× speed difference is ~0.5 ms vs ~0.06 ms — real but not decision-grade,
926
- while implementation risk is. Both paths clear
927
- the actual gate (SAH parity with today's CPU trees — the acceptance test is SAH-based either way,
928
- so the oracle advantage matters for *debugging*, not for *acceptance*). Disposition: treelet port
929
- stays the committed phase-2 path per the stated goal sequencing; **phase 2 opens with an explicit
930
- decision checkpoint** re-weighing H-PLOC (porting bases exist: NexusBVH in CUDA, Morrical's Slang
931
- implementation that can emit WGSL) now that phase 1 will have produced our own SAH/depth/frame
932
- numbers on our own scenes; and the seam is pre-committed so that whichever loses can be replaced
933
- without touching extract/morton/sort or the output contract. If meep had no in-repo treelet
934
- implementation, H-PLOC would be the phase-2 recommendation outright.
935
-
936
- **AR3 — "Cross-workgroup relaxed-atomic handshakes (refit, treelet climbs) are formally racy
937
- under the WGSL memory model — WGSL atomics are relaxed, there is no `globallycoherent`, no
938
- device-scope barrier."** Correct, and this is the plan's most serious technical risk (§7, R1).
939
- Disposition: (a) every production GPU builder ships this pattern (DXR: `InterlockedAdd` +
940
- `globallycoherent`; GPURT likewise), and D3D's guarantees here are not materially stronger than
941
- WGSL's; (b) the engine's line, drawn when `b6049214c` removed OneSweep, is precise and this
942
- design respects it: what was rejected was **unbounded waiting on another workgroup's progress**,
943
- not cross-workgroup communication through relaxed atomic words — csdldf itself communicates
944
- exactly that way, with bounded spin and a fallback. The refit/treelet climbs sit on the accepted
945
- side: arrival-counter handshakes have no spin at all (the losing thread exits; the winner's
946
- inputs are already written), so they assume atomic-word *visibility*, never scheduling fairness;
947
- (c) discipline: all cross-group-communicated state (bounds, counters, links during climbs) goes
948
- through `array<atomic<u32>>` accesses only, never plain loads/stores gated by an atomic — the
949
- strictest formulation available in WGSL; (d) the browser verify harness asserts structural
950
- validity per platform, and a formally-clean fallback (iterative per-level dispatches —
951
- inter-dispatch ordering is guaranteed by WebGPU) is sketched and cheap to add if a platform ever
952
- misbehaves. Not hand-waved: named, bounded, fallback designed.
953
-
954
- **AR4 — "The 32-slot traversal stack is shared TLAS+BLAS and your LBVH can exceed the depth the
955
- current balanced trees keep. Silent geometry drop-out is the failure mode."** The sharpest finding
956
- of the consumer audit (§2.1). Worst-case Karras depth over 30-bit keys with index tiebreaks is
957
- ~30 + log₂(duplicate-run), far beyond 31 in adversarial clustering; today's rotation-balanced
958
- dynamic tree keeps ~1.44·log₂N. Disposition: depth histogram is a first-class assertion in the
959
- verify harness and an A/B metric on real scenes (phase 1 gate includes "no traversal truncation on
960
- benchmark scenes"); treelet restructuring (phase 2) actively shortens chains; and if real content
961
- ever approaches the cliff, `BVH_TRAVERSAL_STACK_SIZE` is a single constant whose 32→64 cost will
962
- be measured rather than feared. Note the cliff is *pre-existing* (deep TLAS+BLAS combinations can
963
- overflow today); the builder makes it measurable for the first time.
964
-
965
- **AR5 — "Reading instance AABBs from the scene database couples the TLAS to renderer-internal
966
- tables; the CPU tree's leaves were a clean input."** The coupling already exists — TLAS leaf
967
- payloads *are* `meshes`-table rows, consumed by `scene_read_mesh` in every traversal; bounds from
968
- the same row is less coupling, not more (one source of truth instead of two). And CPU-side bounds
969
- are simply wrong for GPU-authority rows. The builder core stays decoupled regardless: extraction
970
- is the only scene-aware pass; `graph_bvh_build_lbvh` itself takes flat `leaf_bounds`/
971
- `leaf_payload` and serves BLAS unchanged.
972
-
973
- **AR6 — "Keeping the CPU BVH is duplicated state — kill it."** Rejected: `DirectionalLightShadowmap`
974
- queries it per cascade per frame on the CPU, and the acoustic/probe systems use the same `BVH`
975
- class independently. What *does* die: the per-frame CPU treelet pass over it (phase 1), the
976
- full-tree flatten+upload (phase 1), and the legacy upload path plus its flag (phase 2). The CPU
977
- tree reverts to what it structurally is — a cheap incremental index for CPU queries.
978
-
979
- **AR7 — "The pipeline's backbone is a sort with a forward-progress assumption (tilers, Apple)."**
980
- This objection **won**, pre-emptively: the engine removed the fallback-less OneSweep port
981
- (`b6049214c`) and set the direction — build around CSDLDF. The sort is now a deliverable of this
982
- plan (§4.2.1) whose v1 has no unbounded spin anywhere except inside csdldf's own
983
- bounded-spin-then-fallback protocol, making it correct on every scheduler b0nes164's warning
984
- covers ("tends to run on anything that is not mobile, a software rasterizer, or Apple" — §3.3,
985
- about the removed formulation). The cost is honest: three dispatches per digit instead of one,
986
- tens of microseconds at TLAS scale, with the single-pass v2 recorded for the day a measurement
987
- demands it. What remains of the original objection is only schedule risk — the sort is new
988
- engineering on the critical path — carried as R2.
989
-
990
- **AR8 — "Per-frame full rebuild is wasteful; you should refit-update the GPU tree."** Rebuild-
991
- always is the references' default for TLAS (cheap at instance counts; immune to the
992
- quality decay that refit-only accumulates under motion) and is dramatically cheaper than what
993
- ships today. Refit-only (topology frozen, extraction + refit passes alone — both already exist in
994
- phase 1) is a knob this architecture gets almost for free, and phase 3a *is* that knob applied to
995
- skinned BLAS, where it matters. Deliberately not a phase-1 deliverable: no measurement yet says
996
- the rebuild is the bottleneck.
997
-
998
- **AR9 — "The emission kernel writes leaves and internals from one dispatch with divergent work
999
- per thread (thread i does leaf i and maybe internal i) — split it for clarity?"** No: one dispatch
1000
- of N threads with a tail `if` is the canonical LBVH formulation (DXR ships it), avoids a second
1001
- pass over the sorted arrays, and the emulator tier tests it as a pure function either way.
1002
-
1003
- **AR10 — "New directory `gpu_primitive/bvh/` — is that the right home, or is this a scene
1004
- feature?"** `gpu_primitive/` is where engine-level, consumer-agnostic GPU algorithms live (sort,
1005
- prefix_sum, indirect, hash_table); the builder is exactly that (TLAS and BLAS both consume it).
1006
- Scene-specific extraction lives with the scene code. This mirrors how `graph_radix_sort` is a
1007
- primitive while its callers own their data semantics — the "first-class member, not tack-on"
1008
- requirement made structural.
1009
-
1010
- **AR11 — "Apetrei 2014 fuses emission and refit into one kernel with no searches and no spinning —
1011
- you're knowingly shipping the two-kernel 2012 formulation."** Yes, knowingly (§3.4-4). What the
1012
- fused kernel saves at TLAS scale is one small dispatch and some binary searches; what the split
1013
- formulation buys is the project's best test story — the emission kernel is a pure function, so the
1014
- vitest emulator tier executes the actual WGSL against the actual in-repo oracle, duplicate-key
1015
- runs and all — plus a standalone refit pass that phase 3a and refit-only updates require to exist
1016
- regardless. Apetrei changes neither topology nor the output contract, and is the natural
1017
- optimisation to fold in during phase-2 work — note that GPURT's shipping `Linear` builder *is*
1018
- the Apetrei formulation (§3.2), and it is H-PLOC's substrate, so effort spent there compounds
1019
- toward AR2's alternative rather than against it.
1020
-
1021
- ---
1022
-
1023
- ## 7. Risk register
1024
-
1025
- | # | risk | mitigation |
1026
- |---|---|---|
1027
- | R1 | WGSL relaxed-atomic cross-workgroup visibility (refit/treelet climbs) is beyond formal guarantees | All-atomic access discipline; no unbounded waiting anywhere (arrival-counter losers exit); per-platform structural verification in the harness; per-level-dispatch fallback designed (inter-dispatch ordering is spec-guaranteed); precedent: csdldf ships on the same visibility assumption. See AR3. |
1028
- | 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. |
1029
- | 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. |
1030
- | 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. |
1031
- | 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. |
1032
- | 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). |
1033
- | R7 | `TerrainSystem3.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. |
1034
- | 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. |
1035
- | 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). |
1036
-
1037
- ---
1038
-
1039
- ## 8. Cleanup and hardening backlog (folded into the phases)
1040
-
1041
- Doing this as a first-class citizen also means leaving the neighbourhood cleaner:
1042
-
1043
- - ~~Delete the dead/fallback-less sort variants~~ — **done** (`b6049214c` removed all four,
1044
- OneSweep included). The re-founded sort (§4.2.1) carries a `warn_limited` element-count guard
1045
- from day one, like the scan.
1046
- - ~~Retire the deprecated pre-fallback scan pair once nothing references it:
1047
- `prefix_sum/v1/graph_prefix_scan_csdl.js` + `shader_one_sweep_prefix_scan.js`~~ — **done**; both
1048
- deleted. Nothing referenced either: the graph was the shader's only importer, and none of the
1049
- symbols the pair exported (`graph_prefix_scan_csdl`, `shader_one_sweep_prefix_scan`,
1050
- `ONE_SWEEP_UNIFORMS_STRUCT`, `PREFIX_SCAN_NUM_GROUP_THREADS` / `_NUM_ELEMENTS_PER_GROUP`) was
1051
- named anywhere outside the pair. Same no-fallback class the sort purge removed in `b6049214c` —
1052
- bare decoupled lookback, which assumes cross-workgroup forward progress. `graph_prefix_scan_csdldf`
1053
- / `shader_prefix_scan_csdldf` are the survivors and are what the sort's spine (§4.2.1) dispatches.
1054
- - ~~Supersede the buggy, unused adaptive-morton chunks with the validated fixed-bit
1055
- `chunk_v3_morton_encode`~~ — **done**: the fixed-bit chunk is spec'd bit-exact against
1056
- `v3_morton_encode_magicbits`, and `chunk_calculate_morton_code` /
1057
- `chunk_get_morton_codes_from_unit_coord` are deleted. Neither had an importer — the pair only
1058
- imported each other — and the one textual reference outside it was a sentence in
1059
- `chunk_v3_morton_encode`'s doc comment, reworded with the deletion. Note that `chunk_split_by_3`
1060
- turned out to be invalid WGSL too (`x | x << 16` does not parse — WGSL gives `<<` and `|` no
1061
- relative precedence) and was fixed, since the Morton chunk composes it.
1062
- - ~~Delete `GPUGeometryBVH.js`~~ — **done**. Its header claimed "Currently used"; nothing in the
1063
- tree named the class. It built a CPU per-geometry BVH over raw triangle indices
1064
- (`ebvh_build_for_geometry_morton`, then an optional treelet pass) with both of its tuning
1065
- constants pinned to `0` — so the treelet loop it carried was dead even to itself. The live BLAS
1066
- path is `meshlet_batch_to_bvh.js` building into `GPUGeometryBVHManager`'s arena, with
1067
- `GPUGeometryMeshletsBVH` as the per-geometry record.
1068
- - ~~Decide the fate of `shader/chunk/bvh/packed/`~~ — **deleted**, all nine files; nothing imported
1069
- any of them. *Superseded*: it is a second, competing compaction of BVH2 — a 20-byte node (3 words
1070
- of packed bounds + 2 child words) beside the live 32-byte one — while §5.3 takes compaction in a
1071
- different direction, keeping BVH2 as the construction intermediate and making BVH8 an encode into
1072
- the 80-byte `BVH8_INODE_STRUCT` that already exists with traversal chunks to match. *Broken*:
1073
- `NOTES.md`'s leaf convention ("MSB is set to 0 in `child_1`") is not merely unimplemented, it is
1074
- *inverted* relative to §2.1, where a leaf is `child_1 == 0xFFFFFFFF` — MSB set — while an internal
1075
- node's child index has it clear; `packed_bvh_from_unpacked_node` copies `child_1` through
1076
- untouched, so nothing reconciles the two. *Correction* to the note this replaces: `shader-f16` was
1077
- a red herring — the pair is `pack2x16float`/`unpack2x16float`, core builtins needing no device
1078
- feature (§5.3 says the same of the BVH8 quantised boxes), and both halves validated clean against
1079
- `validate_wgsl_source`, extension list empty, before deletion. **Carry forward into §5.3**: the
1080
- one piece of real logic there, `make_f32_interval_f16_safe`, was wrong and carried its own
1081
- `FIXME` — it widens an interval by an *absolute* `FLT_EPSILON_16` (2⁻¹⁰, the f16 ULP at 1.0), but
1082
- f16 spacing scales with magnitude, so near 1024 (ULP 1.0) the nudge rounds away entirely and the
1083
- bound clips *inward*, dropping geometry from traversal. BVH8's quantised boxes need the same
1084
- conservative outward rounding, and it has to be directed rounding per magnitude — which is what
1085
- the routine's own TODO was pointing at in the DXR Fallback's `CpuBVH2Builder.cpp`.
1086
- - ~~`prefix_sum/v1/test_prefix_sum.js` passes `element_count`/`timer` args that
1087
- `graph_prefix_scan_csdldf` does not accept~~ — **done** in the sort commit.
1088
- - Sort v2 (single-pass digit binning with the per-bin fallback protocol, §4.2.1) — recorded
1089
- upgrade, gated on a measurement from a real consumer.
1090
- - ~~Add `bvh_format.spec.js` locking the §2.1 byte contract~~ — **done**; it holds both producers
1091
- to the same golden bytes.
1092
- - Extend `ComputeShaderEmulator` with sequential atomics (optional; would lift the refit kernel
1093
- into the emulator tier). Its bit-counting builtins (`countLeadingZeros` and siblings) were added
1094
- in phase 1 — `delta` is written in terms of `countLeadingZeros`, so the emission chunks could not
1095
- be executed at all without them.
1096
- - `SoftwareGPUDevice` now accepts a `subgroups` requirement. It runs no WGSL, so the feature changes
1097
- nothing it emulates; refusing it left the orchestration of every subgroup-using pass untestable at
1098
- tier 3. The corollary for future specs: one that stands up a scene and calls
1099
- `GPUSceneContext.update()` must construct its device with `requiredFeatures: ['subgroups']`.
1100
- - `Renderer` requests `Subgroups` as *optional*, so a device without it starts and then throws at
1101
- the first subgroup dispatch. That predates this work (the csdldf scan has the same property) and
1102
- is consistent with subgroups being a stated baseline, but it is the one place where the baseline
1103
- is not enforced where the device is created.
1104
-
1105
- ---
1106
-
1107
- ## 9. Deliverable sequence (summary)
1108
-
1109
- | phase | delivers | new shaders | gate |
1110
- |---|---|---|---|
1111
- | 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 |
1112
- | 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 |
1113
- | 3a | Skinned-clone BLAS refit (per-frame correct animated BLAS) | triangle-leaf extract (refit reused) | deformed-mesh RT correctness on animation scenes |
1114
- | 3b | Full GPU BLAS build (import + procedural) | (extraction variant only) | format + serialization round-trip; trace parity vs CPU-built BLAS |
1115
- | 4 | BVH8 encode + consumer migration, additive | cost/collapse, encode | traversal win measured per consumer before migration |
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** (`animation/skinning/bounds_refresh/chunk_aabb_atomic_f32.js`):
208
+ CAS-spin `aabb_atomic_min_f32` / `aabb_atomic_max_f32` over `atomic<u32>` cells holding plain
209
+ f32 bit patterns, plus `shader_meshlet_bounds_reduce.js` as the proven
210
+ reduce-then-atomic-merge pattern — this is the scene-AABB pass, essentially verbatim, with one
211
+ upgrade: that shader predates the subgroups baseline and reduces through workgroup memory only
212
+ (its own comment concedes subgroup ops would be faster); the builder's variant puts
213
+ `subgroupMin/Max` in front, workgroup memory only across subgroup boundaries. The same file
214
+ also demonstrates the "same buffer bound as `array<atomic<u32>>` in one pass and as typed
215
+ structs in another" idiom the refit pass uses.
216
+ - **Scene database** (`GPUSceneContext` / `SCENE_DATABASE_SPEC`): the `meshes` table row already
217
+ carries `bounding_box: array<f32,6>` (world-space) and is refreshed GPU-side by
218
+ `shader_refresh_all_mesh_bounds` for GPU-authority content — the build's input, already resident.
219
+ - **Shader authoring**: `CodeChunk` dependency DAG, `ShaderResourceSetDescriptor`,
220
+ `WebGPUStruct/Array` with correct WGSL layout math, `ComputeShader.from({...}).dispatch(...)`.
221
+ - **Validation tiers** (the standing rule: a vitest spec may not stand up a real GPU device):
222
+ (a) tree-sitter WGSL parse + `validate_wgsl_source` (`src/shade/wgsl/`) in vitest;
223
+ (b) **`ComputeShaderEmulator`** (`src/shade/wgsl/emulator/`) — compiles WGSL to JS and executes
224
+ it point-wise; not a full implementation (no subgroups, no real barrier scheduling), but exactly
225
+ right for executing *specific functions and chunks* against CPU oracles, and whole kernels when
226
+ they are pure per-invocation functions;
227
+ (c) **`SoftwareGPUDevice`** (`src/shade/device/mock/`) — a recording software `GPUDevice`
228
+ (encoders, bind groups, buffers, mapping, timestamps, validation) that runs **none** of the
229
+ shaders: dispatches land on `SoftwareGPUComputePassEncoder.dispatches` for assertion. The tier
230
+ for host-side orchestration: encode the real frame graph against it and assert the dispatch
231
+ sequence, bindings, buffer sizes/usages, and host-written bytes;
232
+ (d) browser playground verify functions (FrameGraph + `graph_import_typed_buffer` +
233
+ `graph_inspect_gpu_buffer` readback + CPU assertions, registered as lil-gui "review checks");
234
+ `gpu_test.js` offers a standalone device bootstrap. GPU timing via `GPUTimer` +
235
+ `encoder.enable_debug_timers` (note: the `timer` argument `graph_compute_pass` forwards is
236
+ silently dropped by `ComputeShader.dispatch` — use the debug-timers path).
237
+ - **Morton chunks** (`shader/chunk/bvh/morton/`): the fixed 10-bit `chunk_v3_morton_encode`,
238
+ spec'd bit-exact against `v3_morton_encode_magicbits`, is what the build uses (§4.2) for exact
239
+ CPU parity, alongside the 2D encode/decode pairs. A WGSL port of the DXR Fallback's
240
+ *adaptive-bits* `CalculateMortonCodes.hlsli` also lived here (the repo has been absorbing
241
+ reference #1 for a while) — `chunk_calculate_morton_code` plus its
242
+ `chunk_get_morton_codes_from_unit_coord` dependency, which never passed WGSL validation
243
+ (`if (bit)` on a `u32`; an f32/u32 `min` mix upstream). Both were unused and are now deleted (§8).
244
+
245
+ ### 2.4 What does NOT exist yet (the actual gaps)
246
+
247
+ 1. A **key-value radix sort** built around CSDLDF (§4.2.1) — the largest single new piece since
248
+ commit `b6049214c` removed the fallback-less OneSweep port. A first-class `gpu_primitive/sort`
249
+ deliverable in its own right, with the builder as its first consumer.
250
+ 2. A WGSL **3D morton encode** chunk composing `chunk_split_by_3` (2D variants exist; 3D does not).
251
+ 3. A **scene/leaf AABB reduction** pass (pattern exists in `shader_meshlet_bounds_reduce`; the
252
+ general primitive does not).
253
+ 4. The **hierarchy emission** kernel (CPU version exists; WGSL does not).
254
+ 5. The **bottom-up refit** kernel (no GPU refit exists anywhere in the tree).
255
+ 6. Orchestration: a `graph_bvh_build_*` recorder and the `GPUInstancesAccelerationStructure`
256
+ integration, including a live-instance-rows input (§4.1).
257
+ 7. The **treelet optimisation** kernel (phase 2; CPU version exists).
258
+ 8. A BVH2→BVH8 **collapse** pass (phase 4; traversal-side `BVH8_INODE_STRUCT` +
259
+ `chunk_bvh8_node_intersect` + `chunk_bvh8_ray_query_blas_occlusion` already exist as an
260
+ unwired experiment).
261
+
262
+ Scan (with the fallback discipline), reduction idiom, atomic-f32 min/max, indirect dispatch,
263
+ struct/layout codegen, graph plumbing, readback, four validation tiers, CPU oracles: all exist.
264
+ Phase 1's slice is the sort plus five small builder shaders (items 2–5 and an init pass) and one
265
+ integration; items 7–8 belong to later phases.
266
+
267
+ ---
268
+
269
+ ## 3. Research: the references
270
+
271
+ ### 3.1 Microsoft D3D12 Raytracing Fallback Layer (`D3D12RaytracingFallback/src`)
272
+
273
+ The most directly relevant reference — same output shape (binary BVH, 32-byte nodes, software
274
+ stack traversal with `TRAVERSAL_MAX_STACK_DEPTH 32`), and, remarkably, the same two core
275
+ algorithms meep already has on CPU. ~5,400 LOC total for the GPU build path (~26 compute PSOs);
276
+ the meep port is far smaller because sort, ingestion, and orchestration already exist.
277
+
278
+ Pipeline (`GpuBvh2Builder::BuildBVH`), identical skeleton for TLAS and BLAS:
279
+
280
+ 1. **Load elements** — per-geometry dispatches decoding VB/IB into a flat `Primitive[]` scratch
281
+ (BLAS), or one dispatch loading instance descs, dereferencing each BLAS root box, transforming
282
+ its 8 corners to world space, and pre-inverting the transform (TLAS).
283
+ 2. **Scene AABB** — iterative 8:1 ping-pong reduction, O(log₈ n) dispatches.
284
+ 3. **Morton codes** — 30-bit, 10 bits/axis, centroid quantised into the scene AABB (an
285
+ adaptive-bits `SCALED_MORTON_CODES` variant exists but is dead code — meep carried an unused
286
+ WGSL transcription of that same variant until §8 deleted it).
287
+ 4. **Sort** — MiniEngine **bitonic** sort (indirect-dispatched), key=morton, payload=index. Not
288
+ radix — O(n log²n); the CSDLDF radix sort this plan builds (§4.2.1) is strictly better here.
289
+ 5. **Rearrange** — gather elements into sorted order (so leaves need no indirection), optionally
290
+ persisting the inverse permutation in the AS for later refit-only updates.
291
+ 6. **Hierarchy emission** — **Karras 2012**, cited by name in `BuildBVHSplits.hlsli`; one thread
292
+ per internal node; duplicate keys broken by index (same tiebreak as meep's CPU builder). Node
293
+ layout: internal nodes `[0, n-2]` (root = 0), leaves `[n-1, 2n-2]`. Children/parents written to
294
+ a separate 12-byte `HierarchyNode` scratch, not the final format.
295
+ 7. **Treelet reordering** (BLAS only, 0/1/3 passes by build flag) — **Karras & Aila 2013**, cited
296
+ by name; treelet size 7, 128-subset SAH DP in groupshared memory, **one 32-thread group per
297
+ treelet**, constants `Cbox = 1.2` / `Ctri = 1.0` (the very constants in
298
+ `ebvh_optimize_treelet.js`). Three dispatches per pass: clear; find-treelets (bottom-up climb
299
+ with `InterlockedAdd` arrival counters, emitting base-treelet roots once a subtree holds ≥
300
+ `7·2^pass` primitives); reorder (form treelet by repeatedly expanding the largest-area leaf →
301
+ subset DP → rewrite links → climb to the parent treelet within the same dispatch).
302
+ 8. **AABB fitting** — single-dispatch bottom-up refit: one thread per leaf climbs parent links;
303
+ `InterlockedAdd` on a per-node arrival counter; first arrival terminates, second proceeds with
304
+ both children guaranteed written. Buffers declared `globallycoherent`. Also the entire
305
+ refit-only update path (`PERFORM_UPDATE`): topology frozen, leaves written through the cached
306
+ sort permutation, only this pass re-runs.
307
+
308
+ Portability notes that shaped this plan: the builder uses **no wave intrinsics at all**
309
+ (threadgroup size 64 chosen as "safest across vendors") — everything except the sort is portable
310
+ compute; the load-bearing hazard is `globallycoherent` + relaxed-atomic handshakes for the refit
311
+ and treelet climbs, which WGSL can only approximate (§7, R1); the leaf-collapse half of their
312
+ treelet pass (multi-triangle leaves) was never finished (`MAX_TRIS_IN_LEAF 1`), so their shipped
313
+ trees are 1-primitive-per-leaf — same as meep's format; their per-node layout differs (center/
314
+ halfDim, leaf flag in the high bit) but nothing in their pipeline depends on it — the algorithms
315
+ transfer to meep's min/max + `child_1`-sentinel format unchanged.
316
+
317
+ ### 3.2 AMD GPURT (`GPUOpen-Drivers/gpurt`, dev branch, 2026-08 snapshot)
318
+
319
+ The production reference — the shared BVH library under AMD's D3D12 and Vulkan RT drivers.
320
+ Verified against source (a full snapshot sits in the session scratchpad). What it teaches, sorted
321
+ by relevance to meep:
322
+
323
+ **Build-mode architecture.** One shared 64-byte scratch-BVH2 node representation; three builders
324
+ over it as a quality dial: `Linear` = **`BuildFastAgglomerativeLbvh.hlsl`** — an *Apetrei-style*
325
+ single-pass LBVH (bottom-up walk from leaves; parent chosen by comparing adjacent-code deltas;
326
+ first arrival does `InterlockedExchange` on a propagation flag and dies, second arrival owns the
327
+ parent and fits its AABB inline — no separate refit dispatch), with **the identical duplicate-key
328
+ index tiebreak meep's CPU builder uses** (`32 + clz(i ^ j)`); `PLOC` (TG = 256, radius ≤ 10,
329
+ LDS-windowed nearest-neighbour merging, phased task queue); `HPLOC` (TG = 32 = one wave, the
330
+ HPG 2024 algorithm of §3.3). Selection: fast-build and small builds → Linear; fast-trace → PLOC;
331
+ HPLOC as the single-dispatch compromise. **No treelet restructuring exists in GPURT, and the
332
+ older top-down/triangle-splitting builders have been removed from current branches** — the
333
+ quality story is entirely "better sort order + PLOC-family clustering + wide-node encode".
334
+
335
+ **Morton codes.** Default is **variable-bit** morton (axes sorted by extent; leading bits granted
336
+ to dominant axes before regular interleave — the productionised form of the adaptive scheme the
337
+ DXR Fallback left as dead code, and which meep's since-deleted `chunk_calculate_morton_code`
338
+ transcribed), with
339
+ optional surface-area "size bits" interleaved into 64-bit keys. 30-bit codes remain a supported
340
+ end-to-end path. Degenerate/inactive primitives get sentinel codes sorting to the end; identical
341
+ centroids quantise to 0.5.
342
+
343
+ **Sorting.** 4-bit-digit LSD radix (8 passes for 30-bit keys) with a decoupled-lookback scan *or*
344
+ barrier-based hierarchical scans as the portable fallback — and, notably, batched builds always
345
+ use an LDS **merge sort** instead ("merge sort outperforms radix sort in most cases in the
346
+ batched builder"; radix only wins in their parallel path above 50k primitives). Context for meep:
347
+ their radix is a weaker configuration than the OneSweep/CSDLDF family (the successors to exactly
348
+ this lookback-scan lineage); the datapoint says sort choice is not the quality lever — sort
349
+ *order* (morton scheme) is.
350
+
351
+ **Orchestration.** Two maintained execution strategies: a **batched multi-dispatch path** (one
352
+ dispatch per phase, real barriers between phases) and **BuildParallel** (the whole build in one
353
+ persistent-threads dispatch, phase-fenced by spin-loop "task loop" barriers on
354
+ `globallycoherent` buffers). Their own structure is the portability verdict: everything
355
+ single-dispatch leans on device-scope coherence plus inter-workgroup forward progress — neither
356
+ guaranteed in WebGPU — while **the multi-dispatch path is the portable template**, which is
357
+ precisely the shape meep's frame-graph pipeline takes. Small-build specialisations exist
358
+ (whole-build-in-one-workgroup for ≤1024 primitives with an in-LDS sort; a 16-thread trivial
359
+ builder for ≤16) — relevant later for per-geometry BLAS batching.
360
+
361
+ **TLAS specifics.** Instance encode fetches the BLAS root bounds (RDNA4: a tighter KDOP-derived
362
+ bound), transforms by the instance transform, writes scratch leaves, and atomically grows scene
363
+ bounds using the monotonic sign-flip float→u32 mapping + `InterlockedMin/Max` (the cheaper
364
+ alternative to meep's CAS chunk; both are portable WGSL). **Rebraid** runs before morton coding:
365
+ instances are opened into up to `rebraidFactor` (4) leaves pointing at BLAS root children, budget
366
+ distributed by a surface-area-difference priority — the production answer to the
367
+ "huge overlapping instance" quality failure LBVH-family TLAS builds share (§3.3). Updates are
368
+ pure refit via an update stack; topology is never touched — confirming the phase-3a shape.
369
+
370
+ **Wide-node encode.** BVH2 → BVH4/BVH8 collapse by repeatedly opening the child with the largest
371
+ surface area (`ApplyHeuristic`; 8-lane lane-groups cooperate per BVH8 node on RDNA4), producing
372
+ 128-byte quantised 8-wide nodes (12-bit child bounds against a shared per-axis 8-bit exponent,
373
+ children implicitly contiguous). Different final format from CWBVH but the same collapse
374
+ philosophy and the same "BVH2 scratch is canonical, wide is an encode" architecture this plan
375
+ adopts.
376
+
377
+ **Float16/f32 box nodes, triangle pairing, OBBs, DGF** — BLAS-leaf compression machinery, out of
378
+ scope until phase 3b+, noted as the direction wide-format work eventually goes.
379
+
380
+ ### 3.3 Construction literature and WebGPU state of the art
381
+
382
+ The field has converged, and the convergence is recent enough to matter:
383
+
384
+ - **Karras 2012** (binary radix tree from sorted morton codes; one thread per internal node,
385
+ range/split by longest-common-prefix searches) is the canonical GPU emission kernel — what the
386
+ DXR Fallback ships and what meep's CPU builder implements. The duplicate-key index tiebreak is
387
+ mandatory on real content (identical instance positions collide long before 2^30 cells fill).
388
+ - **Apetrei 2014** ("Fast and Simple Agglomerative LBVH Construction") inverts it: one thread per
389
+ leaf walks *upward*, finding each node's parent in O(1) from adjacent-code deltas; an
390
+ `atomicExchange` handshake lets the second-arriving thread continue and fit the parent's AABB on
391
+ the way — **hierarchy emission and refit fuse into one kernel**, no binary searches, same
392
+ topology as Karras (different internal-node index assignment; arXiv:2402.00665 shows how to
393
+ recover Karras indexing inside it if wanted). No spinning anywhere → no forward-progress
394
+ assumption. This is the "speed of light" binary builder in AMD's terminology and the substrate
395
+ H-PLOC is built on.
396
+ - **PLOC (2018) → PLOC++ (2022, Intel) → H-PLOC (2024, AMD)**: parallel locally-ordered
397
+ clustering — merge mutually-nearest neighbours within a search window of the morton-sorted
398
+ cluster list, giving near-agglomerative SAH quality. PLOC++ fuses each iteration into one kernel
399
+ (chunked shared-memory NN search, encoded-atomicMin pair propagation). **H-PLOC** composes
400
+ Apetrei's upward walk with wave-cooperative PLOC merging of per-subtree cluster lists (threshold
401
+ T = wave_size/2, search radius 8), emitting the whole BVH2 in a single kernel: PLOC-class
402
+ quality at ~15% over raw-LBVH build cost. It is **the production default in AMD's stack** —
403
+ GPURT, and RADV as of Mesa 26.0 (Jan 2026; measured ~1 ms/frame TLAS savings, 2–5% total fps in
404
+ shipped titles). Measured TLAS scale: a BVH4 over **1M randomly-sized instances in 2.21 ms** on
405
+ an RX 7900 XT (LBVH: 1.96 ms); ~0.25 ms extrapolated at 100k. GPU TLAS builds at meep's scale
406
+ are cheap in absolute terms regardless of algorithm.
407
+ - **Treelet restructuring (TRBVH 2013 / ATRBVH 2015) has been retired as a primary quality
408
+ mechanism by the field.** The H-PLOC paper's head-to-head: ATRBVH's BVH2 phase is **8.2–13×
409
+ slower than H-PLOC at indistinguishable SAH quality**, with structural waste (overlapping
410
+ treelets repeatedly rebuilt during the ascent). Where more quality is wanted, the literature
411
+ reaches for parallel reinsertion (PRBVH) offline, not treelets. This bears directly on phase 2
412
+ (§5.1, AR2).
413
+ - **How much quality is actually at stake** (Meister & Bittner, JCGT 2022, GPU, secondary/
414
+ incoherent rays, normalized to LBVH = 1.00): PLOC 1.25, ATRBVH 1.39, binned SAH 1.43, SBVH 1.86
415
+ — with per-scene extremes near 2× on mixed-primitive-size content. Coherent primary rays show
416
+ much smaller spreads, and **widening the BVH compresses the gap** (at width 8: PLOC 1.21, SBVH
417
+ 1.54 vs LBVH 0.98) — i.e. the eventual BVH8 phase partially launders binary-topology quality
418
+ differences. LBVH's specific failure mode is *highly varying primitive sizes* (morton codes see
419
+ centroids only); for heavily-overlapping or long-thin instances no bottom-up TLAS builder saves
420
+ you — production drivers attack that with **rebraiding** (opening a BLAS root so one instance
421
+ contributes several TLAS entries), a later, orthogonal lever worth remembering.
422
+ - **Refit vs rebuild for TLAS**: NVIDIA's published best practice is to *rebuild* the TLAS per
423
+ frame rather than refit ("the cost savings to refit likely aren't worth sacrificing quality of
424
+ TLAS"); refit is for BLAS geometry that "bends but doesn't break". Matches this plan (§4, AR8,
425
+ phase 3a).
426
+ - **Wide BVH**: everyone builds binary and converts; nobody constructs BVH8 directly. The
427
+ reference conversion is Ylitie/Karras/Laine 2017 — a bottom-up cost DP deciding each node's role
428
+ (allowing SAH-decided multi-primitive leaves), then top-down emission of **CWBVH**: 80-byte
429
+ 8-wide nodes with child boxes quantised to 8 bits/axis against a per-node origin/scale, 1.9–2.1×
430
+ faster incoherent traversal at 35–60% of the memory. meep's dormant `BVH8_INODE_STRUCT` is
431
+ 80 bytes — the experiment already targets this format, and CWBVH traversal in WGSL is proven in
432
+ the wild (albedo / tinybvh-rs). H-PLOC's single-kernel converter carries a genuine
433
+ forward-progress hazard (documented in the paper — resident consumers spin on resident
434
+ producers); the boring per-level-dispatch collapse (~log₈N ≈ 5–7 launches) is formally safe and
435
+ the right WebGPU answer.
436
+ - **Sorting**: OneSweep (Adinets & Merrill 2022) is the single-pass state of the art and what
437
+ H-PLOC's own pipeline uses — but its bare decoupled lookback spins across workgroups; b0nes164
438
+ (whose port meep briefly carried before `b6049214c` removed it for exactly this) states it
439
+ "tends to run on anything that is not mobile, a software rasterizer, or Apple", and the
440
+ Linebender GPU-sorting survey reaches the same conclusion. The safe formulations are
441
+ reduce-then-scan (`DeviceRadixSort`) and — the one meep has standardised on — **CSDLDF**, the
442
+ decoupled-fallback protocol from b0nes164's Decoupled-Fallback paper, whose scan artifact is
443
+ already ported in-tree. The engine direction is to build the sort around that protocol
444
+ (§4.2.1). 32-bit keys are sufficient for TLAS at ≤1M instances; 64-bit keys (for dense BLAS
445
+ quality) cost ~3× in sort time and, in WGSL (no u64), mean chained two-word sorts — deferred
446
+ until phase 3b demands it.
447
+ - **WebGPU state of the art: essentially every public WebGPU ray tracer builds its BVH on the CPU**
448
+ (three-mesh-bvh — GPU traversal only; WebRTX; strahl — whose published *CPU* build times,
449
+ 327–383 ms for 1M triangles, are the cautionary tale; gnikoloff/re-ovo path tracers; tinybvh's
450
+ WGSL consumers). A WGSL GPU *builder* is near-unoccupied territory. Best porting bases found:
451
+ MircoWerner/VkLBVH (GLSL Karras LBVH, transliterates to WGSL almost mechanically), NexusBVH
452
+ (clean CUDA H-PLOC), and Nate Morrical's H-PLOC in Slang (Slang can emit WGSL), which notably
453
+ expects an external radix sort and recommends OneSweep.
454
+
455
+ ### 3.4 Synthesis — the choice
456
+
457
+ **Phase 1 builds LBVH: morton → CSDLDF radix sort → Karras emission → arrival-counter refit.
458
+ Phase 2 adds Karras & Aila treelet restructuring on top.** Reasons, in order of weight:
459
+
460
+ 1. **Zero algorithm risk.** Both algorithms exist in-tree as tested CPU implementations
461
+ (`ebvh_build_hierarchy_radix`, `ebvh_optimize_treelet`) whose semantics — including the
462
+ duplicate-key tiebreak that is the classic LBVH correctness trap — the GPU kernels
463
+ transliterate. The DXR Fallback is a complete worked example of the same two algorithms as GPU
464
+ compute, in the same 32-byte-node, stack-traversal setting.
465
+ 2. **Maximum reuse, one deliberate build.** Reduce idiom, atomic AABB merge, graph plumbing,
466
+ scan-with-fallback, validation tiers: existing meep facilities. The one sizeable new primitive
467
+ is the key-value radix sort — built around CSDLDF per the engine's sorting direction
468
+ (§4.2.1), as a first-class `gpu_primitive` whose first consumer is the builder and whose
469
+ spine/protocol come straight from the in-repo `shader_prefix_scan_csdldf`.
470
+ 3. **Subgroups-first; portability through the fallback discipline, not through avoidance.**
471
+ Subgroups are a baseline requirement of the engine — every kernel here uses them where they
472
+ pay (wave pre-reduction before atomics in the bounds pass, ballot/prefix machinery in the
473
+ sort's ranking, wave-aggregated allocation and subset evaluation in the treelet DP), following
474
+ the csdldf shader's established idioms (`subgroup_size` builtin handling down to
475
+ `MIN_SUBGROUP_SIZE = 4`, `@diagnostic(off, subgroup_uniformity)` where the analysis
476
+ over-approximates). What keeps the pipeline safe on weakly-scheduled hardware is the CSDLDF
477
+ fallback protocol — never spin without a bounded fallback — not renouncing wave ops. (The DXR
478
+ Fallback's zero-wave-intrinsics posture was *its* portability answer for 2018 D3D12; it is not
479
+ this engine's.)
480
+ 4. **Karras emission + separate refit over the fused Apetrei kernel — for phase 1.** Apetrei's
481
+ single fused kernel is objectively the leaner LBVH (§3.3) and the same topology. Karras wins
482
+ the *first* deliverable on de-risking grounds: the emission kernel is a pure, atomic-free
483
+ function of the sorted keys, which makes it (a) a line-for-line transliteration of the spec'd
484
+ in-repo oracle and (b) executable on the WGSL emulator in vitest — the strongest device-free
485
+ test this project can have, covering the classic LBVH correctness trap (duplicate-key
486
+ handling). The refit kernel it requires is not overhead: refit-only rebuilds (DXR's update
487
+ mode, phase 3a's skinned BLAS) need it as a standalone pass anyway. Apetrei is recorded as the
488
+ recognised follow-up optimisation (AR11) and the substrate H-PLOC builds on.
489
+ 5. **PLOC/H-PLOC deferred, seam kept swappable — with eyes open.** The PLOC family (which AMD
490
+ ships; §3.2, §3.3) produces better trees than raw LBVH and H-PLOC does it at near-LBVH cost.
491
+ It is deferred, not dismissed: it is a genuinely new algorithm with no in-repo oracle, a
492
+ wave-*width*-dependent structure (its cluster threshold is defined in terms of the runtime
493
+ subgroup size — a different thing from merely using wave ops, which are baseline here), and
494
+ real WGSL-uniformity porting friction — while LBVH+treelets reaches today's CPU quality bar
495
+ with two oracles in hand. The pipeline's seam — "sorted leaf
496
+ array in, BVH2 topology out" — is exactly where an H-PLOC stage would replace emission + refit
497
+ + treelets wholesale if the phase-2 gate argues for it (§6, AR2 carries the full argument and
498
+ the numbers).
499
+ 6. **BVH2 stays the canonical intermediate.** All references converge on this: build a binary
500
+ scratch BVH, then encode wide/compressed formats from it. Phases 3–4 consume the same
501
+ intermediate this phase produces.
502
+
503
+ ---
504
+
505
+ ## 4. Phase 1 — GPU TLAS build
506
+
507
+ Replace steps 1–3 of `push_to_gpu()` with a GPU pipeline; keep the CPU dynamic tree for CPU
508
+ queries; keep the CPU upload path as a fallback flag until phase 2 retires it.
509
+
510
+ ### 4.1 Inputs
511
+
512
+ - **`meshes` table** (scene database buffer) — world AABB per row, already GPU-resident and
513
+ CPU-written each frame for CPU-authority rows (`GPUSceneContext.build()` runs, and submits,
514
+ before `tlas.update()` — queue order guarantees visibility), GPU-written for GPU-authority rows.
515
+ - **Live rows list** — `array<u32>` of occupied `meshes` rows. `GPUInstancesAccelerationStructure`
516
+ already learns exactly this from `instance_add`/`instance_remove`; it keeps a compact
517
+ `Uint32Array` mirror and re-uploads it (small: 4·N bytes) only when membership churns
518
+ (`#membership_version`). Dead/reused rows never enter the build — no GPU-side liveness scheme is
519
+ invented.
520
+ - Uniforms: `element_count = N`.
521
+
522
+ Input AABBs come from the scene database rather than from the CPU tree's leaves deliberately: it
523
+ is the single source of truth, it is correct for GPU-authority instances (the CPU is not), and it
524
+ removes the per-frame dependency of the GPU build on CPU-side bounds bookkeeping. One-frame
525
+ staleness for GPU-authority bounds (refreshed later in the frame than `tlas.update()` runs) is
526
+ accepted in phase 1 — today's CPU build has *unbounded* staleness for those rows, so this is
527
+ strictly an improvement; the follow-up that moves TLAS encoding after the bounds-refresh point in
528
+ the frame is noted in §8.
529
+
530
+ ### 4.2 Passes
531
+
532
+ New directory `src/shade/renderer/gpu_primitive/bvh/`, following the `graph_*`/`shader_*`/
533
+ `chunk_*` conventions. The pipeline is one `graph_bvh_build_lbvh({graph, leaf_bounds,
534
+ leaf_payload, element_count, nodes, root})` recorder plus a TLAS-specific extraction pass, so the
535
+ BLAS phase reuses everything downstream of extraction.
536
+
537
+ | # | pass | threads | what it does |
538
+ |---|---|---|---|
539
+ | 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_aabb_atomic_f32` (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. |
540
+ | 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`. |
541
+ | 2 | `graph_radix_sort` | — | the rebuilt CSDLDF-based sort (§4.2.1). Keys 32-bit (30 used), values = leaf indices. Stable, ascending. |
542
+ | 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. |
543
+ | 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. |
544
+
545
+ Edge cases handled at encode time on the CPU: `N == 0` → skip the graph, write `root =
546
+ BVH_NULL_NODE`. Consumers already handle `root == BVH_NULL_NODE` (empty CPU tree uploads it today).
547
+
548
+ **[deviation]** `N == 1` gets no special case at all. It falls out of the same four passes: the
549
+ sort has nothing to reorder and returns its input, emission's tail `if` skips the internal node and
550
+ writes leaf 0 at node 0 with `parents[0] = BVH_NULL_NODE`, and the refit's single thread reads that
551
+ terminator and stops. The host-written 36 bytes the plan called for would have had to take the
552
+ bounds from the CPU tree, whose copy for a GPU-authority row can be arbitrarily stale — the whole
553
+ reason this build reads the `meshes` table. The one thing `N == 1` actually needed was a counters
554
+ buffer WebGPU will accept, so it is sized `max(1, N-1)`.
555
+
556
+ ### 4.2.1 The sort — `gpu_primitive/sort/`, rebuilt around CSDLDF
557
+
558
+ Commit `b6049214c` cleared the ground; this project re-founds the directory with one blessed
559
+ implementation. It keeps the deleted port's interface for continuity —
560
+ `graph_radix_sort({graph, keys, values, element_count}) → {keys, values}`, u32 keys + u32 values,
561
+ ascending, stable, result landing back in the caller's buffers — and is a first-class primitive
562
+ with its own spec and verify harness (the deleted `one_sweep.spec.js` /
563
+ `verify_radix_sort_one_sweep.js` in history are the direct templates for both).
564
+
565
+ **v1 — reduce-then-scan on the csdldf spine** (the shipping shape). LSD radix, 8-bit digits × 4
566
+ passes; per digit pass three dispatches: (a) per-tile 256-bin histogram (subgroup-accelerated,
567
+ workgroup atomics); (b) spine scan of the `tile × bin` counts, flattened column-major so one scan
568
+ yields global digit offsets and per-tile offsets together — run through
569
+ **`graph_prefix_scan_csdldf` as-is** (its inclusive-only output consumed exclusively by
570
+ subtracting own count; its `{count, elements: vec4u}` 16-byte-aligned layout adopted for the
571
+ spine); (c) rank-and-scatter, with the workgroup ranking machinery adapted from the deleted
572
+ binning shader in history (subgroup multi-split, and its two pinned WebGPU lessons carried over).
573
+ Every dispatch is bounded; the only spin anywhere is csdldf's own bounded-spin-then-fallback.
574
+
575
+ **[deviation]** the scatter's ranking is not the deleted port's per-wave-histogram multi-split. That
576
+ formulation needs `workgroup_size / subgroup_size * 256` words of workgroup memory, which at the
577
+ minimum subgroup size of 4 is 16 KB — the entire budget, with nothing left to stage keys in — which
578
+ is precisely why the deleted shader carried a whole second `WLT16` code path with bit-packed
579
+ half-radix histograms. What ships instead sorts the tile in workgroup memory with eight stable
580
+ binary splits (256 threads × 4 keys = a 1024-key tile, ~10.3 KB of workgroup memory at any subgroup
581
+ size). That makes a key's rank its sorted position, gets contiguous per-bin device writes for free,
582
+ and lets the tail tile's padding be identified by position alone. Subgroups carry the wide part of
583
+ each split's scan; the barrier-carrying loop over per-subgroup partials runs a *constant* trip count
584
+ rather than one derived from `subgroup_size`, so no barrier sits under a bound a uniformity analysis
585
+ has to reason about. Ballot-based peer aggregation in the histogram pass is the recorded upgrade
586
+ there; v1 uses workgroup atomics.
587
+ Roughly twelve smallish dispatches per sort against the removed port's seven — at TLAS scale
588
+ (≤1M keys) this is tens of microseconds of difference, and it is correct on every scheduler.
589
+
590
+ **v2 — single-pass digit binning with the fallback protocol** (recorded upgrade, not scheduled).
591
+ OneSweep's chained per-(tile, bin) lookback with the Decoupled-Fallback protocol generalised to
592
+ histogram vectors: split flag words per bin following the csdldf READY/INCLUSIVE rules, fallback
593
+ re-reducing the stuck predecessor tile's histogram from its keys. Pursued only if a measured
594
+ consumer (realistically: BLAS-scale triangle sorts, phase 3b) shows the spine passes matter.
595
+
596
+ The sort ships before the builder consumes it — its own verify harness green in the playground,
597
+ its shaders through all four validation tiers — so builder bring-up debugging never has to ask
598
+ "is it the sort?".
599
+
600
+ ### 4.3 Output layout
601
+
602
+ `[u32 root = 0][internal 0 .. N-2][leaf N-1 .. 2N-2]`, `2N-1` nodes exactly. This differs from the
603
+ CPU path's slot-order-with-holes layout — legal, because (§2.1) nothing consumes ordering, only
604
+ format + root word. Root is node 0 by Karras's construction (internal node 0 spans the whole
605
+ range), which also natively satisfies the BLAS "root must be node 0, no header" convention when the
606
+ same pipeline is pointed at geometry in phase 3.
607
+
608
+ Buffers: the persistent output buffer keeps its `STORAGE | COPY_DST | COPY_SRC` usage and grows
609
+ via `ensure_buffer_capacity` to `4 + 32·(2N-1)`; the staging copy disappears (the refit writes
610
+ final bytes in place). Transients (graph-pooled): `leaf_bounds` 24N, `leaf_payload` 4N, keys/values
611
+ 4N each (+ the sort's own internals), `parents` 4·(2N-1), `counters` 4·(N-1), scene bounds 24 B.
612
+ For N = 100k: ~5–6 MB transient, pooled and reused across frames — versus today's freshly
613
+ allocated ~6.4 MB staging buffer plus its full-buffer copy on every rebuild, and versus the CPU
614
+ milliseconds that dominate the current cost.
615
+
616
+ ### 4.4 Integration in `GPUInstancesAccelerationStructure`
617
+
618
+ - `instance_add/remove/update` unchanged (CPU tree maintenance stays — `DirectionalLightShadowmap`
619
+ and any future CPU query walk it).
620
+ - `push_to_gpu()` becomes: ensure output capacity → refresh rows buffer if membership churned →
621
+ `new FrameGraph()` → import rows + scene database + output → record extraction +
622
+ `graph_bvh_build_lbvh` → `ShadeGPUCommandContext.create` / `encodeGraph` / `finish`. Same frame
623
+ position (inside `GPUSceneContext.update`, after `build()`), same submit-per-change semantics,
624
+ same version guard. **`ebvh_optimize_treelet` no longer runs on the CPU tree** — the tree stays
625
+ a plain SAH-insertion/rotation tree, which is what its CPU consumers actually need.
626
+ - ~~A module-level flag selects GPU vs legacy CPU upload~~ — **the legacy path is gone.** It
627
+ existed for one A/B; that A/B has been run (§4.6), the GPU build won on every axis at instance
628
+ scale, and the flag, the treelet call, the flatten and the whole-buffer upload went with it. It
629
+ was `TLAS_BUILD.use_gpu` — a settable field rather than the `const USE_GPU_TLAS_BUILD` boolean
630
+ the plan named, so the comparison could be a toggle in front of a running scene rather than an
631
+ edit and a reload. Anyone needing the comparison again recovers ~40 lines from history at
632
+ `23ab7bcd2`, the commit before the deletion. Subgroups are a baseline engine requirement, so there
633
+ is no "no-subgroups device" tier to serve: the legacy path (~40 lines) is deleted outright at
634
+ the phase-2 gate, flag and all.
635
+
636
+ **[deviation]** `GPUSceneContext` needs two lines. The TLAS is constructed with `graphics`
637
+ rather than `device` (the build has a frame graph to encode, which needs the buffer allocators), and
638
+ `update()` is handed `scene_database_buffer` — the table the world AABBs live in, which the TLAS has
639
+ no other way to reach. `GPUSceneContext`'s own API is unchanged, and consumers — every reader of
640
+ `tlas.buffer`, and `DirectionalLightShadowmap`'s reader of `tlas.bvh` — need no changes.
641
+
642
+ ### 4.5 Validation
643
+
644
+ Four tiers, each doing the job only it can do:
645
+
646
+ 1. **WGSL validity, vitest** (`bvh_lbvh.spec.js`, sort specs alongside): every new shader parses
647
+ (tree-sitter) and passes `validate_wgsl_source` as assembled — subgroup-using shaders
648
+ included; this is the tier that catches uniformity-adjacent assembly mistakes before a device
649
+ ever sees them.
650
+ 2. **`ComputeShaderEmulator`, vitest — functions and chunks first, whole kernels where pure.**
651
+ The emulator is not a full device, and that is its value: it executes *specific WGSL
652
+ functions* against CPU oracles. Chunk-level: `chunk_v3_morton_encode` vs `v3_morton_encode`;
653
+ the emission kernel's `delta` / `determine_range` / `find_split` chunks vs their counterparts
654
+ inside `ebvh_build_hierarchy_radix` — each as a directly-invoked function over randomized and
655
+ adversarial inputs (duplicate-key runs — the historical failure mode — first among them).
656
+ Kernel-level: `shader_bvh_morton` and `shader_bvh_emit_hierarchy` executed point-wise
657
+ end-to-end (both are barrier- and atomic-free pure functions of their inputs, which the
658
+ emulator schedules faithfully); full-tree topology compared against the CPU builder. The
659
+ refit and sort-scatter kernels are atomics/subgroups-bound and are *not* forced through the
660
+ emulator — their chunks that are pure (bounds merge math, digit extraction, rank arithmetic)
661
+ still get chunk-level emulator coverage.
662
+ 3. **`SoftwareGPUDevice`, vitest — the orchestration tier.** Encode the real thing against the
663
+ recording device: `GPUInstancesAccelerationStructure.push_to_gpu()` and
664
+ `graph_bvh_build_lbvh` run their actual FrameGraph against a `SoftwareGPUDevice`, and the spec
665
+ asserts what was encoded — dispatch sequence and group counts per pass, bind groups resolving
666
+ to the intended buffers, transient sizes/usages/clears, the rows-list upload happening only on
667
+ membership churn, and the `N == 0` / `N == 1` short-circuits (graph skipped, root word written
668
+ host-side — bytes the host wrote are assertable here; shader-written bytes are not, by the
669
+ mock's own contract). This tier pins the integration and the graph wiring in CI, where neither
670
+ the emulator (no host path) nor the browser (no CI) can.
671
+ 4. **Browser playground + end-to-end** — **[not run]** this tier is the human's; meep is a
672
+ library and the playground mounts in the host repo. Both harnesses are in place and registered.
673
+ (`verify_gpu_bvh_build.js` and `verify_radix_sort.js`, registered under "review checks"): randomized instance sets (duplicate positions, degenerate
674
+ AABBs, N ∈ {0, 1, 2, 3, powers of two ± 1, ~100k}); readback via `graph_inspect_gpu_buffer`;
675
+ assert structural validity (every leaf reachable exactly once; both children valid on every
676
+ internal node; parent AABB ⊇ children; root word correct), payload permutation correctness,
677
+ **depth histogram** (§7, R4), and SAH cost vs the CPU oracle on the same input — same sorted
678
+ order fed to both ⇒ topology must match node-for-node modulo the index mapping. Then the real
679
+ consumers: path tracer + RTX shadows on existing playground scenes, CPU-built vs GPU-built A/B
680
+ (the flag), image diff + `GPUTimer` numbers for build and trace.
681
+
682
+ A `bvh_format.spec.js` locking the byte contract of §2.1 (today enforced only by the shaders
683
+ themselves) is added alongside — it pins both the legacy compressor and the GPU layout to the same
684
+ golden bytes.
685
+
686
+ ### 4.6 Acceptance
687
+
688
+ - All four validation tiers green (sort and builder both); traversal-consumer A/B images
689
+ identical-or-better.
690
+ - Depth histogram within the traversal stack budget on benchmark scenes — no silent traversal
691
+ truncation (R4).
692
+ - Build time for 100k instances well under the CPU path's. Calibration from the literature
693
+ (§3.3): AMD measures a full quality-build TLAS over 1M instances at 2.2 ms on an RX 7900 XT,
694
+ ~0.25 ms extrapolated at 100k; raw LBVH ~15% cheaper still. WebGPU overheads on top are exactly
695
+ the novel data point this phase produces — measure with `GPUTimer`, don't assume.
696
+
697
+ **Measured, 2026-08-26** — `verify_gpu_bvh_build` and `verify_gpu_tlas_build` on one machine, all
698
+ 58 builder cases and all 8 TLAS scenes PASS. One machine, one browser, and a harness that makes a
699
+ separate submission per case: treat these as an order-of-magnitude reading, not a benchmark.
700
+
701
+ - **Topology parity is exact.** Every case's SAH matches the CPU oracle's to the last reported
702
+ decimal, at every size and every shape — which is the node-for-node claim, since the SAH is
703
+ summed over the same internal nodes. Including 100k coincident instances.
704
+ - **Build time, 100k scattered instances: ~0.15 ms** of GPU time across all 19 dispatches (morton
705
+ 0.003, sort ~0.082 over four digit passes, emission 0.026, refit 0.040). That sits where §3.3's
706
+ extrapolation put a driver-class builder (~0.2 ms), which is the answer to "what do WebGPU
707
+ overheads cost" — apparently not much. Two outliers in the log (a 0.24 ms first scan and a
708
+ 0.58 ms readback copy in one case) do not recur across the other three digit passes of the same
709
+ build and look like queue bubbles between the harness's per-case submissions; a real number for
710
+ the frame budget has to come from the in-frame path, not from here.
711
+ - **The duplicate-key tiebreak works at scale.** 100,003 instances at *identical* positions — one
712
+ Morton code for the entire scene — build to depth 18, not depth 100,003. The depth histogram
713
+ (`8:1 9:2 12:32 13:128 14:1536 18:98304`, summing to exactly 100,003) is the binary decomposition
714
+ of the index range showing through, which is what `delta`'s `32 + clz32(i ^ j)` produces when no
715
+ code carries information. This is the failure mode the whole emission kernel is careful about,
716
+ now observed on a device.
717
+ - **The ±inf seeding is confirmed.** The `far from origin` TLAS scene (700 instances in a 10-unit
718
+ cube offset ~9000 units from the origin) builds to a normal depth spread of 8..16. A zeroed
719
+ scene-bounds cell would have anchored the bounds at the origin, crushed every centroid into one
720
+ Morton cell, and produced the flat single-depth histogram the coincident case shows.
721
+ - **Depth.** 100k scattered instances build to **depth 22 of the 32-entry stack**, and 20k to
722
+ depth 20. The stack is shared with the BLAS within one query and the occlusion path truncates
723
+ silently past it, so this is a real output constraint — but see the A/B below, where the legacy
724
+ CPU tree turns out to be the *deeper* of the two.
725
+
726
+ **Consumer A/B, 2026-08-26** — `sponza_path_trace`, path tracer, same camera, same frame, toggling
727
+ `TLAS_BUILD.use_gpu` under the running scene. Images verified equivalent by inspection. Frame times
728
+ are medians of three runs; SAH is the sum of internal-node surface areas, which over a fixed leaf
729
+ set is the term that differs between two trees.
730
+
731
+ Sponza as it ships — **103 instances**:
732
+
733
+ | | GPU LBVH | CPU pipeline (insertion + rotations + treelets) |
734
+ |---|---|---|
735
+ | SAH | 43,409 | 22,414 |
736
+ | max depth | 13 | 20 |
737
+ | traced frame | 69.5 ms | 70.6 ms |
738
+
739
+ Sponza plus **20,000 scattered boxes** at sizes spanning two orders of magnitude — the shape that
740
+ troubles a Morton order most, and enough instances that traversal cost is in the TLAS rather than
741
+ in the per-mesh BLAS:
742
+
743
+ | | GPU LBVH | CPU pipeline |
744
+ |---|---|---|
745
+ | SAH | 713,765 | 941,015 |
746
+ | max depth | 20 | 27 |
747
+ | traced frame | **132.6 ms** | 176.1 ms |
748
+ | rebuild, wall clock | **21.7 ms** | 161.2 ms |
749
+
750
+ Read together these say something the plan did not anticipate. At ~100 instances the GPU tree's SAH
751
+ is 1.9× the CPU tree's and the traced frame times are identical — the regression AR1 predicted,
752
+ and invisible, because a TLAS that small is not what traversal spends its time in. At 20k the
753
+ ordering **reverses on every axis**: the GPU tree is 24% better by SAH, seven levels shallower, and
754
+ 25% faster to trace.
755
+
756
+ Why: the CPU tree is built by incremental SAH-guided insertion with local rotations. At a hundred
757
+ instances that is close to optimal. At twenty thousand it is a greedy, order-dependent construction
758
+ whose mistakes rotations only locally repair, while the LBVH's global Morton ordering does not
759
+ degrade. The treelet pass on top buys 2.4% (22,954 → 22,414 over two passes on the 103-instance
760
+ scene) — it repairs neighbourhoods, not a bad global shape.
761
+
762
+ Note also the depth column: at 20k the *legacy* tree reaches depth 27 of a 32-entry stack shared
763
+ with the BLAS, leaving five. R4's cliff is real, and the GPU build moves away from it rather than
764
+ toward it.
765
+ - Known, documented, temporary quality dip vs the treelet-optimised CPU tree (raw LBVH SAH cost is
766
+ worse; phase 2 exists to close exactly this). The flag stays available until phase 2's gate.
767
+
768
+ ---
769
+
770
+ ## 5. Phases 2–4
771
+
772
+ ### 5.1 Phase 2 — tree quality optimisation on the GPU
773
+
774
+ > **Checkpoint, 2026-08-26 — the premise did not survive measurement.** This phase was committed to
775
+ > on the expectation that phase 1 ships a quality regression which treelet restructuring then closes
776
+ > (AR1, AR2, R3). §4.6's A/B says otherwise: at instance scale the GPU LBVH is *better* than the
777
+ > CPU pipeline it replaced — 24% better SAH, seven levels shallower, 25% faster to trace — and at
778
+ > the small end where it is worse by SAH, the difference does not reach the frame. The gate this
779
+ > phase was to be judged against ("SAH cost within noise of the current CPU pipeline, and
780
+ > traversal-heavy consumers at parity or better frame time") is **already met, without treelets**.
781
+ >
782
+ > What follows is therefore no longer a committed plan. It is what phase 2 *would* be, kept because
783
+ > the reasoning is still sound where a case for it exists, and the decision belongs to whoever picks
784
+ > the project up next. The three ways forward, in the order the evidence supports them:
785
+ >
786
+ > 1. **Do nothing here and delete the legacy path.** — **taken.** The legacy CPU upload and
787
+ > `TLAS_BUILD` are gone; phase 3 is the next work.
788
+ > 2. **Take the quality work to the BLAS instead.** Everything above is a statement about a TLAS —
789
+ > thousands of leaves, and leaf *boxes* rather than leaf shape dominating. A BLAS is millions of
790
+ > triangles, where the literature's LBVH-vs-PLOC gap (§3.3) was measured and where it should
791
+ > actually bite. If PLOC/H-PLOC is worth building, that is where its case is.
792
+ > 3. **Build it anyway, for a workload not yet measured.** Content with far more instances, or with
793
+ > heavy overlap, could still find the LBVH's weak spot. That case has to be produced and measured
794
+ > first; `sponza_path_trace`'s box-scatter control is the tool for it.
795
+ >
796
+ > The seam holds whichever of the rest is picked up later: "sorted leaf array in, BVH2 topology out" is where any of this plugs
797
+ > in, and nothing about extract/morton/sort or the output contract has to move.
798
+
799
+ The treelet port, in the DXR Fallback's GPU formulation (§3.1, step 7), appended to the phase-1
800
+ graph after refit:
801
+
802
+ - **Pass A — clear**: zero treelet counters/list.
803
+ - **Pass B — find base treelets**: one thread per leaf climbs `parents[]` with arrival counters
804
+ (the refit trick again), accumulating subtree primitive counts; on crossing the
805
+ `min_prims_per_treelet` threshold, appends the node to the base-treelet list via an atomic
806
+ cursor and stops.
807
+ - **Pass C — reorder**: one **32-thread workgroup per base treelet**: form the 7-leaf treelet by
808
+ greedy largest-area expansion; evaluate all 128 subset AABBs/areas cooperatively; run the
809
+ subset-partition DP (`partition = (partition - delta) & s` enumeration — identical to the CPU
810
+ code); rewrite the ≤6 internal nodes' links and bounds; climb to the parent treelet via arrival
811
+ counters and repeat. Workgroup memory is the correctness layer (the DP must not assume a wave
812
+ width — subgroup size ranges 4–128 at runtime); subgroup reductions/broadcasts accelerate the
813
+ subset-area evaluation and argmin steps per the csdldf idioms. All cross-group traffic through
814
+ `array<atomic<u32>>`, as in refit.
815
+ - Iterations: quality knob, default 1 per build.
816
+
817
+ Treelets were also the answer to the depth risk (§7, R4). That reading has changed too: the
818
+ measured depths put the GPU build *below* the legacy tree, so shortening chains is no longer a
819
+ reason to reach for them.
820
+
821
+ ### 5.2 Phase 3 — BLAS on the GPU
822
+
823
+ Two sub-phases, in value order:
824
+
825
+ - **3a — skinned-clone BLAS refit (per frame). DONE.** Built as planned, refit-only, with the
826
+ phase-1 refit pass unchanged. Four deviations, all recorded here because the code is what won:
827
+
828
+ - **The extraction pass writes node bounds directly, not a `leaf_bounds` array.** A refit has no
829
+ sort and no emission, so nothing consumes a separate array — `shader_blas_refit_leaf_bounds`
830
+ puts each triangle's box straight into the leaf node and the climb reads it there. It gets the
831
+ triangle id by reading the leaf's own `child_2` payload back, so no side table has to agree
832
+ with the tree about which leaf is which.
833
+ - **A clone gets its own *nodes*, but shares its source's *shape*.** Parents and leaf-node
834
+ indices are identical across every copy of a geometry — only bounds are per-instance — so
835
+ `GPUGeometryBVHManager` derives them once per source into two arenas of their own
836
+ (`bvh2_derive_topology`, which walks the compressed bytes from the root so the free list's
837
+ holes are never mistaken for leaves) and every copy points at that.
838
+ - **The BLAS map is keyed by geometry *id*, not by geometry.** A clone and its source share a
839
+ `MeshletGeometry` and collided on it. `obtain(owner, source)` now separates the identity a tree
840
+ is looked up by from the geometry it takes its shape from. This also fixed a live bug in the
841
+ `blas_lookup` table (`blas_lookup_indexing.spec.js`, commit `f1f247438`).
842
+ - **Both settings structs carry arena offsets.** `parents_offset` / `leaf_nodes_offset` join
843
+ `node_word_offset` for the same reason it exists: a build owns its arrays and passes 0, a refit
844
+ is a tenant in shared ones. Counters are sized by *node*, not by leaf — they are indexed by
845
+ node id, and a tree read off an arena numbers its nodes with holes in.
846
+
847
+ Verified on an NVIDIA Lovelace device by `src/shade/playground/skinned_blas_refit/`, which holds
848
+ the refit's root box against the bounds-refresh chain's independently computed box over the same
849
+ deformed vertices — two paths sharing no code, and `min`/`max` do not round, so the bar is bit
850
+ equality. Result: bit-identical at every sample, moving 0.39–0.80 of its own span between poses,
851
+ and all 28 105 internal nodes exactly their own children's union, which is what says the interior
852
+ holds and not merely the root. Four clones, four distinct arena regions on a uniform stride.
853
+
854
+ **Still open:** `shader_build_sdf` reads `blas_lookup[slot_index]` with an SDF atlas slot — a
855
+ third index space over a table that is now unambiguously indexed by geometry id.
856
+ - **3b — full BLAS build (import/procedural).** Same `graph_bvh_build_lbvh` with a triangle
857
+ extraction pass (`leaf_payload = (meshlet << 8) | tri`, computed at extraction). Root lands at
858
+ node 0 with no header — the exact BLAS convention — because Karras emission puts it there. The
859
+ current CPU builder shapes the tree as meshlet-subtrees-under-a-top-tree; **no consumer depends
860
+ on that internal shape** (only on root-at-0 + the leaf payload encoding), so the GPU build's
861
+ global-morton tree is format-legal and expected to trace better. Verify `shader_build_sdf`'s
862
+ root-bounds read and the serialization adapter round-trip (GPU-built BLAS read back once at
863
+ import time for the on-disk cache) before flipping. Batch small geometries through one graph;
864
+ use `graph_build_indirect_dispatch_command` where counts live GPU-side.
865
+
866
+ ### 5.3 Phase 4 — BVH8
867
+
868
+ Traversal-side artifacts already exist (`bvh8/webgpu/BVH8_INODE_STRUCT.js` — 80-byte compressed
869
+ wide node — and `chunk_bvh8_node_intersect`, `chunk_bvh8_ray_query_blas_occlusion`,
870
+ `chunk_ray_get_octant_inv4`, `chunk_sign_extend_s8x4`), currently unwired. The build side follows
871
+ the references' shared design: **BVH2 stays the construction intermediate; BVH8 is an encode.**
872
+
873
+ - **Collapse pass(es)**: bottom-up cost DP over the BVH2 (arrival-counter climb again) deciding
874
+ each node's role in the wide tree per Ylitie et al. 2017 (SAH-driven collapse to ≤8 children,
875
+ which is also where SAH-decided multi-primitive leaves enter, if the leaf format ever grows
876
+ them), then emission of the 80-byte compressed nodes — meep's dormant `BVH8_INODE_STRUCT` is
877
+ already CWBVH-shaped, and CWBVH traversal in WGSL is proven in the wild (§3.3). Conversion runs
878
+ as **per-level dispatches** (~log₈N launches, formally-safe inter-dispatch ordering) rather than
879
+ H-PLOC's single-kernel polling converter, which the paper itself flags for its forward-progress
880
+ requirement — the one place this plan deliberately declines a single-kernel formulation.
881
+ Quantised-box packing needs no `shader-f16` device feature (`pack`-family builtins suffice).
882
+ GPURT's encoder (§3.2) confirms the collapse heuristic — repeatedly open the child with the
883
+ largest surface area until the slots fill — and demonstrates the cooperative refinement
884
+ (8-lane groups per wide node) available if the per-level formulation ever needs the speed.
885
+ - The JCGT data (§3.3) is the planning caveat worth repeating here: widening compresses the
886
+ quality differences between binary builders — so re-run the phase-2 quality debate's numbers on
887
+ BVH8 before spending further on binary-topology quality.
888
+ - Ships **additively**: BVH8 buffers alongside BVH2, consumers migrate per-shader (occlusion /
889
+ RTX shadows first — the existing chunk is the occlusion variant), format compatibility preserved
890
+ throughout; BVH2 bindings retire only where no consumer remains.
891
+
892
+ ---
893
+
894
+ ## 6. Adversarial review
895
+
896
+ The plan above already absorbed several rounds of self-challenge; the surviving objections and
897
+ their dispositions, kept honest:
898
+
899
+ **AR1 — "You're building LBVH in 2026; raw LBVH trees are noticeably worse than SAH trees, and
900
+ phase 1 ships a quality regression."** **Measured, and largely wrong** — see §4.6's A/B: the
901
+ regression appears only at ~100 instances, where it does not reach the frame, and reverses at 20k,
902
+ where the GPU tree beats the CPU pipeline on SAH, depth and traced frame time alike. The objection
903
+ was right that raw LBVH is worse than a *good* SAH tree; what it missed is that incremental
904
+ insertion at scale does not produce one. Original disposition, kept for the record:
905
+ the regression is temporary by design (phase 2 is the same PR series), bounded (TLAS-over-
906
+ instances is the most LBVH-tolerant tier — leaf count is small and leaf AABBs, not leaf shape,
907
+ dominate), measured (SAH + frame-time gates, not vibes), and reversible (flag). The alternative —
908
+ shipping treelets in the same deliverable — was rejected for review size, not difficulty; the
909
+ phases share every buffer.
910
+
911
+ **AR2 — "The field retired treelets. AMD ships H-PLOC. You researched this and still plan a TRBVH
912
+ port — that's sentimentality about in-repo code, not engineering."** The strongest objection in
913
+ this review, and it gets the full numbers. Against the treelet plan: H-PLOC delivers ATRBVH-class
914
+ quality with its BVH2 phase **8.2–13× faster** than the treelet approach (same codebase, same
915
+ GPU, AMD's own measurements); RADV replaced PLOC with H-PLOC as the shipping default in Mesa 26.0;
916
+ treelets have a documented GPU-stability history (the DXR pass carries "TDRs on AMD" workaround
917
+ flags); overlapping treelets are rebuilt repeatedly during the ascent by construction. For the
918
+ treelet plan: the transliteration-with-oracle argument is not sentiment — `ebvh_optimize_treelet`
919
+ has a spec suite pinning the exact DP, the DXR Fallback is a complete worked GPU example of the
920
+ same pass, the DP's cooperation pattern is workgroup-shaped and indifferent to wave width
921
+ (whereas H-PLOC's cluster-list threshold is defined *in terms of* the wave size, which in WGSL is
922
+ a runtime value ranging 4–128 — dynamic `T = subgroup_size/2` handling, degenerate at the low
923
+ end, plus known uniformity-analysis friction; subgroups being the engine baseline makes wave ops
924
+ free to *use*, but it does not make wave-*width-dependent algorithm structure* free to port), and
925
+ at TLAS scale the 8–13× speed difference is ~0.5 ms vs ~0.06 ms — real but not decision-grade,
926
+ while implementation risk is. Both paths clear
927
+ the actual gate (SAH parity with today's CPU trees — the acceptance test is SAH-based either way,
928
+ so the oracle advantage matters for *debugging*, not for *acceptance*). Disposition: treelet port
929
+ stays the committed phase-2 path per the stated goal sequencing; **phase 2 opens with an explicit
930
+ decision checkpoint** re-weighing H-PLOC (porting bases exist: NexusBVH in CUDA, Morrical's Slang
931
+ implementation that can emit WGSL) now that phase 1 will have produced our own SAH/depth/frame
932
+ numbers on our own scenes; and the seam is pre-committed so that whichever loses can be replaced
933
+ without touching extract/morton/sort or the output contract. If meep had no in-repo treelet
934
+ implementation, H-PLOC would be the phase-2 recommendation outright.
935
+
936
+ **AR3 — "Cross-workgroup relaxed-atomic handshakes (refit, treelet climbs) are formally racy
937
+ under the WGSL memory model — WGSL atomics are relaxed, there is no `globallycoherent`, no
938
+ device-scope barrier."** Correct, and this is the plan's most serious technical risk (§7, R1).
939
+ Disposition: (a) every production GPU builder ships this pattern (DXR: `InterlockedAdd` +
940
+ `globallycoherent`; GPURT likewise), and D3D's guarantees here are not materially stronger than
941
+ WGSL's; (b) the engine's line, drawn when `b6049214c` removed OneSweep, is precise and this
942
+ design respects it: what was rejected was **unbounded waiting on another workgroup's progress**,
943
+ not cross-workgroup communication through relaxed atomic words — csdldf itself communicates
944
+ exactly that way, with bounded spin and a fallback. The refit/treelet climbs sit on the accepted
945
+ side: arrival-counter handshakes have no spin at all (the losing thread exits; the winner's
946
+ inputs are already written), so they assume atomic-word *visibility*, never scheduling fairness;
947
+ (c) discipline: all cross-group-communicated state (bounds, counters, links during climbs) goes
948
+ through `array<atomic<u32>>` accesses only, never plain loads/stores gated by an atomic — the
949
+ strictest formulation available in WGSL; (d) the browser verify harness asserts structural
950
+ validity per platform, and a formally-clean fallback (iterative per-level dispatches —
951
+ inter-dispatch ordering is guaranteed by WebGPU) is sketched and cheap to add if a platform ever
952
+ misbehaves. Not hand-waved: named, bounded, fallback designed.
953
+
954
+ **AR4 — "The 32-slot traversal stack is shared TLAS+BLAS and your LBVH can exceed the depth the
955
+ current balanced trees keep. Silent geometry drop-out is the failure mode."** The sharpest finding
956
+ of the consumer audit (§2.1). Worst-case Karras depth over 30-bit keys with index tiebreaks is
957
+ ~30 + log₂(duplicate-run), far beyond 31 in adversarial clustering; today's rotation-balanced
958
+ dynamic tree keeps ~1.44·log₂N. Disposition: depth histogram is a first-class assertion in the
959
+ verify harness and an A/B metric on real scenes (phase 1 gate includes "no traversal truncation on
960
+ benchmark scenes"); treelet restructuring (phase 2) actively shortens chains; and if real content
961
+ ever approaches the cliff, `BVH_TRAVERSAL_STACK_SIZE` is a single constant whose 32→64 cost will
962
+ be measured rather than feared. Note the cliff is *pre-existing* (deep TLAS+BLAS combinations can
963
+ overflow today); the builder makes it measurable for the first time.
964
+
965
+ **AR5 — "Reading instance AABBs from the scene database couples the TLAS to renderer-internal
966
+ tables; the CPU tree's leaves were a clean input."** The coupling already exists — TLAS leaf
967
+ payloads *are* `meshes`-table rows, consumed by `scene_read_mesh` in every traversal; bounds from
968
+ the same row is less coupling, not more (one source of truth instead of two). And CPU-side bounds
969
+ are simply wrong for GPU-authority rows. The builder core stays decoupled regardless: extraction
970
+ is the only scene-aware pass; `graph_bvh_build_lbvh` itself takes flat `leaf_bounds`/
971
+ `leaf_payload` and serves BLAS unchanged.
972
+
973
+ **AR6 — "Keeping the CPU BVH is duplicated state — kill it."** Rejected: `DirectionalLightShadowmap`
974
+ queries it per cascade per frame on the CPU, and the acoustic/probe systems use the same `BVH`
975
+ class independently. What *does* die: the per-frame CPU treelet pass over it (phase 1), the
976
+ full-tree flatten+upload (phase 1), and the legacy upload path plus its flag (phase 2). The CPU
977
+ tree reverts to what it structurally is — a cheap incremental index for CPU queries.
978
+
979
+ **AR7 — "The pipeline's backbone is a sort with a forward-progress assumption (tilers, Apple)."**
980
+ This objection **won**, pre-emptively: the engine removed the fallback-less OneSweep port
981
+ (`b6049214c`) and set the direction — build around CSDLDF. The sort is now a deliverable of this
982
+ plan (§4.2.1) whose v1 has no unbounded spin anywhere except inside csdldf's own
983
+ bounded-spin-then-fallback protocol, making it correct on every scheduler b0nes164's warning
984
+ covers ("tends to run on anything that is not mobile, a software rasterizer, or Apple" — §3.3,
985
+ about the removed formulation). The cost is honest: three dispatches per digit instead of one,
986
+ tens of microseconds at TLAS scale, with the single-pass v2 recorded for the day a measurement
987
+ demands it. What remains of the original objection is only schedule risk — the sort is new
988
+ engineering on the critical path — carried as R2.
989
+
990
+ **AR8 — "Per-frame full rebuild is wasteful; you should refit-update the GPU tree."** Rebuild-
991
+ always is the references' default for TLAS (cheap at instance counts; immune to the
992
+ quality decay that refit-only accumulates under motion) and is dramatically cheaper than what
993
+ ships today. Refit-only (topology frozen, extraction + refit passes alone — both already exist in
994
+ phase 1) is a knob this architecture gets almost for free, and phase 3a *is* that knob applied to
995
+ skinned BLAS, where it matters. Deliberately not a phase-1 deliverable: no measurement yet says
996
+ the rebuild is the bottleneck.
997
+
998
+ **AR9 — "The emission kernel writes leaves and internals from one dispatch with divergent work
999
+ per thread (thread i does leaf i and maybe internal i) — split it for clarity?"** No: one dispatch
1000
+ of N threads with a tail `if` is the canonical LBVH formulation (DXR ships it), avoids a second
1001
+ pass over the sorted arrays, and the emulator tier tests it as a pure function either way.
1002
+
1003
+ **AR10 — "New directory `gpu_primitive/bvh/` — is that the right home, or is this a scene
1004
+ feature?"** `gpu_primitive/` is where engine-level, consumer-agnostic GPU algorithms live (sort,
1005
+ prefix_sum, indirect, hash_table); the builder is exactly that (TLAS and BLAS both consume it).
1006
+ Scene-specific extraction lives with the scene code. This mirrors how `graph_radix_sort` is a
1007
+ primitive while its callers own their data semantics — the "first-class member, not tack-on"
1008
+ requirement made structural.
1009
+
1010
+ **AR11 — "Apetrei 2014 fuses emission and refit into one kernel with no searches and no spinning —
1011
+ you're knowingly shipping the two-kernel 2012 formulation."** Yes, knowingly (§3.4-4). What the
1012
+ fused kernel saves at TLAS scale is one small dispatch and some binary searches; what the split
1013
+ formulation buys is the project's best test story — the emission kernel is a pure function, so the
1014
+ vitest emulator tier executes the actual WGSL against the actual in-repo oracle, duplicate-key
1015
+ runs and all — plus a standalone refit pass that phase 3a and refit-only updates require to exist
1016
+ regardless. Apetrei changes neither topology nor the output contract, and is the natural
1017
+ optimisation to fold in during phase-2 work — note that GPURT's shipping `Linear` builder *is*
1018
+ the Apetrei formulation (§3.2), and it is H-PLOC's substrate, so effort spent there compounds
1019
+ toward AR2's alternative rather than against it.
1020
+
1021
+ ---
1022
+
1023
+ ## 7. Risk register
1024
+
1025
+ | # | risk | mitigation |
1026
+ |---|---|---|
1027
+ | R1 | WGSL relaxed-atomic cross-workgroup visibility (refit/treelet climbs) is beyond formal guarantees | All-atomic access discipline; no unbounded waiting anywhere (arrival-counter losers exit); per-platform structural verification in the harness; per-level-dispatch fallback designed (inter-dispatch ordering is spec-guaranteed); precedent: csdldf ships on the same visibility assumption. See AR3. |
1028
+ | 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. |
1029
+ | 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. |
1030
+ | 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. |
1031
+ | 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. |
1032
+ | 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). |
1033
+ | 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. |
1034
+ | 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. |
1035
+ | 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). |
1036
+
1037
+ ---
1038
+
1039
+ ## 8. Cleanup and hardening backlog (folded into the phases)
1040
+
1041
+ Doing this as a first-class citizen also means leaving the neighbourhood cleaner:
1042
+
1043
+ - ~~Delete the dead/fallback-less sort variants~~ — **done** (`b6049214c` removed all four,
1044
+ OneSweep included). The re-founded sort (§4.2.1) carries a `warn_limited` element-count guard
1045
+ from day one, like the scan.
1046
+ - ~~Retire the deprecated pre-fallback scan pair once nothing references it:
1047
+ `prefix_sum/v1/graph_prefix_scan_csdl.js` + `shader_one_sweep_prefix_scan.js`~~ — **done**; both
1048
+ deleted. Nothing referenced either: the graph was the shader's only importer, and none of the
1049
+ symbols the pair exported (`graph_prefix_scan_csdl`, `shader_one_sweep_prefix_scan`,
1050
+ `ONE_SWEEP_UNIFORMS_STRUCT`, `PREFIX_SCAN_NUM_GROUP_THREADS` / `_NUM_ELEMENTS_PER_GROUP`) was
1051
+ named anywhere outside the pair. Same no-fallback class the sort purge removed in `b6049214c` —
1052
+ bare decoupled lookback, which assumes cross-workgroup forward progress. `graph_prefix_scan_csdldf`
1053
+ / `shader_prefix_scan_csdldf` are the survivors and are what the sort's spine (§4.2.1) dispatches.
1054
+ - ~~Supersede the buggy, unused adaptive-morton chunks with the validated fixed-bit
1055
+ `chunk_v3_morton_encode`~~ — **done**: the fixed-bit chunk is spec'd bit-exact against
1056
+ `v3_morton_encode_magicbits`, and `chunk_calculate_morton_code` /
1057
+ `chunk_get_morton_codes_from_unit_coord` are deleted. Neither had an importer — the pair only
1058
+ imported each other — and the one textual reference outside it was a sentence in
1059
+ `chunk_v3_morton_encode`'s doc comment, reworded with the deletion. Note that `chunk_split_by_3`
1060
+ turned out to be invalid WGSL too (`x | x << 16` does not parse — WGSL gives `<<` and `|` no
1061
+ relative precedence) and was fixed, since the Morton chunk composes it.
1062
+ - ~~Delete `GPUGeometryBVH.js`~~ — **done**. Its header claimed "Currently used"; nothing in the
1063
+ tree named the class. It built a CPU per-geometry BVH over raw triangle indices
1064
+ (`ebvh_build_for_geometry_morton`, then an optional treelet pass) with both of its tuning
1065
+ constants pinned to `0` — so the treelet loop it carried was dead even to itself. The live BLAS
1066
+ path is `meshlet_batch_to_bvh.js` building into `GPUGeometryBVHManager`'s arena, with
1067
+ `GPUGeometryMeshletsBVH` as the per-geometry record.
1068
+ - ~~Decide the fate of `shader/chunk/bvh/packed/`~~ — **deleted**, all nine files; nothing imported
1069
+ any of them. *Superseded*: it is a second, competing compaction of BVH2 — a 20-byte node (3 words
1070
+ of packed bounds + 2 child words) beside the live 32-byte one — while §5.3 takes compaction in a
1071
+ different direction, keeping BVH2 as the construction intermediate and making BVH8 an encode into
1072
+ the 80-byte `BVH8_INODE_STRUCT` that already exists with traversal chunks to match. *Broken*:
1073
+ `NOTES.md`'s leaf convention ("MSB is set to 0 in `child_1`") is not merely unimplemented, it is
1074
+ *inverted* relative to §2.1, where a leaf is `child_1 == 0xFFFFFFFF` — MSB set — while an internal
1075
+ node's child index has it clear; `packed_bvh_from_unpacked_node` copies `child_1` through
1076
+ untouched, so nothing reconciles the two. *Correction* to the note this replaces: `shader-f16` was
1077
+ a red herring — the pair is `pack2x16float`/`unpack2x16float`, core builtins needing no device
1078
+ feature (§5.3 says the same of the BVH8 quantised boxes), and both halves validated clean against
1079
+ `validate_wgsl_source`, extension list empty, before deletion. **Carry forward into §5.3**: the
1080
+ one piece of real logic there, `make_f32_interval_f16_safe`, was wrong and carried its own
1081
+ `FIXME` — it widens an interval by an *absolute* `FLT_EPSILON_16` (2⁻¹⁰, the f16 ULP at 1.0), but
1082
+ f16 spacing scales with magnitude, so near 1024 (ULP 1.0) the nudge rounds away entirely and the
1083
+ bound clips *inward*, dropping geometry from traversal. BVH8's quantised boxes need the same
1084
+ conservative outward rounding, and it has to be directed rounding per magnitude — which is what
1085
+ the routine's own TODO was pointing at in the DXR Fallback's `CpuBVH2Builder.cpp`.
1086
+ - ~~`prefix_sum/v1/test_prefix_sum.js` passes `element_count`/`timer` args that
1087
+ `graph_prefix_scan_csdldf` does not accept~~ — **done** in the sort commit.
1088
+ - Sort v2 (single-pass digit binning with the per-bin fallback protocol, §4.2.1) — recorded
1089
+ upgrade, gated on a measurement from a real consumer.
1090
+ - ~~Add `bvh_format.spec.js` locking the §2.1 byte contract~~ — **done**; it holds both producers
1091
+ to the same golden bytes.
1092
+ - Extend `ComputeShaderEmulator` with sequential atomics (optional; would lift the refit kernel
1093
+ into the emulator tier). Its bit-counting builtins (`countLeadingZeros` and siblings) were added
1094
+ in phase 1 — `delta` is written in terms of `countLeadingZeros`, so the emission chunks could not
1095
+ be executed at all without them.
1096
+ - `SoftwareGPUDevice` now accepts a `subgroups` requirement. It runs no WGSL, so the feature changes
1097
+ nothing it emulates; refusing it left the orchestration of every subgroup-using pass untestable at
1098
+ tier 3. The corollary for future specs: one that stands up a scene and calls
1099
+ `GPUSceneContext.update()` must construct its device with `requiredFeatures: ['subgroups']`.
1100
+ - `Renderer` requests `Subgroups` as *optional*, so a device without it starts and then throws at
1101
+ the first subgroup dispatch. That predates this work (the csdldf scan has the same property) and
1102
+ is consistent with subgroups being a stated baseline, but it is the one place where the baseline
1103
+ is not enforced where the device is created.
1104
+
1105
+ ---
1106
+
1107
+ ## 9. Deliverable sequence (summary)
1108
+
1109
+ | phase | delivers | new shaders | gate |
1110
+ |---|---|---|---|
1111
+ | 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 |
1112
+ | 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 |
1113
+ | 3a | Skinned-clone BLAS refit (per-frame correct animated BLAS) | triangle-leaf extract (refit reused) | deformed-mesh RT correctness on animation scenes |
1114
+ | 3b | Full GPU BLAS build (import + procedural) | (extraction variant only) | format + serialization round-trip; trace parity vs CPU-built BLAS |
1115
+ | 4 | BVH8 encode + consumer migration, additive | cost/collapse, encode | traversal win measured per consumer before migration |