@woosh/meep-engine 3.26.0 → 3.27.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 (236) hide show
  1. package/package.json +1 -1
  2. package/src/core/binary/lz4/LZ4_HIGH_COMPRESSION_PLAN_2026_09_14.md +542 -0
  3. package/src/core/binary/lz4/LZ4_LEVEL.d.ts +33 -0
  4. package/src/core/binary/lz4/LZ4_LEVEL.d.ts.map +1 -0
  5. package/src/core/binary/lz4/LZ4_LEVEL.js +32 -0
  6. package/src/core/binary/lz4/lz4_compress_block_at_level.d.ts +33 -0
  7. package/src/core/binary/lz4/lz4_compress_block_at_level.d.ts.map +1 -0
  8. package/src/core/binary/lz4/lz4_compress_block_at_level.js +47 -0
  9. package/src/core/binary/lz4/lz4_compress_block_hc.d.ts +40 -0
  10. package/src/core/binary/lz4/lz4_compress_block_hc.d.ts.map +1 -0
  11. package/src/core/binary/lz4/lz4_compress_block_hc.js +1382 -0
  12. package/src/core/binary/lz4/lz4_decompress_block.d.ts +2 -1
  13. package/src/core/binary/lz4/lz4_decompress_block.d.ts.map +1 -1
  14. package/src/core/binary/lz4/lz4_decompress_block.js +31 -6
  15. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +14 -7
  16. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  17. package/src/core/geom/3d/shape/HeightMapShape3D.js +87 -78
  18. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  19. package/src/shade/playground/vgeo_runtime/README.md +167 -22
  20. package/src/shade/playground/vgeo_runtime/index.html +112 -62
  21. package/src/shade/playground/vgeo_runtime/main.js +660 -83
  22. package/src/shade/playground/vgeo_viewer/sample_asset.d.ts.map +1 -1
  23. package/src/shade/playground/vgeo_viewer/sample_asset.js +19 -2
  24. package/src/shade/renderer/geometry/bvh/GPUGeometryBVHManager.d.ts +11 -0
  25. package/src/shade/renderer/geometry/bvh/GPUGeometryBVHManager.d.ts.map +1 -1
  26. package/src/shade/renderer/geometry/bvh/GPUGeometryBVHManager.js +16 -8
  27. package/src/shade/renderer/geometry/meshlet/GPUMeshletManager.d.ts.map +1 -1
  28. package/src/shade/renderer/geometry/meshlet/GPUMeshletManager.js +16 -11
  29. package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +73 -5
  30. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +26 -15
  31. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +5 -3
  32. package/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.d.ts +18 -0
  33. package/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.d.ts.map +1 -1
  34. package/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.js +20 -0
  35. package/src/shade/renderer/geometry/virtual/build/vgeo_assemble_pages.d.ts.map +1 -1
  36. package/src/shade/renderer/geometry/virtual/build/vgeo_assemble_pages.js +56 -9
  37. package/src/shade/renderer/geometry/virtual/build/vgeo_build.d.ts.map +1 -1
  38. package/src/shade/renderer/geometry/virtual/build/vgeo_build.js +1 -0
  39. package/src/shade/renderer/geometry/virtual/format/VGeoWriteOptions.d.ts +22 -0
  40. package/src/shade/renderer/geometry/virtual/format/VGeoWriteOptions.d.ts.map +1 -1
  41. package/src/shade/renderer/geometry/virtual/format/VGeoWriteOptions.js +24 -0
  42. package/src/shade/renderer/geometry/virtual/format/dag/VirtualGeometryCluster.d.ts +12 -0
  43. package/src/shade/renderer/geometry/virtual/format/dag/VirtualGeometryCluster.d.ts.map +1 -1
  44. package/src/shade/renderer/geometry/virtual/format/dag/VirtualGeometryCluster.js +13 -0
  45. package/src/shade/renderer/geometry/virtual/format/frame/vgeo_encode_frame_blob.d.ts +8 -1
  46. package/src/shade/renderer/geometry/virtual/format/frame/vgeo_encode_frame_blob.d.ts.map +1 -1
  47. package/src/shade/renderer/geometry/virtual/format/frame/vgeo_encode_frame_blob.js +11 -3
  48. package/src/shade/renderer/geometry/virtual/format/header/VGEO_FORMAT_VERSION.d.ts +9 -0
  49. package/src/shade/renderer/geometry/virtual/format/header/VGEO_FORMAT_VERSION.d.ts.map +1 -1
  50. package/src/shade/renderer/geometry/virtual/format/header/VGEO_FORMAT_VERSION.js +10 -1
  51. package/src/shade/renderer/geometry/virtual/format/page/VGEO_MAX_PARENT_ENTRY_COUNT.d.ts +18 -0
  52. package/src/shade/renderer/geometry/virtual/format/page/VGEO_MAX_PARENT_ENTRY_COUNT.d.ts.map +1 -0
  53. package/src/shade/renderer/geometry/virtual/format/page/VGEO_MAX_PARENT_ENTRY_COUNT.js +17 -0
  54. package/src/shade/renderer/geometry/virtual/format/page/VGEO_MAX_PARENT_REF.d.ts +8 -0
  55. package/src/shade/renderer/geometry/virtual/format/page/VGEO_MAX_PARENT_REF.d.ts.map +1 -0
  56. package/src/shade/renderer/geometry/virtual/format/page/VGEO_MAX_PARENT_REF.js +7 -0
  57. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PAGE_OFFSET.d.ts +3 -0
  58. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PAGE_OFFSET.js +16 -0
  59. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PARENT_ENTRY_SIZE.d.ts +14 -0
  60. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PARENT_ENTRY_SIZE.d.ts.map +1 -0
  61. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PARENT_ENTRY_SIZE.js +13 -0
  62. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PARENT_PAGE_ENTRY_SIZE.d.ts +11 -0
  63. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PARENT_PAGE_ENTRY_SIZE.d.ts.map +1 -0
  64. package/src/shade/renderer/geometry/virtual/format/page/VGEO_PARENT_PAGE_ENTRY_SIZE.js +10 -0
  65. package/src/shade/renderer/geometry/virtual/format/read/VGeoByteSource.d.ts +20 -0
  66. package/src/shade/renderer/geometry/virtual/format/read/VGeoByteSource.d.ts.map +1 -1
  67. package/src/shade/renderer/geometry/virtual/format/read/VGeoByteSource.js +20 -2
  68. package/src/shade/renderer/geometry/virtual/format/read/VGeoContainerHeader.d.ts +4 -0
  69. package/src/shade/renderer/geometry/virtual/format/read/VGeoContainerHeader.d.ts.map +1 -1
  70. package/src/shade/renderer/geometry/virtual/format/read/VGeoContainerHeader.js +4 -0
  71. package/src/shade/renderer/geometry/virtual/format/read/VGeoContainerReader.d.ts +29 -3
  72. package/src/shade/renderer/geometry/virtual/format/read/VGeoContainerReader.d.ts.map +1 -1
  73. package/src/shade/renderer/geometry/virtual/format/read/VGeoContainerReader.js +81 -122
  74. package/src/shade/renderer/geometry/virtual/format/read/VGeoPage.d.ts +40 -0
  75. package/src/shade/renderer/geometry/virtual/format/read/VGeoPage.d.ts.map +1 -1
  76. package/src/shade/renderer/geometry/virtual/format/read/VGeoPage.js +44 -0
  77. package/src/shade/renderer/geometry/virtual/format/read/VGeoPageFetcherCore.d.ts +69 -0
  78. package/src/shade/renderer/geometry/virtual/format/read/VGeoPageFetcherCore.d.ts.map +1 -0
  79. package/src/shade/renderer/geometry/virtual/format/read/VGeoPageFetcherCore.js +236 -0
  80. package/src/shade/renderer/geometry/virtual/format/read/VGeoReadOptions.d.ts +14 -0
  81. package/src/shade/renderer/geometry/virtual/format/read/VGeoReadOptions.d.ts.map +1 -1
  82. package/src/shade/renderer/geometry/virtual/format/read/VGeoReadOptions.js +15 -0
  83. package/src/shade/renderer/geometry/virtual/format/read/vgeo_byte_source_from_descriptor.d.ts +31 -0
  84. package/src/shade/renderer/geometry/virtual/format/read/vgeo_byte_source_from_descriptor.d.ts.map +1 -0
  85. package/src/shade/renderer/geometry/virtual/format/read/vgeo_byte_source_from_descriptor.js +32 -0
  86. package/src/shade/renderer/geometry/virtual/format/read/vgeo_fetch_byte_source.d.ts +10 -0
  87. package/src/shade/renderer/geometry/virtual/format/read/vgeo_fetch_byte_source.d.ts.map +1 -1
  88. package/src/shade/renderer/geometry/virtual/format/read/vgeo_fetch_byte_source.js +21 -1
  89. package/src/shade/renderer/geometry/virtual/format/read/vgeo_page_transfer.d.ts +15 -0
  90. package/src/shade/renderer/geometry/virtual/format/read/vgeo_page_transfer.d.ts.map +1 -0
  91. package/src/shade/renderer/geometry/virtual/format/read/vgeo_page_transfer.js +84 -0
  92. package/src/shade/renderer/geometry/virtual/format/read/vgeo_page_worker_serve.d.ts +22 -0
  93. package/src/shade/renderer/geometry/virtual/format/read/vgeo_page_worker_serve.d.ts.map +1 -0
  94. package/src/shade/renderer/geometry/virtual/format/read/vgeo_page_worker_serve.js +96 -0
  95. package/src/shade/renderer/geometry/virtual/format/read/vgeo_read_page.d.ts.map +1 -1
  96. package/src/shade/renderer/geometry/virtual/format/read/vgeo_read_page.js +55 -0
  97. package/src/shade/renderer/geometry/virtual/format/vgeo_validate_container.d.ts.map +1 -1
  98. package/src/shade/renderer/geometry/virtual/format/vgeo_validate_container.js +202 -13
  99. package/src/shade/renderer/geometry/virtual/format/vgeo_write_container.d.ts.map +1 -1
  100. package/src/shade/renderer/geometry/virtual/format/vgeo_write_container.js +147 -10
  101. package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +637 -0
  102. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryPool.d.ts +33 -36
  103. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryPool.d.ts.map +1 -1
  104. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryPool.js +109 -193
  105. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryRuntime.d.ts +58 -28
  106. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryRuntime.d.ts.map +1 -1
  107. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryRuntime.js +77 -105
  108. package/src/shade/renderer/geometry/virtual/runtime/REVIEW_LEDGER.md +37 -0
  109. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +105 -22
  110. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN_2026_09_14.md +548 -0
  111. package/src/shade/renderer/geometry/virtual/runtime/VGeoFeedbackLoop.d.ts +29 -50
  112. package/src/shade/renderer/geometry/virtual/runtime/VGeoFeedbackLoop.d.ts.map +1 -1
  113. package/src/shade/renderer/geometry/virtual/runtime/VGeoFeedbackLoop.js +47 -489
  114. package/src/shade/renderer/geometry/virtual/runtime/VGeoPageFetcher.d.ts +84 -0
  115. package/src/shade/renderer/geometry/virtual/runtime/VGeoPageFetcher.d.ts.map +1 -0
  116. package/src/shade/renderer/geometry/virtual/runtime/VGeoPageFetcher.js +393 -0
  117. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidency.d.ts +234 -0
  118. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidency.d.ts.map +1 -0
  119. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidency.js +1363 -0
  120. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidencyOptions.d.ts +81 -0
  121. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidencyOptions.d.ts.map +1 -0
  122. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidencyOptions.js +89 -0
  123. package/src/shade/renderer/geometry/virtual/runtime/VGeoRuntimeTables.d.ts +53 -34
  124. package/src/shade/renderer/geometry/virtual/runtime/VGeoRuntimeTables.d.ts.map +1 -1
  125. package/src/shade/renderer/geometry/virtual/runtime/VGeoRuntimeTables.js +407 -124
  126. package/src/shade/renderer/geometry/virtual/runtime/VGeoViewCut.d.ts +100 -0
  127. package/src/shade/renderer/geometry/virtual/runtime/VGeoViewCut.d.ts.map +1 -0
  128. package/src/shade/renderer/geometry/virtual/runtime/VGeoViewCut.js +219 -0
  129. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_EXPAND.d.ts +17 -0
  130. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_EXPAND.d.ts.map +1 -0
  131. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_EXPAND.js +47 -0
  132. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_FLAG_COARSEN.d.ts +20 -0
  133. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_FLAG_COARSEN.d.ts.map +1 -0
  134. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_FLAG_COARSEN.js +19 -0
  135. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_PARAMS_STRUCT.d.ts.map +1 -1
  136. package/src/shade/renderer/geometry/virtual/runtime/cut/VGEO_CUT_PARAMS_STRUCT.js +30 -12
  137. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_access.d.ts.map +1 -1
  138. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_access.js +15 -0
  139. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_collect.d.ts.map +1 -1
  140. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_collect.js +18 -1
  141. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_queue_prepare.d.ts.map +1 -1
  142. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_queue_prepare.js +5 -15
  143. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_queue_remainder.d.ts +3 -0
  144. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_queue_remainder.d.ts.map +1 -0
  145. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_queue_remainder.js +77 -0
  146. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_reseed.d.ts +3 -0
  147. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_reseed.d.ts.map +1 -0
  148. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_reseed.js +186 -0
  149. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_seed.d.ts.map +1 -1
  150. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_seed.js +20 -8
  151. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_traverse.d.ts.map +1 -1
  152. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_traverse.js +580 -188
  153. package/src/shade/renderer/geometry/virtual/runtime/cut/vgeo_cut_feedback_layout.d.ts +15 -18
  154. package/src/shade/renderer/geometry/virtual/runtime/cut/vgeo_cut_feedback_layout.d.ts.map +1 -1
  155. package/src/shade/renderer/geometry/virtual/runtime/cut/vgeo_cut_feedback_layout.js +19 -28
  156. package/src/shade/renderer/geometry/virtual/runtime/graph_vgeo_expand_instances_to_meshlets.d.ts.map +1 -1
  157. package/src/shade/renderer/geometry/virtual/runtime/graph_vgeo_expand_instances_to_meshlets.js +45 -25
  158. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_CUT_STAT.d.ts +1 -0
  159. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_CUT_STAT.js +11 -0
  160. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_CUT_STAT_COUNT.js +1 -1
  161. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_FEEDBACK_OFFSET.d.ts +0 -1
  162. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_FEEDBACK_OFFSET.js +12 -10
  163. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_GROUP_ROW_OFFSET.d.ts +1 -0
  164. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_GROUP_ROW_OFFSET.js +8 -0
  165. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_GROUP_ROW_WORDS.d.ts +7 -2
  166. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_GROUP_ROW_WORDS.d.ts.map +1 -1
  167. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_GROUP_ROW_WORDS.js +8 -3
  168. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PAGE_ROW_OFFSET.d.ts +2 -0
  169. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PAGE_ROW_OFFSET.js +19 -0
  170. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PAGE_ROW_WORDS.d.ts.map +1 -1
  171. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PAGE_ROW_WORDS.js +1 -1
  172. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PARENT_ROW_OFFSET.d.ts +5 -0
  173. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PARENT_ROW_OFFSET.d.ts.map +1 -0
  174. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PARENT_ROW_OFFSET.js +22 -0
  175. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PARENT_ROW_WORDS.d.ts +14 -0
  176. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PARENT_ROW_WORDS.d.ts.map +1 -0
  177. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_PARENT_ROW_WORDS.js +13 -0
  178. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_QUEUE_TARGET_PARENT_BIT.d.ts +17 -0
  179. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_QUEUE_TARGET_PARENT_BIT.d.ts.map +1 -0
  180. package/src/shade/renderer/geometry/virtual/runtime/layout/VGEO_QUEUE_TARGET_PARENT_BIT.js +16 -0
  181. package/src/shade/renderer/geometry/virtual/runtime/make_vgeo_page_worker.d.ts +15 -0
  182. package/src/shade/renderer/geometry/virtual/runtime/make_vgeo_page_worker.d.ts.map +1 -0
  183. package/src/shade/renderer/geometry/virtual/runtime/make_vgeo_page_worker.js +16 -0
  184. package/src/shade/renderer/geometry/virtual/runtime/vgeo_frontier_words.d.ts +23 -0
  185. package/src/shade/renderer/geometry/virtual/runtime/vgeo_frontier_words.d.ts.map +1 -0
  186. package/src/shade/renderer/geometry/virtual/runtime/vgeo_frontier_words.js +28 -0
  187. package/src/shade/renderer/geometry/virtual/runtime/vgeo_page.worker.d.ts +2 -0
  188. package/src/shade/renderer/geometry/virtual/runtime/vgeo_page.worker.d.ts.map +1 -0
  189. package/src/shade/renderer/geometry/virtual/runtime/vgeo_page.worker.js +13 -0
  190. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_page_block_words.d.ts +10 -5
  191. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_page_block_words.d.ts.map +1 -1
  192. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_page_block_words.js +15 -6
  193. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_write_page_block.d.ts +6 -1
  194. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_write_page_block.d.ts.map +1 -1
  195. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_write_page_block.js +38 -1
  196. package/src/shade/renderer/global_illumination/sharc/shader/shader_sharc_evict.d.ts.map +1 -1
  197. package/src/shade/renderer/global_illumination/sharc/shader/shader_sharc_evict.js +4 -3
  198. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  199. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  200. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +6 -3
  201. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  202. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +6 -3
  203. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  204. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.d.ts.map +1 -1
  205. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.js +12 -4
  206. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.d.ts +2 -0
  207. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.d.ts.map +1 -1
  208. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.js +58 -57
  209. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.d.ts.map +1 -1
  210. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.js +3 -5
  211. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.d.ts.map +1 -1
  212. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.js +3 -4
  213. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.d.ts.map +1 -1
  214. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.js +3 -4
  215. package/src/shade/renderer/rasterize/expand/pass0/pass_mesh_cull.d.ts.map +1 -1
  216. package/src/shade/renderer/rasterize/expand/pass0/pass_mesh_cull.js +7 -7
  217. package/src/shade/renderer/rasterize/fast/graph_extract_meshes_from_bucket.d.ts.map +1 -1
  218. package/src/shade/renderer/rasterize/fast/graph_extract_meshes_from_bucket.js +83 -78
  219. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.d.ts.map +1 -1
  220. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.js +7 -1
  221. package/src/shade/renderer/rasterize/standard/graph_rasterize_remaining_opaque.d.ts.map +1 -1
  222. package/src/shade/renderer/rasterize/standard/graph_rasterize_remaining_opaque.js +7 -1
  223. package/src/shade/renderer/scene/GPUSceneContext.d.ts +22 -2
  224. package/src/shade/renderer/scene/GPUSceneContext.d.ts.map +1 -1
  225. package/src/shade/renderer/scene/GPUSceneContext.js +59 -4
  226. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.d.ts.map +1 -1
  227. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.js +92 -16
  228. package/src/shade/renderer/view/GPUViewContext.d.ts +5 -0
  229. package/src/shade/renderer/view/GPUViewContext.d.ts.map +1 -1
  230. package/src/shade/renderer/view/GPUViewContext.js +21 -0
  231. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  232. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +74 -3
  233. package/src/shade/renderer/geometry/virtual/residency/VGeoResidencyManager.js +0 -723
  234. package/src/shade/renderer/geometry/virtual/residency/VGeoResidencyOptions.js +0 -50
  235. package/src/shade/renderer/shader/chunk/jobs/NOTES.md +0 -47
  236. package/src/shade/renderer/shader/chunk/jobs/chunk_job_system.js +0 -102
@@ -3,6 +3,7 @@ import { WebGPUArray } from "../../../../shader/type/WebGPUArray.js";
3
3
  import { CodeChunk } from "../../../../shader/compiler/CodeChunk.js";
4
4
  import { ComputeShader } from "../../../../shader/ComputeShader.js";
5
5
  import { ShaderResourceSetDescriptor } from "../../../../shader/resource/ShaderResourceSetDescriptor.js";
6
+ import { WebGPUExtensionType } from "../../../../../descriptor/WebGPUExtensionType.js";
6
7
  import { COLLECTION_U32_STRUCT_READ } from "../../../../gpu_primitive/COLLECTION_U32_STRUCT_READ.js";
7
8
  import { COLLECTION_U32_STRUCT_WRITE } from "../../../../gpu_primitive/COLLECTION_U32_STRUCT_WRITE.js";
8
9
  import { MESHLET_COLLECTION_WRITE_STRUCT } from "../../../../rasterize/MESHLET_COLLECTION_WRITE_STRUCT.js";
@@ -17,16 +18,19 @@ import { VGEO_NODE_ROW_OFFSET } from "../layout/VGEO_NODE_ROW_OFFSET.js";
17
18
  import { VGEO_NO_CHILD_PAGE } from "../layout/VGEO_NO_CHILD_PAGE.js";
18
19
  import { VGEO_PAGE_ROW_NONE } from "../layout/VGEO_PAGE_ROW_NONE.js";
19
20
  import { VGEO_PAGE_ROW_OFFSET } from "../layout/VGEO_PAGE_ROW_OFFSET.js";
21
+ import { VGEO_PARENT_ROW_OFFSET } from "../layout/VGEO_PARENT_ROW_OFFSET.js";
22
+ import { VGEO_QUEUE_TARGET_PARENT_BIT } from "../layout/VGEO_QUEUE_TARGET_PARENT_BIT.js";
20
23
  import { VGEO_QUEUE_TARGET_GROUP_BIT } from "../layout/VGEO_QUEUE_TARGET_GROUP_BIT.js";
21
24
  import { VGEO_WANT_WORDS } from "../layout/VGEO_WANT_WORDS.js";
22
25
  import { chunk_vgeo_cut_access } from "./chunk_vgeo_cut_access.js";
26
+ import { VGEO_CUT_EXPAND } from "./VGEO_CUT_EXPAND.js";
27
+ import { VGEO_CUT_FLAG_COARSEN } from "./VGEO_CUT_FLAG_COARSEN.js";
23
28
  import { VGEO_CUT_FLAG_CULL } from "./VGEO_CUT_FLAG_CULL.js";
24
29
  import { VGEO_CUT_PARAMS_STRUCT } from "./VGEO_CUT_PARAMS_STRUCT.js";
25
30
  import { VGEO_CUT_WORKGROUP_SIZE } from "./VGEO_CUT_WORKGROUP_SIZE.js";
26
31
 
27
32
  /**
28
- * One wavefront of the virtual-geometry cut — the pass design §7 calls for, and the reason every
29
- * structure under it exists.
33
+ * The virtual-geometry cut — design §7's pass, as a descent that lives inside one dispatch.
30
34
  *
31
35
  * ## The rule
32
36
  *
@@ -35,29 +39,67 @@ import { VGEO_CUT_WORKGROUP_SIZE } from "./VGEO_CUT_WORKGROUP_SIZE.js";
35
39
  * > `own group selectable AND project(parent) > tau AND (project(self) <= tau OR child group not
36
40
  * > selectable)`
37
41
  *
38
- * which is design §4's per-cluster form **plus its first term**. That term is not decoration, and
39
- * omitting it — as §4's own wording does, because §4 was written for a traversal that gets it for
40
- * free — double-draws: a group that is resident but not yet selectable has clusters satisfying the
41
- * rest of the rule while their coarse stand-in is drawn over the same surface, a few microns apart,
42
- * with the depth buffer choosing between them. That reads as corrupt geometry rather than as a
43
- * missing page, which is what makes it hard to attribute and worth stating first.
42
+ * which is design §4's per-cluster form together with its first term. That term is load-bearing
43
+ * and §4 states the rule without it: a group that is resident but not yet selectable has clusters
44
+ * satisfying the rest of the rule while their coarse stand-in is drawn over the same surface, a few
45
+ * microns apart, with the depth buffer choosing between them. That reads as corrupt geometry rather
46
+ * than as a missing page, which is what makes it hard to attribute and worth stating first.
44
47
  *
45
48
  * ## The shape
46
49
  *
47
- * A wavefront BFS, ping-ponged through two queues, one lane per item. Not persistent threads over
48
- * an MPMC queue: WebGPU guarantees no forward progress across workgroups, so a consumer waiting on
49
- * a producer in another workgroup is waiting without a bound.
50
+ * The loop over depth is *inside* this kernel: a dispatch is a spill generation rather than a level
51
+ * of the descent. Depth is what a cut costs. Measured on a 28M-face container at tau = 1 on a 4090:
52
+ * the descent converges at **39 levels**, carrying 15,027 items — an average frontier of 385 items,
53
+ * or **six workgroups of 64 on a 128-SM part** — and an empty prepare/traverse pair costs 3.9 us
54
+ * whatever it contains. Page depth grows like log2(triangles), so a dispatch per level gets *worse*
55
+ * with better assets and no better with a faster GPU.
50
56
  *
51
- * An item is a **node visit** or a **group-chunk visit**, distinguished by one bit of `target`. A
52
- * node tests its metric and either enqueues its children or, being a leaf, enqueues bounded chunks
53
- * of its groups' clusters. Chunks exist because a node leaf covers up to four groups of up to 4096
54
- * clusters each (format §11.5) — legal input, and 16,384 iterations in the lane that popped it if
55
- * the leaf walked them itself.
57
+ * What a lane cannot do is wait for another workgroup: WebGPU guarantees no forward progress across
58
+ * workgroups, so persistent threads over an MPMC queue — Nanite's answer — is unbounded waiting.
59
+ * What a lane *can* do is cooperate with the other lanes of its own subgroup, and that is enough:
56
60
  *
57
- * Descent crosses a page boundary by enqueueing the child page's **node 0**: uniform items, one
58
- * kernel, no special case for "entering a page". `SAME_PAGE` needs no special case either, because
59
- * the CPU resolved it to this page's own index at install, so it looks up a page that is trivially
60
- * resident and falls through to the only question it was really asking — is that group selectable.
61
+ * - Every lane executes its item and says how many children it produced. No lane walks its own
62
+ * subtree while its neighbours idle; production is a **count**, at constant cost.
63
+ * - `subgroupExclusiveAdd` over those counts numbers the wave's whole production densely. The first
64
+ * `subgroup_size` of them are taken back into the wave's own lanes — perfectly packed, one child
65
+ * per lane, whoever produced them — and the descent takes another step without touching memory.
66
+ * - Everything past a full wave **spills** to the global queue under one `atomicAdd`. Breadth the
67
+ * wave cannot hold becomes work for other waves rather than a serial loop inside this one.
68
+ *
69
+ * So the number of dispatches is the number of spill generations, and the narrow top of the DAG —
70
+ * twenty levels that move between 1 and 120 items and draw nothing — is one wave taking twenty
71
+ * steps in registers.
72
+ *
73
+ * This is Karis' "Variable sized work" (graphicrants, 2026-03) with its groupshared scatter
74
+ * removed. That scatter needs HLSL's `GroupMemoryBarrier()` — memory ordering with no execution
75
+ * sync, sound because a wave is in lockstep — and WGSL has no memory-only workgroup barrier.
76
+ * `workgroupBarrier` synchronizes execution as well, which pins every subgroup of the workgroup to
77
+ * the same number of steps and is a deadlock the moment they disagree; `workgroupUniformLoad` is
78
+ * that same control barrier plus a load, and what it returns is one value uniform across the
79
+ * workgroup, which is the opposite of the per-lane read a scatter is made of.
80
+ * {@link vg_child_owner} finds each item's producer by binary search over the same prefix sums
81
+ * instead: `log2(subgroup_size)` shuffles, no workgroup memory and no barrier anywhere in this
82
+ * file, so nothing here needs the workgroup to agree on anything.
83
+ *
84
+ * ## What WGSL will not let this do
85
+ *
86
+ * Every loop around a subgroup operation needs a **uniform trip count**, and nothing launders one —
87
+ * not `subgroupAdd`, not `subgroupMax`, not even `subgroupBroadcastFirst`, which launders a value
88
+ * for a use but not for control flow. Nor may such a loop carry a non-uniform `break`, `continue`
89
+ * or `return`. So the descent runs `params.descent_steps` times and masks; a wave that drains its
90
+ * subtree early pays the remaining steps at about ten instructions each rather than leaving. That
91
+ * makes `descent_steps` a tuning knob rather than a safety bound, and
92
+ * `VGEO_CUT_STAT.QUEUE_REMAINING` is what says when it is set too low.
93
+ *
94
+ * Divergence *inside* the loop is free: Tint models reconvergence, so a non-uniform `if` that opens
95
+ * and closes taints nothing after it, and the spill's per-lane loop is fine because it contains no
96
+ * subgroup operation of its own.
97
+ *
98
+ * ## One lane per cluster
99
+ *
100
+ * A leaf produces one item per cluster of its groups and the wave packs them, so what bounds a
101
+ * lane's work is the packing rather than a chunk size, and consecutive lanes read consecutive
102
+ * cluster rows instead of striding over runs of them.
61
103
  *
62
104
  * ## Why page-granular dedup is enough, and why it is necessary
63
105
  *
@@ -83,6 +125,11 @@ import { VGEO_CUT_WORKGROUP_SIZE } from "./VGEO_CUT_WORKGROUP_SIZE.js";
83
125
  * `max_parent_error` is at least `P.parent_error`, so the LOD cull cannot fire, and the frustum
84
126
  * cull fires only where `C` is off-screen anyway.
85
127
  *
128
+ * Descent crosses a page boundary by producing the child page's **node 0**: uniform items, one
129
+ * kernel, no special case for "entering a page". `SAME_PAGE` needs no special case either, because
130
+ * the CPU resolved it to this page's own index at install, so it looks up a page that is trivially
131
+ * resident and falls through to the only question it was really asking — is that group selectable.
132
+ *
86
133
  * ## Bounds
87
134
  *
88
135
  * Every append is checked and every overflow is counted, and they all degrade in one direction —
@@ -92,10 +139,11 @@ import { VGEO_CUT_WORKGROUP_SIZE } from "./VGEO_CUT_WORKGROUP_SIZE.js";
92
139
  *
93
140
  * ## Bindings
94
141
  *
95
- * Eight storage buffers against the engine's floor of ten (RUNTIME_PLAN §4.9), and the page table
96
- * is not one of them: a page's header is the first words of its own arena slot, so `vg_page_field`
97
- * is an offset into a buffer this kernel already binds rather than a second buffer to bind and keep
98
- * coherent.
142
+ * Nine storage buffers against the engine's floor of ten (RUNTIME_PLAN §4.9) — the tightest kernel
143
+ * of the cut, and `vgeo_cut_shaders.compile.spec.js` holds the exact count rather than the bound.
144
+ * The page table is not one of them: a page's header is the first words of its own arena slot, so
145
+ * `vg_page_field` is an offset into a buffer this kernel already binds rather than a second buffer
146
+ * to bind and keep coherent.
99
147
  *
100
148
  * @author Alex Goldring
101
149
  * @copyright Company Named Limited (c) 2026
@@ -122,9 +170,20 @@ resources.createGroup()
122
170
  // one bit per (instance, page ordinal); cleared per invocation because each expansion site
123
171
  // sees a different instance set
124
172
  .addStorageBuffer("claim", WebGPUArray.from(atomic_u32), true)
125
- // header, stats, touch and want stamps, then the two lists — one per frame, shared by every
126
- // view and bucket, which is what dedups a touch across them
173
+ /*
174
+ The tenth, and the last this kernel can have: `Renderer.js` requires ten storage
175
+ buffers per stage of an adapter and refuses one that offers fewer. It is spent on the
176
+ frontier because the alternative — deriving the page set afterwards from the draw
177
+ records — costs a pass over every drawn cluster to recover a fact the claim already
178
+ knew at the moment it was established.
179
+ */
180
+ .addStorageBuffer("frontier", WebGPUArray.from(atomic_u32), true)
181
+ // header, stats, one touch bit per page row, then the want records — one per frame, shared by
182
+ // every view and bucket, which is what dedups a touch across them
127
183
  .addStorageBuffer("feedback", WebGPUArray.from(atomic_u32), true)
184
+ // one stamp per lookup word, persistent across frames and tagged with the frame that wrote it,
185
+ // so a want is deduped without a lookup-sized clear every frame
186
+ .addStorageBuffer("vg_want_stamps", WebGPUArray.from(atomic_u32), true)
128
187
  // the same collection the static mesh expansion appends to, read downstream by the HZB meshlet
129
188
  // filter and the indirect draw with no knowledge that virtual geometry exists
130
189
  .addStorageBuffer("out_meshlets", MESHLET_COLLECTION_WRITE_STRUCT, true)
@@ -134,37 +193,52 @@ const body = CodeChunk.from(
134
193
  //language=WGSL
135
194
  `
136
195
  const VG_QUEUE_GROUP_BIT = ${VGEO_QUEUE_TARGET_GROUP_BIT}u;
196
+ const VG_QUEUE_PARENT_BIT = ${VGEO_QUEUE_TARGET_PARENT_BIT}u;
137
197
  const VG_PAGE_ROW_NONE = ${VGEO_PAGE_ROW_NONE}u;
138
198
  const VG_NO_CHILD_PAGE = ${VGEO_NO_CHILD_PAGE}u;
139
199
  const VG_GROUP_SELECTABLE = ${VGEO_GROUP_FLAG_SELECTABLE}u;
140
200
  const VG_WANT_WORDS = ${VGEO_WANT_WORDS}u;
141
201
 
142
- fn vg_stat(slot: u32, amount: u32) {
143
- atomicAdd(&feedback[${VGEO_FEEDBACK_OFFSET.STATS}u + slot], amount);
144
- }
202
+ const VG_EXPAND_NONE = ${VGEO_CUT_EXPAND.NONE}u;
203
+ const VG_EXPAND_NODES = ${VGEO_CUT_EXPAND.NODES}u;
204
+ const VG_EXPAND_CLUSTERS = ${VGEO_CUT_EXPAND.CLUSTERS}u;
205
+ const VG_EXPAND_PAGE = ${VGEO_CUT_EXPAND.PAGE}u;
206
+ const VG_EXPAND_PARENTS = ${VGEO_CUT_EXPAND.PARENTS}u;
145
207
 
146
208
  /**
147
- * Reserve count slots of the next wavefront.
209
+ * One unit of work: a node visit, or a single cluster of one group.
148
210
  *
149
- * The count is allowed to run past capacity — the prepare pass clamps it before it becomes a
150
- * dispatch — so every write against the returned base is bounds-checked here as well.
211
+ * The same four words the queue holds, and bit 31 of \`visit\` tells the two apart — a node
212
+ * index below it, a group index above it with \`aux\` naming the cluster inside that group.
151
213
  */
152
- fn vg_queue_reserve(count: u32) -> u32 {
153
- return atomicAdd(&queue_out.count, count);
214
+ struct VGItem {
215
+ instance_slot: u32,
216
+ page_row: u32,
217
+ visit: u32,
218
+ aux: u32,
154
219
  }
155
220
 
156
- fn vg_queue_write(slot: u32, instance_slot: u32, page_row: u32, visit: u32, first_cluster: u32) {
157
- if (slot >= params.queue_capacity) {
158
- vg_stat(${VGEO_CUT_STAT.QUEUE_DROPPED}u, 1u);
159
- return;
160
- }
221
+ /**
222
+ * What an executed item wants done next, as a count and a rule for the k-th child rather than as
223
+ * children written somewhere.
224
+ *
225
+ * This is the whole reason the descent can stay in registers: a producer states its production at
226
+ * constant cost and any lane of the wave can then build child \`k\` of it from five numbers.
227
+ */
228
+ struct VGExpand {
229
+ kind: u32,
230
+ count: u32,
231
+ page_row: u32,
232
+ first: u32,
233
+ span: u32,
234
+ }
161
235
 
162
- let base = slot * 4u;
236
+ fn vg_expand_none() -> VGExpand {
237
+ return VGExpand(VG_EXPAND_NONE, 0u, 0u, 0u, 0u);
238
+ }
163
239
 
164
- queue_out.elements[base] = instance_slot;
165
- queue_out.elements[base + 1u] = page_row;
166
- queue_out.elements[base + 2u] = visit;
167
- queue_out.elements[base + 3u] = first_cluster;
240
+ fn vg_stat(slot: u32, amount: u32) {
241
+ atomicAdd(&feedback[${VGEO_FEEDBACK_OFFSET.STATS}u + slot], amount);
168
242
  }
169
243
 
170
244
  /**
@@ -208,21 +282,12 @@ fn vg_claim_page(instance_base: u32, page_row: u32) -> bool {
208
282
  * the pages that are on screen and evicts them, which is the classic fallback-chain bug the virtual
209
283
  * texture cache already keys on touch to avoid.
210
284
  *
211
- * Deduped across every view and bucket of the frame by one stamp word per page row, because the
212
- * feedback buffer is per frame rather than per invocation.
285
+ * One bit per page row, because the feedback buffer is per frame rather than per invocation: the
286
+ * bit dedups across every view and bucket of the frame and is the record the host reads, so
287
+ * there is no list to fill and no touch a full list could lose.
213
288
  */
214
289
  fn vg_touch_page(page_row: u32) {
215
- if (atomicExchange(&feedback[params.touch_stamp_base + page_row], 1u) != 0u) {
216
- return;
217
- }
218
-
219
- let slot = atomicAdd(&feedback[${VGEO_FEEDBACK_OFFSET.TOUCH_COUNT}u], 1u);
220
-
221
- if (slot >= params.touch_capacity) {
222
- return;
223
- }
224
-
225
- atomicStore(&feedback[params.touch_base + slot], page_row);
290
+ atomicOr(&feedback[params.touch_bits_base + (page_row >> 5u)], 1u << (page_row & 31u));
226
291
  }
227
292
 
228
293
  /**
@@ -241,9 +306,10 @@ fn vg_touch_page(page_row: u32) {
241
306
  * and VGeoResidencyManager maxes across frames.
242
307
  */
243
308
  fn vg_want(source_page_row: u32, child_ref: u32, child_page_index: u32, lookup_base: u32, projected: f32) {
244
- let stamp = params.want_stamp_base + lookup_base + child_page_index;
245
-
246
- if (atomicExchange(&feedback[stamp], 1u) != 0u) {
309
+ // persistent and frame-tagged rather than cleared: a stamp already holding this frame's tag
310
+ // was written by this frame, and anything else is a frame that has gone
311
+ if (atomicExchange(&vg_want_stamps[lookup_base + child_page_index], params.frame_tag)
312
+ == params.frame_tag) {
247
313
  return;
248
314
  }
249
315
 
@@ -282,18 +348,41 @@ fn vg_draw(page_row: u32, cluster: u32, mesh_index: u32) {
282
348
  out_meshlets.elements[slot] = ${MESHLET_DEFINITION_STRUCT.wgsl_ref}(meshlet, mesh_index);
283
349
 
284
350
  vg_stat(${VGEO_CUT_STAT.DRAWN}u, 1u);
351
+
352
+ /*
353
+ And remember the page for next frame. The frontier is where this cut *ended*, not
354
+ everywhere it went: recording at the claim instead would name every page the descent
355
+ passed through on its way down, and reseeding from that set is more work than the root
356
+ descent it replaces rather than less. A page that drew something is a page the cut is
357
+ standing on.
358
+
359
+ A bitmask, so the several clusters of a page that draw cost one bit and no dedup. Per view,
360
+ so two views keep their own answer.
361
+ */
362
+ let frontier_word = page_row >> 5u;
363
+
364
+ if (frontier_word < params.frontier_word_count) {
365
+ atomicOr(&frontier[frontier_word], 1u << (page_row & 31u));
366
+ }
285
367
  }
286
368
 
287
- fn vg_group_selectable(page_row: u32, group: u32) -> bool {
288
- let row = vg_group_base(page_row) + group * VG_GROUP_ROW_WORDS;
369
+ fn vg_group_row(page_row: u32, group: u32) -> u32 {
370
+ return vg_group_base(page_row) + group * VG_GROUP_ROW_WORDS;
371
+ }
289
372
 
290
- return (vg_arena[row + ${VGEO_GROUP_ROW_OFFSET.FLAGS}u] & VG_GROUP_SELECTABLE) != 0u;
373
+ fn vg_group_selectable(page_row: u32, group: u32) -> bool {
374
+ return (vg_arena[vg_group_row(page_row, group) + ${VGEO_GROUP_ROW_OFFSET.FLAGS}u]
375
+ & VG_GROUP_SELECTABLE) != 0u;
291
376
  }
292
377
 
293
378
  /**
294
- * A node: cull, then descend or hand off.
379
+ * A node: cull, then say what it expands into.
380
+ *
381
+ * Nothing is enqueued here. An interior node expands into its child nodes and a leaf into every
382
+ * cluster of its groups, and either is a base and a count — which is what lets the wave take the
383
+ * first \`subgroup_size\` of them into its own lanes and spill the rest.
295
384
  */
296
- fn vg_visit_node(instance_slot: u32, instance_base: u32, page_row: u32, node: u32) {
385
+ fn vg_visit_node(instance_base: u32, page_row: u32, node: u32) -> VGExpand {
297
386
  let row = vg_node_base(page_row) + node * VG_NODE_ROW_WORDS;
298
387
 
299
388
  let sphere = vg_sphere_at(row + ${VGEO_NODE_ROW_OFFSET.SPHERE}u);
@@ -301,7 +390,7 @@ fn vg_visit_node(instance_slot: u32, instance_base: u32, page_row: u32, node: u3
301
390
  if ((params.flags & ${VGEO_CUT_FLAG_CULL}u) != 0u
302
391
  && vg_sphere_outside_frustum(instance_base, sphere)) {
303
392
  vg_stat(${VGEO_CUT_STAT.CULLED_FRUSTUM}u, 1u);
304
- return;
393
+ return vg_expand_none();
305
394
  }
306
395
 
307
396
  let eye = vg_instance_eye(instance_base);
@@ -312,11 +401,26 @@ fn vg_visit_node(instance_slot: u32, instance_base: u32, page_row: u32, node: u3
312
401
  *largest* parent error under this node still projects to no more than tau, nothing under it can
313
402
  be drawn and the whole subtree goes. The node's sphere is the union of the covered clusters'
314
403
  parent spheres, so the projection is conservative in both terms.
315
- */
316
- if (vg_project(vg_arena_f32(row + ${VGEO_NODE_ROW_OFFSET.MAX_PARENT_ERROR}u), sphere, eye, k)
317
- <= params.tau) {
404
+
405
+ **It is a descent-only accelerator**, and the coarsen flag turns it off. Its premise is that
406
+ a subtree nothing can be drawn in is a subtree worth skipping, which is true of a traversal
407
+ that only ever looks for what to draw. A walk up is looking for something else: the groups
408
+ under this node are below the cut, and being below the cut is precisely what sends them to
409
+ their parents. Skipping them here is what made a cut seeded at the leaves draw nothing at all
410
+ -- the subtree carrying the whole walk was pruned at its first node, silently and by a rule
411
+ that is correct for the direction it was written for.
412
+
413
+ A node has no parent list of its own -- the upward edge is per group (format §6.3) -- so
414
+ there is nothing to coarsen *from* here, and entering the subtree is the only way to reach
415
+ the groups that can. That is the cost of walking up through a page: the pages on the way
416
+ are evaluated whole. It is why CUT_PLAN §1 keeps a frontier at all rather than re-deriving
417
+ one, and why the frontier is what makes this affordable rather than this pass.
418
+ */
419
+ if ((params.flags & ${VGEO_CUT_FLAG_COARSEN}u) == 0u
420
+ && vg_project(vg_arena_f32(row + ${VGEO_NODE_ROW_OFFSET.MAX_PARENT_ERROR}u), sphere, eye, k)
421
+ <= params.tau) {
318
422
  vg_stat(${VGEO_CUT_STAT.CULLED_LOD}u, 1u);
319
- return;
423
+ return vg_expand_none();
320
424
  }
321
425
 
322
426
  let child_first = vg_arena[row + ${VGEO_NODE_ROW_OFFSET.CHILD_FIRST}u];
@@ -325,197 +429,479 @@ fn vg_visit_node(instance_slot: u32, instance_base: u32, page_row: u32, node: u3
325
429
  let child_count = child_word & VG_NODE_CHILD_COUNT_MASK;
326
430
 
327
431
  if ((child_word & VG_NODE_LEAF_BIT) == 0u) {
328
- let slot = vg_queue_reserve(child_count);
329
-
330
- for (var i = 0u; i < child_count; i = i + 1u) {
331
- vg_queue_write(slot + i, instance_slot, page_row, child_first + i, 0u);
332
- }
333
-
334
- return;
432
+ return VGExpand(VG_EXPAND_NODES, child_count, page_row, child_first, 0u);
335
433
  }
336
434
 
337
- // A leaf's children are groups. Hand them off in bounded chunks rather than walking them here:
338
- // four groups of four thousand clusters is legal input and would be one lane's whole life.
339
- let group_base = vg_group_base(page_row);
340
-
435
+ // a leaf's children are groups, and its production is every cluster they hold
341
436
  var total = 0u;
342
437
 
343
438
  for (var i = 0u; i < child_count; i = i + 1u) {
344
- let members = vg_arena[
345
- group_base + (child_first + i) * VG_GROUP_ROW_WORDS
346
- + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_COUNT}u
439
+ total = total + vg_arena[
440
+ vg_group_row(page_row, child_first + i) + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_COUNT}u
347
441
  ];
348
-
349
- total = total + (members + params.cluster_chunk - 1u) / params.cluster_chunk;
350
442
  }
351
443
 
352
- var slot = vg_queue_reserve(total);
444
+ return VGExpand(VG_EXPAND_CLUSTERS, total, page_row, child_first, child_count);
445
+ }
353
446
 
354
- for (var i = 0u; i < child_count; i = i + 1u) {
355
- let group = child_first + i;
447
+ /**
448
+ * The k-th cluster under a leaf, as a group and an index inside it.
449
+ *
450
+ * Bounded by the leaf's own group count and branchless, which are both about the same hazard. Every
451
+ * lane of the wave runs this, including the lanes past the end of the wave's production, and those
452
+ * hold a \`k\` with no cluster behind it. A walk that stopped when it found the group would run off
453
+ * the end of the group rows for them, and a run of zero-member rows is an infinite loop rather than
454
+ * a wrong answer — a hung GPU, on data the builder is free to produce.
455
+ *
456
+ * Their result is nonsense and is never executed, because the lane is inactive. It only has to be
457
+ * in-range and finite.
458
+ *
459
+ * It is a loop with a non-uniform trip count and no subgroup operation inside, which WGSL permits
460
+ * and which taints nothing after it.
461
+ */
462
+ fn vg_leaf_cluster(page_row: u32, group_first: u32, group_count: u32, k: u32) -> vec2<u32> {
463
+ var remaining = k;
464
+ var group = 0u;
465
+ var found = false;
356
466
 
467
+ for (var i = 0u; i < group_count; i = i + 1u) {
357
468
  let members = vg_arena[
358
- group_base + group * VG_GROUP_ROW_WORDS + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_COUNT}u
469
+ vg_group_row(page_row, group_first + i) + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_COUNT}u
359
470
  ];
360
471
 
361
- var first = 0u;
472
+ /*
473
+ The latch is the whole of it. Walking on without one is not a slower answer but a wrong
474
+ one: the walk keeps testing \`remaining\` against later groups, and any later group smaller
475
+ than the remainder it already settled on takes the item off the group that owns it. Two
476
+ values of k then name one cluster, and one cluster drawn twice is the exact failure the
477
+ claim bitmask and the own-group term exist to prevent.
478
+ */
479
+ let here = !found && remaining < members;
362
480
 
363
- while (first < members) {
364
- vg_queue_write(slot, instance_slot, page_row, group | VG_QUEUE_GROUP_BIT, first);
481
+ group = select(group, i, here);
482
+ found = found || here;
365
483
 
366
- slot = slot + 1u;
367
- first = first + params.cluster_chunk;
368
- }
484
+ // consumed only while still searching, so this never underflows
485
+ remaining = select(remaining - members, remaining, found);
369
486
  }
487
+
488
+ return vec2<u32>(group_first + group, remaining);
370
489
  }
371
490
 
372
491
  /**
373
- * A run of one group's clusters: the rule, cluster by cluster.
492
+ * One cluster: the rule, once.
374
493
  */
375
- fn vg_visit_group(
376
- instance_slot: u32,
494
+ fn vg_visit_cluster(
377
495
  instance_base: u32,
378
496
  page_row: u32,
379
497
  group: u32,
380
- first_cluster: u32
381
- ) {
498
+ member: u32
499
+ ) -> VGExpand {
382
500
  /*
383
501
  The own-group term. A group that is resident but not selectable draws nothing at all — its
384
502
  coarser selectable ancestors are already covering that surface, and drawing both is the
385
503
  double-draw this whole runtime is arranged to avoid.
386
504
  */
387
505
  if (!vg_group_selectable(page_row, group)) {
388
- return;
506
+ return vg_expand_none();
389
507
  }
390
508
 
391
- let group_row = vg_group_base(page_row) + group * VG_GROUP_ROW_WORDS;
392
-
393
- let members = vg_arena[group_row + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_COUNT}u];
394
- let cluster_first = vg_arena[group_row + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_FIRST}u];
509
+ let group_row = vg_group_row(page_row, group);
395
510
 
396
- let last = min(first_cluster + params.cluster_chunk, members);
511
+ let cluster = vg_arena[group_row + ${VGEO_GROUP_ROW_OFFSET.CLUSTER_FIRST}u] + member;
397
512
 
398
- let cluster_base = vg_cluster_base(page_row);
513
+ let row = vg_cluster_base(page_row) + cluster * VG_CLUSTER_ROW_WORDS;
399
514
 
400
515
  let eye = vg_instance_eye(instance_base);
401
516
  let k = vg_instance_f32(instance_base, ${VGEO_INSTANCE_OFFSET.PROJECTION_K}u);
402
517
  let mesh_index = instances[instance_base + ${VGEO_INSTANCE_OFFSET.MESH_INDEX}u];
403
518
  let lookup_base = instances[instance_base + ${VGEO_INSTANCE_OFFSET.LOOKUP_WORD_OFFSET}u];
404
519
 
405
- for (var i = first_cluster; i < last; i = i + 1u) {
406
- let cluster = cluster_first + i;
520
+ vg_stat(${VGEO_CUT_STAT.CLUSTERS_TESTED}u, 1u);
407
521
 
408
- let row = cluster_base + cluster * VG_CLUSTER_ROW_WORDS;
522
+ /*
523
+ Before the cluster can refine, and the reason is residency rather than the draw: refining
524
+ claims the child page, and a claim is a touch — the LRU's whole input — so an off-screen
525
+ cluster that refined anyway would keep pages nobody can see hot and the tier full of them.
526
+ self_sphere contains the cluster's own vertices (FORMAT §7), so this rejects nothing on
527
+ screen; and every parent of a group shares one self_sphere, so a group's parents decide this
528
+ uniformly and the cut's proofs stand (RUNTIME_PLAN §4.4).
529
+ */
530
+ if ((params.flags & ${VGEO_CUT_FLAG_CULL}u) != 0u
531
+ && vg_sphere_outside_frustum(
532
+ instance_base, vg_sphere_at(row + ${VGEO_CLUSTER_ROW_OFFSET.SELF_SPHERE}u)
533
+ )) {
534
+ vg_stat(${VGEO_CUT_STAT.CULLED_CLUSTER}u, 1u);
409
535
 
410
- vg_stat(${VGEO_CUT_STAT.CLUSTERS_TESTED}u, 1u);
536
+ return vg_expand_none();
537
+ }
411
538
 
412
- let parent_projected = vg_project(
413
- vg_arena_f32(row + ${VGEO_CLUSTER_ROW_OFFSET.PARENT_ERROR}u),
414
- vg_sphere_at(row + ${VGEO_CLUSTER_ROW_OFFSET.PARENT_SPHERE}u),
415
- eye, k
416
- );
539
+ let parent_projected = vg_project(
540
+ vg_arena_f32(row + ${VGEO_CLUSTER_ROW_OFFSET.PARENT_ERROR}u),
541
+ vg_sphere_at(row + ${VGEO_CLUSTER_ROW_OFFSET.PARENT_SPHERE}u),
542
+ eye, k
543
+ );
417
544
 
418
- if (parent_projected <= params.tau) {
419
- // something coarser is covering this surface; this cluster is below the cut
420
- continue;
545
+ if (parent_projected <= params.tau) {
546
+ /*
547
+ Below the cut: something coarser covers this surface. A descent from the root never
548
+ arrives here -- it only descends through a step that projected above tau -- so this
549
+ is the branch a cut that was *kept* between frames lands in when the camera pulls
550
+ back, and stopping is what makes such a cut unable to coarsen.
551
+
552
+ parent_error and parent_sphere are the consuming step's, and every cluster of a group
553
+ shares that one step (FORMAT section 11.16), so this decision is group-uniform and
554
+ the group's first member can carry it alone. That is an election with nothing to
555
+ communicate: the other members reach the same answer and defer to the same lane.
556
+ */
557
+ let group_row_here = vg_group_row(page_row, group);
558
+
559
+ let parent_count = vg_arena[group_row_here + ${VGEO_GROUP_ROW_OFFSET.PARENT_COUNT}u];
560
+
561
+ if ((params.flags & ${VGEO_CUT_FLAG_COARSEN}u) == 0u || member != 0u
562
+ || parent_count == 0u) {
563
+ return vg_expand_none();
421
564
  }
422
565
 
423
- let self_projected = vg_project(
424
- vg_arena_f32(row + ${VGEO_CLUSTER_ROW_OFFSET.SELF_ERROR}u),
425
- vg_sphere_at(row + ${VGEO_CLUSTER_ROW_OFFSET.SELF_SPHERE}u),
426
- eye, k
566
+ return VGExpand(
567
+ VG_EXPAND_PARENTS,
568
+ parent_count,
569
+ page_row,
570
+ vg_arena[group_row_here + ${VGEO_GROUP_ROW_OFFSET.PARENT_FIRST}u],
571
+ 0u
427
572
  );
573
+ }
428
574
 
429
- let child_page_index = vg_arena[row + ${VGEO_CLUSTER_ROW_OFFSET.CHILD_PAGE_INDEX}u];
575
+ let self_projected = vg_project(
576
+ vg_arena_f32(row + ${VGEO_CLUSTER_ROW_OFFSET.SELF_ERROR}u),
577
+ vg_sphere_at(row + ${VGEO_CLUSTER_ROW_OFFSET.SELF_SPHERE}u),
578
+ eye, k
579
+ );
430
580
 
431
- if (self_projected <= params.tau || child_page_index == VG_NO_CHILD_PAGE) {
432
- vg_draw(page_row, cluster, mesh_index);
433
- continue;
434
- }
581
+ let child_page_index = vg_arena[row + ${VGEO_CLUSTER_ROW_OFFSET.CHILD_PAGE_INDEX}u];
435
582
 
436
- let child_page_row = vg_lookup[lookup_base + child_page_index];
583
+ if (self_projected <= params.tau || child_page_index == VG_NO_CHILD_PAGE) {
584
+ vg_draw(page_row, cluster, mesh_index);
585
+ return vg_expand_none();
586
+ }
437
587
 
438
- if (child_page_row == VG_PAGE_ROW_NONE) {
439
- /*
440
- The clamp. Drawing this cluster is not a fallback for a missing one — it is the correct
441
- coarser surface over exactly the region the missing page would have refined, which is
442
- what makes an arriving cut watertight at every moment rather than only at the end.
443
- */
444
- vg_draw(page_row, cluster, mesh_index);
588
+ let child_page_row = vg_lookup[lookup_base + child_page_index];
445
589
 
446
- vg_stat(${VGEO_CUT_STAT.CLAMPED}u, 1u);
590
+ if (child_page_row == VG_PAGE_ROW_NONE) {
591
+ /*
592
+ The clamp. Drawing this cluster is not a fallback for a missing one — it is the correct
593
+ coarser surface over exactly the region the missing page would have refined, which is
594
+ what makes an arriving cut watertight at every moment rather than only at the end.
595
+ */
596
+ vg_draw(page_row, cluster, mesh_index);
447
597
 
448
- vg_want(
449
- page_row,
450
- vg_arena[row + ${VGEO_CLUSTER_ROW_OFFSET.CHILD_REF}u],
451
- child_page_index,
452
- lookup_base,
453
- self_projected
454
- );
598
+ vg_stat(${VGEO_CUT_STAT.CLAMPED}u, 1u);
455
599
 
456
- continue;
457
- }
600
+ vg_want(
601
+ page_row,
602
+ vg_arena[row + ${VGEO_CLUSTER_ROW_OFFSET.CHILD_REF}u],
603
+ child_page_index,
604
+ lookup_base,
605
+ self_projected
606
+ );
458
607
 
459
- let child_group = vg_arena[row + ${VGEO_CLUSTER_ROW_OFFSET.CHILD_GROUP}u];
608
+ return vg_expand_none();
609
+ }
460
610
 
461
- if (!vg_group_selectable(child_page_row, child_group)) {
462
- /*
463
- The bytes are here and the group is still waiting on a page holding one of its *other*
464
- parents, so there is nothing to fetch from this cluster. Another cluster in this same
465
- cut is clamped directly against that page and wants it — monotonicity puts every
466
- ancestor of a cluster projecting above tau above tau as well.
467
- */
468
- vg_draw(page_row, cluster, mesh_index);
611
+ let child_group = vg_arena[row + ${VGEO_CLUSTER_ROW_OFFSET.CHILD_GROUP}u];
469
612
 
470
- vg_stat(${VGEO_CUT_STAT.CLAMPED}u, 1u);
471
- vg_stat(${VGEO_CUT_STAT.CLAMPED_ON_ACTIVATION}u, 1u);
613
+ if (!vg_group_selectable(child_page_row, child_group)) {
614
+ /*
615
+ The bytes are here and the group is still waiting on a page holding one of its *other*
616
+ parents, so there is nothing to fetch from this cluster. Another cluster in this same
617
+ cut is clamped directly against that page and wants it — monotonicity puts every
618
+ ancestor of a cluster projecting above tau above tau as well.
619
+ */
620
+ vg_draw(page_row, cluster, mesh_index);
472
621
 
473
- continue;
474
- }
622
+ vg_stat(${VGEO_CUT_STAT.CLAMPED}u, 1u);
623
+ vg_stat(${VGEO_CUT_STAT.CLAMPED_ON_ACTIVATION}u, 1u);
475
624
 
476
- if (vg_claim_page(instance_base, child_page_row)) {
477
- let slot = vg_queue_reserve(1u);
625
+ return vg_expand_none();
626
+ }
478
627
 
479
- vg_queue_write(slot, instance_slot, child_page_row, 0u, 0u);
480
- }
628
+ if (!vg_claim_page(instance_base, child_page_row)) {
629
+ // another lane, or another parent of this page, got there first
630
+ return vg_expand_none();
481
631
  }
632
+
633
+ return VGExpand(VG_EXPAND_PAGE, 1u, child_page_row, 0u, 0u);
482
634
  }
483
635
 
484
- @compute @workgroup_size(${VGEO_CUT_WORKGROUP_SIZE})
485
- fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
486
- let item = global_id.x;
636
+ /**
637
+ * One entry of a page's parent rows: the page holding that parent, claimed and queued.
638
+ *
639
+ * The mirror of the descent's page crossing, and deliberately page-granular like it. The
640
+ * entry names a cluster as well, and this ignores it: a page reached from any direction is
641
+ * evaluated whole, the rule is a pure function of each row, and visiting a cluster the walk
642
+ * did not need costs only the test. What the cluster half of the entry is for is the
643
+ * refine-side election of CUT_PLAN_2026_09_14 section 3, which is section 7.6's work.
644
+ *
645
+ * A parent that is not resident stops the walk rather than clamping. There is nothing to
646
+ * clamp *to* -- the surface this lane is standing on is below the cut and is not drawn -- so
647
+ * the honest answer is that this instance cannot be served incrementally this frame, which
648
+ * is the fallback section 4 already defines and section 7.6 wires up.
649
+ */
650
+ fn vg_visit_parent(instance_base: u32, page_row: u32, entry: u32) -> VGExpand {
651
+ if (entry >= vg_page_field(page_row, ${VGEO_PAGE_ROW_OFFSET.PARENT_ENTRY_COUNT}u)) {
652
+ return vg_expand_none();
653
+ }
487
654
 
488
- if (item == 0u && queue_in.count > 0u) {
489
- /*
490
- One per wavefront that actually ran. The descent is a fixed number of indirect dispatches
491
- of zero workgroups once the work runs out, so this is the only way to know how deep it
492
- went — and reaching the iteration budget is what turns RUNTIME_PLAN §4.7's measured depth
493
- of about 48 from a fact into an assumption.
494
- */
495
- vg_stat(${VGEO_CUT_STAT.WAVEFRONTS}u, 1u);
655
+ let row = vg_parent_base(page_row) + entry * VG_PARENT_ROW_WORDS;
656
+
657
+ let parent_page_index = vg_arena[row + ${VGEO_PARENT_ROW_OFFSET.PAGE_INDEX}u];
658
+
659
+ if (parent_page_index == VG_NO_CHILD_PAGE) {
660
+ return vg_expand_none();
496
661
  }
497
662
 
498
- if (item >= queue_in.count) {
499
- return;
663
+ let lookup_base = instances[instance_base + ${VGEO_INSTANCE_OFFSET.LOOKUP_WORD_OFFSET}u];
664
+
665
+ let parent_page_row = vg_lookup[lookup_base + parent_page_index];
666
+
667
+ if (parent_page_row == VG_PAGE_ROW_NONE) {
668
+ return vg_expand_none();
669
+ }
670
+
671
+ if (!vg_claim_page(instance_base, parent_page_row)) {
672
+ // this page is already in this instance's cut, from above or from another parent
673
+ return vg_expand_none();
500
674
  }
501
675
 
502
- let base = item * 4u;
676
+ return VGExpand(VG_EXPAND_PAGE, 1u, parent_page_row, 0u, 0u);
677
+ }
503
678
 
504
- let instance_slot = queue_in.elements[base];
505
- let page_row = queue_in.elements[base + 1u];
506
- let visit = queue_in.elements[base + 2u];
507
- let first_cluster = queue_in.elements[base + 3u];
679
+ /**
680
+ * Execute whatever this lane is holding.
681
+ */
682
+ fn vg_execute(item: VGItem, holding: bool) -> VGExpand {
683
+ if (!holding) {
684
+ return vg_expand_none();
685
+ }
508
686
 
509
687
  vg_stat(${VGEO_CUT_STAT.ITEMS}u, 1u);
510
688
 
511
- let instance_base = instance_slot * VG_INSTANCE_WORDS;
689
+ let instance_base = item.instance_slot * VG_INSTANCE_WORDS;
690
+
691
+ if ((item.visit & VG_QUEUE_GROUP_BIT) != 0u) {
692
+ return vg_visit_cluster(
693
+ instance_base, item.page_row, item.visit & ~VG_QUEUE_GROUP_BIT, item.aux
694
+ );
695
+ }
696
+
697
+ if ((item.visit & VG_QUEUE_PARENT_BIT) != 0u) {
698
+ return vg_visit_parent(
699
+ instance_base, item.page_row, item.visit & ~VG_QUEUE_PARENT_BIT
700
+ );
701
+ }
702
+
703
+ return vg_visit_node(instance_base, item.page_row, item.visit);
704
+ }
705
+
706
+ /**
707
+ * Child \`k\` of an expansion, as an item.
708
+ *
709
+ * Pure, and that is what lets the same rule serve both consumers: the lane that keeps a child calls
710
+ * it with values shuffled out of the producer's registers, and the lane that spills one calls it
711
+ * with its own.
712
+ */
713
+ fn vg_child(instance_slot: u32, expand: VGExpand, k: u32) -> VGItem {
714
+ if (expand.kind == VG_EXPAND_NODES) {
715
+ return VGItem(instance_slot, expand.page_row, expand.first + k, 0u);
716
+ }
717
+
718
+ if (expand.kind == VG_EXPAND_CLUSTERS) {
719
+ let at = vg_leaf_cluster(expand.page_row, expand.first, expand.span, k);
720
+
721
+ return VGItem(instance_slot, expand.page_row, at.x | VG_QUEUE_GROUP_BIT, at.y);
722
+ }
512
723
 
513
- if ((visit & VG_QUEUE_GROUP_BIT) != 0u) {
514
- vg_visit_group(
515
- instance_slot, instance_base, page_row, visit & ~VG_QUEUE_GROUP_BIT, first_cluster
724
+ if (expand.kind == VG_EXPAND_PARENTS) {
725
+ // entry first + k of the *producing* page's parent rows; the page it names is
726
+ // resolved and claimed when this item is executed, not here
727
+ return VGItem(
728
+ instance_slot, expand.page_row, (expand.first + k) | VG_QUEUE_PARENT_BIT, 0u
516
729
  );
517
- } else {
518
- vg_visit_node(instance_slot, instance_base, page_row, visit);
730
+ }
731
+
732
+ // VG_EXPAND_PAGE: the child page's node 0, which is how a descent crosses a page boundary
733
+ return VGItem(instance_slot, expand.page_row, 0u, 0u);
734
+ }
735
+
736
+ /**
737
+ * Which lane of the wave produced the item at \`index\` of its densely numbered production.
738
+ *
739
+ * The owner is the **largest** lane whose base is at or below the index — largest, because a lane
740
+ * that produced nothing repeats its neighbour's base, and a repeat sits at the start of the next
741
+ * producer's run rather than the end of the previous one. The bases are non-decreasing, so this is
742
+ * a binary search, and \`span\` starts at a builtin so the loop's trip count is uniform.
743
+ */
744
+ fn vg_child_owner(first: u32, index: u32, lane_count: u32) -> u32 {
745
+ var owner = 0u;
746
+ var span = lane_count;
747
+
748
+ while (span > 1u) {
749
+ let step_half = span >> 1u;
750
+ let mid = owner + step_half;
751
+
752
+ // branchless: a non-uniform if around the next iteration's shuffle would not compile
753
+ owner = select(owner, mid, subgroupShuffle(first, mid) <= index);
754
+
755
+ span = span - step_half;
756
+ }
757
+
758
+ return owner;
759
+ }
760
+
761
+ fn vg_queue_write(slot: u32, item: VGItem) {
762
+ if (slot >= params.queue_capacity) {
763
+ vg_stat(${VGEO_CUT_STAT.QUEUE_DROPPED}u, 1u);
764
+ return;
765
+ }
766
+
767
+ let base = slot * 4u;
768
+
769
+ queue_out.elements[base] = item.instance_slot;
770
+ queue_out.elements[base + 1u] = item.page_row;
771
+ queue_out.elements[base + 2u] = item.visit;
772
+ queue_out.elements[base + 3u] = item.aux;
773
+ }
774
+
775
+ @compute @workgroup_size(${VGEO_CUT_WORKGROUP_SIZE})
776
+ fn main(
777
+ @builtin(global_invocation_id) global_id: vec3<u32>,
778
+ @builtin(subgroup_size) lane_count: u32,
779
+ @builtin(subgroup_invocation_id) lane_index: u32,
780
+ ) {
781
+ let slot = global_id.x;
782
+
783
+ /*
784
+ The queue's count against what the queue holds. \`shader_vgeo_cut_queue_prepare\` runs
785
+ immediately before every dispatch of this kernel and stores exactly this clamp back, so the
786
+ bound is already there — but it was the *read* below that carried it here and not the predicate
787
+ that decides whether a lane executes, and those two have to agree. A lane held past capacity
788
+ reads the clamped index, which is the last item of the queue: it would execute a duplicate of
789
+ an item another lane is already holding, and a cluster visited twice from two lanes is two draw
790
+ records for one surface. That is the one failure the claim bitmask and the own-group term are
791
+ both arranged to prevent, so the predicate does not get to be the looser of the two.
792
+ */
793
+ let queue_count = min(queue_in.count, params.queue_capacity);
794
+
795
+ /*
796
+ One per dispatch that had work, which is one per spill generation rather than one per level of
797
+ the descent: the depth is inside this kernel. Reaching params.descent_steps with items still
798
+ held is what QUEUE_REMAINING reports, and nothing else can.
799
+ */
800
+ if (slot == 0u && queue_count > 0u) {
801
+ vg_stat(${VGEO_CUT_STAT.WAVEFRONTS}u, 1u);
802
+ }
803
+
804
+ var holding = slot < queue_count;
805
+
806
+ let base = slot * 4u;
807
+
808
+ /*
809
+ Read unconditionally and mask the result. A branch here would be a non-uniform if, which is
810
+ harmless on its own — but every lane must reach every subgroup operation below, and a lane that
811
+ returned early is a lane the shuffles are not allowed to read.
812
+ */
813
+ let in_bounds = min(base, params.queue_capacity * 4u - 4u);
814
+
815
+ var item = VGItem(
816
+ queue_in.elements[in_bounds],
817
+ queue_in.elements[in_bounds + 1u],
818
+ queue_in.elements[in_bounds + 2u],
819
+ queue_in.elements[in_bounds + 3u]
820
+ );
821
+
822
+ for (var step = 0u; step < params.descent_steps; step = step + 1u) {
823
+ let expand = vg_execute(item, holding);
824
+
825
+ let first = subgroupExclusiveAdd(expand.count);
826
+ let total = subgroupAdd(expand.count);
827
+
828
+ // ----- what this wave keeps: one child per lane, densely packed, whoever produced it -----
829
+
830
+ let owner = vg_child_owner(first, lane_index, lane_count);
831
+
832
+ let kept = vg_child(
833
+ subgroupShuffle(item.instance_slot, owner),
834
+ VGExpand(
835
+ subgroupShuffle(expand.kind, owner),
836
+ 0u,
837
+ subgroupShuffle(expand.page_row, owner),
838
+ subgroupShuffle(expand.first, owner),
839
+ subgroupShuffle(expand.span, owner)
840
+ ),
841
+ lane_index - subgroupShuffle(first, owner)
842
+ );
843
+
844
+ // ----- what it cannot hold: spilled, packed, under one reservation for the whole wave -----
845
+
846
+ let mine_first = max(first, lane_count);
847
+ let mine_last = max(first + expand.count, lane_count);
848
+
849
+ let spill_count = mine_last - mine_first;
850
+
851
+ let spill_offset = subgroupExclusiveAdd(spill_count);
852
+ let spill_total = subgroupAdd(spill_count);
853
+
854
+ /*
855
+ Elected *before* the branch, so the branch carries no subgroup operation of its own and is
856
+ free to be non-uniform — which lets a wave with nothing to spill skip the reservation
857
+ outright. An atomic on the queue head is a global round trip; a descent step that keeps
858
+ everything it produced is otherwise register work, and would spend more reserving zero
859
+ slots than on the step.
860
+ */
861
+ let spill_elected = subgroupElect();
862
+
863
+ var reserved = 0u;
864
+
865
+ if (spill_elected && spill_total > 0u) {
866
+ reserved = atomicAdd(&queue_out.count, spill_total);
867
+ }
868
+
869
+ // after the branch closes, which Tint permits: it models reconvergence
870
+ let spill_base = subgroupBroadcastFirst(reserved) + spill_offset;
871
+
872
+ for (var i = 0u; i < spill_count; i = i + 1u) {
873
+ vg_queue_write(spill_base + i, vg_child(
874
+ item.instance_slot, expand, mine_first - first + i
875
+ ));
876
+ }
877
+
878
+ item = kept;
879
+ holding = lane_index < total;
880
+ }
881
+
882
+ /*
883
+ Whatever is still held has not been executed, so it goes back to the queue rather than being
884
+ dropped. This is the descent handing its unfinished frontier to the next dispatch, and at
885
+ lane_count items per wave it is the common case rather than an overflow path.
886
+ */
887
+ let tail_count = select(0u, 1u, holding);
888
+
889
+ let tail_offset = subgroupExclusiveAdd(tail_count);
890
+ let tail_total = subgroupAdd(tail_count);
891
+
892
+ let tail_elected = subgroupElect();
893
+
894
+ var tail_reserved = 0u;
895
+
896
+ // as above: a wave holding nothing does not reserve
897
+ if (tail_elected && tail_total > 0u) {
898
+ tail_reserved = atomicAdd(&queue_out.count, tail_total);
899
+ }
900
+
901
+ let tail_base = subgroupBroadcastFirst(tail_reserved) + tail_offset;
902
+
903
+ if (holding) {
904
+ vg_queue_write(tail_base, item);
519
905
  }
520
906
  }
521
907
  `, [
@@ -523,6 +909,12 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
523
909
  MESHLET_DEFINITION_STRUCT.declaration_chunk,
524
910
  ]);
525
911
 
912
+ /*
913
+ The descent is a subgroup cooperating with itself — every lane's item packed into every lane's
914
+ register at each step — so the device has to have that capability and the source has to say so.
915
+ */
916
+ body.addExtension(WebGPUExtensionType.Subgroups);
917
+
526
918
  export const shader_vgeo_cut_traverse = ComputeShader.from({
527
919
  label: "VGeo cut traverse",
528
920
  body,