@woosh/meep-engine 3.60.4 → 3.60.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (379) hide show
  1. package/CHANGELOG.md +2602 -0
  2. package/CONTRIBUTING.md +7 -0
  3. package/MIGRATION_2_TO_3.md +136 -0
  4. package/SECURITY.md +4 -0
  5. package/editor/EditorCameraNavigation.d.ts +2 -1
  6. package/editor/EditorCameraNavigation.d.ts.map +1 -1
  7. package/editor/EditorCameraNavigation.js +51 -57
  8. package/package.json +6 -2
  9. package/src/engine/EngineHarness.d.ts +3 -9
  10. package/src/engine/EngineHarness.d.ts.map +1 -1
  11. package/src/engine/EngineHarness.js +6 -8
  12. package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts +9 -6
  13. package/src/engine/graphics/camera/makeOrbitalCameraController.d.ts.map +1 -1
  14. package/src/engine/graphics/camera/makeOrbitalCameraController.js +44 -71
  15. package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts +64 -0
  16. package/src/engine/graphics/camera/orbital/MomentumAxis.d.ts.map +1 -0
  17. package/src/engine/graphics/camera/orbital/MomentumAxis.js +187 -0
  18. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts +86 -0
  19. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.d.ts.map +1 -0
  20. package/src/engine/graphics/camera/orbital/OrbitalCameraRig.js +319 -0
  21. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts +34 -10
  22. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts.map +1 -1
  23. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js +116 -30
  24. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts +1 -7
  25. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts.map +1 -1
  26. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.js +1 -37
  27. package/src/engine/graphics/ecs/water/Water.d.ts +8 -0
  28. package/src/engine/graphics/ecs/water/Water.d.ts.map +1 -1
  29. package/src/engine/graphics/ecs/water/Water.js +8 -0
  30. package/src/engine/graphics3/WaterSystem.d.ts.map +1 -1
  31. package/src/engine/graphics3/WaterSystem.js +731 -734
  32. package/src/engine/graphics3/decal/DecalTileShader.d.ts +61 -0
  33. package/src/engine/graphics3/decal/DecalTileShader.d.ts.map +1 -0
  34. package/src/engine/graphics3/decal/DecalTileShader.js +138 -0
  35. package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts +10 -4
  36. package/src/engine/graphics3/decal/GPUDecalRenderer.d.ts.map +1 -1
  37. package/src/engine/graphics3/decal/GPUDecalRenderer.js +149 -44
  38. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts +25 -0
  39. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.d.ts.map +1 -0
  40. package/src/engine/graphics3/decal/chunk_decal_tile_vertex.js +55 -0
  41. package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts +22 -0
  42. package/src/engine/graphics3/decal/graph_gather_decal_tiles.d.ts.map +1 -0
  43. package/src/engine/graphics3/decal/graph_gather_decal_tiles.js +70 -0
  44. package/src/engine/graphics3/decal/shader_decal_composite.d.ts +7 -5
  45. package/src/engine/graphics3/decal/shader_decal_composite.d.ts.map +1 -1
  46. package/src/engine/graphics3/decal/shader_decal_composite.js +48 -44
  47. package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts +12 -0
  48. package/src/engine/graphics3/decal/shader_decal_tile_copy.d.ts.map +1 -0
  49. package/src/engine/graphics3/decal/shader_decal_tile_copy.js +78 -0
  50. package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts +11 -0
  51. package/src/engine/graphics3/decal/shader_gather_decal_tiles.d.ts.map +1 -0
  52. package/src/engine/graphics3/decal/shader_gather_decal_tiles.js +80 -0
  53. package/src/engine/input/devices/mouse/MouseEventButtons.d.ts +13 -0
  54. package/src/engine/input/devices/mouse/MouseEventButtons.d.ts.map +1 -0
  55. package/src/engine/input/devices/mouse/MouseEventButtons.js +23 -0
  56. package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts +4 -0
  57. package/src/engine/input/ecs/controllers/KeyboardCameraController.d.ts.map +1 -1
  58. package/src/engine/input/ecs/controllers/KeyboardCameraController.js +9 -45
  59. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  60. package/src/shade/playground/water_refraction/README.md +5 -6
  61. package/src/shade/playground/water_refraction/index.html +1 -1
  62. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  63. package/src/shade/renderer/Renderer.js +7 -5
  64. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts +40 -80
  65. package/src/shade/renderer/camera/orbital/OrbitalCameraController.d.ts.map +1 -1
  66. package/src/shade/renderer/camera/orbital/OrbitalCameraController.js +197 -147
  67. package/src/shade/renderer/fluid/GPUFluidCascade.d.ts +3 -3
  68. package/src/shade/renderer/fluid/GPUFluidCascadeStack.d.ts +1 -1
  69. package/src/shade/renderer/fluid/forcing/GPUFluidForcing.d.ts +6 -6
  70. package/src/shade/renderer/fluid/pressure/GPUFluidPressureSolve.d.ts +1 -1
  71. package/src/shade/renderer/fluid/query/GPUFluidQueries.d.ts +1 -1
  72. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts +2 -2
  73. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +2 -2
  74. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts +2 -1
  75. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.d.ts.map +1 -1
  76. package/src/shade/renderer/geometry/sdf/cards/GPUMeshCardSets.js +18 -1
  77. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts +31 -0
  78. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.d.ts.map +1 -0
  79. package/src/shade/renderer/geometry/virtual/proxy/vgeo_to_proxy_mesh.js +343 -0
  80. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts +17 -0
  81. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.d.ts.map +1 -0
  82. package/src/shade/renderer/geometry/virtual/runtime/vgeo_meshlet_metadata_write.js +29 -0
  83. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.d.ts.map +1 -1
  84. package/src/shade/renderer/geometry/virtual/runtime/vgeo_runtime_meshlet_batch.js +7 -13
  85. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.d.ts.map +1 -1
  86. package/src/shade/renderer/global_illumination/probe_clipmap/GPUProbeClipmapGI.js +1 -8
  87. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts +5 -5
  88. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.d.ts.map +1 -1
  89. package/src/shade/renderer/global_illumination/probe_clipmap/ProbeClipmapSceneHolds.js +10 -10
  90. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts +1 -4
  91. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.d.ts.map +1 -1
  92. package/src/shade/renderer/global_illumination/probe_clipmap/probe/GPUProbeClipmap.js +1 -10
  93. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts +5 -15
  94. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.d.ts.map +1 -1
  95. package/src/shade/renderer/global_illumination/probe_clipmap/probe/chunk_probe_clipmap_hit_surface.js +6 -34
  96. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.d.ts.map +1 -1
  97. package/src/shade/renderer/global_illumination/probe_clipmap/probe/shader_probe_clipmap_shade.js +1 -7
  98. package/src/shade/renderer/hiz/HZBFootprint.d.ts +14 -0
  99. package/src/shade/renderer/hiz/HZBFootprint.d.ts.map +1 -0
  100. package/src/shade/renderer/hiz/HZBFootprint.js +16 -0
  101. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts +10 -0
  102. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.d.ts.map +1 -0
  103. package/src/shade/renderer/hiz/HZB_FOOTPRINT_X.js +11 -0
  104. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts +10 -0
  105. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.d.ts.map +1 -0
  106. package/src/shade/renderer/hiz/HZB_FOOTPRINT_Y.js +11 -0
  107. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts +0 -7
  108. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  109. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +405 -350
  110. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts +12 -0
  111. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.d.ts.map +1 -0
  112. package/src/shade/renderer/hiz/chunk_hzb_footprint_x.js +27 -0
  113. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts +12 -0
  114. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.d.ts.map +1 -0
  115. package/src/shade/renderer/hiz/chunk_hzb_footprint_y.js +27 -0
  116. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts +14 -0
  117. package/src/shade/renderer/hiz/hzb_footprint_texels.d.ts.map +1 -0
  118. package/src/shade/renderer/hiz/hzb_footprint_texels.js +34 -0
  119. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts +0 -5
  120. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
  121. package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +37 -57
  122. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts +11 -0
  123. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0.d.ts.map +1 -0
  124. package/src/shade/renderer/hiz/{shader_hzb_reduce_mip0_clip.js → shader_hzb_reduce_mip0.js} +120 -111
  125. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  126. package/src/shade/renderer/material/GPUMaterialManager.d.ts +7 -0
  127. package/src/shade/renderer/material/GPUMaterialManager.d.ts.map +1 -1
  128. package/src/shade/renderer/material/GPUMaterialManager.js +11 -0
  129. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts +12 -12
  130. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
  131. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +17 -118
  132. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts.map +1 -1
  133. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.js +7 -2
  134. package/src/shade/renderer/water/GPUWaterRenderer.d.ts +0 -6
  135. package/src/shade/renderer/water/GPUWaterRenderer.d.ts.map +1 -1
  136. package/src/shade/renderer/water/GPUWaterRenderer.js +12 -17
  137. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts +35 -0
  138. package/src/shade/renderer/water/medium/graph_water_meshlets.d.ts.map +1 -0
  139. package/src/shade/renderer/water/medium/graph_water_meshlets.js +89 -0
  140. package/src/shade/renderer/water/query/GPUWaterQueries.d.ts +1 -1
  141. package/src/shade/renderer/water/shader_water.d.ts.map +1 -1
  142. package/src/shade/renderer/water/shader_water.js +22 -13
  143. package/src/shade/renderer/water/shader_water_avboit.d.ts.map +1 -1
  144. package/src/shade/renderer/water/shader_water_avboit.js +7 -7
  145. package/src/shade/renderer/water/surface/graph_water_tessellation.d.ts +1 -1
  146. package/src/shade/renderer/water/surface/graph_water_tessellation.js +1 -1
  147. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +9 -1
  148. package/editor/REWORK_PLAN_2026_08.md +0 -692
  149. package/src/CODEC_LAYOUT_VERIFICATION.md +0 -419
  150. package/src/CORRECTNESS_HUNT_2026_07_24.md +0 -142
  151. package/src/DUPLICATION_AUDIT_2026_08_07/00_core_reuse_surface.md +0 -738
  152. package/src/DUPLICATION_AUDIT_2026_08_07/A_core_math_geom.md +0 -79
  153. package/src/DUPLICATION_AUDIT_2026_08_07/B_core_data.md +0 -93
  154. package/src/DUPLICATION_AUDIT_2026_08_07/C_gfx_render.md +0 -80
  155. package/src/DUPLICATION_AUDIT_2026_08_07/D_gfx_features.md +0 -164
  156. package/src/DUPLICATION_AUDIT_2026_08_07/E_physics.md +0 -69
  157. package/src/DUPLICATION_AUDIT_2026_08_07/F_sound.md +0 -110
  158. package/src/DUPLICATION_AUDIT_2026_08_07/G_net_save.md +0 -129
  159. package/src/DUPLICATION_AUDIT_2026_08_07/H_engine_misc.md +0 -72
  160. package/src/DUPLICATION_AUDIT_2026_08_07/I_generation_view.md +0 -74
  161. package/src/DUPLICATION_AUDIT_2026_08_07/K_crosscutting.md +0 -169
  162. package/src/DUPLICATION_AUDIT_2026_08_07.md +0 -254
  163. package/src/DUPLICATION_AUDIT_REMEDIATION.md +0 -250
  164. package/src/DUPLICATION_REVIEW_2026_10_24.md +0 -1072
  165. package/src/REVIEW_2026_08_06/adv-core.md +0 -511
  166. package/src/REVIEW_2026_08_06/adv-gfx.md +0 -480
  167. package/src/REVIEW_2026_08_06/adv-view.md +0 -446
  168. package/src/REVIEW_2026_08_06/api-core.md +0 -296
  169. package/src/REVIEW_2026_08_06/api-engine.md +0 -195
  170. package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +0 -507
  171. package/src/REVIEW_2026_08_06/core-binary.md +0 -348
  172. package/src/REVIEW_2026_08_06/core-collection.md +0 -376
  173. package/src/REVIEW_2026_08_06/core-math.md +0 -221
  174. package/src/REVIEW_2026_08_06/core-process.md +0 -266
  175. package/src/REVIEW_2026_08_06/core-science.md +0 -223
  176. package/src/REVIEW_2026_08_06/engine-ai-nav.md +0 -279
  177. package/src/REVIEW_2026_08_06/engine-animation.md +0 -167
  178. package/src/REVIEW_2026_08_06/engine-asset.md +0 -166
  179. package/src/REVIEW_2026_08_06/engine-ecs.md +0 -920
  180. package/src/REVIEW_2026_08_06/engine-input.md +0 -197
  181. package/src/REVIEW_2026_08_06/engine-misc.md +0 -248
  182. package/src/REVIEW_2026_08_06/engine-terrain.md +0 -52
  183. package/src/REVIEW_2026_08_06/generation.md +0 -176
  184. package/src/REVIEW_2026_08_06/geom-2d.md +0 -434
  185. package/src/REVIEW_2026_08_06/geom-3d.md +0 -117
  186. package/src/REVIEW_2026_08_06/gfx-gi.md +0 -143
  187. package/src/REVIEW_2026_08_06/gfx-render.md +0 -740
  188. package/src/REVIEW_2026_08_06/gfx-texture.md +0 -690
  189. package/src/REVIEW_2026_08_06/prior-criticals.md +0 -49
  190. package/src/REVIEW_2026_08_06/prior-highs.md +0 -138
  191. package/src/REVIEW_2026_08_06/test-gaps.md +0 -364
  192. package/src/REVIEW_2026_08_06/view.md +0 -667
  193. package/src/REVIEW_2026_08_06.md +0 -610
  194. package/src/core/binary/lz4/LZ4_HIGH_COMPRESSION_PLAN_2026_09_14.md +0 -542
  195. package/src/core/bvh2/bvh3/NOTES.md +0 -2
  196. package/src/core/bvh8/NOTES.md +0 -63
  197. package/src/core/cache/wtinylfu/benchmark/CacheWTinylfu.bench.md +0 -243
  198. package/src/core/color/COLOR_FIX_PLAN_2026_08_28.md +0 -417
  199. package/src/core/color/COLOR_REVIEW_2026_08_28.md +0 -779
  200. package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +0 -433
  201. package/src/core/geom/3d/atlas/ATLAS_PORT_PLAN.md +0 -486
  202. package/src/core/geom/3d/atlas/BFF_PLAN.md +0 -174
  203. package/src/core/geom/3d/atlas/REVIEW_02.md +0 -392
  204. package/src/core/geom/3d/gjk/GJK_REVIEW_NOTES.md +0 -146
  205. package/src/core/geom/3d/gjk/NOTES.md +0 -8
  206. package/src/core/geom/3d/topology/TOPO_MESH_RETIREMENT_PLAN.md +0 -135
  207. package/src/core/geom/GEOMETRY_REVIEW_002_API_ISSUES.md +0 -381
  208. package/src/core/geom/GEOMETRY_REVIEW_002_NAMING.md +0 -684
  209. package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +0 -554
  210. package/src/core/model/reactive/evaluation/PREDICATE_APPLICABILITY_PROPOSAL.md +0 -446
  211. package/src/engine/REVIEW_2026_07_03.md +0 -211
  212. package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +0 -815
  213. package/src/engine/control/first-person/DESIGN.md +0 -658
  214. package/src/engine/control/first-person/DESIGN_COLLISION.md +0 -382
  215. package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +0 -563
  216. package/src/engine/control/first-person/TODO.md +0 -160
  217. package/src/engine/ecs/ASYNC_SYSTEMS_PROPOSAL.md +0 -531
  218. package/src/engine/ecs/HIERARCHY_REVIEW.md +0 -717
  219. package/src/engine/ecs/async/WORKER_SYSTEM_PLAN.md +0 -262
  220. package/src/engine/ecs/ik/PLAN.md +0 -179
  221. package/src/engine/ecs/storage/binary/SERIALIZATION_COVERAGE.md +0 -272
  222. package/src/engine/ecs/storage/binary/object-v2/DESIGN.md +0 -199
  223. package/src/engine/ecs/transform/TRANSFORM64_MIGRATION_PLAN_2026_09_04.md +0 -521
  224. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +0 -463
  225. package/src/engine/graphics/ecs/camera/pp/NOTES.md +0 -48
  226. package/src/engine/graphics/render/frame_graph/CORRECTNESS_FIXES_2026_09_04.md +0 -659
  227. package/src/engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md +0 -235
  228. package/src/engine/graphics/sh3/path_tracer/REVIEW_001_ACTION_PLAN.md +0 -490
  229. package/src/engine/graphics/texture/sampler/compare/FLIP_IMPLEMENTATION_PLAN.md +0 -466
  230. package/src/engine/input/GAMEPAD_API_PROPOSAL.md +0 -717
  231. package/src/engine/input/ecs/ism/ISM_DESIGN.md +0 -1523
  232. package/src/engine/input/ecs/ism/ISM_IMPLEMENTATION_PLAN.md +0 -627
  233. package/src/engine/navigation/mesh/OVERLAPPING_SURFACES_2026_08_25.md +0 -661
  234. package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +0 -229
  235. package/src/engine/network/CONGESTION_CONTROL.md +0 -87
  236. package/src/engine/network/MESSAGE_TRANSPORT.md +0 -364
  237. package/src/engine/network/SMOOTHNESS_DESIGN.md +0 -400
  238. package/src/engine/physics/cloth/MEASUREMENTS.md +0 -5304
  239. package/src/engine/physics/cloth/PLAN.md +0 -3056
  240. package/src/engine/physics/fluid/GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md +0 -1382
  241. package/src/engine/physics/fluid/REVIEW_02_PLAN.md +0 -243
  242. package/src/engine/physics/fluid/solver/NOTES.md +0 -46
  243. package/src/engine/sound/SOUND_EMITTER_RETIREMENT_PLAN.md +0 -536
  244. package/src/engine/sound/simulation/IMPLEMENTATION_PLAN.md +0 -432
  245. package/src/engine/sound/simulation/PROBE_PLACEMENT.md +0 -250
  246. package/src/engine/sound/simulation/PROBE_TRANSFER_RESEARCH.md +0 -322
  247. package/src/engine/sound/simulation/REFLECTION_IR_PLAN.md +0 -261
  248. package/src/engine/sound/simulation/VISIBILITY_GRAPH_PLAN.md +0 -121
  249. package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +0 -999
  250. package/src/format/image/avif/DECISIONS.md +0 -545
  251. package/src/format/image/jpeg/NOTES.md +0 -3
  252. package/src/format/scene/gltf/MESHOPT_COMPRESSION_PLAN.md +0 -814
  253. package/src/generation/COORDINATES.md +0 -54
  254. package/src/generation/REVIEW_01_ACTION_PLAN.md +0 -628
  255. package/src/generation/foliage/ANNUAL_SHOOTS_BRIEF_2026_10_25.md +0 -220
  256. package/src/generation/foliage/CROWN_FILLING_BRIEF_2026_10_26.md +0 -172
  257. package/src/generation/foliage/CROWN_FULLNESS_BRIEF_2026_10_26.md +0 -86
  258. package/src/generation/foliage/FOLIAGE_DESIGN.md +0 -547
  259. package/src/generation/foliage/FOLIAGE_PLAN.md +0 -198
  260. package/src/generation/foliage/LEAF_ARCHITECTURE_BRIEF_2026_10_26.md +0 -158
  261. package/src/generation/foliage/LOD_REWORK_BRIEF_2026_10_27.md +0 -521
  262. package/src/generation/foliage/REAL_TIME_TREES_BRIEF_2026_10_27.md +0 -86
  263. package/src/generation/foliage/SELF_ORGANIZING_FRAMEWORK_BRIEF_2026_10_26.md +0 -347
  264. package/src/generation/foliage/SHOOT_SYSTEMS_ALL_SPECIES_BRIEF_2026_10_26.md +0 -248
  265. package/src/generation/foliage/SPEEDTREE_COMPARISON_2026_10_24.md +0 -694
  266. package/src/generation/foliage/TWO_FRONT_ENDS_BRIEF_2026_10_28.md +0 -552
  267. package/src/shade/RENDERER_CONTRACT.md +0 -697
  268. package/src/shade/SHADER_F16_PROPOSAL_2026_10_24.md +0 -263
  269. package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +0 -569
  270. package/src/shade/device/PASS_ENCODER_PROPOSAL_2026_08_31.md +0 -755
  271. package/src/shade/device/mock/EXTENSION_PROPOSAL_2026_08_22.md +0 -458
  272. package/src/shade/device/mock/IMPLEMENTATION_PLAN.md +0 -255
  273. package/src/shade/device/mock/REVIEW_2026_08_22.md +0 -527
  274. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +0 -894
  275. package/src/shade/device/timing/profile/SGPT_FORMAT.md +0 -345
  276. package/src/shade/renderer/NOTES.md +0 -78
  277. package/src/shade/renderer/PLAN.md +0 -8
  278. package/src/shade/renderer/animation/ADDITIVE_BLENDING_PLAN.md +0 -145
  279. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +0 -357
  280. package/src/shade/renderer/atmosphere/NOTES.md +0 -6
  281. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts +0 -76
  282. package/src/shade/renderer/camera/orbital/MomentumAxis.d.ts.map +0 -1
  283. package/src/shade/renderer/camera/orbital/MomentumAxis.js +0 -164
  284. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts +0 -45
  285. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.d.ts.map +0 -1
  286. package/src/shade/renderer/camera/orbital/OrbitalCameraConfig.js +0 -66
  287. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts +0 -98
  288. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.d.ts.map +0 -1
  289. package/src/shade/renderer/camera/orbital/OrbitalCameraInput.js +0 -184
  290. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts +0 -175
  291. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.d.ts.map +0 -1
  292. package/src/shade/renderer/camera/orbital/OrbitalCameraRig.js +0 -411
  293. package/src/shade/renderer/camera/orbital/README.md +0 -81
  294. package/src/shade/renderer/camera/orbital/index.d.ts +0 -6
  295. package/src/shade/renderer/camera/orbital/index.d.ts.map +0 -1
  296. package/src/shade/renderer/camera/orbital/index.js +0 -9
  297. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +0 -964
  298. package/src/shade/renderer/fluid/GPU_FLUID_OCCUPANCY_AND_SOTA_REVIEW_2026_10_26.md +0 -361
  299. package/src/shade/renderer/geometry/bvh/BLAS_REFIT_BATCH_PLAN.md +0 -619
  300. package/src/shade/renderer/geometry/meshlet/COMPACTION_PLAN_2026_09_16.md +0 -674
  301. package/src/shade/renderer/geometry/sdf/NOTES.md +0 -6
  302. package/src/shade/renderer/geometry/virtual/ATTRIBUTE_PLAN_2026_09_18.md +0 -540
  303. package/src/shade/renderer/geometry/virtual/MICRON_COMPARISON_2026_08_20.md +0 -305
  304. package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +0 -913
  305. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +0 -787
  306. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +0 -481
  307. package/src/shade/renderer/geometry/virtual/VK_LOD_CLUSTERS_COMPARISON_2026_08_20.md +0 -301
  308. package/src/shade/renderer/geometry/virtual/WEDGE_PLAN_2026_09_18.md +0 -448
  309. package/src/shade/renderer/geometry/virtual/runtime/CULL_PLAN_2026_09_16.md +0 -385
  310. package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +0 -656
  311. package/src/shade/renderer/geometry/virtual/runtime/REVIEW_LEDGER.md +0 -308
  312. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +0 -657
  313. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN_2026_09_14.md +0 -551
  314. package/src/shade/renderer/geometry/virtual/runtime/SHADOW_PLAN_2026_09_28.md +0 -529
  315. package/src/shade/renderer/global_illumination/lpv/NOTES.md +0 -3
  316. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_CLIPMAP_PROPOSAL_2026_10_27.md +0 -4306
  317. package/src/shade/renderer/global_illumination/probe_clipmap/PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md +0 -410
  318. package/src/shade/renderer/global_illumination/sharc/NOTES.md +0 -8
  319. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +0 -1369
  320. package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +0 -202
  321. package/src/shade/renderer/gpu_primitive/indirect/NOTES.md +0 -94
  322. package/src/shade/renderer/hiz/NOTES.md +0 -3
  323. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts +0 -8
  324. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +0 -1
  325. package/src/shade/renderer/light/cluster/NOTES.md +0 -11
  326. package/src/shade/renderer/light/cluster/PROPOSAL_ALGORITHM_2026_10_16.md +0 -524
  327. package/src/shade/renderer/light/cluster/PROPOSAL_HIERARCHICAL_2026_10_16.md +0 -1101
  328. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts +0 -24
  329. package/src/shade/renderer/material/resident/shader_resident_texture_means.d.ts.map +0 -1
  330. package/src/shade/renderer/material/resident/shader_resident_texture_means.js +0 -120
  331. package/src/shade/renderer/particles/DESIGN.md +0 -1011
  332. package/src/shade/renderer/particles/EMITTER_GLOBAL_STATE_PLAN_2026_10_29.md +0 -280
  333. package/src/shade/renderer/particles/ORIENTATION_PLAN_2026_10_15.md +0 -453
  334. package/src/shade/renderer/path_tracer/PATH_TRACED_MEDIA_PLAN_2026_10_02.md +0 -520
  335. package/src/shade/renderer/postprocess/bloom/NOTES.md +0 -6
  336. package/src/shade/renderer/postprocess/fsr/NOTES.md +0 -4
  337. package/src/shade/renderer/postprocess/gtao/NOTES.md +0 -6
  338. package/src/shade/renderer/postprocess/nss/NOTES.md +0 -332
  339. package/src/shade/renderer/postprocess/perspective/NOTES.md +0 -3
  340. package/src/shade/renderer/postprocess/sscs/NOTES.md +0 -5
  341. package/src/shade/renderer/postprocess/ssr/NOTES.md +0 -14
  342. package/src/shade/renderer/postprocess/ssr/REPROJECTION_PROPOSAL.md +0 -286
  343. package/src/shade/renderer/postprocess/ssr/prefilter/NOTES.md +0 -39
  344. package/src/shade/renderer/postprocess/taa/NOTES.md +0 -11
  345. package/src/shade/renderer/postprocess/upscale-dither/NOTES.md +0 -78
  346. package/src/shade/renderer/postprocess/vrs/NOTES.md +0 -4
  347. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_LEDGER.md +0 -1218
  348. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_PLAN_2026_09_03.md +0 -600
  349. package/src/shade/renderer/restir/reservoir/NOTES.md +0 -6
  350. package/src/shade/renderer/shader/chunk/atmosphere/NOTES.md +0 -6
  351. package/src/shade/renderer/shader/chunk/brdf/FRESNEL_BRIEF_2026_10_16.md +0 -391
  352. package/src/shade/renderer/shader/chunk/color/tonemap/ACES_TONE_MAPPING_BRIEF_2026_10_17.md +0 -153
  353. package/src/shade/renderer/shader/chunk/color/tonemap/HDR_TONE_MAPPING_FORMS_BRIEF_2026_10_16.md +0 -322
  354. package/src/shade/renderer/shader/chunk/color/tonemap/SDR_TONE_MAPPING_BRIEF_2026_10_16.md +0 -247
  355. package/src/shade/renderer/shader/chunk/fsr/NOTES.md +0 -3
  356. package/src/shade/renderer/shader/chunk/fsr/spd/NOTES.md +0 -4
  357. package/src/shade/renderer/shader/chunk/geometry/ray/NOTES.md +0 -4
  358. package/src/shade/renderer/shader/chunk/material/MULTISCATTER.md +0 -159
  359. package/src/shade/renderer/shader/chunk/mboit/NOTES.md +0 -38
  360. package/src/shade/renderer/shader/chunk/spherical_harmonics/l1i/NOTES.md +0 -3
  361. package/src/shade/renderer/shader/chunk/spherical_harmonics/sampling/NOTES.md +0 -8
  362. package/src/shade/renderer/shader/compiler/SHADER_SWITCH_PROPOSAL_2026_10_16.md +0 -927
  363. package/src/shade/renderer/shadow/contact/FINDINGS.md +0 -413
  364. package/src/shade/renderer/shadow/map/csm/NOTES.md +0 -5
  365. package/src/shade/renderer/shadow/ray/NOTES.md +0 -31
  366. package/src/shade/renderer/shadow/sdf/NOTES.md +0 -9
  367. package/src/shade/renderer/shadow/sdf/brixelizer/NOTES.md +0 -5
  368. package/src/shade/renderer/shadow/sdf/cascade/NOTES.md +0 -9
  369. package/src/shade/renderer/shadow/vsm/NOTES.md +0 -12
  370. package/src/shade/renderer/shadow/vsm/PLAN.md +0 -442
  371. package/src/shade/renderer/shadow/vsm/REVIEW_LEDGER.md +0 -43
  372. package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +0 -2444
  373. package/src/shade/renderer/texture/mipmaps/NOTES.md +0 -3
  374. package/src/shade/renderer/volumetrics/NOTES.md +0 -1329
  375. package/src/shade/renderer/volumetrics/VOLUMETRIC_NOISE_PLAN_2026_10_15.md +0 -336
  376. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts +0 -23
  377. package/src/shade/renderer/water/medium/GPUWaterBodies.d.ts.map +0 -1
  378. package/src/shade/renderer/water/medium/GPUWaterBodies.js +0 -75
  379. package/src/shade/wgsl/NOTES.md +0 -4
@@ -1,4306 +0,0 @@
1
- # Probe clipmap GI — proposal, 2026-10-27
2
-
3
- Status: **revision (b): Alex's review of (a) applied, and his answers on every fork. Nothing is
4
- built.** Written against `4a3a41928`. Paths are relative to `packages/meep-engine/src/`. No fork is
5
- open; §15 records how each was settled.
6
-
7
- ## What changed from (a)
8
-
9
- Alex reviewed revision (a). Items 1–8 are his; item 9 follows from them.
10
-
11
- 1. **No triangle work per frame.** Every mesh is cached once as a sparse, NanoVDB-style SDF in size
12
- classes: 2×2×2 bricks at least, then steps of two bricks per axis (§6.2). World bricks are
13
- composed from those caches (§7.7). (a) gathered triangles per cell.
14
- 2. **Material comes from a surface cache of cards**, Unreal's idea, with Lumen's payload: diffuse
15
- albedo and emissive captured per (geometry, material) pair, and recaptured when the material
16
- changes (§6.5, §6.6).
17
- - (a) stored a voxel material lattice, which is expensive.
18
- - The path tracer's material needs uvs, which an SDF cannot give.
19
- - Cards holding uvs, with the material evaluated live, were offered as an alternative. Alex chose
20
- the captured material.
21
- 3. **A primitive can be absent from the SDF world**: drawn by the camera, invisible to GI, for
22
- performance (§6.1).
23
- 4. **Brick providers.** A pass that writes requested bricks plugs in; outputs merge per brick, so the
24
- representation stays sparse and only bricks that scroll in or change are written. Virtual geometry
25
- has caches of its own, baked from a proxy cut from each geometry and composed as a mesh's are (§7.8,
26
- S8).
27
- 5. **Skinned meshes** use their unskinned representation, with hooks for a dense animated patch later
28
- (§6.3, §7.7, §17).
29
- 6. **Pixels pick 2 of the 8 cage probes** by Brick4's weighted reservoir sampling, after a
30
- visibility-only weight pass; only the two are read (§9.1). (a) evaluated all eight.
31
- 7. **Rough specular blends the radiance tap with GGX-convolved SH2** (§9.3). (a)'s prefiltered
32
- radiance levels sit badly with WebGPU's mip limits.
33
- 8. **`GPUGeometrySDFManager` worked and is correct**: it is the bake's reference, generalised in place
34
- (§6). The Brixelizer port is a reference, not reused.
35
- 9. **The field is signed** (D2). (a) chose unsigned because a per-cell triangle gather cannot tell
36
- inside from outside. A whole-mesh bake can, so (a)'s fork 2 dissolves.
37
- 10. **(a)'s two other forks were taken as recommended**: a sparse probe cache (D31), and the mover
38
- list at scene level with a skinned bit (D32).
39
-
40
- What was asked for:
41
-
42
- - A real-time GI built on 3-D clipmaps, selected by a new value of `ShadeIndirectLightingMode`, as a
43
- separate subsystem.
44
- - Rays hit an SDF. Material comes from a scene representation beside it: voxels in the request, a
45
- surface cache after the review.
46
- - The cascades are sparse, NanoVDB-style: fixed-size bricks in an atlas.
47
- - The SDF is 8-bit, traced with hardware texture filtering.
48
- - Shading samples a second 3-D clipmap, of probes. Each probe holds octahedral radiance, octahedral
49
- depth, and SH2 irradiance (in this engine SH2 means L0 + L1, four coefficients).
50
- - Cascade count, cascade resolution, radiance and depth resolutions and rays per probe are settings.
51
- They do not change between frames.
52
- - Existing code (the LPV above all) is reused as far as it goes, and the Enshrouded GI talk is the
53
- main reference.
54
- - Performance and simplicity come first.
55
-
56
- ## 0. Summary
57
-
58
- **Three cached representations and one sampling step.**
59
-
60
- 1. **Geometry caches (§6)**, baked once and shared by every instance.
61
- - A sparse SDF per geometry: 8³-texel bricks on 7³ voxels, signed `r8unorm`, on a grid of
62
- (2i)×(2j)×(2k) bricks with coarser levels. The evaluator is the nearest point through the BLAS,
63
- signed by UE 5.4's vote of 98 rays one band long (S3; first `GPUGeometrySDFManager`'s 64-ray vote).
64
- - Surface cards: axis-aligned cards over depth slabs of the same brick grid, in 8×8-texel pages.
65
- - The layout belongs to the geometry.
66
- - The contents belong to each (geometry, material) pair: the material the SDF cannot hold,
67
- diffuse albedo and emissive, captured through the path tracer's material sampling.
68
- - They are recaptured when the material's version moves.
69
- - These bakes and captures are the only place triangles are read.
70
- 2. **The brick clipmap is the world as rays see it (§7).**
71
- - `C_b` cascades of `N_b³` cells, one brick a cell on the same lattice. It is traced with one
72
- hardware trilinear tap a step and a two-level skip in the NanoVDB manner.
73
- - A cell that scrolled in, or that a change touched, is requested. Every registered provider writes
74
- its part of the requested bricks, and the parts merge by a per-texel minimum.
75
- - The mesh provider composes the cached SDFs of the instances overlapping the cell. It reads no
76
- triangle.
77
- - Plugins write their own; virtual geometry is the first.
78
- - Each brick also names, per 2³-texel cell, the scene row whose surface is nearest. That is how a
79
- hit finds its material.
80
- 3. **The probe clipmap is what shading samples (§8)**, as in (a).
81
- - A sparse cache, wanted from the depth buffer. Octahedral radiance `rgba16float`, depth moments
82
- `rg16float`, SH2 in the engine's YCoCg L1 (`METRO_L1_IRRADIANCE_STRUCT`).
83
- - A fixed ray budget. Hits are lit by emissive, the sun and one picked local light through shadow
84
- rays traced in the bricks, and last frame's probes. Their diffuse albedo and emissive come from the
85
- cards.
86
- 4. **Pixels pick two probes (§9).**
87
- - A visibility-only pass weighs the eight cage probes: trilinear × wrap back face × Chebyshev ×
88
- crush.
89
- - Brick4's WRS picks two. Only those two are read: their SH2 for diffuse; for specular, a radiance
90
- tap blended with GGX-convolved SH2 by roughness, as LPV blends.
91
-
92
- **What is reused.**
93
- - `GPUGeometrySDFManager`'s evaluator (`blas_query_sdf`), `ray_query_blas_nearest` and the triangle
94
- attribute chunks, for the bakes.
95
- - The clipmap toolkit, `GPUTileAtlas3D`, `OffsetAllocator`, the TLAS and its cooperative box query,
96
- and the VSM's mover pass (moved to scene level).
97
- - `GPUResidentMaterialContext` and the path tracer's `sample_material_data`, for card capture.
98
- - The particle free list; the octahedral codec, skirt and solid-angle chunks; SH2 basis, Metro L1,
99
- the GGX zonal fit and Chebyshev.
100
- - LPV's visibility weighting, specular blend, depth two-pass write and pass chain. Brick4's
101
- reservoir pick and its noise sources.
102
- - The world light clusters; the deferred resolve and `compute_indirect_specular`; `ShaderSwitch`
103
- binding by name; the frame-graph helpers.
104
- - The full map is §4.
105
-
106
- **What is new.**
107
- - The sparse mesh SDF storage; card placement, capture and recapture.
108
- - The brick clipmap's build passes, the provider contract and the mesh provider, and the tracer.
109
- - The probe lifetime, schedule and update passes; the pick and the sampling chunks; the Metro form of
110
- the GGX resolve.
111
- - The `ProbeClipmap` mode value and its settings (§10).
112
-
113
- **Forks (§15).** None is open. The last two, both from (a), were taken as recommended:
114
- - probes are a sparse cache, not a dense lattice (D31);
115
- - the mover pass leaves VSM ownership for scene level (D32).
116
-
117
- ## 1. What the tree has
118
-
119
- Audited read-only before designing, by area. The verdict legend is §4's.
120
-
121
- **Indirect lighting modes and their consumers.**
122
- - `ShadeIndirectLightingMode` = {IBL 0, Brick4 1, LPV 2}. `INDIRECT_LIGHTING` is
123
- `ShaderSwitch.enum('indirect lighting', ShadeIndirectLightingMode)`, so a new member joins its
124
- values by itself.
125
- - `Renderer#graph_indirect_lighting` (`shade/renderer/Renderer.js:1104-1313`) picks per mode a
126
- diffuse/specular image-pass pair, or Brick4's fused pass when SSR is off. With SSR on,
127
- `ssr.denoised` replaces the specular pass.
128
- - `shader_resolve_indirect_lighting` adds the pair to scene colour. Its contract: diffuse is
129
- irradiance E with AO multiplied in, specular is radiance before Fresnel.
130
- - Exactly five chunks choose on the switch (`CodeChunkChoice.from(INDIRECT_LIGHTING)`):
131
- - `forward_sample_indirect` (AVBOIT, MBOIT);
132
- - `water_gi_irradiance` / `water_gi_specular`;
133
- - `ssr_sample_radiance`;
134
- - `ssr_add_hit_irradiance`;
135
- - `irradiance_clipmap_gi_sample`, which feeds the particles' irradiance clipmap.
136
- - Every alternative declares its resources with `CodeChunk.requireResource`. Owners bind the union by
137
- name. `switched_shaders_declare_bindings.spec.js` audits all variants.
138
- - **REUSE**: a new value is additive. Every file it touches is listed in §9.7.
139
-
140
- **The Light Probe Volume (LPV).** Per frame, `GPUProbeAtlas.graph_update` runs this chain:
141
- 1. `shader_lpv_build_gbuffer`: one jittered ray per octahedral texel through `ray_query_nearest`. A
142
- miss stores the sky.
143
- 2. `graph_sample_light_probes`: hits sample the LPV itself for the next bounce, scaled by 0.97.
144
- 3. `graph_lpv_shade`: every light, with an occlusion ray, then a temporal blend into an `r32uint`
145
- rgbe9995 atlas with a one-texel border (64² texels, pitch 66).
146
- 4. `shader_lpv_accumulate_depth` → `shader_lpv_atlas_write_depth`: depth moments in two passes,
147
- because `rg16float` cannot be a read-write storage texture.
148
- 5. Borders.
149
- 6. `graph_lvp_atlas_to_sh` → `graph_reduce_atlas_to_sh` → `graph_lpv_store_sh`: SH2.
150
-
151
- Shading:
152
- - `lpv_mask_weights_by_visibility` (`shader/chunk/light/lpv/depth/`) is DDGI's weighting: the wrap
153
- back-face term `((n·ω+1)/2)² + 0.2`, Chebyshev at a biased point, a crush below 0.2. It runs over the
154
- four probes of a tetrahedron.
155
- - `chunk_lpv_sample_light` (`deferred/indirect/`) is the specular this design takes whole.
156
- - The sample vector is `normalize(mix(r, n, α))`, Lagarde's dominant direction.
157
- - A radiance-atlas tap along it is blended with `sh2_color_get_radiance_with_GGX` on the same
158
- vector, by `roughness_to_mip_ratio(roughness)`.
159
- - The ratio reaches 1, SH alone, at roughness 0.7 (`SPHERE_PROBE_MIP_MAX_ROUGHNESS`).
160
-
161
- Verdicts:
162
- - The chain is **GENERALISE**. It is a pass shape this design follows, but it is coupled to
163
- `lpv_probes`, the tetrahedral mesh and triangle tracing.
164
- - The weighting is **EXTRACT**: the per-probe term becomes shared.
165
- - The specular blend is **REUSE**.
166
- - The depth two-pass is **GENERALISE**.
167
- - LPV's radiance readers do a manual four-load bilinear with rgbe decode, and the border it paints is
168
- never read. Here the radiance must be hardware-filterable, which is one reason the atlas is
169
- `rgba16float` (§8.2).
170
-
171
- **Brick4.** Its tree and its CPU bake are not applicable. Its sampler is, and (a) missed it:
172
- - `brick4_node_sample_probes_meta` weighs the eight cage probes without loading them: trilinear × a
173
- back-face smoothstep.
174
- - `brick4_probe_meta_pick2` makes the pick:
175
- - it draws one probe with `sample_discrete_wrs_mat2x4`, the weighted reservoir sampling of
176
- Fajardo et al.'s *Stochastic Texture Filtering*;
177
- - it zeroes that probe and draws a second with fresh noise;
178
- - it renormalises the pair's weights into a blend.
179
- - Noise comes from two sources:
180
- - image passes use STBN at (pixel, frame), as `brick4_sample_specular` and
181
- `shader_brick4_indirect_lighting` do;
182
- - forward passes use a hash of position, normal and frame, `jitter_from_vectors`, private to
183
- `chunk_brick4_sample_forward_indirect.js`.
184
- - The fused pass picks once. It then re-weights the pair separately for diffuse (against the bent
185
- normal) and for specular (against the specular direction).
186
- - Brick4's standalone diffuse (`brick4_sample_sh`) still evaluates all eight.
187
- - Verdict: **EXTRACT** the selection and the forward hash. Brick4 keeps its address mapping.
188
-
189
- **The irradiance clipmap** (`volumetrics/irradiance_clipmap/`):
190
- - 6 × 32³ cascades at 0.25 m, Y and CoCg `rgba16float` 3-D atlases on a `GPUTileAtlas3D`.
191
- - Its own stateless placement (`IrradianceCascadePlacement`) over linear storage, so a one-voxel move
192
- rebuilds the cascade.
193
- - Per texel: GI SH from the mode, directional lights through their shadow maps, and world-cluster
194
- point/spot lights up to `IRRADIANCE_CLIPMAP_LIGHT_BUDGET` = 32.
195
- - Lit particles are its only consumer.
196
-
197
- Verdicts: **REUSE** its light and shadow lookups. Its placement is not a model: it duplicates the
198
- clipmap toolkit (§16).
199
-
200
- **Clipmap toolkit.**
201
- - `ClipmapWindowSet` (hysteretic placement, `clamp_corner`, `storage_cell`, `decode`),
202
- `window_scroll_regions` (at most three disjoint exposed boxes), `quantize_hysteretic`, and
203
- `clipmap_select_finest` (CPU and WGSL).
204
- - `CLIPMAP_LEVEL_STRUCT` (32 B; `CLIPMAP_LEVEL_LIMIT` levels), `chunk_clipmap_contains`,
205
- `chunk_clipmap_storage_coord` (no production user yet), `grid_tile_window_storage` (the fluid's).
206
- - The fluid's exposed-slab flow is `GPUFluidCascadeStack.record_tick` → `window_scroll_regions` →
207
- `shader_fluid_cascade_fill`.
208
- - **REUSE** as is. This is what `GPUClipMap3D`'s deletion (`f2f6bc7b6`) named as its replacement.
209
-
210
- **Atlases.**
211
- - `GPUTileAtlas3D`: equal tiles with a border parameter and several synchronised formats. Its doc
212
- names "a brick SDF atlas" as an intended client.
213
- - Slots are handed out on the host (`allocate`, `free`), and a grow replaces the texture.
214
- - **GENERALISE**: a bulk reserve, so the GPU free list owns the slots (§7.10).
215
- - `OffsetAllocator` (`core/binary/allocator/`) hands out ranges of a linear store. **REUSE** for the
216
- mesh SDF indirection and the card page tables.
217
-
218
- **Scene distance fields.**
219
- - `GPUGeometrySDFManager` (`geometry/sdf/`):
220
- - Storage: per geometry, a dense 50³ `r32float` tile in a `GPUTileAtlas3D`. The slot is the
221
- geometry id and is never freed.
222
- - Build: `shader_build_sdf` evaluates `blas_query_sdf` at every voxel:
223
- - the exact nearest point through the BLAS (`point_query_blas_nearest`);
224
- - a sign from 64 Fibonacci rays (`blas_query_volume_sign`), inside past 60% back faces;
225
- - for open meshes, a wrap of negative distances past 4 voxels.
226
- - Reach: it is not attached (`GPUGeometryManager.sdf` throws), and no frame reaches its readers
227
- (`chunk_geometry_sdf_query_distance`, `chunk_sdf_geometry_raymarch`, `chunk_scene_sdf_raymarch`,
228
- `shader_scene_sdf_debug_viz`). Its spec tests storage, not distances.
229
- - Alex: it worked, and it is correct. **GENERALISE in place** (§6): the evaluator stays, the
230
- storage becomes sparse.
231
- - `CascadedSceneSDF`:
232
- - One dense `r16float` volume despite the name, built once from 65 scene queries per texel.
233
- - It is constructed only by an uncalled `Renderer.obtains_scene_sdf`.
234
- - It is the only precedent in the tree for hardware-filtered SDF sphere tracing
235
- (`shader_scene_sdf_soft_shadows`). **Not applicable** as a cache.
236
- - `shadow/sdf/brixelizer/`: a port of FidelityFX Brixelizer's trace side. It was never verified and
237
- does not compile. Alex: a reference only.
238
-
239
- **Scene rows.**
240
- - The entity is the row (`row_of_entity`). `GPUSceneRows.attach_mesh(entity, geometry, material,
241
- skinned)` gives it a `meshes` row.
242
- - `ShadedGeometrySystem` calls it for every `ShadedGeometry`, passing
243
- `ShadedGeometryFlags.Skinned`.
244
- - It calls it again when `shaded_geometry_announce_change` reports an in-place change.
245
- - `ShadedGeometryFlags`: InView 1, CastShadow 2, ReceiveShadow 4, DrawMethodLocked 8, Visible 16,
246
- DeferredBoundsUpdate 32, Skinned 64. Shade reads none of them for meshes, and a row carries no flag
247
- on the GPU.
248
- - `MESH_INSTANCE_METADATA_STRUCT` holds geometry, material, node, box, sphere and lightmap chart:
249
- 68 B of fields in an 80 B row. A `u32` fits without growing it, as `global_flags` did in
250
- `NODE3D_STRUCT`.
251
- - Virtual geometry is a `MeshletGeometry` with `isVirtualGeometry`, on the same rows, and its BLAS is
252
- empty.
253
- - A skinned row draws a per-instance clone (`GPUMeshSkinningContext.obtain_clone`). The CPU entry
254
- keeps the source geometry.
255
- - Signals: `onMeshReleased`, and `for_each_mesh` for walks. Nothing announces a write.
256
- - **GENERALISE**: a `flags` word, an `SDFWorld` bit carried as `Skinned` is, and `onMeshWritten`
257
- beside `onMeshReleased` (§6.1).
258
-
259
- **Scene queries.**
260
- - The TLAS (`GPUInstancesAccelerationStructure`) is GPU-resident and maintained by the scene context.
261
- `graph_bvh_build_lbvh` rebuilds it in full whenever a row changes. Its leaves are mesh rows (the
262
- leaf's `child_2`).
263
- - BLASes are built on the CPU per geometry over meshlets; skinned clones are refit on the GPU every
264
- frame (`GPUBLASRefitBatch`).
265
- - `chunk_bvh_query_aabb_cooperative`: one 64-lane workgroup per box, level-synchronous, ≤ 128 sorted
266
- candidates, drops counted, two seam functions. Its only user is fluid forcing.
267
- - `point_query_blas_nearest` and `ray_query_blas_nearest` answer per geometry; the sign vote uses the
268
- second.
269
- - Verdicts: **REUSE** the TLAS and the cooperative query for the mesh provider (§7.7). Use the BLAS
270
- queries in the bakes only (§6.4, §6.5).
271
-
272
- **Change detection.**
273
- - `shader_vsm_movers` + `shader_vsm_skinned_movers` append, for every mesh row whose matrix, box,
274
- geometry or material changed, the union of its box now and before (`VSM_MOVER_LAYOUT`, 4,096
275
- boxes, overflow counted).
276
- - Last-seen state is `VSMMoverTracker`.
277
- - It is a transient inside `graph_vsm_feedback` and exists only while a virtual shadow map is
278
- registered.
279
- - **EXTRACT** to scene level (D32), with a bit telling a skinned row's box from a rigid one (§7.4).
280
-
281
- **Material.**
282
- - `sample_material_data` (`path_tracer/shader/chunk_sample_material_data.js`) does two things:
283
- - it interpolates uv, vertex colour, normal and tangent from a triangle and barycentrics;
284
- - it samples the material: albedo × colour factor × vertex colour, ORM, emissive × factor, opacity.
285
- - It reads `GPUResidentMaterialContext` (on `GraphicsContext`): the material table
286
- (`INDIRECT_MATERIAL_STRUCT`) and a 2-D array of 64² texture slots, each with its mip chain down to
287
- one texel since 2026-10-30, sampled by `indirect_sample_texture` at an explicit level with each
288
- sampler's wrap mode: one stochastic tap of the trilinear filter by default, which at level 0 is the
289
- stochastic bilinear tap it always was, or the filter itself where the shader's
290
- `INDIRECT_TEXTURE_FILTER` is `Exact`.
291
- - Its taps and dither draw on the path tracer's random state. Alpha is stochastic: a hit is opaque
292
- with probability equal to its opacity (`chunk_ray_shaded_query_occluded`, through
293
- `sample_material_alpha`).
294
- - `ShadeMaterial.version` moves on every change. `GPUResidentMaterialContext.obtain_material` repacks
295
- a material whose version moved, on walks it already makes: the scene's drawn list, and the
296
- manager's materials when the manager's version moves. The single place that happens is
297
- `#pack_material`.
298
- - **REUSE** both chunks in card capture, with a seed fixed per texel (§6.5). **GENERALISE** the
299
- resident context with a signal from `#pack_material` when a material is repacked, which is what
300
- recapture keys on.
301
-
302
- **What rays could not see.** vgeo rows are in the TLAS with an empty BLAS, and DynamicMesh has no row.
303
- Under (b):
304
- - virtual geometry is baked from a proxy cut from it, and composed as a mesh (§7.8, S8);
305
- - DynamicMesh stays out by construction: it changes every frame, and a cache of it would be a cache of
306
- nothing (§17).
307
-
308
- **Lights at arbitrary world points.**
309
- - `shade_directional_lights` takes a position only.
310
- - Shadow maps do not answer off-screen points honestly. CSM reads *lit* outside its view-fitted
311
- cascades. VSM pages exist only where the main view's depth demanded them, and with none resident a
312
- point reads lit.
313
- - Local-light shadows are sampled by direction or projection, so they work anywhere in range. Their
314
- refresh is scored by on-screen area, and a cleared region reads unshadowed.
315
- - The world light clusters (`light/world_cluster/`): 8³ cells per cascade, ≤ 128 lights per cell,
316
- 4,096 candidates. They are keyed by the irradiance clipmap's levels and built inside its
317
- `graph_update`. **GENERALISE** the keying.
318
- - Misses: `sample_environment_color` (level 0 of the octahedral environment), `get_ibl_irradiance`,
319
- `environment_sh`.
320
-
321
- **Settings.**
322
- - There is no settings class.
323
- - The precedent for "rarely changes, reallocate on change" is the shadow knobs: validated setters on
324
- `Renderer`, pushed every frame into `GPUSceneShadowmapContext`, which rebuilds what was built with
325
- another value.
326
- - Sub-objects (`avboit`, `ssr`, `volumetrics`) group a feature's fields.
327
-
328
- **SH conventions.**
329
- - `sh2_*` is L0 + L1, ordered `[DC, y, z, x]`.
330
- - `METRO_L1_IRRADIANCE_STRUCT` is {CoCg vec2f, Y vec4f}, 32 B, its Y in (x, y, z, DC) order.
331
- - Its `1/(2π)` convention, under which the resolve returns E/π, is pinned by
332
- `chunk_volumetrics_irradiance_sh.spec.js`.
333
- - It has a diffuse resolve (`resolve_irradiance_sh_to_diffuse`) and no specular one.
334
- - `sh2_approximate_GGX_zh` is MJP's fit of the GGX lobe's band-1 zonal factor, `1.6671/(1.6572 + α)`.
335
- `sh2_color_convolve_with_GGX` and `sh2_color_get_radiance_with_GGX` apply it to RGB SH2; LPV is
336
- their user.
337
-
338
- ## 2. What the field ships
339
-
340
- Ordered by how directly each shapes this design.
341
-
342
- ### 2.1 Enshrouded (Keen Games) — the main reference
343
-
344
- Source: Jakub Kolesik, *Realtime Global Illumination in Enshrouded*, Graphics Programming
345
- Conference 2024 ([video](https://www.youtube.com/watch?v=57F1ezwH7Mk), 50 min; read from its
346
- captions, so the numbers are as spoken). Timestamps in brackets.
347
-
348
- **Building blocks** [2:32–4:34]
349
- - A "spatial cascaded cache": a 32³ world-aligned grid around the player, 12 cascades, 2 m spacing
350
- at cascade 0, doubling per cascade.
351
- - Cells are activated by the depth buffer and by a buffer of ray-hit positions.
352
- - It is capped at 16k live cells, always full, and garbage-collected: a cell that leaves the
353
- screen loses quality over frames and expires, and is reused if seen again before then.
354
- - The world as a simplified SDF traced by "SDF rays". Very small objects are absent and medium ones
355
- appear only at high settings.
356
- - They had Vulkan ray-tracing prototypes and chose SDF rays to have **one code path on all
357
- hardware**, scalable by SDF resolution and by which SDFs a tier uses.
358
- - A BVH sits over the SDFs.
359
-
360
- **Three probe kinds** [4:34–11:12]
361
- 1. **Frustum probes.** Cells activated by the depth buffer, pushed out of walls with the SDF, then
362
- offset toward the camera and along the surface normal.
363
- - Octahedral radiance 32² (ultra), 16² (mid), 8² (low) with a hand-made border, plus octahedral
364
- irradiance at 12² or 8².
365
- - Both are RGB9E5 in a 128×128-probe atlas. Distance is RGBA16F, because the occlusion test
366
- needed the precision.
367
- 2. **World-cache probes** at ray hits where no frustum probe exists. Same shaders; 4×4 octahedral
368
- with no border; SH irradiance; denser, and storing no occlusion.
369
- - The chain stops there: point lights get three bounces and full GI two.
370
- 3. **Ambient probes** for fog: 16³ × 8 cascades. They trace nothing and accumulate about 10% of the
371
- other probes' ray segments.
372
- - They feed a 32×32-tile froxel volume with 384/256/128 slices, about 50 µs on low-end hardware.
373
-
374
- **Pipeline** [11:12–19:54]
375
- - Fully GPU-driven with indirect dispatches.
376
- - A fixed ray budget: 100k / 250k / 500k rays a frame on low / mid / high (Q&A [45:27]).
377
- - A new probe asks for 512 rays and decays fast to 16, rising again when it sees change. Requests
378
- are scaled down to the budget.
379
- - Rays are batched by a probe-index scan.
380
- - Ray directions are importance-sampled from each probe's own radiance (a linear CDF walk with a
381
- rotating start corner), because 16–32 uniform rays a frame left the probes flickering.
382
- - **Uniform rays every 5th frame** keep the depth moments alive, which importance sampling
383
- starves.
384
- - Rays are sorted by position and direction, and hits by kind (terrain, sky, building) to avoid
385
- shader branches.
386
- - **Hit shading** [19:23]: emissive from the voxel; directional light with an SDF shadow ray shot
387
- beforehand; point lights from a "light cache" (a cascaded grid of per-cell light lists) with SDF
388
- shadows; then GI from the probes.
389
-
390
- **Temporal and spatial filtering** [20:25–26:33]
391
- - A moving-average estimator (short and long-term means, variance-driven blend; from the
392
- *Stochastic All The Things* talk and Ray Tracing Gems) at 5 × u32 per texel, which the talk
393
- counts as a heavy bandwidth cost.
394
- - Depth is a per-texel mean and mean² clamped to the probe spacing, tested with Chebyshev and
395
- filtered in LDS.
396
- - Ray and history reprojection between neighbouring probes, 4:1, which carries light hundreds of
397
- metres into caves.
398
- - Irradiance by a cosine-weighted loop over the rays.
399
-
400
- **Specular** [27:03]: radiance prefiltered by a cosine-weighted blur and downsample into mip-like
401
- levels, tuned against a ground-truth mode, then blended with FidelityFX SSSR because characters are
402
- not in the SDF [33:10].
403
-
404
- **Sampling** [29:04–31:39]
405
- - Trilinear over the eight cage probes, but **stochastic**: one probe per frame by weight (trilinear
406
- × Chebyshev), with stochastic cascade crossing in the overlap.
407
- - Two screen-space reservoirs, temporally reprojected and spatially filtered.
408
- - An à-trous denoise, needed mostly for specular.
409
- - Dead probes are those whose rays sum to under 1 mm, i.e. inside a wall. They are excluded but
410
- still trace 16 rays a frame.
411
-
412
- **Lessons stated** [41:19–42:53]: bandwidth dominates (Steam Deck); RGB9E5 everywhere, decoded in
413
- LDS; fixed buffer sizes; settings for every system. The admitted hole is foliage: trees are not in
414
- the SDF [49:08].
415
-
416
- **What this design takes, and what it leaves.**
417
-
418
- | Enshrouded | Here | Why |
419
- |---|---|---|
420
- | SDF rays instead of hardware RT | same (§7) | one path on every WebGPU device; the request |
421
- | material from the voxel world; normal from the SDF | surface cards holding captured diffuse albedo and emissive (Lumen's payload); normal from the field's gradient (§6.5, §6.6) | the review: voxel attributes are expensive |
422
- | very small objects absent, medium ones only on high | a per-primitive membership flag (§6.1) and a per-cascade resolution cull (§7.7) | the review; the cull keys on a runtime fact (E4) |
423
- | a BVH over the SDFs | the TLAS over instance boxes, queried per requested cell (§7.7) | reuse |
424
- | cascaded cache, depth-buffer activation, expiry | probe clipmap with want → slot → retention (§8.3) | memory follows what is visible, not the grid |
425
- | octahedral radiance + border; distance with mean/mean² | same, `rgba16float` and `rg16float` with the engine's skirt chunks (§8.2) | hardware bilinear for every consumer tap |
426
- | irradiance as octahedral 12² (frustum) or SH (world cache) | SH2 YCoCg L1 for every probe | the request; 32 B a probe |
427
- | fixed ray budget; per-probe requests scaled to it | budget in rays a frame; period follows the live count (§8.5) | same intent, deterministic schedule |
428
- | importance-sampled directions + every-5th-frame uniform | the same: every 5th update uniform, the rest a CDF walk over the probe's history from a rotating corner, each texel held under 64 rays (§8.6, as built 2026-10-29) | Alex found the noise of one jittered ray a texel visible (D11) |
429
- | moving-average estimator, 5 u32 per texel | the same estimator, its short mean over a response time in seconds; 6 words a texel with the atlas's (§8.8) | built 2026-10-29, once Alex judged the noise visible (D11); the knob stays physical |
430
- | SDF shadow ray per hit for the sun | same (§8.6) | shadow maps answer "lit" off screen (§1) |
431
- | light cache for point lights | the world light clusters, keyed by this clipmap (§8.7) | reuse |
432
- | world-cache probes at hits | hits want the probes they read, one step from the screen, the one probe kind (§8.3) | the off-screen bounce measured short at S5a (D31) |
433
- | stochastic 1-of-8 + reservoirs + denoise | 2 of 8 by Brick4's WRS, resolved by TAA (§9.1, §9.4) | the review; two probe reads, no denoiser |
434
- | prefiltered radiance levels for specular | the radiance tap blended with GGX-convolved SH2 by roughness (§9.3) | the review: WebGPU's mips; LPV's blend |
435
- | ambient probes and froxel GI | the existing irradiance clipmap samples this mode's probes (§9.5) | fog has no GI in any mode today |
436
-
437
- ### 2.2 UE 5.4 Global Distance Field
438
-
439
- The production reference for the brick clipmap.
440
-
441
- **Clipmaps**
442
- - 4 clipmaps (up to 6) of 252 voxels a side, i.e. 36 pages × 7 voxels.
443
- - Clip 0 is a ±25 m half-extent at 19.8 cm voxels, doubling per clipmap.
444
-
445
- **Pages and atlas**
446
- - **A page is 8³ texels in the atlas with 7 unique voxels**: texel 7 duplicates the next page's
447
- texel 0, every page computes all 512 itself, and the lookup `offset·8 + frac(uv·36)·7 + 0.5`
448
- keeps trilinear inside the page. There is no border pass.
449
- - Atlas: 3-D `R8_UNORM`. Page table: `R32_UINT` with `0xFFFFFFFF` = no page, allocated from a GPU
450
- free-list stack.
451
- - Distance is encoded `saturate(d/(2R) + 0.5)` with **R = 4 voxels**.
452
- - A page is kept iff its centre's distance is within R plus the page radius.
453
-
454
- **Empty-space skipping**: a dense `R8` coarse mip at a quarter of the resolution, rebuilt by five
455
- eikonal propagation passes whenever a clipmap updates. Pages are read only where the coarse distance
456
- is under 4 voxels.
457
-
458
- **Composition**: from the mesh distance fields of §2.3, culled per tile into lists of up to 511
459
- objects, with a minimum object radius of max(20 cm, ½ voxel).
460
-
461
- **Invalidation and budget**
462
- - Add, remove and transform changes record the old and new boxes, expanded by R. At 1,024 boxes or
463
- more the clipmap is fully recaptured.
464
- - 2 clipmaps update a frame, staggered.
465
- - Scrolling is toroidal and exposes slabs only.
466
-
467
- **Trace**
468
- - Starts in the first clipmap containing the point by over half a voxel, clipping the ray to each
469
- clipmap box shrunk by half a voxel.
470
- - At most 256 steps a clipmap. A hit is d < ~½ voxel (the "expand"); the minimum step is ½ voxel.
471
- - The normal is a 6-tap central difference.
472
-
473
- ### 2.3 UE 5.4 mesh distance fields, composition, and Lumen's surface cache
474
-
475
- The production reference for §6 and §7.7.
476
-
477
- **Mesh distance fields**
478
- - Bricks of 8³ texels with 7 unique on the corner lattice, `G8`, a band of 4, and 3 mips. An empty
479
- brick is `0xFFFFFFFF` and reads +band.
480
- - Resolution: 0.2 voxels a centimetre (5 cm) × a per-mesh scale. Mip 0 is capped at 128 voxels a
481
- side at scale 1, 18 bricks. Mip i has ⌈mip₀ / 2^i⌉ bricks a side and is re-voxelised from the mesh,
482
- not downsampled.
483
- - The band is 4 voxel *diagonals*, and the encoding is the global field's `(d + band) / 2band`.
484
- - A brick is stored unless all its texels saturate at one end, so a brick wholly inside reads +band,
485
- as here.
486
- - Sign:
487
- - Rays are traced only where the unsigned distance is inside the band.
488
- - There are 98 of them (a 7×7 stratified hemisphere and its mirror), each one band long.
489
- - The voxel is inside when more than 25% hit back faces. Two-sided triangles never count.
490
- - Translucent sections, and primitives with distance-field lighting disabled, are left out: the
491
- precedent for the membership bit (D25).
492
-
493
- **Composition**
494
- - Objects are culled to the clipmap, then to tiles of 4³ pages, then to each page's 4³-voxel tile in
495
- LDS, 511 a pass. The passes loop, so there is no hard cap.
496
- - Per object:
497
- - an early-out when the box distance is already past the running minimum;
498
- - inside the volume, the mesh SDF scaled by the object's smallest axis scale;
499
- - outside it, the SDF at the clamped point plus the box distance.
500
- - The resolution cull is radius > max(20 cm, ½ voxel). Emissive objects are exempt.
501
- - The mip is not chosen by the clipmap's voxel. A sample walks the resident mips from coarse to fine
502
- and stops at the first whose distance exceeds a quarter of its band. Mips stream in by distance:
503
- 3 inside clipmap 1's box, 2 inside clipmap 3's, 1 elsewhere.
504
- - Mostly-static and movable layers are merged by a minimum: the static/dynamic category E4 refuses.
505
-
506
- **The object grid**
507
- - Layout: a page has 4³ cells. Each cell holds the 4 nearest objects as a `uint4`: a 24-bit instance
508
- index and an 8-bit distance. That is 1 KiB a page.
509
- - It is filled by its own pass, one thread per cell.
510
- - At a hit:
511
- - the lookup point is the hit plus half a voxel along the field's normal;
512
- - each entry's mesh cards are sampled in turn, until the summed weight reaches 0.9 or an entry is
513
- empty.
514
-
515
- **Mesh cards**
516
- - Cards are axis-aligned in mesh space, six directions.
517
- - Surfels:
518
- - Per column of a 10 cm grid, 32 jittered rays march through successive hits.
519
- - A hit is kept if it faces the card and follows an empty cell. Its minimum ray Z is the layer after
520
- the previous hit.
521
- - Clustering, per direction:
522
- - first the outer projection (near plane 0);
523
- - then, greedily, the near plane that best covers the unassigned surfels. A near plane N sees a
524
- surfel at layer Z when its minimum ray Z ≤ N ≤ Z.
525
- - This is how concave meshes get interior cards.
526
- - The weakest clusters are dropped down to 12 cards a mesh (32 at most; the runtime lookup is a
527
- 32-bit mask per direction).
528
- - A mesh with no card has no fallback.
529
-
530
- **The surface cache**
531
- - Layers: 128² physical pages in a 4096² atlas, holding depth `G16`, diffuse albedo `RGBA8` (stored
532
- as its square root), opacity, normal, emissive `R11G11B10`, and three lighting layers.
533
- - Card resolution:
534
- - recomputed from distance at every update, 8 to 2048 texels;
535
- - a locked distance level always resident, with finer pages paged in by GPU feedback;
536
- - idle pages kept for 256 frames.
537
- - Capture:
538
- - the mesh is rasterised through an ortho camera on the card, at most 300 pages a frame;
539
- - a recapture follows a resolution change, an explicit surface-cache invalidation on the
540
- primitive, or a slow refresh of the oldest pages;
541
- - a material change is not tracked.
542
-
543
- **Sampling at a hit**
544
- - Cards are selected by the normal's sign per axis, weighted by n², and every card holding the point
545
- within the bias is blended.
546
- - Depth test: four depth taps, each weighted by `1 − saturate((|t − depth| − bias)/(¼·bias))` in card
547
- depth units. For a global-SDF hit the bias is three half-voxels of the hit's clipmap.
548
- - With no valid card, the hit is opaque black.
549
-
550
- **Lumen**: probe rays trace the global distance field and are lit by the surface cache's final
551
- lighting. 5.4 has removed the voxel lighting that earlier versions used for global-SDF hits.
552
-
553
- ### 2.4 UE 5.4 Lumen world-space radiance cache
554
-
555
- The production reference for sparse probe allocation.
556
-
557
- **Clipmaps and allocation**
558
- - 4 clipmaps of 48³ at 1.04 m (clip 0).
559
- - The indirection (`R32_UINT`) is cleared and rebuilt every frame: consumers mark the eight probes
560
- around each sample, and a probe is reused if marked within 8 frames, else freed.
561
- - 16,384-probe capacity (128² atlas). Overflow renders incorrectly.
562
-
563
- **Probes**
564
- - 32² equal-area octahedral `R11G11B10F` radiance plus `R16F` hit distance.
565
-
566
- **Tracing**
567
- - A budget of 300 probes × cost per frame. Never-traced probes go first, then priority by how long
568
- since each was traced relative to its use.
569
- - **No history blend and no jitter**: a retrace overwrites.
570
- - 256–4,096 rays per probe, against the global distance field only; hits are lit by the surface
571
- cache.
572
-
573
- **Sampling**
574
- - Eight-probe trilinear with sphere parallax.
575
- - Visibility weighting (Chebyshev³ × `((n·ω+1)/2)² + 0.2`) is used only in the off-by-default
576
- irradiance mode.
577
-
578
- Lumen can overwrite on every retrace because its screen probes filter downstream. Here consumers
579
- sample the probes directly, so the blend stays (§14, D11).
580
-
581
- ### 2.5 AMD FidelityFX Brixelizer and Brixelizer GI
582
-
583
- From the FidelityFX SDK manual and source (v1.1.4) and the GDC 2024 talk. The port in
584
- `shadow/sdf/brixelizer/` is a reference only.
585
-
586
- **The field**
587
- - Cascades of 64³ voxels (up to 24), each voxel holding at most one brick through a 1 MiB brick map.
588
- The toroidal wrap is `(voxel + offset) & 63`.
589
- - Bricks are 8³ samples spanning one voxel (7 intervals, shared samples, no apron) in a 512³ R8
590
- atlas: 2^18 bricks, 128 MB.
591
- - The manual calls them **unsigned** distance fields; the stored value saturates at one voxel.
592
- - Two-level per-cascade AABB trees (4³ fan-out) skip empty space.
593
-
594
- **The build**
595
- - Triangles are referenced into every voxel their box overlaps. Each writes exact distances into
596
- its box's brixels through 32-bit `InterlockedMin`, then a 12-sweep eikonal fill runs per brick.
597
- - *Static* cascades persist under invalidation jobs; *dynamic* cascades are rebuilt every frame,
598
- merged by a per-brixel min. That is the static/dynamic category this engine refuses (E4).
599
- - The per-triangle scatter is the footprint-dependent cost E3 refuses.
600
-
601
- **Brixelizer GI**
602
- - The SDF carries no material, so its radiance cache is fed only by reprojecting previous frames.
603
- - Screen probes 8×8 octahedral, a per-brick SH2 cache, about 2 ms at 4K at half scale on an RX 7900
604
- XTX.
605
-
606
- What this design keeps from it: the per-texel min as the merge of independent writers (§7.6), here
607
- between providers rather than between static and dynamic cascades.
608
-
609
- ### 2.6 DDGI
610
-
611
- Majercik et al., JCGT 2019 and 2021, and the RTXGI SDK.
612
-
613
- **2019 paper**
614
- - Irradiance `R11G11B10F` at 8×8 octahedral per probe; distance and distance² `RG16F` at 16×16;
615
- one-texel gutters.
616
- - Hysteresis 0.85–0.98 across its results.
617
- - 64–128 rays a probe a frame.
618
- - Distance texels gathered with a cosine power (50 in RTXGI).
619
- - Leak control: Chebyshev visibility, a shading point offset along the normal and toward the eye,
620
- the wrap back-face weight `((n·ω+1)/2)² + 0.2`, and crushing of small weights.
621
- `lpv_mask_weights_by_visibility` already is this.
622
- - 1–2 m spacing for human-scale scenes.
623
- - 6 ms at 1080p on an RTX 2080 Ti for its teaser scene.
624
-
625
- **2021 additions**
626
- - Probe states and relocation out of geometry.
627
- - A back-face hit writes irradiance 0 and shortens its depth by 80%.
628
- - A per-texel convergence boost: a change over 25% drops the hysteresis by 0.15; over 80%, to 0.
629
- - Scrolling volumes clear only the plane that wraps.
630
-
631
- **RTXGI defaults**
632
- - 256 rays; hysteresis 0.97.
633
- - Normal bias 0.1 and view bias 0.1, in world units.
634
- - Back-face threshold 0.25 for the fixed classification rays (0.1 for random rays); minimum
635
- front-face distance 1.0.
636
- - Relocation clamped to 45% of a cell.
637
- - A probe every 2–3 m recommended.
638
-
639
- ### 2.7 Godot 4 SDFGI, and the HDDAGI draft
640
-
641
- **SDFGI** (4.3 source and docs)
642
- - Up to 8 cascades of 128³ cells, Y at 75%.
643
- - Voxelized albedo, emission and facing at half size.
644
- - The SDF is built by jump flood from solid voxels and stored **unsigned** in R8 as `(1 + cells)/255`.
645
- - Probes: 17³ per cascade, 8 cells apart, *all updated every frame*, 16 rays by default; L3 SH in a
646
- 30-frame history ring.
647
- - Its documented limits: visible cascade shifts; convergence time; an SDF that updates only when the
648
- camera crosses cascades, so moved doors give stale GI; dynamic meshes contribute nothing; leaks;
649
- one of the engine's most demanding GI options.
650
-
651
- **HDDAGI** (reduz, PR #86267, an open draft never merged; revived as #119869 in May 2026)
652
- - Replaces the full-cascade jump flood with HDDA over local updates.
653
- - Claims 2.43 → 0.93 ms on a GTX 1650, unaffected by camera motion.
654
-
655
- The lesson taken here: **never rebuild a whole cascade, and never update every probe every frame.**
656
- §7.4 rebuilds only exposed and changed cells; §8.5 traces only wanted probes, within a budget.
657
-
658
- ### 2.8 kajiya's irradiance cache
659
-
660
- Embark Studios' research renderer.
661
- - 12 sparse 32³ clipmaps over a dense indirection, with a 65,536-entry pool. Entries are allocated
662
- on demand and freed a few frames after their last use.
663
- - Each entry is a 4×4 octahedral set of temporal reservoirs, 4 texels refreshed a frame, convolved
664
- to L1 SH.
665
- - Usually under 16k entries, about 0.55 ms on an RX 6800 XT.
666
- - It is queried only at ray hits: a cache behind a screen-space solver, not the shading
667
- representation. That is why it can be so small.
668
-
669
- ### 2.9 Specular from two SH bands
670
-
671
- - **The GGX lobe as a zonal kernel.** Convolving radiance SH with a radially symmetric lobe scales
672
- each band by the lobe's zonal factor. MJP's *SHforHLSL* fits GGX's band-1 factor as
673
- `1.6671/(1.6572 + α)`: about 1 at α = 0, and 0.63 at α = 1, near the clamped cosine's 2/3. The
674
- tree has the fit as `sh2_approximate_GGX_zh`.
675
- - **Dominant direction.** Lagarde and de Rousiers (*Moving Frostbite to PBR*, 2014) bend the lookup
676
- from the reflection vector toward the normal as roughness grows. LPV and Brick4 both do this.
677
- - **Luma and chroma.** Metro Exodus (GTC 2019) stores luma as L0 + L1 and chroma as a constant, and
678
- rescales chroma by the ratio of directional to mean luma at resolve. The same ratio carries over to
679
- a GGX resolve (§9.3).
680
- - **The blend.** Two bands cannot hold a sharp lobe, and a single radiance tap cannot hold a wide
681
- one. LPV blends the two by `roughness_to_mip_ratio`, Frostbite's roughness-to-mip curve.
682
-
683
- ### 2.10 What wins on performance and simplicity
684
-
685
- | Component | Chosen | Over | Because |
686
- |---|---|---|---|
687
- | Brick layout | UE page: 8³ texels, 7 voxels, corner lattice | 8³ + apron (10³) | no border pass; 512 B a brick; Brixelizer agrees |
688
- | Distance | signed `r8unorm` over ±4 voxels, ½-voxel hit | unsigned shell, (a) | whole-mesh bakes know the sign; relocation and back faces follow from it; the ½-voxel hit keeps thin walls (D2) |
689
- | Empty space | 4³-cell occupancy count + cell entry (HDDA) | UE's coarse eikonal mip; Brixelizer's AABB trees | incremental, toroidal, no propagation passes and no tree build |
690
- | World build | composed from cached per-mesh SDFs, as UE composes | triangles per cell, (a); per-triangle scatter (Brixelizer); raster voxelize (Godot) | no triangle is read per frame; cost follows the instances in a cell |
691
- | Mesh SDF | sparse bricks in size classes, with levels | `GPUGeometrySDFManager`'s dense tiles | the review; memory follows the surface |
692
- | Material | cards holding diffuse albedo and emissive captured per (geometry, material), recaptured when the material's version moves | the voxel lattice, (a); cards holding uvs with the material evaluated live | the review: no material work at a hit (§15) |
693
- | Probe residency | sparse cache from the screen | dense lattice | 10× less memory and work at the same lattice (§11) |
694
- | Rays | Enshrouded's: guided by the probe's radiance, every 5th update uniform (§8.6) | one jittered ray per texel, (b) as first built | its noise measured visible (D11) |
695
- | Temporal | exponential over a response time | moving-average estimator | one history, 8 B a texel |
696
- | Pixel sampling | 2 of 8 by WRS | all eight, (a); 1 of 8 + reservoirs + denoise (Enshrouded) | the review; two probe reads, no denoiser |
697
- | Specular | radiance tap ⊕ GGX-convolved SH2 | prefiltered levels | the review; LPV's blend |
698
-
699
- ## 3. Requirements
700
-
701
- **From the request (R)**
702
- 1. A new `ShadeIndirectLightingMode` value selects a separate subsystem.
703
- 2. Rays hit an SDF. Material comes from a scene representation beside it (R11).
704
- 3. The scene clipmap is sparse: fixed-size bricks in an atlas.
705
- 4. The SDF is 8-bit and traced with hardware filtering.
706
- 5. Shading samples a 3-D clipmap of probes: radiance and depth octahedral, irradiance SH2.
707
- 6. Cascade count, cascade resolution, radiance and depth resolutions and rays per probe are settings.
708
- A change reallocates; nothing changes per frame.
709
- 7. Maximal reuse; the LPV is an explicit source.
710
- 8. Enshrouded is the main reference.
711
- 9. SotA first, prioritising performance and simplicity.
712
-
713
- **From the review of (a) (R)**
714
- 10. No triangle work per frame: meshes are cached as SDFs, NanoVDB-style, in size classes of 2×2×2
715
- bricks and up in steps of two bricks per axis.
716
- 11. Material comes from a surface cache in Unreal's manner, not from voxels: diffuse albedo and
717
- emissive captured per (geometry, material), recaptured on a material change.
718
- 12. A primitive can be absent from the SDF world.
719
- 13. Providers write requested bricks; their outputs merge per brick; virtual geometry is served by
720
- one.
721
- 14. Skinned meshes use their unskinned representation now, with hooks and TODOs for a dense patch.
722
- 15. Pixels pick 2 of 8 probes by Brick4's WRS, after visibility-only weights.
723
- 16. Rough specular blends radiance with GGX-convolved SH2.
724
- 17. `GPUGeometrySDFManager` is the reference for the bake; the Brixelizer port is not reused.
725
-
726
- **From the engine's rules (E)**
727
- 1. **Work follows the live count.** Dispatches are indirect over device counts; capacity sizes memory
728
- only.
729
- 2. **Bounded per-thread loops**, with budgets in tens. Each is stated with what is lost past it.
730
- 3. **Gather per bin.** No thread's cost follows an element's footprint.
731
- 4. **No static/dynamic category.** A row's treatment follows facts re-observed each frame, such as
732
- "changed this frame" and its size against the voxel.
733
- 5. **The CPU does O(changes)** and never passes over GPU-resident state.
734
- 6. **GPU consumers sample on the GPU**, at their own resolution.
735
- 7. **Separate passes for separate cost profiles.** Share data, not shaders.
736
- 8. **Shader switches, not overrides or template codegen**; concrete modules.
737
- 9. **Physical knobs in world units and seconds.** Caps are safety nets, stated as chosen numbers.
738
- 10. **The scalable path first.** A small-n fast path keys on the runtime count.
739
- 11. **No allocation in per-frame host code.**
740
- 12. **Tests on all four tiers.** No mocks, hooks or `.each`; a fix is red then green.
741
-
742
- ## 4. The reuse map
743
-
744
- Verdicts:
745
- - **REUSE**: as is.
746
- - **GENERALISE**: the piece grows a parameter, and its clients move onto it.
747
- - **EXTRACT**: the piece becomes shared, and its current owner migrates.
748
- - **COMPOSE**: built from existing pieces.
749
- - **NEW**: written generic.
750
-
751
- ### 4.1 Host side
752
-
753
- | Need | Engine machinery | Verdict |
754
- |---|---|---|
755
- | Mesh SDF bake, cache lifetime | `GPUGeometrySDFManager` | **GENERALISE in place**: sparse bricks in size classes with levels, `r8unorm`, cards, a budgeted bake queue (§6) |
756
- | Mesh SDF indirection, card page tables | `OffsetAllocator` | **REUSE** |
757
- | Window placement, nesting, exposed slabs | `ClipmapWindowSet`, `quantize_hysteretic`, `window_scroll_regions` (`core/geom/clipmap/`) | **REUSE** for both clipmaps |
758
- | Exposed-slab flow | `GPUFluidCascadeStack.record_tick` → `window_scroll_regions` → fill pass | **REUSE as pattern**; the brick and probe passes are their own kernels |
759
- | World and mesh brick atlases | `GPUTileAtlas3D` (r8unorm, content 8³, border 0) | **GENERALISE**: `reserve(capacity)` grows the footprint without the host handing out slots; the GPU free list owns them |
760
- | Card pages; probe radiance and depth atlases (2-D) | `GPUProbeAtlas`'s tile placement (`index_to_grid2d`, width at `maxTextureDimension2D`, growth in rows) | **EXTRACT** a format- and tile-parameterised 2-D tile atlas; LPV keeps its 66-texel pitch on it |
761
- | Free slot stacks | the particle pool (`shader_particle_init_free_list`, `dead[--DEAD]`) | **EXTRACT** the init pass and the pop/push chunk into `gpu_primitive/`; particles migrate |
762
- | Growth on demand | the vgeo high-water pattern; `GPUDatabaseReadback` | **REUSE**: failures counted on the device, read back, capacity doubled, never shrunk |
763
- | Changed world boxes | `shader_vsm_movers`, `shader_vsm_skinned_movers`, `VSMMoverTracker`, `VSM_MOVER_LAYOUT` | **EXTRACT** to a scene-level mover list with a skinned bit; VSM and this mode read it (D32) |
764
- | SDF membership | `ShadedGeometryFlags`; `ShadedGeometrySystem` → `attach_mesh`; the row's tail padding | **GENERALISE**: an `SDFWorld` bit forwarded as `Skinned` is, mirrored into a row `flags` word (§6.1) |
765
- | Row change stream | `GPUSceneRows.onMeshReleased` | **GENERALISE**: `onMeshWritten` beside it |
766
- | Skinned source geometry | the rows' entry keeps the source; `GPUMeshSkinningContext.obtain_clone` | **REUSE**: a clone's SDF header aliases its source's (the hook, §6.3) |
767
- | Material slot means; material changes | `GPUResidentMaterialContext` (`#pack_material`); `ShadeMaterial.version` | **GENERALISE**: a mean per texture slot when the slot is written (§6.6), and a signal when a material is repacked, which recapture keys on (§6.5) |
768
- | Settings | the shadow knobs' validate → push → rebuild-on-mismatch; the `avboit` / `ssr` sub-objects | **REUSE as pattern**: `ProbeClipmapSettings` (§10) |
769
- | Mode plumbing | `INDIRECT_LIGHTING`, `ShaderSwitchSet`, `#graph_indirect_lighting` | **REUSE**: additive (§9.7) |
770
- | Frame graph | `graph_compute_pass`, `graph_compute_indirect_pass`, `graph_import_texture` / `_buffer`, `IndirectComputeCommandBuildPass`, `build_indirect_compute_dispatch_command` | **REUSE** |
771
- | Profiling and stats | `GPUProfileSession`, `graph_record_stat` | **REUSE** |
772
-
773
- ### 4.2 WGSL
774
-
775
- | Need | Engine chunk | Verdict |
776
- |---|---|---|
777
- | Exact distance and sign at a point | `blas_query_sdf`, `point_query_blas_nearest`, `blas_query_volume_sign` | **REUSE** in the bake. S3 replaced the vote with UE's, `blas_query_band_sdf` over the BLAS's point and ray queries, and deleted the other two |
778
- | A ray against one geometry | `ray_query_blas_nearest` | **REUSE** in card capture |
779
- | The triangle at a hit | `geometry_read_triangle_vertices` | **REUSE** in card capture |
780
- | Material at a triangle hit | `sample_material_data`, `sample_material_alpha` | **REUSE** in card capture, with a seed fixed per texel |
781
- | Clipmap levels, finest level, blend weight | `CLIPMAP_LEVEL_STRUCT`, `clipmap_select_finest`, `chunk_clipmap_contains` | **REUSE** |
782
- | Toroidal storage coordinate | `chunk_clipmap_storage_coord` (no production user) | **REUSE**: it gets its first users |
783
- | Folded indirect dispatch | `chunk_folded_workgroup_index` | **REUSE** |
784
- | Instances overlapping a box | `chunk_bvh_query_aabb_cooperative` (TLAS seam) | **REUSE**: second user |
785
- | Mesh and node rows | `scene_read_mesh`, the scene database access chunks, `NODE3D_STRUCT` | **REUSE** |
786
- | Octahedral direction codec | `uv_octahedral_unit_encode` / `_decode` (the one WGSL pair) | **REUSE** |
787
- | Octahedral skirt for hardware bilinear | `texture_octahedral_skirt_texel_uv`, `texture_octahedral_skirt_uv` | **REUSE** (the environment map's, not LPV's unread border) |
788
- | Texel solid angle | `octahedral_solid_angle` | **REUSE** |
789
- | SH2 basis; L1 storage and diffuse resolve | `sh2_basis_at`; `convert_to_irradiance_sh`, `resolve_irradiance_sh_to_diffuse`, `get_color_from_irradiance_sh`, `METRO_L1_IRRADIANCE_STRUCT` | **REUSE** |
790
- | GGX zonal factor | `sh2_approximate_GGX_zh` | **REUSE**; its Metro-form resolve is NEW (§9.3) |
791
- | Specular blend and sample vector | `chunk_lpv_sample_light`'s mix by `roughness_to_mip_ratio`; `normalize(mix(r, n, α))` | **REUSE** |
792
- | Chebyshev bound | `compute_chebyshev_weight` | **REUSE** |
793
- | Probe weight (wrap back face, crush) | inside `lpv_mask_weights_by_visibility` | **EXTRACT** a per-probe `probe_visibility_weight`; LPV migrates |
794
- | Pick 2 of 8 | `sample_discrete_wrs_mat2x4`; the selection inside `brick4_probe_meta_pick2` | **REUSE**; **EXTRACT** `pick2_wrs_mat2x4`, Brick4 keeps its address mapping |
795
- | Noise | `chunk_stbn_sample_vec2`; `jitter_from_vectors` (private to Brick4's forward chunk) | **REUSE**; **EXTRACT** the hash |
796
- | Directional lights | `directional_lights_iteration_mask`, the light database records | **REUSE** |
797
- | Local lights at a world point | `world_light_cluster_metadata_by_texel`, `irradiance_clipmap_get_point_light_info_by_index` / `_spot_…` | **GENERALISE** the cluster keying from `IRRADIANCE_CLIPMAP_STRUCT` to a `CLIPMAP_LEVEL_ARRAY`; the light chunks **REUSE**. Their shadows were `volumetrics_{point,spot}_light_shadow` until 2026-10-30, and are shadow rays in the bricks since (§8.7) |
798
- | Sky for misses; final fallback | `sample_environment_color`, `get_ibl_irradiance` | **REUSE** |
799
- | Specular split at a pixel | `compute_indirect_specular`, `tSplitSum`, inside the deferred resolve | **REUSE** |
800
- | SDF soft-shadow penumbra | inside `shader_scene_sdf_soft_shadows` | **EXTRACT** the estimator as a pure chunk; the dense-volume shader keeps calling it. Not done (S3e): its classic estimate cannot meet §12's oracle, and the brick clipmap's was Quílez's two-sided one (§7.9), itself replaced by shadow rays on 2026-10-30 (§8.7) |
801
- | Appends | `chunk_collection_u32_append` | **REUSE** |
802
- | Parallax toward a probe's hit | LPV's iterative correction in `lpv_sample_light` | **GENERALISE** to take a depth-moment reader (§9.3) |
803
-
804
- ### 4.3 Writing what does not exist
805
-
806
- | Need | Verdict |
807
- |---|---|
808
- | Mesh SDF classify, evaluate and store passes; card placement, capture and recapture; the bake and capture queues | **NEW**, inside `GPUGeometrySDFManager`, which owns every cache's storage |
809
- | The (geometry, material) pair bookkeeping and the row → card set table | **NEW**, on the GI, which decides what is requested |
810
- | `mesh_sdf_sample`, `mesh_card_surface` | **NEW**, emulator-testable pure chunks |
811
- | World brick clear, store, scroll, mover marking, coarse occupancy passes; the provider contract | **NEW** |
812
- | `MeshSDFBrickProvider` | **NEW**, written against the same contract as any plugin |
813
- | `brick_clipmap_trace`, `brick_clipmap_distance`, `brick_clipmap_object`, `brick_clipmap_soft_shadow` | **NEW**, emulator-testable pure chunks over seam readers |
814
- | Probe retire, want, admit, schedule, trace, shade, accumulate, publish passes | **NEW**; the chain follows LPV's (trace → shade with feedback → temporal → border → SH) |
815
- | `probe_clipmap_pick`, `probe_clipmap_sample_irradiance` / `_radiance` / `_sh` | **NEW**, on the extracted weight and pick |
816
- | `resolve_irradiance_sh_to_specular` | **NEW**, beside the diffuse resolve in `l1i/` |
817
- | `ProbeClipmapSettings`, `GPUBrickClipmap`, `GPUProbeClipmap`, `GPUProbeClipmapGI`, `BrickProvider` | **NEW** |
818
- | A triangle voxelizer, and (a)'s per-cell triangle gather | **not written**: the caches replace both |
819
-
820
- ## 5. Architecture
821
-
822
- **Geometry caches** belong to `GPUGeometrySDFManager` on the geometry manager. They outlive the mode,
823
- and any future SDF reader can use them.
824
-
825
- **Per view**, one `GPUProbeClipmapGI`. It owns a `GPUBrickClipmap` and a `GPUProbeClipmap`, both
826
- anchored on the view's camera, and a list of providers with the mesh provider first. It allocates on
827
- the first frame rendered in the mode, and destroys on leaving the mode, on a settings change and with
828
- the renderer. There is one graph scope a frame, after the G-buffer and the shadow maps and before
829
- volumetrics:
830
-
831
- ```
832
- host: place brick + probe windows; scroll boxes per moved cascade (≤ 3 each)
833
- read back last frame's failure counts → grow capacity (rare)
834
- bake queue: ≤ mesh_sdf_bake_budget bricks a frame, only while caches fill
835
-
836
- geometry caches (once per geometry): classify ─► evaluate ─► store ─► place cards ─► capture
837
- │ mesh brick atlas + indirection, card pages
838
- scene mover list (EXTRACTed, read by VSM too) + GI invalidation list
839
- │
840
- brick: scroll ─► movers ─► take ─► clear ─► providers (mesh, vgeo, …) ─► free ─► store ─► coarse
841
- (pending ring, ≤ brick_build_budget a frame)
842
- │ brick map, SDF atlas, object cells, occupancy
843
- probe: retire ─► want (depth) ─► admit ─► schedule ─┐
844
- ▼
845
- trace (+ sun shadow rays) ─► shade ─► light (+ local shadow ray) ─► accumulate ─► publish
846
- (rays = trace list × rays_per_probe ≤ ray_budget)
847
- │ indirection, states, records (SH2),
848
- │ radiance + depth atlases
849
- pixels: weigh 8 (visibility only) ─► WRS pick 2 ─► SH2 diffuse; radiance ⊕ GGX-SH2 specular
850
- consumers: deferred diffuse / fused pass, AVBOIT + MBOIT, water, SSR, irradiance clipmap
851
- ```
852
-
853
- Every dispatch is sized by a live count (E1):
854
- - an indirect command over a count an earlier pass wrote on the device;
855
- - or a count the host knows exactly: the scroll boxes' cells, one thread per 8×8 screen tile for
856
- `want`, one per occupancy block for `coarse` (≤ `C_b·(N_b/4)³`), and the bake's queued bricks.
857
-
858
- Nothing is sized by a capacity. Persistent resources are imported every frame and written as side
859
- effects, so a frame that has no reader for them culls nothing that has to persist.
860
-
861
- ## 6. Geometry caches
862
-
863
- ### 6.1 What is cached, and for which rows
864
-
865
- **A row is in the SDF world** when both hold:
866
- - its `ShadedGeometry` carries `ShadedGeometryFlags.SDFWorld`, a new bit (128), on by default as
867
- `CastShadow` is;
868
- - its material is not `TransparencyMode.Transparent`. Glass does not occlude GI rays; this is (a)'s
869
- rule, now applied at the row.
870
-
871
- How the bit travels:
872
- - `ShadedGeometrySystem` forwards it to `attach_mesh`, as it forwards `Skinned`. A change goes
873
- through `shaded_geometry_announce_change` and re-attaches, as a material change does.
874
- - `GPUSceneRows` mirrors it into a new `flags` word of the `meshes` row: bit 0, `MESH_FLAG_SDF_WORLD`.
875
- The word sits in the row's tail padding, so the row stays 80 B.
876
- - A row outside the SDF world is drawn by the camera and invisible to every GI ray. It occludes
877
- nothing, emits nothing into GI and bounces nothing. That is the point of the bit: performance.
878
-
879
- **A geometry is cached** when at least one row in the SDF world draws it.
880
- - A virtual geometry's cache is baked from a proxy cut from it, and adopted into it (§7.8, S8).
881
- - DynamicMesh has no row, so it is absent by construction.
882
-
883
- **What is cached, and keyed by what**:
884
- - per geometry: the sparse SDF (§6.2–6.4) and the card layout (§6.5). Every instance and every
885
- material shares them.
886
- - per (geometry, material) pair: a card set, the captured contents of that layout (§6.5). Every
887
- instance drawing the pair shares it.
888
- - A material change recaptures that material's card sets and rebuilds nothing else.
889
-
890
- **Ownership and lifetime.** `GPUGeometrySDFManager`, generalised in place, owns the storage of all
891
- three and runs the bakes and captures. Its evaluator stays; its dense tiles go.
892
- - A geometry and a pair are requested on the first `onMeshWritten` of a row in the SDF world that
893
- draws them. The event is new, beside `onMeshReleased`.
894
- - The GI keeps row counts per geometry and per pair on the CPU, updated by the two events
895
- (O(changes), E5). A row that changes material moves one count to another.
896
- - At zero rows a pair's card set is freed, and with the geometry's last row its SDF and layout go
897
- too. A geometry or pair that comes back is baked or captured again.
898
- - Triangles are read only by the bake, once per geometry, and by capture, once per pair and again per
899
- material change.
900
-
901
- ### 6.2 Grid and size classes
902
-
903
- - **Voxel.** v = `mesh_sdf_voxel_size`, in the geometry's local units. The default 0.125 is the
904
- world's finest voxel, so a mesh at unit scale is as fine as cascade 0.
905
- - **Grid box.** The geometry's bounds grown by R = 4v on every side, so the band fits.
906
- - **Bricks per axis**: `b_a = max(2, 2·⌈E_a / 14v⌉)`, with E_a the grid box's extent on axis a. The
907
- spare voxels pad both sides evenly.
908
- - These are Alex's size classes: 2×2×2 bricks at least, then steps of two bricks per axis,
909
- independently.
910
- - Even counts make the first coarser level exact, and the minimum leaves a one-brick level.
911
- - **Safety net.** `b_a ≤ mesh_sdf_max_bricks` (default 18, i.e. 126 voxels: UE's per-mesh cap at
912
- unit scale, §2.3). Past it, v grows for that geometry until the longest axis fits. Such a geometry
913
- is coarser than asked, and the bake counts it.
914
- - The cap also bounds a geometry's level-0 indirection to 18³ entries, 23 KiB.
915
- - At the default voxel it reaches 15.75 m before a mesh coarsens.
916
- - **Levels.** Level k has voxel v·2^k and ⌈b_a / 2^k⌉ bricks per axis, down to one brick per axis:
917
- L = 1 + ⌈log₂ max_a b_a⌉, from 2 to 6 levels. A coarse cascade samples a coarse level (§7.7).
918
-
919
- ### 6.3 Encoding and storage
920
-
921
- - **Value**: signed, in UE's encoding: `e = saturate(d/(2R) + 0.5)`, R = 4 voxels of the level, in
922
- `r8unorm`. One LSB is 8/255 = 0.031 voxel.
923
- - **Bricks**: 8³ texels on the corners of 7³ voxels, the world's lattice (§7.2). Every geometry and
924
- level shares one `r8unorm` `GPUTileAtlas3D`, the mesh brick atlas. Slots come from the extracted GPU
925
- free list.
926
- - **Indirection**: per level, one `u32` per brick: a slot, or EMPTY on one side of the surface. An
927
- EMPTY brick has no texel within the band: EMPTY (`0x80000000`) reads +R, EMPTY inside
928
- (`0x80000001`) reads −R. All levels of all geometries share one storage buffer; ranges come from
929
- `OffsetAllocator`.
930
- - Amended in S2 (2026-10-27). EMPTY first read +R on both sides. More than a band inside a solid,
931
- a band brick below zero then met an EMPTY brick at +R, and the two read as a surface that is not
932
- there: on Sponza, card captures and the scene march stopped on it. Alex chose the sign.
933
- - Amended in S3 (2026-10-27), when Alex chose UE 5.4's vote (§6.4). A texel with no surface within
934
- the band casts no ray and reads +R, so a brick with none is outside and the bake writes EMPTY
935
- alone. EMPTY inside stays in the format and its readers; no mesh bake writes it.
936
- - As built (S2), the same buffer is the bake's arena. It holds the bake's counters at word 0, the
937
- frame's log and tallies, and each geometry's count of bricks still to settle, one word before
938
- its first entry. The ring's buffer carries the frame's staging.
939
- - That keeps evaluate at ten storage buffers, beside the five a BLAS query reads, within
940
- `SHADE_DEVICE_FLOOR`. Classify and evaluate first bound 12 and 14.
941
- - **Header**: `MESH_SDF_STRUCT`, 64 B per geometry id. It holds:
942
- - grid origin and v;
943
- - brick counts and level count;
944
- - the levels' indirection offsets;
945
- - the card layout's offset (§6.5);
946
- - a state: requested, baking, resident.
947
- - **Deep interiors** read −R (amended in S2): a solid is below zero all through, and its surface is
948
- its one zero crossing. UE's mesh distance fields are instead a signed shell as deep as the band,
949
- and read +R past it.
950
- - `GPUGeometrySDFManager`'s wrap of negative distances past 4 voxels existed so that an open mesh's
951
- false inside could not run away. The wrap is dropped (D27).
952
- - With EMPTY signed, the band no longer bounds a false inside: an open mesh reads −R as far as its
953
- vote calls inside. **Measured on Sponza** (S2): its geometry 102 is an open box around the
954
- building — a bottom and four outer walls, wound outward, no top. From the building's air more than
955
- 60% of the vote's rays meet its faces from behind (57 to 61 of 64), so that air is inside it, and
956
- all of it reads −R, the courtyard included. With EMPTY at +R the same vote put a false floor R above the floor. At every
957
- point the vote calls inside, at least 3 of the 64 rays leave through the open top and meet
958
- nothing; from inside a closed mesh none does. Put to Alex.
959
- - Amended in S3 (2026-10-27): with UE's vote, deep interiors read +R again, past a signed shell.
960
- Measured on Sponza, over the 7 geometries checked in S2: UE's vote calls outside 401,141 texels
961
- the 64-ray vote had inside and more than R/2 deep, 382,809 of them in nearly closed geometry 2.
962
- 102's false inside is a floor 0.338 m deep, where the 64-ray vote filled the building's air to
963
- 9.56 m. A ray from deeper than the shell inside a solid can stop where the vote's sign flips,
964
- about R/2 in, as a ray down the courtyard stops on 102's false floor. Alex chose this with the
965
- measurement before him (`ue-vote-measurement-2026-10-27.md`, in the shared notes).
966
- - A probe inside a solid reads −R, and its rays hit at their first step (§7.2, §8.4).
967
- - **Skinned (the hook).** A skinned row draws a clone geometry id.
968
- - The clone's header is an alias: it names its source's id, and readers follow it once. It is
969
- written when `obtain_clone` makes the clone, so it tracks the source through baking to resident.
970
- - The bind pose stands at the row's node.
971
- - TODO: a dense patch rebuilt from the deformed vertices replaces the alias (§17).
972
- - Until then, a character whose pose strays far from its bind pose is better left out of the SDF
973
- world. Enshrouded keeps characters out entirely.
974
-
975
- ### 6.4 The bake
976
-
977
- Geometries go through a host queue in request order. Each runs four steps:
978
- 1. **classify**: one thread per (level, brick).
979
- - It finds the exact unsigned distance from the brick's centre to the mesh with
980
- `point_query_blas_nearest`: one BLAS query a brick.
981
- - If that distance, less the brick's half-diagonal (6.06 voxels), is at least R, no texel can be in
982
- the band, and the brick is EMPTY.
983
- - Then **sign** (amended in S2): a 64-lane workgroup per classified brick. A brick classify dropped
984
- casts the 64-ray vote from its centre, a ray a lane, and its last lane writes EMPTY or EMPTY
985
- inside. A kept brick's workgroup leaves at once.
986
- - Amended in S3 (2026-10-27): sign is gone. UE's vote casts no ray where the surface is not
987
- within the band, so classify settles a brick it drops as EMPTY, as before S2.
988
- - Classify first cast the vote itself, a thread's 64 rays one after another. It then cost
989
- 1,250 µs at the median on the 4090 against 89 µs without the vote, and 2,835 µs on the iGPU,
990
- 28 ms at worst. As its own pass, sign costs 56 µs at the median on the 4090 (761 µs at p90,
991
- 919 µs at worst), and classify is back to its 89 µs. On the iGPU sign costs 1,832 µs at the
992
- median, 6.1 ms at p90 and 31 ms at worst, against the serial vote's 2,835 µs, 4.5 ms and 28 ms:
993
- there the cost is the vote's rays either way, all in the frame that admits a large geometry,
994
- and they are not budgeted.
995
- 2. **evaluate**: a thread per texel of each surviving brick, eight 64-lane workgroups a brick, as the
996
- dense build maps them.
997
- - Each texel is `blas_query_sdf`, as the dense build evaluates it: exact nearest point plus the
998
- 64-ray vote at 60%.
999
- - Amended in S3 (2026-10-27): each texel is `blas_query_band_sdf`, UE 5.4's vote (§2.3). It
1000
- takes the nearest point within the band, and only there casts 98 rays one band long, in UE's
1001
- directions; the texel is inside past 25% back faces.
1002
- - A field is two-sided while one of its holders' materials draws both sides. Then no face is a
1003
- back face, and it casts no ray at all. A change of its holders' side bakes it again at the
1004
- next bake, so holds that move within a frame bake nothing.
1005
- - A brick whose texels are all at |d| ≥ R is EMPTY after all, on the side most of its texels are.
1006
- Under UE's vote such a texel cast no ray and reads +R, so the brick is EMPTY (S3).
1007
- - Amended in S2 (2026-10-27). This revision first had one 64-lane workgroup per brick, eight
1008
- texels a lane. A lane then runs its eight texels' votes one after another, and a single brick
1009
- cost more than the 1 ms budget (see **Budget**).
1010
- 3. **store**: a slot is popped and 512 bytes written.
1011
- - As built, the frame's take hands out no more bricks than the free list has slots, so no pop
1012
- fails.
1013
- - A take held back by the slots logs itself starved. The host then doubles the atlas and the free
1014
- list, and the held bricks wait on the ring for a later frame. This replaces counting failed pops.
1015
- 4. **cards**: the layout's placement (§6.5). Capture runs per pair once the layout exists.
1016
-
1017
- **Budget.** At most `mesh_sdf_bake_budget` bricks are evaluated a frame.
1018
- - A brick costs 512 × (1 + 64) ≈ 33k BLAS queries, and what that takes follows its geometry's tree.
1019
- Under UE's vote (S3) it is at most 512 × (1 + 98), and only texels with the surface within the band
1020
- cast rays, each one band long.
1021
- - This is a time knob like `shadow_update_budget`. Its default, **23**, is set by measurement against
1022
- 1 ms a frame on the 4090.
1023
-
1024
- The mapping, measured in S2 on Sponza's largest geometry (6,722 bricks, 2,211 triangles), over its
1025
- first N bricks with timestamp queries. All three write the same bytes.
1026
- - *8 a lane*: one 64-lane workgroup a brick, eight texels a lane (this revision's first).
1027
- - *1 a thread*: a thread a texel (as built).
1028
- - *1 ray a lane*: a workgroup a texel, a vote ray a lane.
1029
-
1030
- | bricks | 4090: 8 a lane | 4090: 1 a thread | 4090: 1 ray a lane | iGPU: 8 a lane | iGPU: 1 a thread | iGPU: 1 ray a lane |
1031
- |---:|---:|---:|---:|---:|---:|---:|
1032
- | 1 | 1,208 µs | 103 µs | 36 µs | 1,705 µs | 806 µs | 1,566 µs |
1033
- | 8 | 1,564 µs | 142 µs | 142 µs | 3,481 µs | 2,107 µs | 9,062 µs |
1034
- | 64 | 2,009 µs | 234 µs | 2,374 µs | 14,425 µs | 12,690 µs | 72,844 µs |
1035
- | 512 | 4,212 µs | 2,169 µs | 23,201 µs | — | — | — |
1036
-
1037
- **The default.** Measured over Sponza's whole fill, with budgets from 16 to 192 cycled in blocks of
1038
- frames in one process, so that each saw every stretch of the fill.
1039
- - Sponza has 103 geometries, all coarsened by the cap. Its glTF merges each material's copies into
1040
- one mesh that spans the building.
1041
- - On the 4090, evaluate costs 798 µs plus 8.6 µs a brick at the median, which reaches 1 ms at 23
1042
- bricks.
1043
- - At 23, the whole fill's evaluate costs 840 µs a frame at the median, 1.66 ms at p90 and 5.3 ms at
1044
- worst. Every geometry is resident after 5,712 frames of baking.
1045
- - On the AMD iGPU, 23 bricks cost 26 ms a frame at the median and 79 ms at worst. No budget fits
1046
- 1 ms there: one brick costs 2.3 ms. A frame of 2,048 bricks outlasted the 2 s TDR and lost the
1047
- device.
1048
-
1049
- **UE's vote differs** (§2.3). It casts 98 rays, each only one band long, and counts back faces past
1050
- 25%, never on two-sided triangles. The bake keeps `GPUGeometrySDFManager`'s vote (64 unbounded rays,
1051
- 60%), which Alex has confirmed correct. If the bake measures slow, UE's band-length rays are the first
1052
- lever, since a ray that stops at the band is far cheaper than one that crosses the mesh. That changes
1053
- which points vote inside, so it would be measured against the current vote first. It is not needed to
1054
- meet the 4090's 1 ms. Whether the iGPU's cost above calls for it is put to Alex.
1055
-
1056
- **Adopted in S3 (2026-10-27).** Measured against the 64-ray vote (`ue-vote-measurement-2026-10-27.md`,
1057
- in the shared notes):
1058
- - the 4090's evaluate is the same at the median, 559 + 5.58 µs a brick against 568 + 5.54;
1059
- - the iGPU's is 16–19% less a frame;
1060
- - sign's 5.0 ms over Sponza's fill is gone.
1061
-
1062
- Alex chose it.
1063
-
1064
- **Completion.** The header turns resident, and the GI invalidates the world boxes of the geometry's
1065
- rows in the SDF world (§7.4). The CPU knows those rows from its bookkeeping (§6.1). Each box is the
1066
- row's geometry box under its entity's `Transform64`, projected as `GPUSceneRows.compute_world_bounds`
1067
- projects one.
1068
-
1069
- ### 6.5 Surface cards
1070
-
1071
- **Placement** runs on the bake's level-0 occupancy once the geometry is resident: five passes a
1072
- geometry, as built in S2 (below). It is UE's surfel clustering (§2.3) at brick granularity.
1073
- - The directions are ±X, ±Y, ±Z of local space.
1074
- - Along a direction δ, a column is a line of bricks.
1075
- - A **run start** is an occupied brick at layer z whose predecessor along δ is empty or outside the
1076
- grid. Its m is the layer after the previous run's end (0 for the first run).
1077
- - A card (δ, [k₀, k₁)) looks along δ through brick layers k₀ to k₁ − 1.
1078
- - In each column it sees the first surface after layer k₀.
1079
- - It covers a run start (z, m) when m ≤ k₀ ≤ z < k₁: nothing occupied lies between its near face
1080
- and the run.
1081
- - The search:
1082
- - Each direction starts with one card over the whole grid, its outer projection. This covers each
1083
- column's first run.
1084
- - Each further card splits a slab of some direction at the layer that newly covers the most run
1085
- starts. The split's far part covers from its own near face whatever the near part loses, so
1086
- coverage never shrinks.
1087
- - It stops when every run start is covered, or at 32 cards a geometry, UE's ceiling (its default is
1088
- 12). A run start left uncovered reads the material mean (§6.6).
1089
- - A convex mesh gets six cards; a room or an atrium gets interior slabs.
1090
-
1091
- **The layout**, per geometry.
1092
- - A card's texels are the brick grid projected along δ at level-0 resolution: one 8×8-texel page per
1093
- column, on the corner lattice of the bricks' faces.
1094
- - A page exists only where the slab holds an occupied brick in that column. The layout records which
1095
- columns those are.
1096
-
1097
- **Card sets**, one per (geometry, material) pair: the layout's contents for that material.
1098
- - Per card, a page table: one `u32` per column of the card, naming its page. As built, the tables
1099
- are the layout's, shared by every card set of the geometry, and a card set's pages are a run of
1100
- atlas tiles from `OffsetAllocator`, in the layout's page order (below).
1101
- - A header: on the device the geometry, the material's resident id, the first tile and the emissive
1102
- scale s_e; on the host the material version captured and a state (waiting for its layout,
1103
- capturing, resident).
1104
- - Pages live in the extracted 2-D tile atlas as two `rgba8unorm` planes:
1105
- - √(diffuse albedo), and depth;
1106
- - emissive / s_e, and a channel kept at 0.
1107
- - **Diffuse albedo** is albedo × (1 − metalness), Lumen's diffuse colour. It is stored as its square
1108
- root, as UE stores it, which spends the 8 bits where dark albedos need them.
1109
- - **Emissive** is divided by s_e, the largest component of the material's emissive factor. The
1110
- resident emissive textures are 8-bit, so emissive never exceeds s_e, and 8 bits under that scale
1111
- lose nothing the source had.
1112
- - **Depth** is the surface's distance from the slab's near face over the slab's depth, mapped onto
1113
- [1/255, 1]. A texel whose ray found nothing in the slab stores 0.
1114
- - A texel costs 8 B and a page 512 B, the size of one SDF brick.
1115
-
1116
- **The row → card set table**: one `u32` per `meshes` row, written by the GI on `onMeshWritten` from
1117
- the row's pair.
1118
- - It holds `GPU_DATABASE_NULL` for a row with no card set: a virtual geometry, or a pair still
1119
- capturing.
1120
- - A skinned row's pair names the source geometry, which its CPU entry keeps.
1121
-
1122
- **Capture**, one thread per page texel of a pair:
1123
- 1. A ray in local space starts at the slab's near face and runs along δ, with t ≤ the slab's depth,
1124
- through `ray_query_blas_nearest`.
1125
- 2. Alpha is treated as the path tracer treats it. A hit is opaque with probability equal to its
1126
- opacity (`sample_material_alpha`), drawn from the texel's fixed seed.
1127
- - A transparent draw continues the ray past the hit, at most 4 times (chosen). Past that the texel
1128
- is empty.
1129
- 3. At the opaque hit, `sample_material_data` runs with the material's resident row and an identity
1130
- instance transform (the card is in local space).
1131
- - Since 2026-10-30 both reads take the material's textures at the texel's footprint, trilinearly
1132
- (`mesh_card_texture_level`; the capture is `INDIRECT_TEXTURE_FILTER` `Exact`): the card's texel
1133
- steps carried along δ onto the triangle and through its uv per position, the level
1134
- log2(texels × 64), the last level for a triangle edge-on.
1135
- - Its random state is seeded from (pair, texel), which fixes its alpha draws and its dither.
1136
- - A recapture of an unchanged material therefore writes the same bytes.
1137
- 4. The texel stores √(albedo × (1 − metalness)), depth, and emissive / s_e.
1138
-
1139
- **Capture by raster** (`MeshCardCaptureMode.Raster`, the default since 2026-10-30), UE's way: the
1140
- cards are drawn rather than traced, through the material's own template.
1141
- - The trace above took one level-0 tap of the material's 64² resident copy a texel and kept it, a
1142
- point sample of a texture a card texel covers many texels of. On Sponza's outer wall the brick
1143
- texture's dark band repeats every 12.8 card texels; it landed on a row of sample points once and
1144
- between rows at its other repeats, a hard seam across the wall. It now reads the copy's mips at
1145
- the footprint (step 3), and over that wall its cards differ from the raster's by 0.77 codes on
1146
- average, against 10.5 before.
1147
- - A pair is taken whole. Its cards are drawn by one indirect draw, an instance a meshlet of each card
1148
- the layout has (a compute pass writes the count), each card into a cell of the pair's region of a
1149
- scratch: orthographic along δ, the lattice point at the texel's centre, the slab the depth range,
1150
- so the hardware clips to it as the trace's t ≤ depth does. Culling is off: a card sees either side.
1151
- - The fragment stage is the template's `material_surface` and `material_opacity`, with the material's
1152
- one bind group, the G-buffer pass's: its textures, their mips and hardware filtering, at the card
1153
- texel's footprint, unbiased (mip bias 0). Coverage is
1154
- drawn as the trace draws it, from a seed fixed per (pair, card, texel); the depth test keeps the
1155
- nearest opaque draw. A template without a surface function is traced.
1156
- - A scatter then moves each page's texels from the pair's cells into its atlas tiles, in the trace's
1157
- encoding.
1158
- - Measured on Sponza: the cards' colours agree with the trace's to 3 codes on average over the wall,
1159
- floor, ceiling and banners, without the seam or the trace's speckle. Recapturing all 154,138 pages
1160
- at the largest budget, interleaved in one process, cost the trace 9.1–12.7 ms and the raster 0.6 ms
1161
- on the RTX 4090, and 335 ms against 33 ms on the AMD iGPU.
1162
-
1163
- **Recapture.**
1164
- - `GPUResidentMaterialContext` signals when it repacks a material whose version moved (§4.1). The GI
1165
- keeps the reverse map from a material to its pairs and queues each for recapture: O(changes), E5.
1166
- - A recapture rewrites the pair's pages in place, since the layout is the geometry's and has not
1167
- changed. Until then, the pair's hits read the previous capture.
1168
- - A material that changes every frame stays queued, and its GI lags by the queue's wait. The budget
1169
- bounds what that costs. UE has the same lag, and does not notice a material change by itself
1170
- (§2.3).
1171
-
1172
- **Budget.** At most `card_capture_budget` pages a frame: new pairs first, then recaptures in the
1173
- order queued.
1174
- - A page costs 64 texels × (a ray, up to 4 alpha draws, a material sample).
1175
- - This is a time knob. Its default, 16,384 pages, is derived against 1 ms a frame on the RTX 4090, as
1176
- the bake's is.
1177
- - Sponza's 103 card sets (154,138 pages) were recaptured with the budgets interleaved frame by frame
1178
- in one process. 16,384 pages cost 0.33, 0.43 and 0.87 ms at the median in three runs, and
1179
- 32,768 went past 1 ms in all three: the cost turns steeply between the two.
1180
- - The runs shared the 4090 with another load of 40–73%, which is in the medians. The least loaded
1181
- frames cost 0.11 ms at 16,384.
1182
- - At the default Sponza's cards are captured in 10 frames.
1183
- - On the AMD iGPU (RDNA2) a page costs 0.78 µs. 16,384 pages cost 33.5 ms at the median, and about
1184
- 1,290 fit 1 ms (1,024 cost 0.80 ms).
1185
-
1186
- **As built in S2.**
1187
- - Placement is five passes a geometry, each sized by a count the host knows or by the merge's
1188
- dispatch:
1189
- - histogram, a thread a column of the six directions: run starts by (m, z);
1190
- - split, a thread a direction: its 2-D prefix sums, then its greedy splits by gain;
1191
- - merge, one thread: the six directions' splits taken by gain, the global greedy; the card records;
1192
- the page pass's dispatch;
1193
- - pages, a thread a column of every card;
1194
- - log.
1195
- - Placement runs in a range sized for any layout of the grid. Its log record gives the columns and
1196
- pages it took. On that readback the geometry gets a range of exactly that size, and the next bake
1197
- copies the layout into it and gives the placement's range back.
1198
- - The layout is then its header, the records, the column tables and the page list:
1199
- 131 + columns + pages words.
1200
- - At most 7 geometries are placed a frame, ⌊1 ms / 140 µs⌋ on the 4090.
1201
- - A recapture writes the record, s_e included, at its first page. A recapture longer than a frame
1202
- reads its pages not yet recaptured under the new s_e until it finishes.
1203
- - Measured over Sponza's fill (103 geometries, one material each):
1204
- - 12 cards a geometry at the median, 26 at most, none at the ceiling;
1205
- - 1,322 pages a geometry at the median, 154,138 in all, 79 MB of atlas; the layouts 391,055 words,
1206
- 1.5 MB.
1207
- - Placement costs 140 µs a geometry at the median on the 4090, and 220 µs on the iGPU. The split pass
1208
- is most of it, 76 µs and 130 µs: its six threads walk up to 26 steps over every layer each, 2.3 ms
1209
- at worst on the 4090.
1210
-
1211
- ### 6.6 Material at a hit
1212
-
1213
- `mesh_card_surface(p, n, row) -> surface`:
1214
- 1. The row → card set table gives the card set. If it is null, go to step 6. The row's node gives
1215
- `global`. Move p and n into local space, with the inverse taken on demand as the scene's other
1216
- readers take it.
1217
- 2. Take the directions that face the surface. For each axis a with n_a ≠ 0, the cards look along
1218
- −sign(n_a)·e_a, with weight n_a², as UE weighs them.
1219
- 3. Scan each such direction's cards, which are sorted by near layer and number at most 32 in all. A
1220
- card passes when both hold:
1221
- - its slab holds p's depth within the tolerance τ;
1222
- - its nearest texel's stored depth is within τ of p's depth.
1223
- Otherwise the card sees something in front of p.
1224
-
1225
- τ is 1.5 voxels of the hit's cascade, in local units, plus one LSB of the slab. That is UE's bias
1226
- for a global-SDF hit, three half-voxels of its clipmap (§2.3). A hit is only as exact as its
1227
- cascade, so the tolerance must grow with it.
1228
- 4. Within a direction, the passing card nearest the surface serves.
1229
- 5. Each serving card gives its nearest texel, and the texels are blended by n_a², as UE blends every
1230
- card that passes:
1231
- - diffuse albedo, Σ w·(stored)² / Σ w;
1232
- - emissive, Σ w·stored·s_e / Σ w.
1233
- - One texel a card is enough: UE's four depth-weighted taps per card smooth what a probe's many
1234
- rays average anyway.
1235
- 6. With no passing card, or no card set (a virtual geometry, a plugin's object, a pair still
1236
- capturing), the hit takes the **material mean**:
1237
- - diffuse: the colour factor × the albedo slot's mean × (1 − the metallic factor × the ORM slot's
1238
- mean metalness);
1239
- - emissive: the emissive factor × the emissive slot's mean.
1240
- `GPUResidentMaterialContext` computes a slot's mean when it writes the slot: one reduction per
1241
- upload (§4.1).
1242
- - UE has no such fallback: a hit no card sees is opaque black. Here it keeps the bounce's energy
1243
- roughly right.
1244
-
1245
- Cost per hit:
1246
- - one table entry, one row and one node read;
1247
- - for each facing direction, a scan of its cards with a page and two texel loads per candidate;
1248
- - no material evaluation.
1249
-
1250
- As built in S2, `mesh_card_surface(card_set, p, n, tolerance)` takes the card set and p and n in
1251
- local space: steps 2–5. The table lookup and the move to local space are the caller's: the
1252
- MeshCards debug view's now, the hit shading's in S4. The material mean (step 6) comes with S4.
1253
-
1254
- **As built (S4d, 2026-10-28)**:
1255
- - **Steps 1 and 6** are `probe/chunk_probe_clipmap_hit_surface.js`, around `mesh_card_surface`. The
1256
- row's node moves the hit into local space by `mat4_inverse`, the normal by the transpose of the
1257
- node's linear part, and the tolerance by the transform's mean scale, the cube root of its
1258
- determinant. Row 0 names no object and is black.
1259
- - **The texture slots' means** are `material/resident/shader_resident_texture_means.js`, two passes
1260
- `GPUResidentMaterialContext.build_textures` records after it writes the slots: a lane a row of a
1261
- slot's 64, then a thread a slot over its rows. No thread reads more than 64 values. The means are
1262
- stored as the atlas stores texels, so an albedo's is premultiplied by its coverage and `rgb / a` is
1263
- the mean colour of what the slot covers. They live in `buffer_texture_means`, which doubles when the
1264
- slots outgrow it.
1265
- - The four layout constants moved out of the material context into
1266
- `RESIDENT_MATERIAL_TEXTURE_LAYOUT.js`, which the passes and their readers share.
1267
- - On the device, Sponza: 70 slots, each mean within 7.5e-7 of the atlas read back and averaged on
1268
- the CPU, on both GPUs.
1269
-
1270
- ## 7. The brick clipmap
1271
-
1272
- ### 7.1 Cascades
1273
-
1274
- - `C_b` cascades (`brick_cascade_count`, default 6), each a window of `N_b³` cells
1275
- (`brick_cascade_resolution`, a power of two, default 32).
1276
- - A cell is one brick of 7 voxels a side. The voxel is `h_c = h₀·2^c` (`brick_voxel_size` = h₀,
1277
- default 0.125 m).
1278
- - At the defaults, a cell is 0.875 m at cascade 0, and the windows span 28, 56, 112, 224, 448 and
1279
- 896 m.
1280
- - h₀ = s₀/8 puts eight voxels across one probe cell at the probe defaults (§8.1).
1281
- - **Placement** is `ClipmapWindowSet` (size `N_b`, step 1, hysteresis 0.1 cells, the fluid's
1282
- value), coarsest first. Each child is held inside its parent by `clamp_corner` with a one-parent-
1283
- cell margin, so a ray leaving cascade c is inside c + 1.
1284
- - As built (S3): the fluid's `FluidCascadePlacement` already composed these with a jump that
1285
- re-places the stack, and is generalised into `ClipmapCascadePlacement` (`core/geom/clipmap/`)
1286
- with the step, hysteresis and margin as options. The fluid keeps its own.
1287
- - **Storage wraps**: a cell sits in storage cell `cell mod N_b` (a mask, since `N_b` is a power of
1288
- two). Cascades stack along z of one texture.
1289
-
1290
- ### 7.2 Brick layout and encoding
1291
-
1292
- - **Lattice.** 8³ texels sit on the corners of the brick's 7³ voxels. Texel `t` is at voxel
1293
- `7·cell + t`; texel 7 duplicates the next brick's texel 0, and every brick computes all 512 of its
1294
- own.
1295
- - **Lookup.** The atlas texel coordinate is `slot_origin + 0.5 + (p − cell_min)/h`, with the local
1296
- part in [0, 7]. Hardware trilinear never reads a neighbouring slot, and there is no border pass.
1297
- This is the UE page, and the mesh bricks use the same lattice (§6.3).
1298
- - **Value.** Signed, `e = saturate(d/(2R) + 0.5)` with R = 4 voxels of the cascade, decoded
1299
- `d = 2R·(e − 0.5)`.
1300
- - One LSB is 0.031 voxel, UE's precision.
1301
- - `r8unorm` storage writes need `texture-formats-tier1`, which is on `SHADE_DEVICE_FLOOR`.
1302
- - **Hit.** A ray hits when d < ½ voxel of its cascade (UE's surface expand), so a wall thinner than a
1303
- voxel stays a hit.
1304
- - **Normal.** The field's gradient, which points out of the solid.
1305
- - **Inside.** A ray that starts inside a solid reads a negative distance at its first step and hits
1306
- there at once. §8.4 counts it as a back face.
1307
- - **Filter precision.** RDNA2 rounds bilinear weights to 1/256 (measured in the octahedral skirt
1308
- work). Neighbouring texels differ by at most one voxel (|∇d| ≤ 1), so the weight error is at most
1309
- 1/256 voxel per axis, about 1/128 of the hit threshold.
1310
-
1311
- ### 7.3 Storage
1312
-
1313
- **Brick map**: `r32uint`, `N_b × N_b × N_b·C_b`. Each entry is one of:
1314
- - `0xFFFFFFFF`, **UNKNOWN**: not classified since entering the window. The engine's null.
1315
- - `0x80000000`, **EMPTY**: no surface within one voxel of the cell, outside.
1316
- - `0x80000001`, **EMPTY inside** (S3): the same, inside a solid, on the side most of its staged
1317
- texels are on, as a mesh brick's EMPTY is (§6.3). A ray that starts in one hits at once (§7.2).
1318
- - a slot index.
1319
- - Between free and store only, `0x80000002` marks a cell that needs a brick and holds no slot yet.
1320
-
1321
- **Occupancy**: `r32uint`, `(N_b/4)³` blocks per cascade, stacked. The value is the count of the
1322
- cells among a block's 64 that a ray must read: bricks, UNKNOWN and EMPTY inside. 0 means the tracer
1323
- may skip the whole block.
1324
- - As built (S3): blocks are in storage space, as the map is. A window whose corner is not a
1325
- multiple of 4 puts cells of both its ends in one block, so a ray clamps a block it skips to the
1326
- window. The window's step stays one cell.
1327
-
1328
- **SDF atlas**: the `GPUTileAtlas3D` above, usage `TEXTURE_BINDING | STORAGE_BINDING`, sampled with
1329
- a linear clamp sampler.
1330
-
1331
- **Object cells**: a storage buffer, 64 `u32` per slot. Entry i is the `meshes` row whose surface is
1332
- nearest in the brick's i-th 2³-texel cell.
1333
- - 0 means none: row 0 is the scene root and never a mesh.
1334
- - 256 B a slot, against (a)'s 1 KiB material lattice.
1335
-
1336
- ### 7.4 What is built each frame
1337
-
1338
- **Pending cells** live in a persistent FIFO ring of *storage* cell keys (cascade + storage index,
1339
- one word). A per-storage-cell pending bit keeps any key from appearing twice.
1340
- - A taken key builds whatever world cell its storage slot holds at that moment, decoded from the
1341
- current window. A cell that scrolled out while queued is therefore never built, and the cell that
1342
- replaced it is.
1343
- - The ring never holds a stale entry, and it holds every key at most once: `C_b·N_b³` words, 768 KiB
1344
- at the defaults.
1345
- - As built (S3): the ring's capacity is the power of two at or past `C_b·N_b³`, 1 MiB at the
1346
- defaults, because its head and tail run on through the u32 wrap and a power of two divides it.
1347
- - A key's pending bit clears in free, once the key is off the ring and built, so a mover may
1348
- queue it again.
1349
-
1350
- The ring is fed by three passes:
1351
- - **scroll** (one thread per exposed cell, a count the host knows from its scroll boxes):
1352
- - the storage cell's old entry, if a slot, is pushed to the free list (the cell it belonged to has
1353
- left the window);
1354
- - the entry becomes UNKNOWN;
1355
- - the key is queued unless already pending.
1356
- - **movers** (one thread per (occupancy block, box) pair):
1357
- - The boxes are:
1358
- - the scene mover list's, except boxes flagged skinned, because skinned rows stand in bind pose
1359
- (the hook: a dense patch reads them, §6.3);
1360
- - the GI's invalidation list: boxes the host writes on bake completions (§6.4) and on SDF
1361
- membership changes it sees through `onMeshWritten`, and boxes providers append on the device
1362
- (§7.6).
1363
- - Every box is expanded by that cascade's R.
1364
- - The resolution cull: a box whose diagonal is under half a voxel of the cascade is skipped there.
1365
- - An overlapping pair tests the block's 64 cells (bounded) and queues the overlapped ones.
1366
- - The pair count is blocks × boxes: it follows the box count and never a box's footprint (E3).
1367
- - Past 1,024 boxes (UE's full-recapture threshold), or a list past its capacity, every key of every
1368
- cascade is re-queued instead: one pass over the keys rather than millions of pairs.
1369
- - As built (S3d):
1370
- - The movers' command, one thread, reads the scene list's count and the host's row count and
1371
- writes either the pair dispatch or the full re-queue's, into a buffer of its own that the
1372
- movers and the re-queue only read.
1373
- - A pair skips a pose-only box (bind pose, the hook). Otherwise it tests its block's 64 cells
1374
- against the box grown by the cascade's band, each cell's world position decoded from the
1375
- window.
1376
- - A row's box is read from the `meshes` table on the device. The host's list comes from the GI's
1377
- holds: rows drawing a geometry whose bake completed, and rows whose SDF membership changed.
1378
- They are rows as the tables address them, `row_of_entity`, not entities.
1379
- - Entities were what the first device run listed. Every completion rebuilt its neighbour's
1380
- box, and the cells inside Sponza's hull were never rebuilt. Once that was fixed, a build
1381
- made under caches turning resident is the same, cell by cell, as one made after they are
1382
- all resident.
1383
- - A fresh clipmap skips the movers: its scroll has queued every cell. On the iGPU, the first
1384
- frame's joins and movers, about 200 boxes the size of Sponza, had cost 35.8 ms for nothing.
1385
- - Measured on Sponza: the movers cost 3 µs at the median on the 4090 (25 µs at p90, 524 µs at
1386
- worst), and 1.6 µs on the iGPU (0.85 ms at worst). An idle frame of the whole build is now
1387
- 19–20 µs on the 4090 and 13–14 µs on the iGPU.
1388
- - **take** (one thread): `n = min(count, brick_build_budget)` keys leave the ring, each with a build
1389
- slot 0..n−1, and the indirect commands are written.
1390
- - As built (S3): no more than the free list's slots, as the mesh bake's take is (§6.4). Every cell
1391
- taken may need a slot, so no pop of store's fails. A take held back counts itself starved,
1392
- and the host grows the capacity on the readback. That replaces counting failed pops and
1393
- re-queueing their cells.
1394
- - The commands, with the taken keys' first and count, go into a buffer of their own that every
1395
- later pass binds read-only and dispatches through. A dispatch that writes a buffer cannot use
1396
- it as its indirect command, and free and store write the free count (the device's validation
1397
- refused the first build, which kept them in one buffer).
1398
-
1399
- ### 7.5 The build
1400
-
1401
- - **clear**: one thread per staging word. Distance bytes become 255 (+R) and objects 0.
1402
- - **providers**: in registration order, each its own indirect passes over the taken keys (§7.6).
1403
- - **free**: one thread per taken key.
1404
- - `needs_brick` holds when some staged texel has |d| < 1 voxel. Every point of the cell is within
1405
- √3/2 voxel of a texel and |∇d| ≤ 1, so a surface crossing the cell always qualifies.
1406
- - If not, and the entry is a slot, the slot is pushed and the entry becomes EMPTY.
1407
- - As built (S3): a workgroup a taken cell, eight texels a lane. Its last lane decides, so that
1408
- nothing waits on a barrier. A cell that needs no brick becomes EMPTY on the side most of its
1409
- 512 texels are on. One that needs a brick and holds no slot is marked for store.
1410
- - **store**: one workgroup per cell that needs a brick.
1411
- - The slot is the existing one, or one popped by lane 0.
1412
- - A failed pop leaves the entry UNKNOWN, counts the failure for growth, and re-queues the cell.
1413
- - Otherwise the 512 staged bytes go into the atlas tile and the 64 objects into the object cells.
1414
- - As built (S3): a workgroup a taken cell. Lane 0 pops for a marked cell, writes the entry, and
1415
- hands the slot to the other lanes through `workgroupUniformLoad`. Take's bound keeps every
1416
- pop from failing.
1417
- - **coarse**: one thread per occupancy block of every cascade touched this frame. It counts the
1418
- block's non-EMPTY cells.
1419
- - As built (S3): a 64-lane workgroup per taken cell recounts that cell's block, a lane a cell,
1420
- sized by take's count. Scroll marks each block it exposes as all to read until its cells are
1421
- built, since a ray must not skip a cell it should read from the next cascade out. A first
1422
- version counted every block of every cascade each frame: 31 µs a frame on the iGPU with
1423
- nothing to build.
1424
- - Measured on Sponza with no provider yet, so every cell builds EMPTY. On the 4090 the first
1425
- frame costs 332 µs, scrolling all 196,608 cells in 52 µs, and a frame of the fill (4,096
1426
- cells) 31–42 µs. An idle frame costs 10–14 µs, the floor of five passes. On the iGPU the
1427
- first frame costs 3.45 ms, a fill frame 545–562 µs, an idle one 7–19 µs. In the fill a block
1428
- is recounted by each of its taken cells, about 140 µs of the iGPU's frame. Store's first
1429
- dispatch costs 249 µs on the 4090 and 2.06 ms on the iGPU, once.
1430
-
1431
- Frees run before pops (separate dispatches), because a stack under concurrent push and pop is not
1432
- safe. The staging is 768 B a build slot: 3 MiB at the default budget.
1433
-
1434
- ### 7.6 The provider contract
1435
-
1436
- This is the plugin system.
1437
-
1438
- **Host interface.**
1439
- - A `BrickProvider` has one method, `graph_write_bricks(graph, ctx)`.
1440
- - `GPUProbeClipmapGI.add_provider(provider)` and `remove_provider(provider)` manage the list.
1441
- - The mesh provider is registered first and is written against the same contract as any plugin.
1442
- - `ctx` holds:
1443
- - the taken keys, with their count on the device;
1444
- - the brick levels uniform;
1445
- - the staging: distance bytes and objects;
1446
- - the scene database and the TLAS;
1447
- - the invalidation append list.
1448
-
1449
- **The contract.**
1450
- 1. **Ownership.** A workgroup owns whole taken cells: within a pass no two threads touch the same
1451
- staged word, and there are no atomics.
1452
- 2. **Distance.** Per texel, `staged = min(staged, q(d))`, where q is the shared encoding of §7.2.
1453
- - q is monotone, so the minimum of the bytes is the byte of the minimum.
1454
- - The order of providers cannot change the result.
1455
- 3. **Object.** Per 2³ cell, the provider's row at its own nearest texel replaces the staged object
1456
- when the provider's cell minimum is below the staged cell minimum (taken from the staged bytes
1457
- before its own merge). Ties keep the staged object.
1458
- 4. **Safety.** A provider may under-estimate distance, which only slows steps. It never
1459
- over-estimates, which would tunnel.
1460
- 5. **Objects are `meshes` rows**, so a hit finds its material through the row (§6.6). A provider that
1461
- cannot name a row writes 0, and its hits take the material of no row: zero albedo, counted.
1462
- 6. **Invalidation.** When a provider's data for a region changes (streaming, an edit), it appends that
1463
- world box to the invalidation list, and next frame's movers queue the cells.
1464
- 7. **Retry** (S9). A provider that could not finish a cell this frame sets the cell's retry word in the
1465
- staging (`BRICK_STAGING_RETRY_WORD`). Free decides the cell as any other, keeps its key pending and
1466
- puts it back on the ring, so the cell is built again from the start. What was merged meanwhile is
1467
- published for those frames, and can over-estimate where the unfinished work would have read lower;
1468
- the frame that finishes the cell keeps contract 4.
1469
-
1470
- A provider sees every taken key. One with nothing in a cell writes nothing and pays only its own
1471
- early-out.
1472
-
1473
- ### 7.7 The mesh provider
1474
-
1475
- **Superseded in part by S9 (2026-10-29).** Alex, after S8's holes: "Go with (b): batches of 64 spread over
1476
- workgroups by count, with an atomic min/max merge." The cap of 64 instances below bound from cascade 2 on Zorah, and
1477
- the instances it kept were the lowest rows, in load order: the ground under the greenhouse's ring lost to the kitbash
1478
- numbered before it. The accept, the per-instance level and the texel stand; the cap, and one workgroup doing all of a
1479
- cell, are gone. What is built now is "As built (S9)", at the end of this section.
1480
-
1481
- `MeshSDFBrickProvider` runs one 64-lane workgroup per taken key.
1482
-
1483
- **Gather.** A TLAS cooperative query with box = cell ⊕ R_c. The leaf accept takes a row when all of
1484
- these hold:
1485
- - `MESH_FLAG_SDF_WORLD` is set;
1486
- - its geometry's header is resident (a skinned clone resolves through the alias, the hook);
1487
- - the resolution cull passes: box diagonal ≥ ½ voxel of the cascade.
1488
- - UE uses max(20 cm, ½ voxel) and exempts emissive objects.
1489
- - Here an emissive object under the cull is out of that cascade. The ½-voxel hit would fatten it
1490
- into a lamp a voxel wide, and at cascade 5 that is 4 m.
1491
- - the geometry is not virtual (a virtual geometry has no header).
1492
-
1493
- **Per instance**, up to 64 in LDS:
1494
- - the inverse of `node.global`, and the instance's world box;
1495
- - σ, the smallest axis scale. It is exact for uniform scale and an under-estimate otherwise (contract
1496
- 4). UE scales the same way;
1497
- - the level `k = clamp(⌊log₂(h_c / (σ·v))⌋, 0, L − 1)`, the coarsest level no coarser than the
1498
- cascade.
1499
- - Inside the clamp, its voxel is within a factor of two of the cascade's, and its band
1500
- (4·σ·v·2^k) reaches past half the cascade's (4·h_c).
1501
- - Past a level's band a sample under-estimates (contract 4). That only slows steps, and it can only
1502
- keep `needs_brick` true, never make it false.
1503
- - At the clamps: an instance scaled up past the cascade's voxel samples level 0, coarser than
1504
- asked. A small geometry at a far cascade samples its one-brick level.
1505
- - Past 64 instances, the rest are dropped and counted; a dropped instance can leave a hole. UE loops
1506
- its LDS passes instead and has no cap, at the price of an unbounded loop. The cap is chosen: coarse
1507
- cascades are where it binds, since a 28 m cell at cascade 5 holds every instance over the 2 m cull.
1508
- The LDS is 64 × 64 B.
1509
-
1510
- **512 texels**, eight a lane: the minimum over instances of `σ·mesh_sdf_sample(header, k, M⁻¹·p)`,
1511
- with the argmin row.
1512
- - First the box test, UE's early-out: an instance whose box lies farther than the texel's running
1513
- minimum is skipped. At coarse cascades most of the 64 are only this test.
1514
- - `mesh_sdf_sample`:
1515
- - outside the grid box, the distance to the box plus R. This is a true lower bound: the surface lies
1516
- inside the bounds, which lie at least R inside the box. UE instead adds the box distance to the
1517
- SDF at the clamped point;
1518
- - in an EMPTY brick, ±R by its side (§6.3);
1519
- - in a slot, one hardware trilinear tap of the mesh brick atlas.
1520
- - Per 2³ cell, the argmin row of the cell's nearest texel. Then the merge (contract 2 and 3).
1521
-
1522
- **Cost.** Per texel, at most 64 box tests, plus one 3×4 transform, one indirection load and one tap
1523
- for each instance the box test lets through. It follows the instances in the cell, never a triangle
1524
- count (E3), and no triangle is read.
1525
-
1526
- **As built (S3c).**
1527
- - The box test skips an instance whose box lies farther than the running minimum, or than zero
1528
- when the minimum is inside a solid. So a deeper overlapping instance is still sampled, and the
1529
- minimum does not depend on the order of the instances. Under non-uniform scale it can skip an
1530
- instance whose under-estimated sample would have been lower; the result still never
1531
- over-estimates (contract 4).
1532
- - Objects: a 2³ cell's four lanes fold their texels into workgroup atomics, and the last of them
1533
- decides, so that no lane waits on another.
1534
- - One word holds the nearest texel's byte and its row, the lowest row on a tie.
1535
- - Rows fit in 24 bits; a row past them names no object (contract 5).
1536
- - The kernel's barriers keep it off the emulator, as the cooperative query is kept. Its parts are
1537
- tier-2 pure chunks: the leaf filter, the instance, the texel, the object bid. The texel is held to
1538
- the analytic distance of two scaled spheres within the field's error, and to the least of what
1539
- each instance reads alone. The glue is checked on the device.
1540
- - Measured on Sponza (S3c), the bricks made again once every cache was resident:
1541
- - The 4090: the provider costs 58 µs at the median a frame of 4,096 cells, 166 µs at p90, 315 µs
1542
- at worst; a frame of the fill totals 80–350 µs.
1543
- - The iGPU: 1.06 ms, 2.1 ms and 6.1 ms; a frame of the fill 1.6–6.7 ms.
1544
- - All 196,608 cells are built in 48 frames, 5,315 of them with a brick (5,311 on the iGPU, whose
1545
- bilinear weights round to 1/256), within the initial capacity of 12,288.
1546
- - 58 instances were dropped past 64 in a cell, the gather itself dropping nothing.
1547
- - With EMPTY signed (§6.3) the building's air reads inside its open hull (geometry 102), so the
1548
- courtyard is solid in the world bricks too; put to Alex.
1549
-
1550
- **As built (S9, 2026-10-29)**: every instance whose box comes within the band of a taken cell is sampled there,
1551
- however many. Three passes, each sized by a count on the device, and a one-thread command
1552
- (`shader_mesh_brick_command`) before each but the walks from the root:
1553
- - **Walk** (`shader_mesh_brick_gather`), a workgroup a walk: the cooperative query's level-at-a-time expansion and
1554
- the S3 accept, nothing dropped.
1555
- - A level keeps 512 nodes (`MESH_BRICK_FRONTIER`); the children past them leave in task records of 64 nodes, each
1556
- walked by the next round of the same kernel. On Zorah's TLAS (12,608 leaves, 27 levels) the widest level of a
1557
- cell's walk is 758 nodes at cascade 2 and 2,410 at cascade 5, so every walk ends in its third round.
1558
- - Rounds start at two and grow by one, from a read-back count, when the last round could not finish a walk, up to
1559
- six: enough for any LBVH of 2^20 leaves.
1560
- - Accepted rows leave in batch records of 64, and the rows left when the walk ends in one more.
1561
- - An iteration of a level costs the workgroup a barrier and a `workgroupUniformLoad`: lane 0 hands every lane
1562
- where the next iteration's children start and, on the level's last, how many nodes the next level holds.
1563
- - **Batch** (`shader_mesh_brick_batch`), a workgroup a record: its rows sorted, its 64 instances in workgroup memory,
1564
- 512 texels × 64 as S3c samples them, merged into the cell's accumulators by atomic maxima of 255 less the byte and
1565
- of the object key.
1566
- - **Resolve** (`shader_mesh_brick_resolve`), a workgroup a cell the walks listed: its accumulators into the staging
1567
- by contracts 2 and 3. A walk lists a cell the first time it flags it, for a batch made or a walk left unfinished;
1568
- most taken cells have no instance within their band and cost the walk from the root alone.
1569
- - Contract 7: a record past its capacity, a level past the budget, or nodes past the last round's frontier leave the
1570
- cell unfinished. It is retried, and the capacity grows to the power of two at or past the most asked for, or the
1571
- rounds by one. Of the records a walk asks for at once, those below the capacity are kept, so a command never
1572
- dispatches a record that went unwritten.
1573
- - The texel compares in the staged byte, the lowest row on a tie, so the object no longer depends on the order of
1574
- the instances; the batch sorts its rows, so a cell of one batch reads the same whatever order its walk found them.
1575
- - **Device, Zorah** (S8's load, filled; from the aerial view, the GI released and all 196,608 cells of the windows
1576
- built again, 60 frames; S8's provider against this one in one process, old/new/new/old), with the frontier at 1,024
1577
- nodes as S9 landed it; the 512 it has now follows below:
1578
-
1579
- | | iGPU, S8 → S9 | 4090, S8 → S9 |
1580
- |---|---|---|
1581
- | brick view's hole pixels | 10,323–10,379 → 41 | 10,071–10,078 → 36 |
1582
- | the provider over the rebuild | 76.9–78.0 → 132.6–132.7 ms | 7.2–7.6 → 16.0–18.1 ms |
1583
- | walk / batches / resolve | 71.4 / 56.4 / 4.7 ms | 5.5–7.6 / 8.5–9.7 / 0.5–1.1 ms |
1584
- | a frame of it, p50 / p90 / worst | 1.15 / 2.0 / 6.4 → 1.08 / 3.6–4.2 / 17.7 ms | 0.09–0.11 / 0.17 / 0.9–1.4 → 0.20 / 0.65–0.73 / 1.3–1.4 ms |
1585
- | at rest | 1.4–1.5 → 8.5–11.2 µs | 3.1 → 16.4 µs |
1586
- | rebuild against rebuild, bricks that differ | 276 → 0 | 254 → 363 |
1587
-
1588
- - The 36–41 pixels left are the dome's lattice, thinner than cascade 2's voxel: S8's open item, not the cap. The
1589
- ground cells under the ring, S8's open item, were the cap.
1590
- - Nothing retried, no record past a capacity, and every walk ended in the second round: at most 1,431 batches and
1591
- 220 tasks a frame (iGPU), within the initial capacities; at most 805 cells a frame listed for the resolve.
1592
- - Where the cost goes, from one-off arms in the same process with a part of the work deleted:
1593
- - The batches sample every instance: 257,216 rows a rebuild, where S8's provider dropped 37,780 instances past 64
1594
- a cell, 93,700 candidates past 128 and 43,500 frontier nodes, so sampled at most 125,736. The batch pass with
1595
- nothing sampled costs 0.3–0.5 ms, and its sort 1.7 ms on the iGPU (within the noise on the 4090).
1596
- - The walk's floor, a workgroup a taken cell with its memory cleared and its task read, is 25.8 ms on the iGPU and
1597
- 0.67 ms on the 4090; S8's walk alone, 48.3 and 2.3–2.4 ms. On the 4090 this walk cut to what S8's walked (256 nodes
1598
- a level, the rest dropped) costs 3.3 ms against its 4.4 ms: 1.1 ms is walking every node, and the 0.9–1.0 ms
1599
- left over S8's walk is this walk's own bookkeeping, not split further.
1600
- - Alex asked for a hand-off once a window of four iterations instead of each, the spill and the ring sized for the
1601
- window (11.3 KiB of workgroup memory against 9.1). Measured paired against this walk, it was no faster on the 4090
1602
- (the walk's median 4.36 against 4.29 ms, six trials each) and 27% slower on the iGPU (90.3 against 71.3 ms, four
1603
- each); a window of two was 4% slower there. Not taken: on the iGPU the walk slows with its workgroup memory.
1604
- - So the frontier went the other way, halved to 512 nodes (Alex, on this measurement): the walk's workgroup memory
1605
- from 9.1 to 5.1 KiB. Paired against 1,024 (four trials each on the iGPU, six on the 4090, after a warm-up whose
1606
- rounds grew to three, retrying 8 and 2 cells; none after): on the iGPU the walk 71.1 → 43.0 ms, the provider
1607
- over the rebuild 132.0 → 104.1 ms, its median frame 1.08–1.27 → 0.65 ms; on the 4090 the rebuild even (17.9 ms
1608
- by the mean). At rest the third round's command and walk add 2.0 µs on the iGPU and 4.1 µs on the 4090 (9.8 and
1609
- 20.5 µs); batches 7,077 → 7,331, the rows of a cell split over more walks; holes alike. Against 1,024, 30 of the
1610
- iGPU's 2,684 bricks differ, the texel's box test being order-dependent for a one-sided field, as before.
1611
- - At rest, seven dispatches (three one-thread commands, and four passes dispatched with no workgroup) against S8's
1612
- one.
1613
- - Tier 2: free's retry (red on S8's free), the resolve's contracts, list and tail, the command, the walk's spill
1614
- partition, the texel's order-free row (red on S8's chunk). Tier 3: the provider's dispatches and their commands, its
1615
- buffers and their growth. The walk and the batches run on the device only. 25 of 25 mutants were killed.
1616
-
1617
- **TODO hook (skinned).** The leaf accept, where a clone resolves to its source's header, is where a
1618
- dense animated patch takes over later.
1619
-
1620
- ### 7.8 The virtual geometry provider
1621
-
1622
- **Superseded by S8 (2026-10-29).** Alex, after S7e: "Move vgeo to per-geometry signed SDF caches (baked once from a
1623
- cut, composed per instance) instead of fixing the per-cell sign in place." The per-cell provider below (S7b–S7e) is
1624
- deleted; S7a's cut refactor and S7e's measurements stand. What is built now is "As built (S8)", at the end of this
1625
- section.
1626
-
1627
- S7, its own stage. vgeo rows are mesh rows with a virtual geometry: their BLAS is empty and they have
1628
- no cache. The provider, per taken key:
1629
- 1. **Gather**: the mesh provider's TLAS query, accepting vgeo rows in the SDF world only.
1630
- 2. **Cut**: a box cut of each instance's cluster DAG. It takes clusters whose error is under ½·h_c
1631
- and whose bounds overlap cell ⊕ R. This is the §4 traversal of `VIRTUAL_GEOMETRY_DESIGN.md`, with
1632
- an error bound in world units in place of the pixel threshold.
1633
- - Where children are not resident, the resident ancestor serves, and a want is recorded.
1634
- - When the finer pages arrive, the provider appends the cell's box to the invalidation list
1635
- (contract 6).
1636
- 3. **Distance**: 512 texels per cell against the cut's triangles, up to 256 in LDS (chosen; past it,
1637
- dropped and counted).
1638
- - The sign comes from the nearest triangle's orientation.
1639
- - A texel it cannot sign is written unsigned. That is positive, so the ½-voxel hit still catches
1640
- the surface.
1641
- 4. **Objects**: the vgeo row. Hits take the material mean (§6.6) until vgeo has cards, which belong to
1642
- its own plan.
1643
-
1644
- Triangle work here happens per requested cell (scroll-ins and changes), never per frame. It is bounded
1645
- by the cut error: at error ½·h_c, a cell's triangles number on the order of its surface in voxels.
1646
-
1647
- **What the vgeo runtime gave, read before building (2026-10-29)**. The plan and its choices are the shared
1648
- note `s7-vgeo-provider-plan-2026-10-29.md`; Alex took them with "make sure we don't introduce more
1649
- coupling. If you need to refactor how wants work - please do so. Keep performance in mind. [...] DRY and
1650
- KISS".
1651
- - vgeo rows are ordinary `meshes` rows in the scene's TLAS with an empty BLAS;
1652
- `geometries[row.geometry].vg_row` names the geometry's root page and page lookup.
1653
- - The cut's rule was fused into the camera's traverse kernel with its drawing, claims and subgroup
1654
- scheduling, so no other kernel could call it.
1655
- - Wants reach the residency only through the frame's feedback slots, whose reading wrote a view's fields.
1656
- - No event says a page arrived: counters and lookups only.
1657
- - A cut of its own a lane a pair would be a per-thread loop of hundreds of steps. The camera cut's wavefront
1658
- is the engine's answer to that (one item a lane a step, a uniform step bound, spills between dispatches),
1659
- so the box cut runs it rather than a walk of its own.
1660
-
1661
- **As built (S7a, 2026-10-29)**: the vgeo runtime, refactored so that a box cut runs the camera cut's rule and
1662
- descent, the camera's cut unchanged.
1663
- - `chunk_vgeo_cut_rule`: the cut's items and expansions, and the rule as two verdicts, `vg_cut_node` and
1664
- `vg_cut_cluster` (`VGEO_CUT_VERDICT`). The importing kernel defines the two culls,
1665
- `vg_cut_sphere_outside` and `vg_cut_box_outside`.
1666
- - `chunk_vgeo_cut_descent`: what the descent does with a verdict (counts, wants, claims, the walk up) and the
1667
- wavefront's scheduling, with one more hook, `vg_cut_take`, for what taking a cluster means. The camera's
1668
- traverse is that chunk with its frustum culls and `vg_draw`.
1669
- - `graph_vgeo_cut_descent`: the seam's seed, wavefront and tail, for any cut's instance records and traverse
1670
- kernel. The seam keeps its collect.
1671
- - `vg_want` and `vg_stat` are chunks of their own, so a kernel that only counts binds no claim buffer.
1672
- - A feedback slot is any `VGeoFeedbackConsumer`'s: the loop hands each consumer its slot's counters through
1673
- `read_feedback`, and whether it shared the slot. `VGeoViewCut` implements it. The runtime fills a slot's
1674
- own params (`fill_slot_params`) for any cut.
1675
- - Measured on Zorah (the textured UE rebuild: 1,012 containers, 12,597 instances) from its manifest's camera
1676
- at 1280×720. Blocks of frames alternated the refactored traverse with the one before S7a, for every cut of
1677
- the frame, once after the rule moved and again after the descent did.
1678
- - The frame view's counters are the same between the arms on both GPUs: 32,618 clusters drawn, 163,353
1679
- items, 10,822 pages, 144,079 clusters tested once settled. The 4090's second run held 118 clamped
1680
- clusters in both arms.
1681
- - The traverse's 80 dispatches a frame cost the same: p50 0.96 and 1.00 of before on the RTX 4090
1682
- (1.93 against 1.92 ms the second time), 0.99 and 0.98 on the AMD iGPU (4.19 against 4.27 ms).
1683
- - Tier 2: the cut agreement spec runs the traverse on the emulator unchanged. `VGeoFeedbackLoop.spec.js`
1684
- adds a consumer that is not a view (handed its counters, told when it shared its slot) and a slot's params
1685
- against the feedback's wire layout, which no spec held before. `graph_vgeo_expand.spec.js` counts the
1686
- wavefronts a cut records and encodes a cut's graph on the software device, which no spec did either.
1687
- 20 of 20 mutants were killed.
1688
-
1689
- **As built (S7b, 2026-10-29)**: the gather and the box cut, which leave each taken cell a list of the clusters in
1690
- its cut. Not registered yet: S7c's distance pass is the lists' reader.
1691
- - `VGeoBrickProvider`, a `BrickProvider` with a `VGeoViewCut` of its own. In a frame the scene's vgeo runtime has
1692
- open, it records the gather, the claim widths scanned by `graph_prefix_scan_csdldf`, and `graph_vgeo_cut_descent`
1693
- with `shader_vgeo_brick_traverse`, then publishes the cut's feedback: its wants reach the residency through its
1694
- own slot, ranked with the camera's, and its counters come back to its cut. With no runtime, or none open, it
1695
- records nothing.
1696
- - `shader_vgeo_brick_gather`: a 64-lane workgroup a taken cell, dispatched by take, running the mesh provider's
1697
- cooperative TLAS query over the cell ⊕ R.
1698
- - `vgeo_brick_accept` takes a row in the SDF world whose geometry is virtual and whose box is at least ½ voxel
1699
- across.
1700
- - `vgeo_brick_pair` writes a cut's instance record for each. k is σ_max/(½·h_c), the distance weight is 0 and
1701
- there is no eye, so `vg_project` is the world error over ½·h_c against τ = 1. The cell ⊕ R box, taken into the
1702
- instance's object space as the bounds of its eight corners, goes in the record's frustum words, and the
1703
- taken cell in word 30.
1704
- - A flattened instance makes no pair. Pairs past `budget × 8` records are dropped and counted, as is what the
1705
- query drops.
1706
- - `shader_vgeo_brick_traverse`: `chunk_vgeo_cut_descent`, culled by the box's six faces, a sphere as its cube.
1707
- The take puts a cluster onto its cell's list as its pair and its meshlet row, up to 64. A list counts past what
1708
- it holds, and the clusters past it are counted dropped (`MESHLETS_DROPPED`).
1709
- - Shared rather than copied: `chunk_vgeo_cut_instance` (what any collect writes of an instance record, now under
1710
- the camera's collect too) and `chunk_brick_provider` (a taken cell's key, window, voxel and band, and the
1711
- resolution test, now under the mesh provider too).
1712
- - Tier 2, on the emulator:
1713
- - `vgeo_brick_traverse.spec.js` runs the seed, prepare, the box traverse and the remainder over a built torus
1714
- of 26 pages. Each list is the CPU oracle's cut at the pair's error with the pair's box as its planes
1715
- (`VGeoCutView.plane_count`, new: a camera tests five).
1716
- - Six fine cells around the ring and over and under the tube take 8 to 10 clusters each, and the whole ring
1717
- at a coarse error 7 over 3 pages.
1718
- - With a page withheld, the cell takes the oracle's stand-in, counts the clamp, and wants the page through
1719
- the cut's slot.
1720
- - Ten instances in one cell take 70 clusters: the list holds 64, counts 70, and 6 are counted dropped.
1721
- - `vgeo_brick_pair.spec.js` covers the accept and the record under a skew turn and a non-uniform scale:
1722
- k, w, the eye in a buffer of stale NaNs, the box, the cell, and the cut's own fields. It also covers the
1723
- drops.
1724
- - Tier 3: `VGeoBrickProvider.spec.js` on the software device.
1725
- - Nothing is recorded without an open runtime.
1726
- - The gather runs through take, then the cut's wavefronts, between clear and free.
1727
- - τ is 1 with the camera's at 3; the cut has a slot of its own; its tail publishes the frame's feedback;
1728
- destroy gives back what it made.
1729
- - 37 of 38 mutants were killed. The survivor never culls by a sphere, which changes nothing on the torus: its
1730
- pages hold a node each. A page's sphere lies outside the box only if the cluster refining into it does, so
1731
- the sphere decides at a page's inner nodes alone.
1732
- - The gather is a barrier kernel the emulator cannot run, as the mesh provider's is. Its glue is checked on the
1733
- device in S7c, once the lists have a reader.
1734
-
1735
- **As built (S7c, 2026-10-29)**: the distances, and the provider registered: the GI runs it after the mesh
1736
- provider, before the plugins.
1737
- - `shader_vgeo_brick_distance` runs a 256-lane workgroup for each taken cell that the box cut put a cluster in.
1738
- The traverse lists a cell at its first take (`vgeo_brick_filled`), and `graph_build_indirect_dispatch_command`
1739
- turns the count into the dispatch. The plan's two passes are kept as the cut and this one: the triangles are
1740
- culled at the start of this pass, so the frame carries each cell's 516-byte cluster list rather than a triangle
1741
- list (about 42 MB at 4,096 cells).
1742
- - Triangles: a lane a triangle, two of the list's clusters at a time. A triangle whose box misses its pair's
1743
- (the cell ⊕ R, in the instance's space) lies past the band from every texel and is skipped. The rest go into
1744
- workgroup memory, up to 256 (§7.8's choice), past it dropped and counted. They are held in the cell's own
1745
- space, the translation taken less the cell's origin first, and wound out of the surface their instance
1746
- draws: `triangle_winding_is_reversed` swaps b and c.
1747
- - Texels, two a lane, `vgeo_brick_texel`: the nearest triangle within the band, signed by its side. Of
1748
- triangles as near, the one whose normal lies most along the texel's offset decides; "as near" is a tolerance
1749
- derived from the closest points' rounding in the cell's space, 2.0·10⁻³ of the squared distance. A material
1750
- drawn on both sides signs positive, as UE's two-sided fields do. Past the band a texel is unsigned, so
1751
- positive.
1752
- - The merge: the bytes meet in workgroup memory. A lane a staged word takes each byte's least (contracts 1 and
1753
- 2). A lane an object cell writes the vgeo row where its nearest texel is nearer than the staged minimum from
1754
- before the merge (contract 3).
1755
- - Shared rather than copied:
1756
- - `chunk_brick_object`: the encoding and the object bids, out of the mesh provider, renamed `brick_*`;
1757
- - `chunk_read_meshlet_triangle_positions`, which `read_meshlet_triangle_bounds` is now built on;
1758
- - `chunk_brick_provider_cell`, split out of `chunk_brick_provider`;
1759
- - `chunk_vgeo_brick_box`, the traverse's too.
1760
-
1761
- A cell's list carries its key. `graph_vgeo_cut_descent` returns the records at the seed's version, their claim
1762
- bases in them.
1763
- - Tier 2, `vgeo_brick_distance.spec.js`:
1764
- - the cull, past each of the six faces;
1765
- - the placement: a turn, a scale, an offset origin, reversed, the reach, sided;
1766
- - the texel over an octahedron against a brute force of the closest points: the distance to 5 places, the
1767
- sign, the row's triangle as near as the nearest, and none past the band;
1768
- - two faces meeting at 15°, whose planes disagree in front of their edge: the face the texel stands over
1769
- decides, in either order;
1770
- - a two-sided surface, positive on both sides.
1771
-
1772
- `vgeo_brick_traverse.spec.js` holds the filled list: each cell that took a cluster, once. Tier 3: the
1773
- distance pass between the cut and free; the GI's order with the scene's vgeo open. 30 of 30 mutants were
1774
- killed.
1775
- - Device, on both GPUs: two tori built in the page (128×48, 40 pages), one of them mirrored. The mode was entered
1776
- again until no fill clamped, which was the second fill (S7d is the re-queue), and every brick texel was held to
1777
- the analytic distance.
1778
- - The mean error is 1.9–2.4 bytes and p99 is at most 8 bytes (a quarter voxel), against the half-voxel bound
1779
- of 17. No cell the surface crosses is without a brick.
1780
- - 65 texels at cascade 0 (of 180,751 within the band) have the wrong sign. The vgeo builder's simplification
1781
- flipped 2 of the torus's triangles, at its inner top (cos −0.99 and −0.66 against the analytic normal), and
1782
- the nearest triangle's side follows them.
1783
- - The 256-triangle cap: 538 triangles were dropped, in cascade-1 cells whose 3.75 m box takes in a quarter of a
1784
- torus. 159 texels there are over the bound on the 4090, 178 on the iGPU, since atomic order picks the
1785
- triangles dropped. On a coarser torus (64×24) 275 are dropped and cascade 1 is clean, while three 15 m
1786
- cascade-3 cells over both tori drop instead.
1787
- - Inside the tube past the band (1.05 m against cascade 0's 0.5 m band), 24,314 texels are unsigned, so
1788
- positive, as §7.8 writes them.
1789
- - A fill frame, p50 / p90 / max:
1790
-
1791
- | | gather | cut | distances |
1792
- |---|---|---|---|
1793
- | 4090 | 10 / 12 / 12 µs | 39 / 138 / 881 µs | 58 / 91 / 399 µs |
1794
- | iGPU | 605 / 619 / 728 µs | 20 / 63 / 248 µs | 1.3 µs / 1.04 / 6.49 ms |
1795
-
1796
- The gather is a workgroup a taken cell, whose launch is the iGPU's 605 µs; the mesh provider's 1.07 ms here
1797
- is the same floor. With no vgeo in any cell, the cut's empty wavefronts cost 22 µs (4090) and 20 µs (iGPU).
1798
- - Dispatching the distances by the taken cells instead cost 1.73 ms on the iGPU with no vgeo in any cell, all
1799
- of it launch. By the filled count it costs 1.3 µs.
1800
- - Sponza's census is the same on the tree before S7b and after, so both refactors left the mesh provider as it
1801
- was: 7,171 bricks in every census (progressive, again, fresh), every cascade's counts, and the instances
1802
- dropped past 64 (402, 460, 58).
1803
-
1804
- **As built (S7d, 2026-10-29)**: a cell built with a stand-in waits, and is built again once what it waited on
1805
- arrives (contract 6).
1806
- - The box traverse records a cell's first stand-in in its list header (`VGEO_BRICK_CELL_OFFSET`): the page it
1807
- stood in for, as its lookup word, and the group in that page. The first claimant writes them, decided by an add
1808
- on the header's count of stand-ins, so no compare-and-swap is needed. A clamp (the page not resident) and a held
1809
- group (resident, not selectable) wait alike, until the page is resident and the group selectable.
1810
- - The distance pass puts each cell with a stand-in on a waiting list. The list holds budget × 4 cells; those past
1811
- it are dropped and counted.
1812
- - `shader_vgeo_brick_wait` heads the provider's passes each frame, a thread a cell the last frame left waiting.
1813
- A cell whose page is resident and whose group is selectable is queued with `brick_clipmap_enqueue`, so exactly
1814
- the cells that stood something in are built again, once what they waited on is there. The rest wait on, onto
1815
- the list this frame's distances add to; the two lists trade places each frame.
1816
- - Contract 6's seam on the device: the provider context hands a provider the clipmap's queue, as `state`, `ring`
1817
- and the new `pending` bits. A pass that queues cells writes `state` and hands its version on.
1818
- - `vg_group_row` and `vg_group_selectable` move into `chunk_vgeo_cut_arena`, so a pass that asks whether a group
1819
- is drawable binds only the arena.
1820
- - Tier 2, `vgeo_brick_wait.spec.js`, over the runtime's tables with a page withheld:
1821
- - a cell is queued, while one waits on the missing page and one on a group that page holds back;
1822
- - installing the page queues those two and no other;
1823
- - a key already pending is not queued twice;
1824
- - a list whose count runs past its capacity is read only as far as it holds.
1825
-
1826
- `vgeo_brick_traverse.spec.js` checks that a clamp records its page and group, and that nothing is recorded when
1827
- all is resident; the torus's lookup now starts past word 0. Tier 3: the wait runs before the gather, indirect;
1828
- the distances bind the list the wait keeps onto; the lists trade places; destroy gives them back. 16 of 17
1829
- mutants were killed. The survivor drops the gather's reset of a cell's stand-ins, inside a barrier kernel that
1830
- only the device runs.
1831
- - Device, on both GPUs: the tori of S7c in one fill, never left.
1832
- - The 4090 had 187 cells waiting at frame 10 and none at frame 20, by when all 15 pages were resident; nothing
1833
- clamped after frame 20. On the iGPU none waited at frame 10, and nothing clamped after it.
1834
- - The bricks are those S7c built by entering the mode again until no fill clamped. At cascade 0 the mean error
1835
- is 1.865 bytes on the 4090 and 1.862 on the iGPU, p99 6, with the same 66 and 64 texels of the wrong sign.
1836
- Cascades 2 to 5 have the same counts and errors; cascade 1 differs only in the texels the 256-triangle cap
1837
- leaves over the bound, by atomic order. No cell the surface crosses is without a brick.
1838
- - The wait costs 4.1 µs a frame at p50 on the 4090 (11 µs at worst) and 1.8 µs on the iGPU (6.2 µs).
1839
- - The iGPU's distances reached 13.1 ms in one frame, against a p90 of 1.4 µs. Zorah (S7e) is where that is
1840
- measured on a real scene.
1841
-
1842
- **As built (S7e, 2026-10-29)**: Zorah lit by its own geometry, measured; and the wait keeps its cells' wants.
1843
- - Zorah, the textured UE rebuild (1,012 containers, 12,597 instances, 332,613 pages), one grey material, the
1844
- manifest's sun, 1280×720. Zorah has no other geometry, so all the probes see of it is the vgeo provider's bricks.
1845
- - At ground level, in an arcade, IBL leaves the frame black (mean luma 6.3 of 255; Zorah's IBL sky is black).
1846
- ProbeClipmap lights it from the sunlit floor: 19 to 37 over five runs, the probes 240 frames after the fill.
1847
- - Of the probes' rays, 70% hit Zorah's bricks at the manifest camera, 76% at ground level and 48% from the air.
1848
- - Timings, p50 of a settled frame.
1849
- - The AMD iGPU: IBL 39.5 ms, ProbeClipmap 62.8 ms (+23.3 ms). That is the trace's 11.0 ms (8 ms on Sponza: Zorah's
1850
- bricks are denser), the fused indirect pass's 9.3 ms (as S6c2 measured it), light 2.1 ms, and every other pass
1851
- under 0.6 ms. The provider at rest costs ~20 µs: wait 3.5, gather 1.6, cut 11.1, distances 1.4.
1852
- - The 4090, sharing its GPU with another process (its medians stand, its maxima do not): 6.9 → 8.3 ms. The trace is
1853
- 1.2 ms and the fused pass 81 µs.
1854
- - The fill on the iGPU (196,608 cells at 4,096 a frame, and the cells queued again): 62 ms a frame at p50, 69 ms
1855
- at p99 and 132 ms at worst. The distances take 56.6 ms in their worst frame, the cut 6.6 ms and the gather 3.6 ms.
1856
- - **§7.8's bound does not hold on Zorah: its caps bind as budgets.** Over one fill on the iGPU (the 4090 is within
1857
- 10%):
1858
- - 46% of the clusters the box cut took are past a cell's 64 (1.44M of 3.13M): 39% in cascade 0, rising to 79% in
1859
- cascade 5;
1860
- - 18–19.5M triangles are past a cell's 256: 9.8M in cascade 0, 6.4M in 1, 2.0M in 2;
1861
- - 80–150k pairs are past the records (103k in cascade 1), and the TLAS query drops 182–224k candidates and 48k
1862
- nodes.
1863
-
1864
- A cell ⊕ R box in kitbashed content holds many overlapping instances and layered surfaces. The cut's error bounds
1865
- how big a triangle is, not how many surfaces cross a box.
1866
- - What the image measures, and what it does not come from.
1867
- - Holes: from the air the ground plane's bricks are missing beside the greenhouse, and cells of the ground, the
1868
- greenhouse and the tower read inside out.
1869
- - Of the probes, 17–48% are dead and 8–16% of rays meet back faces. Sponza on the mesh caches has 1.6–4% dead and
1870
- 2% back-face rays.
1871
- - With the provider's cut 4× and 16× coarser, the drops fall about threefold (triangles 19.3M → 6.2M → 5.6M,
1872
- clusters past a list 46% → 26% → 29%). The back faces, the dead probes and the holes stay. **The caps are not
1873
- what the image suffers from.**
1874
- - Each texel the least over its instances of that instance's own signed distance, in place of the nearest
1875
- triangle of all of them, is no better: back faces 8–18% and dead probes 18–54%. The nearest triangle's side is
1876
- the open question on content made of single-sided, interpenetrating pieces.
1877
- - The two tori of S7c, as vgeo and as plain meshes: of the probes deep inside a tube, vgeo leaves 4 alive and 36
1878
- dead, while the meshes' signed fields relocate 26 out alive and leave 14 dead. Past the band a vgeo interior is
1879
- unsigned (§7.8), so its probes die where a mesh's move out.
1880
- - **The wait keeps its cells' wants.** On Zorah, 826 cells (iGPU) and 888 (4090) waited for ever, every one on a
1881
- page not resident.
1882
- - A want lasts the reading it is in: past its 64 fetches a reading, `VGeoResidency.apply` drops what it defers.
1883
- The camera's cut wants its pages again every frame; the box cut wants only while it cuts.
1884
- - Now the traverse records what a cell's first stand-in's want is made of, in the cell's header
1885
- (`VGEO_BRICK_WAIT_OFFSET`): the page, the group, the row the stand-in was found in and that page's own index, the
1886
- child-table entry, and the error. The distances copy it onto the waiting list whole.
1887
- - Each frame, the wait wants an absent page again through the provider's slot, while that row still holds that page
1888
- (`vg_lookup[lookup + source_page] == source`). Once the row holds another page, the cell is queued again instead.
1889
- The wait writes the frame's feedback ahead of the gather and hands its version on.
1890
- - A group held back carries no want, as in the cut.
1891
- - On Zorah no cell is left waiting on an absent page (827 → 0). 7 to 47 wait at rest, every one on a resident page
1892
- whose group the activation rule holds back. That is the runtime's own gap, which the 4090's camera cut shows
1893
- too: 1,539 clusters held for good, where S7a saw 118.
1894
- - Tier 2 (`vgeo_brick_wait.spec.js`, over the runtime's tables with a page withheld): a cell waiting on an absent page
1895
- wants it again each frame, through its source, at its error; one whose source row holds another page is queued
1896
- again and wants nothing; a held group wants nothing; the cells kept go onto the list whole. The traverse spec holds a
1897
- clamp's want words to what the residency needs: the source row holds the page named, whose child table names the
1898
- page withheld at the entry recorded, and the error is above τ. Tier 3: the wait binds the frame's feedback and the
1899
- want stamps. 17 of 18 mutants were killed. The survivor shortens the distances' copy onto the list, inside a barrier
1900
- kernel that only the device runs.
1901
- - Open, measured, cause not established: on both GPUs, the lit frame at the manifest camera has dark blotches on a
1902
- lattice over a column whose bricks are clean.
1903
-
1904
- **As built (S8, 2026-10-29)**: a virtual geometry's cache is baked from a proxy cut from it, adopted into it, and
1905
- composed by the mesh provider; the per-cell provider is deleted. The plan and its one fork are the shared note
1906
- `s8-vgeo-geometry-caches-plan-2026-10-29.md`; Alex took (a), a readback of the cut's triangles into the host's own
1907
- geometry path.
1908
- - **The caches** (`GPUGeometrySDFManager`).
1909
- - `onRequested` is sent with a record each time its cache is queued: at its first holder, at a new voxel size, and
1910
- when its holders' side changes. `is_requested` says a cache waits queued, held or not, as the bake bakes it until
1911
- a trim; `holds_two_sided` says the side a bake of it takes.
1912
- - **Sources** (`add_source`, a `MeshSDFSource`) run first in each `graph_bake`. A source makes the caches of
1913
- geometries with no tree of their own, which wait queued as a geometry whose tree is not resident yet does.
1914
- - `adopt(target, proxy)`: the resident cache baked for the proxy becomes the target's, nothing copied. The target's
1915
- header takes the proxy's layout, entries and side, and the proxy's reads None, as dropped; `onBaked` is sent with
1916
- the target. The cache has no cards, the target having no tree to capture them with, so a vgeo hit keeps the
1917
- material's mean (§17). Held for another side than the proxy was baked for, it is queued again at the next bake.
1918
- - A bake is recorded only for work there is: a queue of geometries no bake can admit yet, and placements dropped or
1919
- adopted away, record none.
1920
- - **The source** (`VGeoSDFProxies`, made with the runtime by `GPUSceneContext.enable_virtual_geometry`).
1921
- - A job a geometry asked for, 32 at once. Each frame a job's geometry is cut whole by the camera cut's own descent:
1922
- `shader_vgeo_sdf_collect` writes its record (`chunk_vgeo_cut_instance`), the claim widths are scanned, and
1923
- `graph_vgeo_cut_descent` runs `shader_vgeo_sdf_traverse`, which culls nothing and puts every cluster in the cut onto
1924
- a list under its job, counting stand-ins and the clusters past the list. It cuts under a slot of its own at τ = 1,
1925
- k = 1/(½·v₀) in the geometry's own units, v₀ its cache's level-0 voxel. A clamp wants its page there; since a want
1926
- lasts one reading, the job is cut again each frame until its cut stands nothing in.
1927
- - `shader_vgeo_sdf_decode`, a workgroup a listed cluster, counts every job's vertices and triangles. For a job
1928
- whose last cut stood nothing in, it writes the clusters out in room those counts made: positions as stored,
1929
- triangles as indices into the job's own vertices.
1930
- - One readback a frame: the jobs' counters, the cut's own (a subtree left undescended voids the frame for every
1931
- job), and the meshes. A write-out whose counts disagree with its room's is cut again.
1932
- - The proxy: one a frame on the host, `meshlet_geometry_build_from_geometry` (2.2–4.1 µs a triangle, measured),
1933
- added to the geometry manager, its cache held with the side the geometry's holders draw. When it turns resident,
1934
- the next update adopts it into the geometry and removes the proxy. A proxy whose mesh another's has is that one's
1935
- record: only one adopts, and the other geometry is extracted again.
1936
- - A job whose geometry is no longer queued is let go; a new voxel size restarts every job.
1937
- - **The holds** (`ProbeClipmapSceneHolds`): a vgeo row holds its cache as a mesh row does. Its pair's card set waits for
1938
- good, since an adopted cache has no card layout.
1939
- - **Deleted**: `brick/vgeo/` (the gather, the box cut, the distances, the wait and their specs), its registration in
1940
- the GI, and the provider context's `budget` and `pending`, which only it used.
1941
- - **Device, Zorah** (as S7e loads it: 1,012 geometries, 12,597 instances, one grey material, the manifest's sun,
1942
- 1280×720), entered once with the default settings.
1943
- - The fill: every one of the 1,012 caches resident after 3,160 frames on the 4090 (31 s) and 3,200 on the AMD iGPU
1944
- (247 s). 1,013 proxies were built, one twice (a proxy came out alike to another's), from 423,584 triangles: step
1945
- 0's "a few hundred thousand". 8 geometries were coarsened (step 0's 8). The bake evaluated 52,911 bricks, the
1946
- atlas grew to 65,536 slots, and the caches hold 35.3 MB. Their cuts took 34–42k clusters over ~1,180 readings and
1947
- stood one in 6–9k times while their pages came; no wavefront ran out, nothing was dropped, and the cluster list
1948
- never grew past 4,096.
1949
- - The fill's pace, 0.32 geometries a frame, is mostly the bake's budget of 23 bricks a frame, at 52 bricks a
1950
- geometry: it averaged 16.7 a frame, idle in 27% of the frames while proxies waited on their pages, their write-out
1951
- or their build. Zorah's proxies cost less a frame than Sponza's meshes: the evaluate is 652 µs at p50 and 9.0 ms at worst on
1952
- the 4090, 3.7 ms at p50, 17.7 ms at p90 and 48.9 ms at worst on the iGPU, inside what the budget costs on Sponza
1953
- (§6.4: 26 ms at the median and 79 ms at worst on the iGPU). The extraction's own passes, in the 1,734 frames that
1954
- cut: the cut 48 µs at p50 and 499 µs at worst on the 4090 (36 and 337 µs on the iGPU), the decode 4 µs at p50.
1955
- - The host: a frame that builds a proxy is 4.4 ms at p50 against 3.0 ms without on the 4090's host, 20.7 ms at p99,
1956
- and 190 ms once, for the largest geometry's proxy (the iGPU run's host: 4.8 / 3.2 / 20.4 / 150 ms).
1957
- - The frame while filling, p50 / p90: 3.82 / 5.56 ms on the 4090 and 66.0 / 80.5 ms on the iGPU (158.9 ms at worst).
1958
- At rest nothing of the extraction runs: 3.28 ms on the 4090 and 63.0 ms on the iGPU, S7e's 62.8 ms.
1959
- - The probes and their rays, after the fill, both GPUs alike:
1960
-
1961
- | view | dead probes, S7e → S8 | back-face rays, S7e → S8 | lit luma, S7e → S8 |
1962
- |---|---|---|---|
1963
- | manifest | 23% → 3.2% | 12% → 4.7–5.0% | 89 → 91 |
1964
- | aerial | 18% → 2.0% | 9% → 4.5–5.1% | 74 → 76 |
1965
- | ground | 49% → 6.8–7.0% | 17% → 5.5–6.6% | 21 → 79–83 |
1966
-
1967
- Sponza on the mesh caches has 1.6–4% dead and 2% back-face rays. The arcade at ground level, which S7e lit at 19 to
1968
- 37, is lit at 79 to 83 from the sunlit floor. From the air the bricks read as Zorah, the greenhouse's dome and the
1969
- tower whole, where S7e's read inside out (`s8f_igpu_aerial_bricks.png` against `s7e2_aerial_bricks.png`).
1970
- - Sponza's census is unchanged (4090): 7,171 bricks, every cascade's counts, 104 geometries baked, resident at frame
1971
- 355.
1972
- - Open, measured, cause not established: from the air, cells of the ground under the greenhouse's ring are still
1973
- black (the ray passed through); the column at the manifest camera still has its dark blotches on a lattice; its
1974
- flutes, narrower than a voxel, read as back faces in the brick view. A proxy's id is not given back
1975
- (`GPUGeometryManager` hands ids out once), so the fill takes Zorah's ids from 1,012 to 2,024.
1976
- - Tier 2 (`vgeo_sdf_extract.spec.js`, over a built torus in the runtime's tables and in a meshlet pool): a job's list is
1977
- the CPU oracle's cut at its error with no cull, and its counts are the listed clusters'; written out, its mesh is
1978
- every triangle of those clusters as the meshlet pool's own reader reads them back; a room too small takes no cluster
1979
- past it; a missing page is a stand-in, wanted through the slot; past the list's capacity the count runs on. Tier 3:
1980
- the caches' adopt, sources, requests and waits (`GPUGeometrySDFManager.spec.js`, a bake done on the device standing
1981
- in its log); the proxies' host half end to end (`VGeoSDFProxies.spec.js`, the device's readbacks standing in); a vgeo
1982
- row's hold. 43 of 43 mutants were killed.
1983
-
1984
- ### 7.9 Tracing
1985
-
1986
- `brick_clipmap_trace(origin, direction, t_min, t_max)` is a pure chunk over seam readers, so the
1987
- emulator can run it against CPU-built bricks (§12).
1988
-
1989
- - **Start**: the finest cascade whose window holds the origin by ½ voxel.
1990
- - **Loop**, at most 64 steps (chosen):
1991
- - **Window**: if `p = o + t·d` leaves the window shrunk by ½ voxel, move to the next cascade; past
1992
- the last cascade the ray is a MISS.
1993
- - **Block**: an occupancy of 0 advances t to the block's exit (slab test).
1994
- - **Entry**:
1995
- - UNKNOWN continues in the next cascade from the current t. Unbuilt space is read from the
1996
- parent, not passed through.
1997
- - EMPTY advances to the cell's exit.
1998
- - A slot reads d. If d < ½ voxel the ray HITs; otherwise `t += max(d, ½ voxel)` (UE's minimum
1999
- step).
2000
- - **Hit**: position, cascade and slot. The normal is a 4-tap tetrahedral gradient, its taps clamped
2001
- to the brick's [0, 7] (UE uses 6).
2002
- - **Unresolved**: a ray whose budget is spent is returned as unresolved, and its caller discards the
2003
- sample (§8.8).
2004
- - **Per step**: one occupancy load, one entry load, and one atlas sample inside bricks.
2005
-
2006
- Three more chunks:
2007
- - `brick_clipmap_distance(p)` is the finest cascade's signed sample at p, with an EMPTY cell read as
2008
- ≥ R. It is for probe relocation.
2009
- - `brick_clipmap_object(hit)` reads the object cell of the texel nearest to the hit moved ½ voxel
2010
- along the normal, onto the ray's side. With a thin wall's two faces inside one voxel, this picks the
2011
- face the ray hit. UE offsets its object lookup the same way.
2012
- - `brick_clipmap_soft_shadow(origin, direction, sin_angle)` is the same march. It returns the
2013
- classic penumbra estimate: the minimum over its steps of `d / (t·sin θ)`, clamped to [0, 1].
2014
- - The estimate is extracted from `shader_scene_sdf_soft_shadows`.
2015
- - θ is the light's angular radius from its record (`disk_radius`), a physical quantity, not a
2016
- softness knob.
2017
-
2018
- **As built (S3e)**, `brick/chunk_brick_clipmap_trace.js`:
2019
- - The cascade count is a parameter of each chunk: `brick_clipmap_trace(origin, direction, t_min,
2020
- t_max, cascade_count)`, with `direction` unit length so that `t` is a distance.
2021
- - They read `brick_levels`, the map and the occupancy as sampled `u32` textures, the atlas through
2022
- the mesh bricks' sampler, and `brick_objects`.
2023
- - The hit carries its kind (hit, miss, unresolved), `t`, cascade, slot, position, normal, the
2024
- field where it stands, and its cell's corner.
2025
- - A step past the hit's threshold is `d` itself, at least half a voxel, so UE's minimum step needs
2026
- no `max`.
2027
- - EMPTY inside is a hit where the march stands. Its field reads −R and its normal is along the
2028
- ray, so §8.4's gradient rule counts it as a back face with no case of its own.
2029
- - A skipped block is clamped to the window at both ends (§7.3). Past the window's far edge the
2030
- ray goes on in the next cascade from there, never from the block's storage end.
2031
- - `brick_clipmap_distance`: in a point no window holds, or one only UNKNOWN cells cover, it reads R
2032
- of the coarsest cascade.
2033
- - `brick_clipmap_object` moves the hit along the normal, out of the solid. A front face's normal
2034
- faces the ray, and a back face is counted, never shaded (§8.4), so the ray's side needs no test.
2035
- - Tier 2, `brick_clipmap_trace.spec.js`, on spheres, boxes and planes built into bricks on the CPU
2036
- the way the build makes them:
2037
- - every hit lies within half a voxel and one LSB of the surface, with its normal;
2038
- - skipping blocks finds the same hits as a march that reads every block;
2039
- - a skip stops at either edge of a window whose corner is not a multiple of 4;
2040
- - UNKNOWN cells defer to the parent;
2041
- - a ray through empty space crosses four 16-cell windows within its 64 steps, which it could not
2042
- cell by cell;
2043
- - a ray grazing a plane between half a voxel and one spends its steps unresolved;
2044
- - a wall thinner than a voxel is hit, and each of its faces reports its own row.
2045
- - Mutants: 31 of 32 killed. The survivor, the start's half-voxel inset, is equivalent: without it
2046
- the loop's window test moves the ray on at its first step.
2047
- - `brick_clipmap_soft_shadow` is not built yet. §12's oracle cannot be met by the estimate above,
2048
- and that is put to Alex. At a sphere's edge, where the ray to the light's centre grazes it, half the
2049
- disk shows. The classic estimate reads 0 there, so its penumbra lies wholly outside the geometric
2050
- shadow, and it is 0.55 from the analytic fraction at worst. Quilez's two-sided estimate marches on
2051
- into the occluder, clamps the minimum to [−1, 1] and maps it through `¼(1 + x)²(2 − x)`; it is
2052
- 0.085 from it at worst (θ = 0.05 rad, a unit sphere 10 units away).
2053
-
2054
- **As built (S3e's soft shadow, 2026-10-28)**: Alex asked for quality where its cost is a fair trade, and
2055
- left the estimator to the measurement. It is Quílez's two-sided estimate.
2056
- - `brick_clipmap_soft_shadow(origin, direction, t_min, t_max, sin_angle, cascade_count)` walks the
2057
- tracer's march, which both now share (`brick_clipmap_march`). A step passes empty space or changes
2058
- cascade, leaves the last window, or stands in a cell to read.
2059
- - In each brick it reads the clearance: the field less the tracer's half-voxel expand, so that a wall
2060
- thinner than a voxel shadows as it is hit.
2061
- - The least of the clearance over `t·sin θ` goes below zero inside the occluder. The march goes on
2062
- into it, half a voxel a step, until the least reaches −1.
2063
- - Clamped to [−1, 1], the least maps through `¼(1 + x)²(2 − x)`.
2064
- - EMPTY inside is dark. An EMPTY cell or a skipped block adds nothing: its clearance is at least a
2065
- voxel, wider than a sun's cone within its cascade's range.
2066
- - **Quality**, on the CPU, against the analytic disk past the surface the tracer hits. 12 cases of θ
2067
- (the sun's 0.0047 to 0.05 rad), radius and distance, each marched as the tracer marches:
2068
-
2069
- | estimate | mean error | worst error | at the edge (0.5) |
2070
- |---|---:|---:|---:|
2071
- | classic, one-sided (above; `shader_scene_sdf_soft_shadows`) | 0.129 | 0.566 | 0 |
2072
- | the engine's `scene_sdf_soft_shadow`, with Aaltonen's triangulation | 0.330 | 1.0 | 0 |
2073
- | **two-sided** | **0.0145** | **0.099** | 0.5 ± 0.022 |
2074
-
2075
- Aaltonen's triangulation breaks under the expand: a step can more than double the clearance, and
2076
- its square root's argument goes negative.
2077
- - **Cost**, paired against the one-sided estimate: the sun's shadow rays on Sponza, from random origins
2078
- about the camera.
2079
- - On the 4090 it is the same within its noise (1M rays: 725 against 755 µs). On the iGPU it is 33–36%
2080
- more: 8.9 against 6.7 ms for 131,072 rays, 68.2 against 50.1 ms for 1M.
2081
- - For a 0.05 rad light it costs 24% / 59% more.
2082
- - The cost is the inner penumbra's rays, which the one-sided estimate stops dark and this one
2083
- marches on. Stepping by the field's depth inside the occluder did not change it.
2084
- - **What it buys** on Sponza: 8.7% of the sun's rays, which the one-sided estimate reads as umbra, see
2085
- part of the sun. The mean visibility is 0.584 against 0.560.
2086
- - The tracer through the shared march costs 2.9% more on the iGPU, paired, with the same hits. The
2087
- alternative was a second copy of the traversal.
2088
- - Tier 2:
2089
- - past a sphere, within 0.1 of the analytic disk (0.043 measured), and half at the edge within 0.1
2090
- (0.512);
2091
- - dark under its middle, lit well clear of it;
2092
- - a wall thinner than a voxel hides the sun;
2093
- - a ray inside a solid, in a brick or EMPTY inside, is dark;
2094
- - one past `t_max` or out of every window is lit.
2095
- - Mutants: 13 of 15 killed.
2096
- - A linear map in place of `¼(1 + x)²(2 − x)` survives within the oracle's tolerance. On the CPU its
2097
- mean error is 35% more, and its worst no more.
2098
- - Never stopping in the umbra is equivalent: the least only falls.
2099
- - **Replaced 2026-10-30** by a shadow ray toward a point of the sun's disk, and the chunk deleted (§8.7, as
2100
- built): the estimate reads the field's clearance about one ray, so two occluders from either side of
2101
- the cone read as one. Under a slit narrower than the disk it saw 0.675 of the sun where the disk is
2102
- 0.327 open.
2103
-
2104
- **As built (S3f)**: `ProbeClipmapDebugView.BrickClipmap` traces each pixel's camera ray through the
2105
- world's bricks (`debug/graph_probe_clipmap_debug_brick_clipmap.js`).
2106
- - A hit shows the field's normal, tinted warmer the coarser its cascade; a back face is red, and a
2107
- ray whose steps ran out magenta.
2108
- - `GPUBrickClipmap.write_bindings` hands a reader what the tracer's chunks read, as the geometry
2109
- caches' does.
2110
-
2111
- Measured on Sponza with its caches resident, at the defaults: 6 cascades of 32³, 0.125 m voxels,
2112
- 4,096 cells a frame. The numbers are for the RTX 4090 / the AMD iGPU.
2113
- - **Build**: a fresh clipmap builds in 48 frames, 196,608 cells at 4,096 a frame.
2114
- - It holds 7,171 bricks, 5,147 of them in cascade 0. No cell is UNKNOWN and none is EMPTY inside,
2115
- the same on both GPUs.
2116
- - Under the 64-ray vote, S3d's fresh build held 5,315 bricks and 2,564 cells EMPTY inside.
2117
- UE's vote leaves deep interiors at +R, and their shells add cells with a surface.
2118
- - A frame of the fill costs 145 µs / 1.63 ms at the median and 411 µs / 6.8 ms at worst, 7.2 /
2119
- 106 ms in all. The mesh provider is most of it, 104 µs / 1.08 ms at the median.
2120
- - **Idle**: 22 µs / 15 µs a frame.
2121
- - **Walking** 13.4 m through the building at 3.35 m/s, the build keeps up: nothing is queued at any
2122
- fourth frame. A frame costs 27 / 15 µs at the median and 125 µs / 1.56 ms at worst.
2123
- - **Trace, coherent**: the debug view at 1280×720, a ray a pixel, costs 63–88 µs / 9.3 ms. The Mesh
2124
- SDF view of the same pixels costs 1.7–2.1 ms / 160–189 ms.
2125
- - **Trace, incoherent**, as probes cast rays: random origins in a 28 m box about the camera, uniform
2126
- directions.
2127
- - 131,072 rays, the default `ray_budget`, cost 165–215 µs / 6.9–7.0 ms to the clipmap's edge, and
2128
- 165–195 µs / 3.1–3.2 ms within 8 m.
2129
- - 1,048,576 rays cost 650–725 µs / 54 ms, about 1.5 G / 19 M rays a second.
2130
- - 0.6–0.7% of them run out of their 64 steps unresolved; within 8 m, 0.01%. 3.6–4.7% start inside
2131
- a solid and meet a back face.
2132
-
2133
- ### 7.10 Capacity and growth
2134
-
2135
- - **Initial capacity** is `2·N_b²·C_b` bricks (12,288 at the defaults): a ground plane through every
2136
- cascade, two cells thick.
2137
- - **Growth**: failures are counted on the device and read back. On a non-zero reading the host
2138
- doubles capacity (as built in S3, the count is take's starved takes, §7.4):
2139
- - `GPUTileAtlas3D.reserve`, which adds layers and widens past `maxTextureDimension3D`, copying
2140
- texels;
2141
- - the object cells, reallocated with a copy;
2142
- - the free list, extended by the new slot range.
2143
- - The mesh brick atlas and the card pages grow the same way.
2144
- - Nothing shrinks, in the manner of the vgeo descent bound.
2145
- - **Ceiling**: the atlas's `capacity_limit` (256³ slots at 2048) and the adapter's
2146
- `maxStorageBufferBindingSize`.
2147
-
2148
- ## 8. The probe clipmap
2149
-
2150
- ### 8.1 Cascades and lattice
2151
-
2152
- - `C_p` cascades (`probe_cascade_count`, default 5) of `N_p³` lattice points
2153
- (`probe_cascade_resolution`, a power of two, default 32).
2154
- - Spacing is `s_c = s₀·2^c` (`probe_spacing` = s₀, default 1 m), so the windows span 32 to 512 m.
2155
- - **Probes sit at the world-aligned lattice points `s_c·i`.** A point uses cascade c's cage, the
2156
- eight corners of `floor(p/s_c)`, when the whole cage is in the window.
2157
- - **Placement and nesting** are the bricks' (§7.1). The brick clipmap's 896 m outer window is wider
2158
- than the probes' 512 m, so rays from the outermost probes travel at least 192 m before they read
2159
- as sky.
2160
- - **Cascade choice and blend** use `clipmap_select_finest` over the probe levels, with a blend
2161
- skirt of 2 cells: one cell of the coarser cascade, the distance over which the spacing doubles.
2162
-
2163
- ### 8.2 Storage
2164
-
2165
- | Resource | Layout |
2166
- |---|---|
2167
- | Indirection | `r32uint`, `N_p × N_p × N_p·C_p`, toroidal; slot or `0xFFFFFFFF` |
2168
- | States | storage buffer, one `u32` a slot: NEW / ALIVE / DEAD and the update count. Its own array, so the pick reads 4 B and not a record (§9.1) |
2169
- | Records | storage buffer, 64 B a slot: cascade and world cell (3 × 21-bit signed, two words), wanted frame, traced time (s), relocation offset (3 × f16), back-face fraction, SH2 as `METRO_L1_IRRADIANCE_STRUCT` (32 B) |
2170
- | Radiance | 2-D `rgba16float` atlas; a slot's tile is `(n_r + 2)²`: the octahedral map plus a one-texel skirt. One level only (§9.3). Slots are placed by `index_to_grid2d`, as in `lpv_atlas_patch_texel_coord` |
2171
- | Depth | 2-D `rg16float` atlas, tile `(n_d + 2)²`. Moments of `min(t, (1 + 0.45)·√3·s_c)/s_c`: normalised by spacing, so every cascade has the same precision, and clamped at the farthest a cage tests a relocated probe (D9), past which a hit cannot occlude anything the cage shades |
2172
- | Wanted bits | `N_p³·C_p` bits, cleared each frame |
2173
- | Lists | active (double-buffered), wanted, trace (≤ U, §8.5) |
2174
- | Free list | the extracted slot stack (§4.1) |
2175
-
2176
- `n_r` is `radiance_resolution` (default 16) and `n_d` is `depth_resolution` (default 8). Both are
2177
- powers of two and independent.
2178
-
2179
- **Why `rgba16float` and not LPV's `r32uint` rgbe9995.** Every consumer reads radiance with one
2180
- hardware bilinear tap per probe. LPV's packed format forces four loads plus decodes and a manual
2181
- bilinear. The memory measurement on the AMD iGPU found manual octahedral sampling 12× slower than
2182
- hardware taps.
2183
-
2184
- The price: a texture cannot be read and written in one dispatch without `texture-formats-tier2`, so
2185
- the update is two passes (§8.8). That is LPV's own answer for its depth atlas.
2186
-
2187
- **As built (2026-10-30), the record packed**, 48 B a slot, `PROBE_RECORD_STRUCT.pack()`:
2188
- - the cell, `vec3<i32>`;
2189
- - one flags word: the back-face share as unorm16 in the low half; ten bits of flags above it, the screen's
2190
- mark the first; the cascade in the top six bits;
2191
- - the wanted and the traced frame, `u32`, of the frames the clipmap has recorded: exact where f32 seconds
2192
- lose their step as the clock runs on, and an update happens in a frame anyway. The blend's Δt is the
2193
- frames since the update and τ is `response_frames` (default 30, half a second at 60 fps), so the probes
2194
- take no clock from the caller. Eviction's age buckets count frames: 0, 1, 2–3, 4–7, … Retire evicts by the bucket a probe was
2195
- counted in, its age a frame before. With integer ages a probe one frame younger than an asked bucket
2196
- aged into it between schedule and retire, and was taken uncounted;
2197
- - the SH, `vec3<u32>` of half floats: Y's xy, Y's zw, CoCg, held to ±65,504. Half the Metro L1 struct's
2198
- 32 B.
2199
-
2200
- ### 8.3 Lifetime
2201
-
2202
- Per frame, in this order:
2203
-
2204
- 1. **retire** (thread per active probe). A probe is freed when either holds:
2205
- - its world cell is no longer in its cascade's window (`clipmap_contains` against the current
2206
- corner);
2207
- - it was last wanted more than `retention_time` ago (default 1 s). Since 2026-10-30 instead: the last
2208
- frame's schedule evicted it, by the pressure on the slots, least recently wanted first (as built,
2209
- below the measured churn).
2210
-
2211
- Freeing it means:
2212
- - its slot is pushed;
2213
- - its indirection entry is cleared if it still names this slot.
2214
- The rest are re-appended to the active list.
2215
- - Running this before `want` means a scroll needs no pass of its own: the vacated storage cells
2216
- are emptied before any newly exposed cell can claim them.
2217
- 2. **want** (thread per 8×8-pixel tile; 32,400 at 1080p).
2218
- - One pixel per tile per frame, chosen by STBN. Its world position comes from depth and its
2219
- normal from the G-buffer; sky pixels are skipped.
2220
- - The biased point (§9.1) picks cascade c. The eight cage cells of c, and of c + 1 inside the
2221
- blend skirt, are `atomicOr`'d into the wanted bits. A bit that was clear appends its cell to the
2222
- wanted list.
2223
- - At a cascade's outer edge a probe cell projects to `H / (N_p·tan(fov/2))` pixels: 58 px at
2224
- 1080p and 60°, 19 px at 120°. A tile size of 8 therefore samples every cell several times a
2225
- frame.
2226
- 3. **want hits** (thread per ray of the last frame's updates; S5a, D31 reversed).
2227
- - A front-face hit that bounces anything wants the cages its pick reads, as a pixel does, since the
2228
- next bounce reads those probes (§8.7). So a probe a hit needs is admitted where nothing on screen
2229
- wanted one, and kept while hits read it.
2230
- - Only a probe a pixel wanted has hits that want: the chain stops after one step, as Enshrouded's
2231
- world-cache probes do. Unstopped, every surface the windows hold would come to want a probe, the
2232
- dense lattice D31 turned down.
2233
- - It reads the last frame's rays before this frame's trace writes over them.
2234
- 4. **admit** (thread per wanted cell).
2235
- - An EMPTY entry pops a slot (a failure is counted), writes a NEW state, a record and the
2236
- indirection entry, and appends to the active list.
2237
- - An existing probe refreshes its wanted frame.
2238
- - Either way the record is marked the screen's when a pixel wanted the point, and the hits'
2239
- otherwise: what the next frame's hits read to stop the chain.
2240
-
2241
- Consumers weight a NEW probe at zero until its first update.
2242
-
2243
- **As built (S4a, 2026-10-28)**, `probe/`: `GPUProbeClipmap` places the windows, keeps the storage, and
2244
- records the frame's lifetime as one graph pass: retire, want, admit's command, admit, schedule.
2245
- - **The levels uniform holds the cage windows**: the lattice window's corner, a size of `N_p − 1`, the
2246
- cascade's spacing. `clipmap_select_finest` over it answers the finest cascade whose whole cage holds a
2247
- point (§8.1). Retire tests a probe's point against the lattice window, one point wider.
2248
- - **The record is plain fields**, 64 B as §8.2 sized it: the cell as `vec3<i32>`, the cascade, the
2249
- wanted time, the traced time, the back-face fraction, the Metro L1 struct. §8.2's three 21-bit cells
2250
- and a cascade are 66 bits, which two words do not hold. (S4a kept the relocation offset here; S4b
2251
- moved it into the state.)
2252
- - **"Wanted this frame" is the wanted bit**, which the frame clears. Retention reads a **wanted time**,
2253
- seconds since the clipmap's first frame on the caller's clock: the renderer's wall clock, since the
2254
- probes are a cache of the lighting, not part of the simulation (its `time_delta_seconds` is 0 by
2255
- default).
2256
- - **The indirection is filled with none on the first frame**, a thread a texel: a new `r32uint` texture
2257
- reads 0, which is a slot.
2258
- - **Want skips a cage the bias moves out of its window**: its points would alias storage cells other
2259
- points hold. In the skirt the next cascade's cage is wanted on its own.
2260
- - **Want claims each cage once a workgroup.** A workgroup's 64 tiles want a few cages between them, so
2261
- the first lane to want a cage claims it in a 256-slot table in workgroup memory and sets its bits; the
2262
- rest want it through that lane. Storage wraps by the power-of-two mask, not by the Euclidean mod, an
2263
- integer division on the iGPU.
2264
- - Measured on Sponza at 1280×720, paired in the same frames: on the iGPU want took 343 µs as first
2265
- written, 264 with the mask alone, 317 with the claims alone, and **117** with both. On the 4090:
2266
- 32.8, 29.7, 9.2 and **7.2** µs.
2267
- - What it cost as first written, on the iGPU: 105 µs of loads, selection and keys, 140 of the bits'
2268
- atomics, 90 of the appends.
2269
- - **The lifetime a frame**, p50 on Sponza at 1280×720 with about 770 probes live: on the 4090 about 24
2270
- µs (retire 7, want 7, admit 5, the two commands 2 each); on the iGPU about 129 µs (want 117, retire 6,
2271
- admit 4).
2272
- - **On the device** (one-offs on both GPUs): free plus live is the capacity, every live probe has one
2273
- indirection entry and no two share one, a walk through the building admits and scrolls without
2274
- starving, and the probes a turn to the sky leaves unwanted go after the retention time.
2275
- - Commands: admit's command (one thread) sizes admit over the wanted list, and schedule (one thread)
2276
- sizes the next frame's retire over the frame's active list; S4b gives schedule the period and the
2277
- trace list. Every dispatch is indirect over a device count, or want's screen tiles.
2278
- - **Extracted**: `chunk_folded_dispatch` (`gpu_primitive/indirect/`), the folded command the
2279
- one-thread passes write; the brick take, the brick movers' command and the fluid forcing's command
2280
- migrated onto it.
2281
- - Capacity at the defaults: 16,384 slots. Growth doubles on a pop the free list could not serve.
2282
- - Tier 2, `probe_clipmap_lifetime.spec.js`, two cascades of 8³ points on the emulator:
2283
- - a pixel wants its cage, and admit gives each point one NEW probe there, named by its entry;
2284
- - in the skirt the next cascade's cage too, biased by its own spacing;
2285
- - the bias moves a cage across a cell face, and out of its window;
2286
- - the sky and a point no window holds want nothing; two tiles' shared points are admitted once;
2287
- - a probe wanted again keeps its slot; one unwanted keeps it for the retention time, then gives it
2288
- back;
2289
- - a probe whose point leaves its window gives its slot back before the point the move exposed in that
2290
- storage cell claims it; one at the window's far lattice point stays;
2291
- - a pop the free list cannot serve is counted;
2292
- - admit's and schedule's commands cover their lists.
2293
- - a lane's two cages are claimed apart though their least points share a storage cell.
2294
- - Mutants: 29 of 29 killed.
2295
- - Tier 3, `GPUProbeClipmap.spec.js`: the first frame's fills and the passes' order, sizes and commands;
2296
- the cage windows; the clock; the capacity; growth; destroy.
2297
-
2298
- **As built (S5a, 2026-10-28), the hits' wants**: `probe/shader_probe_clipmap_want_hits.js`, between want
2299
- and admit's command, through the launch the last frame's rays had, `PROBE_DISPATCH_RAYS`.
2300
- - **Want's cages are one chunk**, `probe/chunk_probe_clipmap_want.js`: the pick's cages, the claims and
2301
- the appends. Want and the hits' wants both call it, so a hit wants what its pick reads.
2302
- - **A wanted list entry's top bit says a pixel wanted the point**, `PROBE_WANTED_SCREEN`. Want runs
2303
- first, so a point both want is listed once, as the screen's. Admit copies the bit into the record's
2304
- `screen` word, which sits in the padding the record had: still 64 B.
2305
- - **The row says which rays want**: shade leaves a front-face hit's albedo there and zero in every other
2306
- ray's, so a ray that takes no bounce wants nothing.
2307
- - **The trace list is cleared after the hits' wants**, which read the last frame's list, origins and
2308
- rays, told that frame's U and R by the host.
2309
- - Tier 2, `probe_clipmap_lifetime.spec.js`:
2310
- - a pixel's points are listed as the screen's, and so are their probes;
2311
- - a front-face hit of a screen probe wants its pick's cage where its ray ends, and admit gives the
2312
- points NEW probes, the hits';
2313
- - a probe only hits wanted has hits that want nothing;
2314
- - misses, unresolved rays, back faces, black hits, and updates past the list's count or bound want
2315
- nothing;
2316
- - a point a pixel and a hit both want is listed once, as the screen's;
2317
- - a probe the screen wanted and then only hits did becomes the hits', and the screen's again; a slot a
2318
- screen probe gave back is the hits' when a hit's point takes it;
2319
- - the cage viewed back along the ray, and the next cascade's in the skirt.
2320
- - Tier 3, `GPUProbeClipmap.spec.js`: the pass's place, its command, and the last frame's U and R.
2321
- - Mutants: 24 of 24 killed.
2322
-
2323
- **Measured (2026-10-30), the churn in a still scene.** Sponza at 1280×720, the clock at 60 Hz, the camera
2324
- and the light still, 3,600 frames settled. Then 600 frames, each read back: every slot's state and record,
2325
- the wanted list, the trace list and the period. A slot's probe is followed by its point and its update
2326
- count. Modelled as §8.5 has it, due agreed with the trace list on every probe past its first update, those
2327
- a full list turned away aside: 2.7 million checks on each GPU, none apart.
2328
- - **Hits' probes come and go, about four a frame.**
2329
- - The RTX 4090 admitted 3.9 a frame and retired 3.8–3.9 (three runs); the AMD iGPU 4.1 and 4.0.
2330
- - The screen admitted none.
2331
- - Every probe retired had gone unwanted for 1.017 s, the retention time and a frame.
2332
- - **They are the probes up to three updates old**, §8.6's 11%. Of the 4090's 501 (11.4% of the alive),
2333
- 487 were admitted within the 600 frames; of the iGPU's 532 (12.2%), 509. The few others are hits'
2334
- probes wanted on about one frame in twenty, whose want seldom falls on their turn.
2335
- - **The same points come back.** Of the 896 points admitted in the 10 s, 650 were admitted again, three
2336
- times at the median.
2337
- - **Each is wanted by one far ray.** A twin of the hits' wants kept, for each point, its shortest wanting
2338
- hit and the rays that wanted it.
2339
- - A point admitted was wanted by one ray (p50 and p90), 10.8 m from its probe at the median (p10
2340
- 5.3 m). Of those rays 60% were a uniform update's.
2341
- - A point kept was wanted by 6 rays a frame at the median, at 3.2 m.
2342
- - **Why far.** A radiance texel's ray stands for the texel's cone, t·√(4π)/n_r across where it lands:
2343
- 0.22·t at n_r = 16, which past t = 4.5 m is wider than a cage of the finest cascade. That cascade holds
2344
- 95% of the admissions. Such a cage is landed in by chance, once every few seconds (a probe both
2345
- admitted and retired within the 600 frames lived 1.3 s at the median), and the retention frees its
2346
- probes in between. The next landing finds no probe, so its ray takes no bounce from them.
2347
- - **What it costs**: the admitted probes' first updates and their next three cast 2,560–2,870 of the
2348
- frame's 52,600–53,700 rays on both GPUs, 4.8–5.5%, and are 1.3% of the updates. On the AMD iGPU the
2349
- passes a ray (trace, shade, light, the hits' wants) take 5.4 ms at the median and those a probe 2.0 ms,
2350
- so by those shares about 0.3 ms a frame; not timed against a build without it.
2351
- - **What moves it**, on the 4090, in admissions a frame against 3.9:
2352
- - a retention of 3 s: 0.7. Those up to three updates old fall to 5.9%, and 3.5% more probes are live;
2353
- - every request held at R, 256: 3.0. Those are the rays before the variable requests (§8.6), 2.3 times
2354
- as many, so the requests are not the cause;
2355
- - half the ray budget, T from 6 to 10: 5.1, and 15.9% up to three updates old. A hit renews a probe
2356
- at most once a hitter's period, while the retention is in seconds, so the churn grows with the live
2357
- count;
2358
- - the hits' wants and the light pass's pick at the finest cascade whose spacing covers the texel's
2359
- footprint, a twin of both: 2.4, with 11% fewer probes live and 4% fewer rays. At twice the footprint:
2360
- 1.9, with 30% fewer probes and 9% fewer rays. Both gather their admissions where the cascade changes
2361
- (t of 4.4 m and 2.2 m at the median). The cascade then follows each ray's distance, not the point, so
2362
- a point's wants split between two cascades.
2363
- - The frame's mean luma: 72.3 as built (72.4 on the iGPU), 71.5 at 3 s, 71.9 and 71.8 by footprint.
2364
- As built read 71.0–72.0 between runs (§8.6).
2365
- - **Hits' probes are retraced less often than every T frames.**
2366
- - The 3,077 hits' probes alive through the 600 frames were wanted on 67% of frames at the median, 11%
2367
- at p10.
2368
- - They were updated 6.8 times a second at the median and 0.8 at p10; the screen's, at least 6.6.
2369
- - At p90, 1.2 s had passed since their last update; at p99, 6 s.
2370
- - A hit wants a probe in the frame after its hitter's update, so its point's bit is set on fewer of
2371
- the probe's turns than a pixel's is.
2372
- - The passes did what this section and §8.5 said. The churn was the retention's, on points that hits land
2373
- in once every few seconds; a fixed retention was the departure from Enshrouded, whose cache evicts by
2374
- pressure (Alex, 2026-10-30), built below.
2375
-
2376
- **As built (2026-10-30), eviction by pressure, not by a retention time.** Enshrouded's cache stays full and
2377
- evicts every frame, more the fuller it is, so a probe keeps its history as long as there is room.
2378
- - **Retire** frees a probe whose point left its window, or one the last frame's schedule asked for.
2379
- `retention_time` is gone, from the settings and the passes: a hard break.
2380
- - **How many**: as many slots as the free ones fall short of the frame's wanted points and 5% of the
2381
- capacity (`PROBE_FREE_SLACK`, Alex's). The wanted points are the most a frame can admit, a camera cut's
2382
- included, and the slack is room for a burst of them the frame it comes.
2383
- - **Which, the least recently wanted first**: retire counts each probe it keeps and could evict into one of
2384
- 32 binades of the seconds since it was last wanted (`probe_clipmap_age_bucket`, the float's exponent, 1 s
2385
- beginning the middle one), `PROBE_COUNTER_AGES` on. Schedule walks them from the oldest, taking a bucket
2386
- whole while the shortfall takes it and the rest of the shortfall from the next, and writes that to
2387
- `probe_eviction`; retire's threads take that bucket's while a counter, `PROBE_COUNTER_EVICTED`, is under
2388
- it. Past the youngest bucket anything was counted in, schedule asks for nothing. No sort, no pass of its
2389
- own, nothing read back.
2390
- - **In use, never evicted**: a probe a want waits on, NEW or wanted since its last update (§8.5), and one
2391
- wanted the last frame (retire takes that frame's time). Where those fill the slots, an admission starves
2392
- and the capacity grows, as before.
2393
- - **Device, Sponza, the 4090**, the still view, 600 frames, against the base before §8.5's as-built note:
2394
- - admitted 0.007 probes a frame against 3.9, retired none against 3.8;
2395
- - the young probes' rays 19 a frame against 2,600; the frame's 54,800 against 53,800 (with §8.5's change
2396
- alone 58,500, the churn's probes taking their second updates);
2397
- - 5,400 probes live against 4,500;
2398
- - the probes up to three updates old still 11.3% of the alive: probes kept that hits want once in a few
2399
- seconds, updated when they are.
2400
- - **A walk**, the courtyard's length and back over 1,200 frames, the 4090:
2401
- - at the default capacity, 16,384, at least 6,849 slots stayed free: nothing was evicted;
2402
- - at N = 16, 4,096 slots, 488 frames evicted, no admission starved and the capacity never grew. The
2403
- frame's wanted points were half the slots there, so a frame short of room freed every probe out of use
2404
- older than about two frames.
2405
- - **The sun doubled**, frames to 50%, 90% and 95% of the change in the mean band 0 of the probes wanted:
2406
- 37–42, 98–106 and 117–133, against the base's 38–41, 85–99 and 117–135, two runs each.
2407
- - **The AMD iGPU**, the still view, separate processes and not paired, against the base:
2408
- - admitted 0.007 a frame against 4.0, the young probes' rays 24 against 2,840, the frame's 54,700
2409
- against 53,700;
2410
- - a want waited 6 frames for its update at the median, the screen's 13 and the hits' 20 at p99;
2411
- - the trace list held U, 512, nearly every frame against 477 a frame: the period counts every probe a
2412
- want waits on (§8.5), so the update fills its budget, where the hits' probes it skipped left it
2413
- short. The probe passes summed 7.91 ms at the median against 7.55, of which trace, shade and light
2414
- 5.25 against 5.02;
2415
- - retire 26.6 µs against 21.3, admit 13.6 against 8.3, schedule 3.5 against 0.8, the due passes 9.4
2416
- against 15.4.
2417
- - Tier 2, `probe_clipmap_lifetime.spec.js`: a probe nobody wants keeps its slot while there is room; past
2418
- the room the probes wanted longest ago go first, as many as the room lacks, a bucket in part where it
2419
- lacks part of one; one that leaves its window as its bucket is partly evicted takes none of it; a NEW
2420
- probe and one wanted the frame before are never evicted, and an admission the room still lacks starves;
2421
- a shortfall the evictable probes cannot cover asks for those counted, none younger. The first three
2422
- were red against the retention time; the rest guard the protections. Mutants: 13 of 13 killed.
2423
- - Tier 3, `GPUProbeClipmap.spec.js`: retire's times, schedule's slack, the eviction zero as made.
2424
-
2425
- ### 8.4 Relocation and classification
2426
-
2427
- **Relocation**, at the start of every update:
2428
- - With q the lattice point and d = `brick_clipmap_distance(q)`: while d < d_min,
2429
- `q += ∇d·(d_min − d)`, at most 3 times. The field is signed, so a probe inside the band is pushed
2430
- out.
2431
- - The offset is clamped to 0.45·s_c, RTXGI's 45%. The probe stays in its own Voronoi cell, and the
2432
- cage stays the lattice's.
2433
- - d_min = ¼·s_c.
2434
- - As built (S4b): see §8.5's as-built note; relocation's offset lives in the state's second word.
2435
- - RTXGI's minimum front-face distance is 1.0 m at its recommended 2–3 m spacing, about a third of
2436
- a cell. Lumen offsets only below 0.05 of a cell.
2437
- - A quarter keeps a probe well clear of the 0.1·s_c receiver bias (§9.1) and of the ½-voxel
2438
- expand its own rays start beside.
2439
-
2440
- **Classification**: DEAD when more than 25% of an update's rays hit back faces. That is RTXGI's
2441
- threshold for its fixed classification rays, and UE's mesh-SDF sign vote uses the same fraction.
2442
- Every update's rays cover the whole sphere, one per texel, which is what the fixed-ray threshold
2443
- assumes.
2444
- - A back face is a hit whose field gradient points along the ray: the ray came from inside a solid.
2445
- A ray that starts inside one hits at its first step (§7.2).
2446
- - A probe deeper than the band reads +R and is not relocated, but its rays reach the band from
2447
- inside, as back faces.
2448
- - A DEAD probe weighs zero and is traced once every 8 periods, so it notices when the scene changes.
2449
-
2450
- ### 8.5 Scheduling
2451
-
2452
- - At most `U = floor(ray_budget / rays_per_probe)` probes update a frame (512 at the defaults).
2453
- - **schedule** (one thread): `P_w` = probes wanted this frame, and `T = max(1, ceil(P_w / U))`. T
2454
- frames is the period, so it follows the live count.
2455
- - Then one thread per active probe. A probe is **due** if it was wanted this frame and any of these
2456
- holds:
2457
- - it is NEW;
2458
- - `(frame + h(slot)) mod T == 0`;
2459
- - it is DEAD and `(frame + h(slot)) mod 8T == 0`.
2460
- - NEW probes append first, as their own dispatch; the rest fill the trace list up to U.
2461
- - Every wanted probe is retraced every T frames. With 8,000 wanted probes that is T = 16: 0.27 s at
2462
- 60 fps.
2463
- - A camera cut that admits more than U new probes spreads them over frames. The stale ones simply
2464
- wait a period.
2465
-
2466
- **As built (S4b, 2026-10-28)**:
2467
- - **The state is two words**: the kind and updates, then where relocation moved the probe, three
2468
- 10-bit two's-complement fields of 0.45 of its spacing (a step under a thousandth of it, zero at the
2469
- lattice point). §9.1's pick needs every corner probe's position for its wrap term and its depth tap,
2470
- and §8.2's 4-byte state left the offset in the 64-byte record: a corner is now one 8-byte load.
2471
- - **Schedule** (one thread) sets T from the wanted list's count, and writes the command over the
2472
- frame's active list that the due passes dispatch through, and the next frame's retire.
2473
- - **Due** runs twice over the active list: NEW probes, then ALIVE ones on their turn and DEAD ones on
2474
- theirs in eight periods. "Wanted this frame" is the point's wanted bit. A probe's turn is
2475
- `(frame + lowbias32(slot)) mod T`. Past U the list's count goes on rising and nothing is written; the
2476
- update's command clamps to U.
2477
- - **Relocate** is its own pass over the trace list, a thread a probe, in a second graph pass that reads
2478
- the brick clipmap's last write of the frame (`GPUBrickClipmap.graph_update` now returns it), so it
2479
- orders after the build. The field's gradient is the new `brick_clipmap_gradient`: four taps of
2480
- `brick_clipmap_distance` a voxel across, zero where the field is flat.
2481
- - **On the device**, Sponza at 1280×720 with the caches baked:
2482
- - relocation moved 450 of 769 live probes on the 4090 (430 of 746 on the iGPU), some to the limit;
2483
- wanted probes are the cages of visible surfaces, so most sit within a quarter cell of one;
2484
- - T = 2 for 646 wanted at U = 512; nothing starved;
2485
- - schedule, the two due passes and the update's command: about 11 µs on the 4090, 8 on the iGPU;
2486
- - relocate: 24.6 µs on the 4090 and 23.4 on the iGPU for 512 probes. The same on both, so it is
2487
- latency-bound: three dependent steps of five field reads, over a launch of eight workgroups.
2488
- - Emulator fixed alongside: `extractBits` on `i32` now sign-extends (WGSL §17.9); it had zero-extended
2489
- both types, and the offset's decode depends on it.
2490
- - Tier 2: `probe_clipmap_schedule.spec.js` (the period, NEW first, unwanted never due, the turns, the
2491
- bound, the commands), `probe_clipmap_relocate.spec.js` (free space, a face, inside a solid, a
2492
- concave corner, the limit, a flat field, the cascade's own clearance), `chunk_probe_clipmap.spec.js`
2493
- (the offset's encoding), and the gradient in `brick_clipmap_trace.spec.js`.
2494
- - Mutants: 19 of 21 killed. The two survivors are equivalent on the fields the specs build: the early
2495
- exit where the field is flat, and the gradient's flatness threshold on fields exactly flat.
2496
-
2497
- **As built (2026-10-30), due waits on a want, not on this frame's bit.** The hits' wants (§8.3) set a
2498
- point's bit the frame after their probe's update, so a probe only hits keep had its bit on few of its
2499
- turns: measured on Sponza (§8.3), the hits' probes were updated 0.8 times a second at p10 against the
2500
- screen's 6.6 at the least, and 6 s had passed since the last update at p99, 21 s at most.
2501
- - **Waiting**: a probe is NEW, or a pixel or a hit wanted it since its last update, its wanted time past
2502
- its traced time. The update comes after admit in its frame, so a want in the frame of an update is
2503
- served by it. Due lists the NEW in its first pass, and a waiting ALIVE or DEAD probe on its turn.
2504
- - **The period counts the waiting**, `T = max(1, ⌈P_w / U⌉)` with P_w the probes waiting, not the
2505
- points wanted this frame. Retire counts the waiting probes it keeps and admit those the frame's wants
2506
- add, one atomic a subgroup each (`subgroup_atomic_add_u32`), into `PROBE_COUNTER_WAITING`, the
2507
- counters' fourth word; schedule takes the period from it and empties it.
2508
- - **Device, Sponza, the 4090**, the same still view and 600 frames, against master:
2509
- - the hits' probes alive through them, 2,984: 4.7 updates a second at p10 and 8.2 at the median,
2510
- against 0.8 and 6.8; 0.42 s since the last update at p99 and 1 s at most, against 7.2 s and 21 s;
2511
- - the screen's: 8.3 a second at the median against 10.8, the period 7 frames against 5–6;
2512
- - the frame's rays 58,500 against 53,800, the young probes' 5,300 of them against 2,600: the churn's
2513
- probes now take their next updates when a hit wants them again (§8.3);
2514
- - the trace list held more than U on 454 of the 600 frames, 524 a frame on average: due lists every
2515
- NEW probe besides a period's share of the waiting, so its count sits at U, and the last ones past it
2516
- wait their next turn.
2517
- - Tier 2: `probe_clipmap_schedule.spec.js` (the period over the waiting; a probe wanted between its turns
2518
- due on its turn; one whose wants its update served, the same frame's included, not due; a NEW one at
2519
- once), `probe_clipmap_lifetime.spec.js` (retire and admit counting each waiting probe once, through the
2520
- period). Each was red against the shaders before. Mutants: 8 of 8 killed.
2521
-
2522
- ### 8.6 Rays
2523
-
2524
- **trace**: one thread per ray, `trace_count × rays_per_probe` rays.
2525
- - **Texel**: R = `rays_per_probe`.
2526
- - R ≥ n_r²: R/n_r² rays per texel.
2527
- - R < n_r²: the texels `(update·R + k)` under a fixed bit-reversal permutation of the n_r²
2528
- texels, so each update covers a spread subset.
2529
- - **Direction**: `uv_octahedral_unit_decode((texel + ξ)/n_r)`, with ξ from STBN at (slot, update
2530
- count). This is one stratified jittered ray per texel, LPV's own pattern.
2531
- - **Origin and range**: the relocated probe, with t_min = one voxel of the brick cascade there.
2532
- - **Sun**: after `brick_clipmap_trace`, a hit traces `brick_clipmap_soft_shadow` toward each
2533
- directional light that casts shadows, up to 2 (a chosen cap; further lights are unshadowed at
2534
- hits).
2535
- - The shadow ray starts one voxel off the hit along the field's normal, outside the ½-voxel expand.
2536
- - This is the same pass because it is the same cost profile, an SDF march (E7).
2537
- - **Output**: a hit record {t, or MISS / UNRESOLVED; cascade; slot; normal; back-face bit; sun
2538
- visibilities}.
2539
-
2540
- **As built (S4c, 2026-10-28)**, `probe/shader_probe_clipmap_trace.js`:
2541
- - **The ray record** is 24 B (`PROBE_RAY_STRUCT`; 28 B with the light in three half floats until
2542
- 2026-10-30, now one RGB9E5 word): `t`; the direction and the hit's normal,
2543
- each octahedral in two unorm16; the `meshes` row of the hit's surface, from `brick_clipmap_object`;
2544
- the flags (kind, back face, the hit's cascade); and the light. Trace writes the
2545
- direct irradiance there and shade writes the radiance over it. The hit's position is the ray's
2546
- start plus `t` along the direction, which shading recomputes; the start was the probe's origin until
2547
- the ray origins below. (S4c's record held the two suns'
2548
- visibility in place of the light; S4d moved E_dir into the trace, below.)
2549
- - **The light as RGB9E5 (2026-10-30, Alex: the half floats were a poor encoding).** The engine's
2550
- `rgbe9995` chunks: one word, the three channels sharing an exponent. The encoder clamps to its
2551
- largest value, 65,408, which replaced the half-float holds in shade and the light pass and still
2552
- keeps the half-float atlases finite. A channel far below the brightest loses precision: beside
2553
- 65,408, 2 and 3 round to 0.
2554
- - Measured paired in one process. The five passes that read or write the record (trace, shade,
2555
- light, accumulate, the hits' wants) ran as twins built from master's WGSL, swapped in at
2556
- `setPipeline`, the two encodings each twice. There were 120 frames an arm, the clock at 60 Hz:
2557
- - 4090: Sponza +14 µs a frame across the five, all in the trace (+3%; its repeats differ by 3–10
2558
- µs); Emerald Square's avenue the same.
2559
- - iGPU: Sponza −124 µs (−2%; shade and light −4% each); Emerald the same.
2560
- - The image, settled 1,200 frames, master and the tree in separate processes: on Sponza they differ
2561
- by 1.6–1.7 codes a channel on average, and two runs of the tree by 1.8. The probes' noise is not
2562
- the same from process to process. On Emerald's avenue they differ by 0.01.
2563
- - **The texel mapping** is `chunk_probe_clipmap_ray_texel`, both ways (a ray's texel, a texel's rays),
2564
- so accumulation finds each texel's rays without a scatter. The bit-reversed subset repeats every
2565
- n_r²/R updates, which keeps its product in range.
2566
- - **The noise** is STBN at the texel moved by the slot's hash, so neighbouring probes do not jitter
2567
- alike, and at the update count.
2568
- - **The origin** is the lattice point plus the state's offset; t_min one voxel of the brick cascade
2569
- there; t_max the coarsest window's diagonal. Since S4d relocation writes it, `vec4(origin,
2570
- spacing)`
2571
- at the probe's place in the trace list (`probe_origins`), at the offset as encoded, and the update's
2572
- passes after it read it there.
2573
- - **Back faces** follow the gradient rule as written. Of a probe inside a solid, a ray heading out
2574
- through the nearer face is one; a ray nearly along the solid's faces can read as a front face, but
2575
- classification's share stays well past a quarter (over half for a probe in a slab, in the spec).
2576
- - **A surface turned from a light marches no shadow toward it**: the light gives it `max(0, n·l)`,
2577
- zero there. The light keeps its place among the shadowed, so which lights are shadowed does not turn
2578
- on the surface's orientation.
2579
- - **E_dir is the trace's** since S4d: Σ colour·max(0, n·l)·visibility over every directional light,
2580
- the first two that cast shadows by their soft shadow (a shadow ray since 2026-10-30), at a front-face hit. The trace reads the lights
2581
- already, and shade would have needed the light table as an eleventh storage buffer (below).
2582
- - **On the device**, Sponza at 1280×720 with the caches baked, 131,072 rays a frame (U = 512 × R = 256):
2583
- 96% hit, 3.4% miss, 0.2% unresolved, 2.5% back faces; the mean hit at 2.6 m; 13% of the sun seen at
2584
- front-face hits.
2585
-
2586
- | | AMD iGPU, p50 | RTX 4090, p10 |
2587
- |---|---:|---:|
2588
- | trace, as built | 7.9 ms | 355 µs |
2589
- | without the sun's shadow | 4.2 ms | 62 µs |
2590
- | the march alone, no lights, no object | 3.9 ms | 57 µs |
2591
-
2592
- - The facing test saved 4–7% of the shadow: most hits are on walls nearly parallel to Sponza's high
2593
- sun, whose shadow rays graze the wall they leave.
2594
- - On the 4090 the pass's p50 swung between runs, to 2–4 ms on some, while the same frame's twin
2595
- without the shadow held at 60 µs. Not explained yet; S4g measures it on a quiet machine.
2596
- - **At the default ray budget the update's rays cost about 8 ms a frame on the iGPU.** Reported with
2597
- S4's timings: `ray_budget` is the knob (§10), and the cost is linear in it.
2598
- - Tier 2, `probe_clipmap_trace.spec.js`, over bricks built from analytic fields:
2599
- - a ray a texel, every texel once; more rays a texel each, each inside it; fewer, a spread subset
2600
- that a run of updates covers;
2601
- - a miss out of every window; a hit at a sphere within half a voxel, with its row;
2602
- - a ray from inside a solid hits at its first step, a back face out through the nearer face; from
2603
- past the band, always; a probe beside a solid sees only front faces;
2604
- - the sun whole under an open sky, none under a roof; only the first two shadowed lights traced; no
2605
- shadow from a back face or from a surface turned from the light;
2606
- - neighbouring probes jitter apart; a probe within half a voxel of a surface steps off it; the ray
2607
- starts at the relocated probe; the rays of a probe past U are not traced.
2608
- - Mutants: 18 of 18 killed.
2609
-
2610
- **As built (ray guiding, 2026-10-29): Enshrouded's importance-sampled rays** (D11 reversed in its rays'
2611
- half). Alex found the noise of one jittered ray a texel visible and asked for Enshrouded's scheme,
2612
- rotating corner and all.
2613
- - **Which updates are guided**: every `PROBE_UNIFORM_PERIOD`-th update (5, the talk's) is uniform, as
2614
- above. So are a probe's first n_r²/R updates, one when R ≥ n_r², which together reach every texel:
2615
- Enshrouded gives a new probe its most rays for the same reason, and the guide would weigh a texel no
2616
- ray has reached as dark. Every other update is guided, except a DEAD probe's, which are all uniform:
2617
- readers weigh it at nothing, and only uniform rays reclassify it. Guided, its rare updates kept it dead
2618
- for 40 periods, and Sponza's dead probes rose by a quarter.
2619
- - **The guide** (`probe/shader_probe_clipmap_guide.js`), three passes a thread a row of each listed
2620
- probe's radiance map before the trace, per-invocation as projection is:
2621
- - rows: the luminance of the row's history texels;
2622
- - weights: each texel's share of the probe's luminance in fixed point (`PROBE_GUIDE_SCALE`), held to
2623
- `(PROBE_TEXEL_RAYS − 2 − R/n_r²)/R`;
2624
- - starts: the first ray of each walk position, `⌈R·Σ_{q<p}(w_q + e)/W − φ⌉`, with `e` the even share
2625
- of what the weights leave of the scale, and `φ` a hash of slot and update count. The walk is the
2626
- map's rows from the corner `updates mod 4` names.
2627
- - Integer weights make the walk's sums exact, so trace and accumulation read the same rays for each
2628
- texel. The hold leaves every texel under 64 rays, the gather budget, and what it takes off is spread
2629
- over every texel. A dark history spreads its rays evenly, which classification can trust.
2630
- - Kept: `probe_clipmap_guide_words` = 2·n_r² + 2·n_r + 2 words a listed probe (1.1 MiB at U = 512).
2631
- - **The trace** bisects the first rays for its walk position, at most log2(n_r²) steps. A texel's later
2632
- rays rotate its STBN jitter by R2 (`probe_clipmap_guided_ray_jitter`). The light pass, shade and the
2633
- hits' wants are unchanged: any per-ray noise serves the light pass's pick.
2634
- - **What it does to the rays**: on Sponza the back faces fall from 3–4% of the rays to 2.5–3%. The
2635
- hits shift toward lit surfaces, whose sun shadow rays are the trace's cost.
2636
- - **Measured, Sponza 1280×720.** Every comparison is the tree at period 1 (every update uniform, the
2637
- tree without guiding) against period 5, in separate runs on each GPU; values are given as AMD iGPU /
2638
- RTX 4090.
2639
- - **Screen, S4g's stability method.** TAA and AO were off; SSR on, so that the deferred diffuse and its
2640
- twins draw. The runs took 240 frames after the bake and 240 to settle, with the probes' clock fixed
2641
- at 60 Hz. Each number is the mean relative luma change of a pixel against the previous frame at the
2642
- same surface point, and **none moves**:
2643
-
2644
- | p50, period 1 → 5 | still | orbit |
2645
- |---|---|---|
2646
- | eight corners blended (the probes alone) | 0.55 → 0.56% / 0.54 → 0.55% | 3.83 → 4.01% / 3.95 → 4.09% |
2647
- | the shipped pick | 14.1 → 14.4% / 14.3 → 14.2% | 19.0 → 19.7% / 18.7 → 20.1% |
2648
- | the frame, SSR off (fused pass) | 10.7 → 10.3% / 10.3 → 10.2% | |
2649
-
2650
- Per frame, the pick's draw of two of eight is most of the diffuse's change (14% against 0.55% with
2651
- all eight blended), as S6's estimator session also found.
2652
- - **Probes**, the estimator session's probe-level method (`msme.js`, `probe_reads`):
2653
-
2654
- | period 1 → 5 | AMD iGPU | RTX 4090 |
2655
- |---|---|---|
2656
- | texels, an update apart, p50 | 1.05 → 1.37% | 0.92–1.03 → 1.27–1.29% |
2657
- | SH band 0, an update apart, p50 | 0.27 → 0.40% | 0.26–0.28 → 0.36–0.38% |
2658
- | atlas texels, 24 frames apart, Σ abs(ΔL) / Σ L | 8.7 → 7.5% | 8.7 → 7.2% |
2659
- | a doubled sun, 50% / 90% taken | | 44 / 160 → 34 / 117 frames |
2660
- | its halving, 50% / 90% | | 55 / 183 → 44 / 140 frames |
2661
-
2662
- Guiding makes the probes follow a light change about a quarter sooner. Their noise between one
2663
- update and the next rises a third, while over four updates it falls by a sixth. The uniform update
2664
- every fifth, which gives a texel one ray where the guided ones gave it several, is the likely source
2665
- of the update-to-update jitter; this is not isolated.
2666
- - **Before the multi-scale mean** (master at d1ca833d6, the exponential blend) the probes' noise fell
2667
- without the rise: the atlas 24.9 → 18.0% / 24.6 → 17.9%, the eight corners' p90 1.30 → 0.92% /
2668
- 1.24 → 0.87%. The screen did not move then either. The estimator took most of what guiding
2669
- took off.
2670
- - Dead probes fall by 9–17%. An alive probe is re-tested only on its uniform updates, a dead one on
2671
- every update, so a probe near the quarter spends more of its time alive. A probe inside a solid is
2672
- dead from its first update, which is uniform.
2673
- - **Paired cost**: one page, periods 1 and 5 alternating in blocks of 16 frames (ABBA, the first 4 of
2674
- each dropped), the camera still, p50 in each arm, on master with the multi-scale mean:
2675
-
2676
- | | AMD iGPU | RTX 4090 |
2677
- |---|---:|---:|
2678
- | trace | 6.79 → 7.58 ms | 335 → 470 µs |
2679
- | accumulate | 429 → 676 µs | 11 → 21 µs |
2680
- | the guide's three passes (both arms) | 496 µs | 28 µs |
2681
- | shade and light | +76 µs | ±1 µs |
2682
-
2683
- In all, **+1.6 ms a frame on the iGPU and +0.17 ms on the 4090**. The trace's rise is the sun's soft
2684
- shadow at the lit hits that guided rays favour.
2685
- - Tier 2: `probe_clipmap_guide.spec.js` (a dark history's even spread, shares by luminance, the hold,
2686
- the corner, U), `probe_clipmap_trace.spec.js` (each walk position's run in its texel, a texel's rays
2687
- apart, a uniform update and a DEAD probe ignoring the guide), `probe_clipmap_accumulate.spec.js`
2688
- (a texel's run averaged, the period by rarity on uniform updates for radiance and depth, a depth texel
2689
- as a mean of means), `probe_clipmap_project.spec.js` (a guided update keeps the kind; a dark guide's
2690
- update and a DEAD probe's classify).
2691
- - Mutants: 16 of 16 killed, among them the trace, accumulation and projection as they were.
2692
-
2693
- **The adversarial review (2026-10-29) found two defects in the first cut, both fixed and pinned:**
2694
- - **With R < n_r², a new probe took 5× the updates to see its sphere.** Its first update reached R texels
2695
- and cleared the rest, and the guide weighed the cleared ones dark. At n_r = 16 and R = 64, the whole
2696
- sphere was seen at update 15, not 3.
2697
- - Now a probe's first n_r²/R updates are uniform (above).
2698
- - Measured on the 4090, Sponza orbiting at R = 64, two runs each: the alive probes' mean irradiance
2699
- (E/π) was 0.0137 every update uniform, 0.0079 before the fix, and 0.0121 after it.
2700
- - The 12% that remains is not traced. The likely source is the guide itself at R < n_r²: a texel
2701
- whose sample read dark takes its next ray from the uniform updates, which reach it once in
2702
- 5·n_r²/R updates. At the default R = n_r², still, the guided irradiance matches the uniform within
2703
- 0.5%.
2704
- - **A guided depth texel took only the radiance texels that took rays**, so where it mixes lit and dark
2705
- directions (a back face, a shadowed wall) its depth leaned to the lit ones'.
2706
- - Now a guided update takes a depth texel only when every radiance texel inside it took rays. Its
2707
- period counts the least reached of them.
2708
- - Measured on Sponza at the defaults, still: the median depth moment was 1.590 every update uniform,
2709
- 1.599 before the fix, and 1.584 after it. The means agree within 0.3%, so on Sponza the bias was
2710
- small.
2711
-
2712
- **As built (ray origins, 2026-10-29): each ray starts at a point of its own**, uniform in a ball of
2713
- `PROBE_RAY_ORIGIN_JITTER` = 1/16 of the spacing around the probe, as Brick4's bake jitters its probes
2714
- (Alex's request).
2715
- - The point is `chunk_probe_clipmap_ray_origin`'s, a hash of the probe's slot and the ray's packed
2716
- direction. Shade, light and want hits put the hit back together from the same start, so the record
2717
- carries nothing new. The trace's jitter moves the direction every update, and the start with it.
2718
- - A sixteenth is half a brick voxel at the default sizes, well inside the relocation clearance (¼), so
2719
- wherever relocation reached the clearance every ray starts outside the solid.
2720
- - Measured on Sponza, the camera still, two runs a radius on the 4090 and one on the iGPU (harness:
2721
- session 481e40f0's scratchpad, `device/oj.js`):
2722
-
2723
- | radius, of the spacing | texel change an update apart, p50 | dead probes | depth σ, p50 |
2724
- |---|---:|---:|---:|
2725
- | 0 | 1.11–1.19% | 3.2% | 0.046 |
2726
- | 1/16 | 1.00–1.01% | 3.5% | 0.051 |
2727
- | 1/8 | 1.17–1.26% | 4.5% | 0.073 |
2728
- | 1/4 | 1.40–1.67% | 5.9% | 0.122 |
2729
-
2730
- - Alex compared the converged eight-corner diffuse at ¼ against 0. The jittered image is markedly
2731
- smoother, and sharp-contrast artifacts are gone. The whole-image |Δ|/L read under its own noise floor,
2732
- so the change is local, and that metric misses it. He chose 1/16.
2733
- - Brick4's own radius, 5e-5 m, changes nothing here: an SDF march has no float ties for it to break.
2734
- - Rebuilding hits from the probe instead of the ray's start is measurably wrong from ⅛ up: E/π +3.5–4%
2735
- and 10–13% more probes, from hits wanting the wrong cells. Matching costs at most one timer step a
2736
- pass on either GPU.
2737
- - Cost, measured at ¼ (paired by blocks of frames in one page): the trace +3.5% on the 4090 (442 →
2738
- 458 µs), nothing on the iGPU.
2739
- - Tier 2: `probe_clipmap_trace.spec.js` (the starts within the ball and spread over it; a wall's `t`
2740
- from each ray's own start), `probe_clipmap_light.spec.js` (a probe of one ray placed by its start; the
2741
- pick's hits from it), `probe_clipmap_lifetime.spec.js` (a hit across a lattice plane from its probe
2742
- wants the cage its start puts it in).
2743
- - Mutants: 5 of 6 killed. The survivor is shade rebuilding its hit from the probe: its spec has no card
2744
- resident, so no hit position reaches its output.
2745
-
2746
- **As built (a probe's own count of rays, 2026-10-30): the frame's rays are laid out by listed probe**, the
2747
- first stage of `PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md`. Every probe still casts R; what
2748
- changes is that nothing assumes it.
2749
- - **The layout.** After the trace command, the ray counts pass writes each listed probe's count of rays
2750
- (`probe_ray_counts`), and `graph_prefix_scan_csdldf`'s shaders scan them into `probe_ray_sums`. The k-th
2751
- probe's rays are `[sums[k − 1], sums[k])`, and the last sum is the frame's count, which a one-thread pass
2752
- turns into the rays' command.
2753
- - **Each pass finds its probe.** Trace bisects the sums (`lower_bound_branchless`, as the mesh expansion
2754
- does) and writes the probe's place in the list into the ray's flags, above the cascade, which is now
2755
- three bits. Shade and the hits' wants read it there. The light pass, accumulation and the guide also
2756
- read the probe's own count from the sums.
2757
- - **Past the last ray.** The threads of the launch's last workgroup past the frame's last ray write a
2758
- record of no ray (kind 3, `PROBE_RAY_NONE`). Every pass after trace stops there, so none needs the
2759
- frame's count. The rays buffer is rounded up to whole workgroups for them.
2760
- - **R still means the uniform update.** A probe's first n_r²/R updates are uniform, and the rate's
2761
- uniform term (§8.8) keeps the settings' R. A guided update's walk, its gather and its expected rays
2762
- take the probe's own count.
2763
- - **Storage buffers:** shade stays at 9, the light pass and accumulation go to 9, and trace goes to 8.
2764
- - **Device, Sponza, 4090,** clock at 60 Hz, 900 frames settled, two runs of each build alternating:
2765
- master and the layout agree within master's own run-to-run spread (frame luma 71.8/71.8 against
2766
- 71.0/72.0, alive probes 4,393–4,463 against 4,400–4,403, SH E/π mean 0.0131–0.0134 against 0.0133).
2767
- The passes' times are unchanged. The ray counts cost 4 µs and the rays' command 2 µs.
2768
- - Tier 2: `probe_clipmap_trace.spec.js` (a probe casting its own count after the probes before it, each
2769
- ray naming it; the records of no ray), `probe_clipmap_accumulate.spec.js` (two rays a texel averaged
2770
- where R says one), `probe_clipmap_guide.spec.js` (the walk sharing out the update's own count),
2771
- `probe_clipmap_schedule.spec.js` (the ray counts and the rays' command). Each was red against the
2772
- shaders before it.
2773
-
2774
- **As built (variable rays, 2026-10-30): each probe asks for its next update's rays**, the second stage of
2775
- `PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md`: Enshrouded's requests, on the period kept (F1).
2776
- - **Counts and requests.** The ray counts pass writes two parts for each listed probe. The first is what
2777
- the budget does not scale: a uniform update's R (a NEW probe's among them), and a DEAD probe's fewest,
2778
- n_r²/4 (`PROBE_RAYS_PER_TEXEL_MIN`). The second is what it does scale: a guided update's request, at
2779
- least the fewest. Both are scanned, and the ray layout pass gives the requests what the budget leaves
2780
- past the counts: all of it when they fit, in proportion when they do not (F2). The k-th probe's rays
2781
- end at its counts' sum plus its requests' sum so scaled, rounded down, so the frame never passes the
2782
- budget and needs no third scan.
2783
- - **The request** is the slot's own record, `probe_requests` (16 bytes a slot, not packed into `screen`
2784
- as F6 had it: the statistics below need three words). Projection writes it after every update:
2785
- - after a probe's first update, the most, 4·n_r² (`PROBE_RAYS_PER_TEXEL_MAX`), Enshrouded's new probe;
2786
- - when its light has moved, the most again;
2787
- - otherwise half the last request, down to the fewest.
2788
- - **Moved.** Accumulation leaves each blended texel's fall from its mean, luminance, in its own
2789
- deviation and held to ±3 (`PROBE_CHANGE_SIGNIFICANCE`), in a fourth word of the maps. Projection sums
2790
- them over the root of their count: the update's change score. Moved is the score past 3 of the probe's
2791
- own running deviation (at least 1) from its own running mean, both kept over the estimator's variance
2792
- window, 2τ, a running mean while young. A score past their reach counts at it, so a change in the light
2793
- does not widen the probe's spread at once, while a lasting shift of its own is taken in within a few
2794
- windows. A first update clears the slot's statistics, which were the last owner's.
2795
- - **DEAD probes** are due every period at the fewest rays (F4), not every eighth at R;
2796
- `PROBE_DEAD_PERIODS` is gone.
2797
- - **Not built: F3**, weighing a sample by its rays in the estimator. It waits on a measurement.
2798
- - **Device, Sponza, 4090,** clock at 60 Hz, the camera and the light still:
2799
- - false alarms, the probes past 64 updates asking for the most, 3,600 frames settled: 4.2% when the
2800
- change score summed the raw falls against zero, 3.2% with each texel's fall bounded, 1.1–1.3% as
2801
- built, against the normal tail's 0.27%;
2802
- - the probes up to three updates old are 11% of the alive ones (477 of 4,387) and 69–76% of them ask
2803
- for the most, as new probes do. That churn is the lifetime's admissions and retirements, measured in
2804
- §8.3, not the request's;
2805
- - the frame's rays, the last frame's layout of each run: 48,704–65,856, against master's U·R;
2806
- - trace p50 404–600 µs against master's 726–790 µs, separate processes and not paired;
2807
- - the noise between reads six frames apart: texels 1.25–1.49% against master's 1.23%, band 0
2808
- 0.38–0.46% against 0.35%;
2809
- - the sun doubled, the alive probes' mean band 0 reaches 50%, 90% and 95% of the change in 40, 156
2810
- and 246 frames, against master's 42, 177 and 257;
2811
- - the post-load dark spell is unchanged: measured with the first build of the request, the probes
2812
- were resident at 17.6 s and converged at about 32–35 s, master's timeline.
2813
- - **Storage buffers:** the ray counts pass binds 5, the ray layout 3, projection's probe pass 6.
2814
- - Tier 2: `probe_clipmap_project.spec.js` (the most after a first update; moved past three deviations
2815
- and the halving to the fewest; a steady offset sips once held; a jump asks the most while it lasts; a
2816
- lasting shift is taken in; a new probe forgets the slot's last owner), `probe_clipmap_accumulate.spec.js`
2817
- (the fall in its own deviation, bounded), `probe_clipmap_schedule.spec.js` (the counts by kind, the
2818
- budget's proportion, DEAD every period). Each was red against the shaders before it.
2819
-
2820
- **As built (the probes' order, 2026-10-30): the trace list is sorted by its probes' places, and the rays
2821
- are not sorted**, the end of `PROBE_RAYS_VARIABLE_AND_SORTED_SCOPE_2026_10_30.md` §2, where every
2822
- measurement is.
2823
- - **The order pass** (`shader_probe_clipmap_order.js`) runs after the update's command: a workgroup a run
2824
- of 1,024 listed probes (`PROBE_ORDER_RUN`, through `PROBE_DISPATCH_ORDER` over the live count) sorts
2825
- its run in place by the cascade above the Morton code of the probe's lattice point in the cascade's
2826
- window, with the bitonic workgroup sort (`chunk_sort_bitonic_workgroup`). Every pass after it reads a
2827
- probe's place from the sorted list, so neighbouring probes' rays run in neighbouring waves. Past one
2828
- run, the list is ordered within each run.
2829
- - **Why not the rays.** A wave already holds one probe's rays walking its texels in rows. Every ray sort
2830
- measured costs more than it buys: global radix keys, local sorts by direction (8×8 or 4×4 bins, chunks
2831
- of 2,048 to 512), within each probe, and position bits above the bin. On the iGPU at 131k rays the
2832
- local sorts lose 4–22%, a hash-keyed control 29–57% of the trace. The 4090 is level throughout.
2833
- - **Device:** iGPU 1–4% off the rays' stretch at 131k rays in most batches, the 4090 level; the pass
2834
- costs 11.8 µs on the 4090. Storage buffers: 3.
2835
- - Tier 1: the validity spec. Tier 2: `probe_clipmap_schedule.spec.js` (a workgroup a run, up to U). Tier
2836
- 3: `GPUProbeClipmap.spec.js` (the pass after the update's command, through its command). The sort
2837
- itself runs workgroup barriers the emulator cannot, so the device checks it: each run ordered and a
2838
- permutation of the list.
2839
-
2840
- ### 8.7 Hit shading
2841
-
2842
- **shade**: one thread per ray.
2843
-
2844
- | Ray | Radiance |
2845
- |---|---|
2846
- | MISS | `sample_environment_color` along d |
2847
- | UNRESOLVED | no sample |
2848
- | back face | 0, counted for classification; its depth sample is 0.2·t (DDGI 2021's 80% shortening), so the probe reads as occluded that way |
2849
- | HIT | `L = emissive + ρ_d·E/π`, with E = E_dir + E_loc + E_ind |
2850
-
2851
- The surface comes from `brick_clipmap_object` and `mesh_card_surface` (§6.6): diffuse albedo ρ_d and
2852
- emissive.
2853
-
2854
- The three irradiance terms of a HIT:
2855
- - **E_dir** is Σ colour·max(0, n·l)·visibility over `directional_lights_iteration_mask`.
2856
- - **E_loc** comes from the world light clusters at the hit, generalised to this mode's brick levels
2857
- so that every hit position has a cell.
2858
- - At most 32 lights, `IRRADIANCE_CLIPMAP_LIGHT_BUDGET`'s value. Past that, lights are dropped.
2859
- - Each light goes through `irradiance_clipmap_get_{point,spot}_light_info_by_index`. One of them is
2860
- picked by WRS on its luminance there and divided by the chance of the pick; its shadow is a ray
2861
- traced in the bricks (as built 2026-10-30, below). Until then each light was summed, its shadow from
2862
- `volumetrics_{point,spot}_light_shadow`, and a light whose shadow map was cleared lit hits
2863
- unshadowed.
2864
- - **E_ind** is this mode's own diffuse sampling (§9.2) at the hit, with last frame's SH: the next
2865
- bounce.
2866
- - Its pick takes the ray's own STBN sample as noise; the probe blend averages it.
2867
- - Where no probe exists, the bounce is **zero**, not the environment. A hit behind the camera in a
2868
- cave must not bring daylight in.
2869
- - The hit wants its cage (§8.3), so its probes stand there a frame or two later.
2870
- - LPV's 0.97 factor is dropped: albedo below 1 already bounds the series.
2871
-
2872
- **Hits are diffuse only**, as Lumen's surface cache lights diffusely (D7).
2873
- - The cards hold diffuse albedo and emissive: the payload Alex chose.
2874
- - A metal's diffuse albedo is zero, so a metal bounces nothing but its emissive. That is Lumen's
2875
- limitation too.
2876
-
2877
- **As built (S4d, 2026-10-28)**, `probe/shader_probe_clipmap_shade.js`, a thread a ray after trace
2878
- through the same command:
2879
- - **Its radiance goes over the ray's light** (RGB9E5 since 2026-10-30): no buffer of its own. With one, shade
2880
- would bind eleven storage buffers, past the ten a stage has (§12): the rays, the origins, the trace
2881
- list, the scene database, the texture means, the SDF headers and indirection, the card sets, the card
2882
- rows, and the radiance. In place it binds nine.
2883
- - A miss takes `sample_environment_color` along its direction; a front-face hit `emissive +
2884
- ρ_d·E_dir/π`, its surface from `probe_clipmap_hit_surface` (§6.6) at its cascade's voxel; an
2885
- unresolved ray and a back face zero. Radiance is held to the largest half float, so a bright sky
2886
- cannot write an infinity that a zero weight turns into a NaN.
2887
- - **The depth samples are accumulation's**: they follow from t, the kind and the spacing, which
2888
- accumulation reads anyway.
2889
- - E_loc and E_ind join E at S5 (§13).
2890
- - On the device, Sponza: 93–94% of the 131,072 rays are front-face hits. Cards serve 88.2% of them;
2891
- the material mean serves the other 11.8%, rows whose card set does not see the point. No hit
2892
- lacked a card set or an object. Misses see a black environment, as the harness's scene sets none.
2893
- 87% of front-face hits come back black: they are in the sun's shadow, and E_loc and E_ind come at
2894
- S5.
2895
-
2896
- | | AMD iGPU, p50 | RTX 4090, p50 |
2897
- |---|---:|---:|
2898
- | shade, as built | 3.27 ms | 45 µs |
2899
- | every hit on its material's mean, no card read | 224 µs | |
2900
- | the shell: the record, the decode, a miss's sky | 207 µs | |
2901
-
2902
- - **The card lookup is 93% of the pass on the iGPU.** `mesh_card_surface` reads every card
2903
- record's direction for each facing axis, up to 96 records a hit, and a page and two texels for
2904
- each candidate.
2905
- - **So the layout's header names each direction's first card** (`MESH_CARD_LAYOUT_DIRECTION_FIRST`,
2906
- six words the merge writes, the records being direction by direction already), and the scan reads
2907
- its direction's run alone. Measured interleaved against the old scan, first and second alternating
2908
- frame by frame, 120 frames, p50: iGPU 1.64 ms against 3.29 ms, 4090 26.6 µs against 42 µs.
2909
- - Tier 2: `probe_clipmap_shade.spec.js` (misses by direction, the material mean lit by E/π, nothing
2910
- from an unresolved ray or a back face, row 0 black, the half-float cap, the rays past U); the card
2911
- branch in `mesh_cards_capture.spec.js`, through a node twice the size, turned and moved, at hits
2912
- within 1.25 voxels of the surface and past the tolerance; the means in
2913
- `shader_resident_texture_means.spec.js` against means taken on the CPU, across rows, columns and
2914
- layers of slots.
2915
- - Mutants: 43 of 44 killed. The survivor removes the means' dispatches, which the software device
2916
- cannot see on the texture build's own encoder; the device check reads the means back against the
2917
- atlas.
2918
-
2919
- **As built (S5a, 2026-10-28)**, E_ind: `probe/shader_probe_clipmap_light.js`, a pass of its own after
2920
- shade, through shade's own launch.
2921
- - **Why its own pass.** Shade binds nine of a stage's ten storage buffers, and the pick takes two more.
2922
- The light pass binds five.
2923
- - **The albedo.** Shade leaves a front-face hit's diffuse albedo in the ray's `row`, `pack4x8unorm`, and
2924
- zero in every other ray's. Nothing after shade reads `row`.
2925
- - **The pick** is made at the hit, along its normal, with the view back along the ray.
2926
- - Its noise is the ray's own jitter, `probe_clipmap_ray_jitter`, extracted from the trace into
2927
- `chunk_probe_clipmap.js`; the trace aims its ray with it.
2928
- - It reads last frame's SH, since the projection writes the records later in the frame.
2929
- - Where nothing is found the bounce is zero, as above.
2930
- - **Tier 2.**
2931
- - `probe_clipmap_light.spec.js` checks:
2932
- - a hit takes its albedo by its probes' SH;
2933
- - misses, unresolved rays, back faces and black hits keep their light, and nothing is found nowhere;
2934
- - the pick is made at the hit, viewed back along the ray, with the ray's own jitter, across two
2935
- probes, against the chunks themselves;
2936
- - the half-float hold;
2937
- - a fresh jitter each update and round.
2938
- - Shade's stash is checked in `probe_clipmap_shade.spec.js`.
2939
- - Mutants: 18 of 18 killed, six by tests added for them.
2940
- - **Measured, the multi-bounce furnace**: a closed room of ρ = 0.5 glowing L_e, no light, the clock held
2941
- at 1/60 s, 900 frames. On the RTX 4090:
2942
- - With the rule above, E reaches **0.836 of πL_e/(1 − ρ)** at p50, p01 0.718.
2943
- - 12% of the front-face hits find no probe. Every one lies on a wall the camera does not see, behind it
2944
- or beside it, since probes stand only where pixels want them, and the rule makes their bounce zero.
2945
- - A twin that falls back to the tracing probe's own SH where nothing is found reaches 0.994 (p01
2946
- 0.988), but only because a uniform room makes every probe's irradiance the right one.
2947
- - **Alex, 2026-10-28: the hits want their probes (D31 reversed in part), and nothing stands in.** "If we
2948
- get no indirect bounce, it is better to lose energy than to invent energy." A hit whose cage has no
2949
- probe wants it (§8.3), and its bounce is zero until that probe's first update, a frame or two later.
2950
- - **Measured with the hits' wants**, same room and clock:
2951
- - E reaches **0.994** at p50 (p01 0.988) on the RTX 4090 and on the AMD iGPU, against 0.836 without.
2952
- No front-face hit finds nothing, against 12%. At ρ = 0.8 it reaches 0.992 after 1,800 frames.
2953
- - The room holds 418 probes, against 228: the walls the camera does not see.
2954
- - **On Sponza in the sun**, the camera still at 1280×720, the defaults (512 probes a frame), with the
2955
- hits' wants and without:
2956
-
2957
- | | nave view | toward a side wall |
2958
- |---|---|---|
2959
- | irradiance before AO, mean of the frame, 4090 | 0.108 vs 0.110 | **0.054 vs 0.050** |
2960
- | the same, iGPU | 0.112 vs 0.110 | **0.055 vs 0.050** |
2961
- | points wanted a frame | 2,600–3,200 vs 640 | 2,000–2,100 vs 160 |
2962
- | live probes | 4,600 vs 770 | 3,500 vs 180 |
2963
- | period T | 6–7 vs 2 frames | 4–5 vs 1 |
2964
- | the hits' wants pass | 25 µs 4090, 0.44 ms iGPU | 17 µs, 0.42 ms |
2965
- | trace, iGPU | 6.6 vs 4.9 ms | 7.6 vs 2.2 ms |
2966
-
2967
- - Toward the wall, where most of the building is off screen, the hits bring 7–9% more light. In the
2968
- nave view the frame's mean moves less than its own frame-to-frame swing.
2969
- - **Stability is unchanged** by the longer period: S4g's measure, the change of the diffuse from frame
2970
- to frame at reprojected points, p50 on the 4090, still 16.3% against 16.3%, orbiting 22.3% against
2971
- 25.1%. A probe the hits keep around the camera is there when the orbit brings its surface on screen.
2972
- - **The cost is the budget**: with the hits' probes wanted, each frame's updates reach U where the
2973
- screen alone left them under it, so the trace costs what the ray budget allows (§8.5). On the iGPU
2974
- that is most of the GI's growth.
2975
-
2976
- **As built (S5b, 2026-10-28)**, E_loc, in the light pass beside E_ind:
2977
- - **The world light clusters take any clipmap's windows.** `graph_build_world_light_clusters` binds a
2978
- uniform that starts with a `CLIPMAP_LEVEL_ARRAY`, and is told the cascade count and a border of the
2979
- clipmap's own cells. The irradiance clipmap passes its uniform and its tiles' skirt, as it always had
2980
- them; the probe clipmap the bricks' levels and no border, so every hit has a cell.
2981
- - **A cell by position**, `chunk_world_light_cluster_lookup_at`: the cell of a cascade holding a world
2982
- point, the nearest past the cells.
2983
- - **At a front-face hit**: the cell of the hit's brick cascade, at most `IRRADIANCE_CLIPMAP_LIGHT_BUDGET`
2984
- point lights and as many spots, each the irradiance clipmap's at the footprint of a voxel of that
2985
- cascade, its cosine at the normal, its map's nearest texel from a voxel off the surface. The light pass
2986
- binds 8 storage buffers. The renderer hands the GI the frame's shadow map atlas.
2987
- - **Measured on the device**, a closed room of albedo 0.5, nothing glowing, one point light inside: one
2988
- frame through a twin that adds E_loc alone, every front-face hit facing the light against I·cosθ/d²
2989
- under the range window.
2990
- - 0.9958 at p50 on both GPUs, and 1.0039 above p99: the albedo the hits read, 127/255 from the cards
2991
- and 128/255 from the material mean, against the 0.5 the check assumed. E_loc is exact.
2992
- - 0.27% of those hits read shadowed, all within a few centimetres of the floor's far corner 7.6 m from
2993
- the light, at a grazing angle, their points 0.12 m into the slab: acne a voxel's lift does not clear
2994
- there. None do without the light's shadow.
2995
- - **Its cost**, paired on Sponza (the light pass with E_loc and a twin without it, alternating frames),
2996
- p50:
2997
-
2998
- | | 4090 | iGPU |
2999
- |---|---:|---:|
3000
- | no local light | 28.7 against 27.6 µs | 1.70 against 1.64 ms |
3001
- | 8 shadowed point lights along the nave | 34.8 against 27.6 µs | 2.32 against 1.58 ms |
3002
-
3003
- - Tier 2: `probe_clipmap_light.spec.js`, against the light chunks themselves; `world_light_clusters.spec.js`
3004
- for assign with one light, which the emulator's lane-serial run serves exactly, since lane 0 loads the
3005
- batch before any lane reads it, and for the lookup.
3006
- - Mutants: 27 of 29 killed. The two left: the cascade bound in assign, which the launch sized by the
3007
- cascade count makes redundant; the cull box's far corner, which the host spec has no light to cull
3008
- against, and which the device check shows holding its light.
3009
-
3010
- **As built (2026-10-30), shadow rays at hits.** Alex: a shadow map is made for the main view, so where rays
3011
- reach it is wrong in most places, and the sun's cone soft shadow is made in another domain than the rays',
3012
- so it is bound to leak light and shadow. Both are traced now, through the bricks the hit's own ray met:
3013
- - **The sun**, in the trace pass: for each of the first `PROBE_SHADOWED_SUNS` lights that cast shadows, one
3014
- ray from a voxel off the hit along its normal toward a point of the light's disk, drawn as the path
3015
- tracer draws it (`cone_sample_direction`), from `pcg3d` of the ray's slot and packed direction and the
3016
- light's place among the shadowed. The sun is seen whole if the ray misses, not at all if it hits or
3017
- spends its steps. The probe's blend of its rays integrates the penumbra.
3018
- - **The local lights**, in the light pass: one of the cell's point and spot lights, picked by weighted
3019
- reservoir sampling over their luminance at the hit, with one number stretched over the candidates as
3020
- `sample_discrete_wrs` does. The pick lights the hit by its irradiance over the chance of its pick, w/W,
3021
- so the probe's blend averages the picks to the cell's sum. Its shadow is one ray from a voxel off the
3022
- surface toward a point of the light's sphere (`sphere_sample_volume`, as the path tracer), stopping short
3023
- of it by the light's near clip or a voxel, whichever is more: the field's half-voxel expand at that end,
3024
- as the lift is at the other. A light that casts no shadow traces none. The pick and the point are
3025
- `pcg4d` of the ray's slot and direction, apart from the origin's draw.
3026
- - What a ray cannot answer is no light, not all of it: a march that spends its steps shadows.
3027
- - The light pass binds the brick textures in place of the shadow atlas, the same 8 storage buffers. The
3028
- GI no longer reads the shadow maps, and the renderer no longer hands it their atlas.
3029
- - `brick_clipmap_soft_shadow` is deleted with its specs; nothing else called it.
3030
- - **Cost**, paired on Sponza at 1280×720 (the sun and the playground's 8 shadowed point lights along the
3031
- nave; HEAD's passes and these in blocks of 4 frames, ABBA, each block's first frame dropped), p50:
3032
-
3033
- | | 4090 | iGPU |
3034
- |---|---:|---:|
3035
- | trace (the sun) | 364.5 against 415.7 µs | 9.01 against 9.25 ms |
3036
- | light (local lights) | 43.0 against 28.7 µs | 3.38 against 2.43 ms |
3037
- | shade, unchanged, the clock's check | ×1.000 | ×0.995 |
3038
-
3039
- A hit ray stops at its first occluder, where the cone estimate marched on into the umbra, so the sun
3040
- costs less. One traced ray in the light pass costs more than reading every light's map. On the iGPU the
3041
- two together are 0.72 ms a frame more.
3042
- - Tier 2:
3043
- - `probe_clipmap_trace.spec.js`: under a slit 0.5 m wide in a roof, a sun of 0.25 rad, 128 probes'
3044
- rays each see the whole sun or none, and on average 0.258 of it against the 0.327 of the disk the
3045
- slit lets through, computed on the CPU with the walls standing out half a voxel (1,024 rays read
3046
- 0.295: the bricks round the slit's corners). The cone estimate read 0.675 there.
3047
- - `probe_clipmap_light.spec.js`, over bricks built from boxes: a slab between a point light and the
3048
- hit shadows it, the floor under the hit and a slab past the light do not, and a light that casts no
3049
- shadow is lit through the slab; a slab shadows a spot; a light 3 cm under a ceiling lights 16 hits
3050
- from under it to 4 m out; over 256 rays at one hit, each ray is one of four lights over the chance of
3051
- its pick, each light picked within four sigma of its share, and the mean is their sum.
3052
- - Red against HEAD's passes, its light pass given a cleared atlas, as a light never drawn has: the
3053
- slab between the point light and the hit lit it, so did the slab over the spot, and every ray was the
3054
- four lights' sum.
3055
- - Mutants: 11 of 11 killed: no division by the pick's chance; the first light always; the number not
3056
- stretched; the ray to the light's point, or short of it by its near clip only; from the surface, not
3057
- a voxel off it; a light's no-shadow flag ignored; a hit counted as light, for the sun and the local
3058
- light; the sun's disk centre alone; one draw for every probe.
3059
-
3060
- ### 8.8 Update
3061
-
3062
- **accumulate**: one workgroup per traced probe.
3063
- - **Blend weight**: α = 1 on the first update. After that, α = max(1/(n+1), 1 − e^(−Δt/τ)), with Δt
3064
- the seconds since the probe's previous update and τ = `response_time` (default 0.5 s).
3065
- - DDGI's 0.97 a frame at 60 fps is τ = 0.55 s.
3066
- - As built (2026-10-30): Δt is the frames since the update and τ = `response_frames` (default 30), §8.2.
3067
- - **Radiance**: each texel hit this update takes the mean of its rays, blended with its history.
3068
- The history is read from the atlas through a sampled binding.
3069
- - **Depth**: each depth texel takes the moments of the rays landing in it (a miss counts as the
3070
- depth limit, D9), blended the same way.
3071
- - Texels without a ray keep their history; so does an unresolved ray's texel.
3072
- - **The transient holds the probe's whole maps**, new texels and kept ones: n_r² × 8 B + n_d² × 4 B
3073
- per traced probe, 1.2 MiB for U = 512. `publish` cannot read the atlas it writes, and it needs
3074
- every texel to rebuild the skirts.
3075
- - **SH2**: Σ over all n_r² texels (new or kept) of `radiance × sh2_basis_at × octahedral_solid_angle`.
3076
- The reduction is a subgroup sum plus one LDS pass, stored through `convert_to_irradiance_sh`. There
3077
- is one temporal state, the radiance; the SH follows it.
3078
- - **Classification**: the back-face fraction updates the state (§8.4).
3079
- - **Stall bound**: a blended value stops moving when α·|Δ| is under half a half-float ULP. That is
3080
- within 0.5 ULP/α of its target: about 0.1% at T = 16, and 1.6% when probes update every frame.
3081
- - Measured on the 4090, `rgba16float` render-target writes truncate toward zero. Whether storage
3082
- writes do is unmeasured; if they do, this bias is one-sided. §12 measures it.
3083
-
3084
- **publish**: one workgroup per traced probe. It rewrites the probe's whole tiles from the transient
3085
- through storage bindings, with their one-texel skirts (`texture_octahedral_skirt_texel_uv` names each
3086
- skirt texel's fold twin).
3087
-
3088
- **As built (S4e, 2026-10-28)**: four passes after shade, each a thread an element, through commands the
3089
- update's command writes. **The one-workgroup-a-probe design above is not what was built.** The emulator
3090
- runs a workgroup's lanes one after another and treats a barrier as a no-op, and its subgroup sum
3091
- returns the lane's own value, so a reduction across a workgroup cannot be tested there. Every pass is
3092
- a per-invocation function instead. Each is tier 2 whole, and no thread's loop runs past 64.
3093
- - **accumulate** (`probe/shader_probe_clipmap_accumulate.js`): a thread a texel of the n_r² radiance
3094
- and n_d² depth maps. It gathers its rays through `probe_clipmap_texel_ray` and writes the probe's
3095
- maps (`probe_maps`, `probe_clipmap_maps_words` = 3·n_r² + n_d² words a probe): rgba as half floats,
3096
- then the texel's resolved and back-face rays, then the depth moments.
3097
- - **α is a texel's, not the probe's**: max(1/(a+1), 1 − e^(−Δt·p/τ)), where a is the texel's count of
3098
- the updates that reached it, kept in the radiance's alpha and capped at 255, and p the updates
3099
- between a texel's rays (n_r²/R below a ray a texel). Below a ray a texel, a texel's first ray is
3100
- then taken whole whenever it comes, which the probe's count would get wrong. Depth blends by the
3101
- probe's α.
3102
- - **The radiance is a multi-scale mean since 2026-10-29** (D11; `shader/chunk/filter/chunk_multiscale_mean.js`):
3103
- the texel's sample feeds Enshrouded's estimator. Its state past the mean is a slot's n_r² ×
3104
- 16 B in `probe_estimators` (short mean, deviation, blend reduction, inconsistency, as halves
3105
- rounded stochastically): 4 KiB a slot at n_r = 16.
3106
- - The short mean blends by the α above, its variance by the same with 2τ. The mean takes at least
3107
- 1/(a+1) until the count's cap, so a young texel is its running mean; the estimator's blend
3108
- reduction starts at nothing and would otherwise hold a new probe on its first ray.
3109
- - **The blend reduction follows by the reference's 0.1 a sample** (fixed 2026-10-29), not by the short
3110
- blend as first built. The catch-up takes the last sample's reduction. Once samples are a response
3111
- time apart, the short blend is 1 − 1/e, the reduction tracked each sample, and the long mean fell back
3112
- fastest from the bright ones. Measured on the emulator over samples of mean 1 and deviation 1, at
3113
- short blends of 0.55, 0.63, 0.78 and 0.9:
3114
- - the texel settled 7, 18, 28 and 39% low;
3115
- - at 0.1 a sample, 2, 3, 7 and 15%, with the same step response.
3116
-
3117
- A texel the guide passes over has a short blend of 0.63 at 60 Hz with a period of 6 frames. At
3118
- 15 fps, every texel's short blend is past 0.55. At the short blend of 0.18 that the table below ran
3119
- at, the fix moves nothing (error 13.9 → 14.0%, update to update 0.69 → 0.70%), and that table was
3120
- not measured again.
3121
- - The firefly clip, `0.1 + short + 8·deviation`, waits until 1/(a+1) is no longer the variance's
3122
- blend. Its 0.1 is the reference's, in radiance units; on Sponza under a sun of 7.2 a texel's
3123
- luma is under 0.26 at p99, so it clips only far outliers there.
3124
- - Measured on Sponza, the camera still, 4090 (`scratchpad/device/msme.js`, 2026-10-29):
3125
-
3126
- | τ | texel change an update | band 0 change | 50% / 90% of a doubled sun | 50% / 90% of its halving |
3127
- |---|---|---|---|---|
3128
- | 0.5 s, before | 10.8% | 1.54% | 29 / 105 frames | 29 / 102 |
3129
- | 0.5 s | 1.0% | 0.27% | 55 / 389 | 54 / 212 |
3130
- | 0.25 s, before | 20.5% | 3.0% | 17 / 79 | 17 / 77 |
3131
- | 0.25 s | 5.0% | 1.24% | 23 / 234 | 24 / 99 |
3132
- | 0.125 s | 26% | 4.9% | 19 / >390 | 6 / 37 |
3133
-
3134
- The screen's diffuse barely moved: a frame against the last at the same point, 4.72% → 4.66% of
3135
- all eight corners and 16.1% → 15.8% of the pick, still; 9.4% → 9.0% orbiting. The SH averages
3136
- a probe's texels, so what changes a still frame's diffuse is not the probes' radiance.
3137
- - Cost, the old pass beside the new in the same frames, which goes first alternating: 4090 15–16
3138
- against 13 µs; AMD iGPU 860–1,264 against 337–523 µs, 2.0–2.5×. A twin with the estimator's
3139
- arithmetic replaced by a plain blend costs what the shipped pass does: the time is the state's
3140
- load and store, not the estimator. Run order moves the iGPU's figures by 2×, so the split
3141
- between loading and packing the state is not settled.
3142
- - **A new probe's history is its slot's last owner's**: on its first update a texel no ray reached is
3143
- cleared, radiance to nothing and depth to a miss's moments (L, L²), L the depth limit (D9).
3144
- - Depth samples, in spacings: a miss L, a back face 0.2·t, a hit t, each held to L. A depth texel
3145
- gathers R/n_d² rays, so the GI refuses R > 64·n_d².
3146
- - **Since ray guiding (2026-10-29, §8.6)**: on a guided update a texel's rays are its run in the guide.
3147
- Its mean needs no weight for the guide's choice, since its rays spread evenly over it.
3148
- - p is one over the share of updates that reach the texel, averaged over the uniform period:
3149
- (min(R/n_r², 1) + (P − 1)·min(R·pdf, 1))/P. A texel the guide passes over then blends what the
3150
- uniform updates bring as fully as its rarity asks, and the response stays τ.
3151
- - A guided depth texel is the mean of its radiance texels' own means, taken only when every one of
3152
- them took rays. A plain mean would lean to the brighter ones' depths, and so would a mean over
3153
- only the lit ones. Its period counts a uniform update as reaching it, and a guided one as often
3154
- as its least reached radiance texel.
3155
- - Classification takes only uniform rays: a guided update keeps the kind and the back-face share,
3156
- since guided rays shun back faces (§8.4).
3157
- - **the projection** (`probe/shader_probe_clipmap_project.js`): a thread a row of a probe's radiance
3158
- map sums its texels as `convert_to_irradiance_sh(L, ω)·dω/(2π)` and its rays' counts; then a thread a
3159
- probe sums its rows into the record's SH, its back-face share, its time, and its state: dead past a
3160
- quarter, alive at or below, its kind kept by an update with no ray resolved, its update counted.
3161
- - **publish** (`probe/shader_probe_clipmap_publish.js`): a thread a texel of the probe's two tiles,
3162
- skirts included, each skirt texel its fold twin's (`texture_octahedral_wrap_texel_coordinates`), the
3163
- atlases stored into and nothing else.
3164
- - On the device, Sponza: after the caches bake and 60–90 still frames, 742–769 live probes, every
3165
- one updated (about 300 updates on the 4090, 112 on the iGPU). 13 are dead, the back-face share's
3166
- p50 is 0.8% and its p90 3.5–3.9%, and no SH is not finite. Irradiance resolved facing the six
3167
- axes, E/π: p50 0.0003–0.0004, p90 0.045–0.046, max 0.38, the same on both GPUs. Most of their rays
3168
- hit surfaces in the sun's shadow (87%, §8.7), and the sun is the only light until S5.
3169
-
3170
- | p50 | AMD iGPU | RTX 4090 |
3171
- |---|---:|---:|
3172
- | accumulate | 163 µs | 8.2 µs |
3173
- | the projection's rows | 295 µs | 7.2 µs |
3174
- | the probes' projection | 15.8 µs | 12.3 µs |
3175
- | publish | 189 µs | 6.1 µs |
3176
-
3177
- The rows are most of it on the iGPU. They work out each texel's solid angle and SH afresh, for
3178
- every probe.
3179
- - Tier 2: `probe_clipmap_accumulate.spec.js` (a first update whole, α's two terms and the count's cap,
3180
- the time between a texel's rays, unresolved rays and back faces, a new probe's clearing, depth by
3181
- kind in spacings, a finer depth map, U), `probe_clipmap_project.spec.js` (a constant radiance and a
3182
- lit hemisphere against the engine's own resolve, the dead threshold, a blind update, U),
3183
- `probe_clipmap_publish.spec.js` (both tiles' skirts against the fold, nothing else written).
3184
- - Mutants: 33 of 33 killed, five of them by tests added for them: a new probe's depth cleared, each
3185
- texel's solid angle against one integrated from the decode, and the three launches the update's
3186
- command adds.
3187
-
3188
- **Measured (S4g, 2026-10-28): the stores round toward zero, so accumulate rounds them itself.**
3189
- - Both GPUs gave the half toward zero for all 131,072 values tried, on each of three paths:
3190
- `pack2x16float`, `textureStore` into `rgba16float`, and a render target's write. The stall above is
3191
- therefore a whole ULP/α, and one-sided: below the target.
3192
- - α follows the frame rate, since the GI's clock is the caller's (`performance.now()` in `Renderer`).
3193
- - On §12's closed room glowing L, after 900 frames to settle, E/πL was:
3194
-
3195
- | | 60 Hz | 240 Hz |
3196
- |---|---:|---:|
3197
- | rounded by the store, toward zero | 0.985 | 0.941 |
3198
- | rounded to nearest before the store | 0.992 | 0.970 |
3199
- | rounded stochastically (built) | 0.999 | 0.999 |
3200
-
3201
- The first row is the 1.5% and 5.9% that ULP/α predicts.
3202
- - **Accumulate now rounds every stored component stochastically onto a half itself**, for radiance and
3203
- for depth: `shader/chunk/math/chunk_f16_round_stochastic.js`, with noise from `pcg3d(slot, texel,
3204
- updates)`.
3205
- - The value it hands the store is a half already, so the device's own rounding never applies.
3206
- - The blend moves in expectation however small its step.
3207
- - The emulator's `pack2x16float` rounds to nearest even, so tier 2 sees half the devices' stall, and on
3208
- both sides. `probe_clipmap_accumulate.spec.js` pins convergence from either side at α = 1/64; it was
3209
- red before the change.
3210
-
3211
- **As built (re-sampling, 2026-10-30): Enshrouded's probe reprojection, a step of its own, off by default.**
3212
- Alex asked for it as a prototype he can switch for testing, chasing convergence, blur accepted.
3213
- - **The step**: `probe/shader_probe_clipmap_resample.js`, recorded before accumulate only when
3214
- `ProbeClipmapSettings.resampling` is on. A thread a radiance texel of each listed probe B, through the texels'
3215
- command. It traces nothing: it reads the atlases as the last publish left them.
3216
- - 4 distinct of B's 26 neighbours in its cascade's 3×3×3 (`PROBE_RESAMPLE_NEIGHBOURS`), a start and a stride
3217
- coprime with 26 from a hash of the slot, the texel and the update count. Over its updates a texel meets
3218
- all 26.
3219
- - The point B sees: along the texel's centre at B's mean depth there. A neighbour (ALIVE, its record's cell
3220
- the point asked for, since the storage wraps) is read toward it, weighed by how well its depth confirms a
3221
- surface there: s²/(s² + (d − μ)²), s² its depth's variance plus the receiver bias squared.
3222
- - Two-sided because a neighbour that sees past the point is looking past a point in mid-air, which B's
3223
- texel puts there where it mixes a near depth with a far one.
3224
- - Nothing depth cannot confirm: a texel whose depth is at the limit takes nothing, nor is a point at the
3225
- limit or past it from the neighbour read.
3226
- - **Accumulate** pools a texel's rays with its re-samples, each a ray's worth at a visibility of one, through
3227
- the `PROBE_CLIPMAP_RESAMPLING` switch (`probe/chunk_probe_clipmap_resampled.js`): off, its WGSL is what it
3228
- was. Only a texel some ray reached takes them.
3229
- - **Measured on Sponza, both GPUs (`scratchpad/device/resample.js`, 2026-10-30)**: settle at one end, cut the
3230
- camera to the other, where the finest probes are new. The eight-corner diffuse in 8×8 blocks each frame,
3231
- against a reference of four times the rays without re-sampling. 3 runs an arm; mean error, [range]:
3232
-
3233
- | frames after the cut | 4090 off | 4090 on | iGPU off | iGPU on |
3234
- |---|---|---|---|---|
3235
- | 1–10 | 0.140 [0.106–0.181] | 0.158 [0.139–0.174] | 0.119 [0.100–0.129] | 0.132 [0.113–0.158] |
3236
- | 11–30 | 0.090 | 0.105 | 0.089 | 0.101 |
3237
- | 31–120 | 0.055 | 0.073 | 0.058 | 0.067 |
3238
- | 121–480 | 0.038 | 0.080 | 0.039 | 0.069 |
3239
- | 481–1200 | 0.037 | 0.094 | 0.030 | 0.081 |
3240
- | first frame under 5% | 53–108 | never | 71–101 | never |
3241
-
3242
- - Worse in every window on both GPUs, and growing: the re-samples are the neighbours' histories, which hold
3243
- the re-samples they took, so the field settles on a fixed point of its own and drifts toward it. Against its
3244
- own settled frame, re-sampling converges no faster either (4 and 3 runs: 4090 31–120 frames 0.087 against
3245
- 0.060; 481–1200, 0.022 against 0.026).
3246
- - Settled: frame-to-frame change 0.0064 against 0.0069; the mean +4.7% (4090) and +3.3% (iGPU) over the
3247
- reference, against +2.0% and +0.4% off; |diff| 9.3% and 7.8% against 3.6% and 2.3%.
3248
- - Taking what depth cannot confirm made it worse: one-sided Chebyshev, with B's far texels read along their
3249
- own direction and points past the limit at the limit alone, came out +11.5% on the 4090; two-sided, +9.8%;
3250
- without the points past the limit, +6.5%; without either, +3.2% in its first run.
3251
- - The sun doubled once settled: 90% of the step in 182–288 frames on the 4090 against 190–260; on the iGPU
3252
- 280–335 against 192–261.
3253
- - Cost, the arms alternating frame by frame in one process: the pass 18.4 µs on the 4090 (the update's trace
3254
- ~0.9 ms), 1.03 ms on the AMD iGPU (its trace 4.9 ms); accumulate unchanged on both.
3255
- - Tier 2: `probe_clipmap_resample.spec.js` (the picks, every neighbour met, the weight both ways and with the
3256
- neighbour's spread, the read direction, nothing unconfirmed, the wrap, the kinds, a texel no ray reached),
3257
- `probe_clipmap_accumulate.spec.js` (the pool, the switch off, a texel no ray reached); 12 of 12 mutants of the
3258
- pass killed, 3 of 3 of the pool. Tier 3: `GPUProbeClipmap.spec.js` (recorded only when on, before accumulate,
3259
- through the texels' command).
3260
-
3261
- **As built (re-sampling confidence, 2026-10-30): a re-sample weighs what it reads.** Alex asked for the
3262
- neighbour's estimator spread, its sample count and its age.
3263
- - **Confidence**: σ²_B / (σ²_B + σ²_A / n_A). σ²_A is the deviation of the neighbour's texel the point falls in
3264
- (`probe_estimators`, luma), n_A that texel's count (the radiance alpha), so σ²_A / n_A is the variance of its
3265
- mean; σ²_B is the variance of one of B's own rays, from B's texel. Both absolute, floored at 1e-5, so a dark
3266
- neighbour is not doubted for being dark. A neighbour no noisier than one of B's rays counts as one; one whose
3267
- mean is as noisy as a ray, as half.
3268
- - **Freshness**: e^(−age / `response_frames`), age the frames since the neighbour's `traced_frame`.
3269
- - Still at most a ray's worth: visibility × confidence × freshness.
3270
- - **No solid angle.** Rays are jittered over their texel's octahedral square and averaged unweighted; the
3271
- solid angle enters at the projection, per texel. A re-sample is one tap along B's texel centre, and the
3272
- neighbour's bilinear tap averages its own texel's footprint, which is not B's. Neither is corrected.
3273
- - **Measured** (`scratchpad/device/ab/`, 2026-10-30): plain is the pass of the block above, confidence this
3274
- one, against references of four times the rays without re-sampling taken that day. 4090 3 runs an arm, iGPU 2;
3275
- mean [range]. Stability settles 1,500 frames, TAA off: |Δ| frame to frame over the frame's mean, the same pixel
3276
- still, the reprojected point on an orbit; probe texels |Δ| between two reads of a probe updated between them.
3277
-
3278
- | | 4090 off | plain | confidence | iGPU off | plain | confidence |
3279
- |---|---|---|---|---|---|---|
3280
- | still, eight corners | 0.0506 | 0.0476 | 0.0481 | 0.0506 | 0.0474 | 0.0482 |
3281
- | still, pick | 0.144 | 0.130 | 0.131 | 0.144 | 0.131 | 0.132 |
3282
- | orbit, eight corners | 0.111 | 0.076 | 0.090 [0.084–0.096] | 0.110 | 0.075 | 0.083 |
3283
- | orbit, pick | 0.237 | 0.152 | 0.201 [0.180–0.215] | 0.238 | 0.151 | 0.176 |
3284
- | probe texels an update | 0.019 | 0.023 | 0.022 | 0.020 | 0.033 | 0.027 |
3285
- | vs reference, 31–120 | 0.082 | 0.073 | 0.098 | 0.056 | 0.070 | 0.078 |
3286
- | vs reference, 121–480 | 0.046 | 0.064 | 0.066 | 0.032 | 0.079 | 0.061 |
3287
- | vs reference, 481–1200 | 0.030 | 0.069 | 0.053 | 0.029 | 0.084 | 0.055 |
3288
- | first frame under 5% | 197–282 | never | 455–888 | 32–117 | never | 465–494 |
3289
- | vs own settled, 31–120 | 0.070 | 0.071 | 0.087 | 0.055 | 0.065 | 0.073 |
3290
- | vs own settled, 121–480 | 0.035 | 0.036 | 0.049 | 0.031 | 0.028 | 0.046 |
3291
- | settled mean vs reference | −0.9% | +2.2% | −0.7% | +0.6% | +3.2% | +0.5% |
3292
- | sun ×2, 90% (frames) | 234–255 | 245–287 | 237–263 | 220–229 | 224–251 | 216–218 |
3293
- | the pass (µs) | | 18.4 | 34.1 | | 905 | 1,484 |
3294
-
3295
- - Confidence takes the bias out: the settled mean is off's on both GPUs. The per-pixel error stays above
3296
- off's, so what is left is spatial, the blur.
3297
- - Re-sampling is quieter in every arm, most on an orbit: plain −31% at the eight corners, −36% at the pick;
3298
- confidence keeps about half of that. Still, −5% to −10% in both.
3299
- - It converges no faster in any arm, toward the reference or toward its own settled frame; confidence is the
3300
- slowest of the three toward its own. A probe's texels move more an update with re-sampling.
3301
- - Confidence doubles the pass.
3302
- - Tier 2 adds to `probe_clipmap_resample.spec.js`: the confidence (a count of 4 and of 1, a dark neighbour,
3303
- the floor, the floor against a deviation above it), the freshness at two ages, the spread read from the
3304
- neighbour's texel the point falls in. Every mutant of the terms killed. Tier 3: the pass's frame and
3305
- response immediates, the estimators bound.
3306
-
3307
- ## 9. Sampling
3308
-
3309
- ### 9.1 The pick: `probe_clipmap_pick(position, normal, view_direction, ξ) -> pick`
3310
-
3311
- 1. `clipmap_select_finest` gives cascade c and the blend weight b toward c + 1.
3312
- 2. **Biased point**: `p_b = p + (0.8·n + 0.2·v)·0.1·s_c`.
3313
- - The mix is LPV's `mix(normal, view_direction, 0.2)`.
3314
- - The length is a chosen value between the precedents: RTXGI's defaults are 0.1 m along each of n
3315
- and v at a recommended 2–3 m spacing, and LPV's is 0.05 of its probe span. It is a tuned order of
3316
- magnitude in both, not a derivation, and the leak cases of §12 check it.
3317
- 3. **Weigh the eight corners of `floor(p_b/s_c)`, visibility only.** Per corner:
3318
- - the indirection entry, then the state word: missing, NEW or DEAD probes weigh 0;
3319
- - one bilinear depth tap toward p_b at distance `|p_b − probe|/s_c`;
3320
- - weight = trilinear × `probe_visibility_weight`: the wrap back face, Chebyshev from the tap, and
3321
- the crush.
3322
- - The wrap is `((n·ω+1)/2)²`, without DDGI's + 0.2. With it, a probe behind Sponza's thin outer
3323
- wall, which Chebyshev passes at 0.94–0.97 through the grazing texels of an 8² depth map, held ≈0.25,
3324
- above the crush: 11% of the wall's pick, 32% in cascade 1, where the wall sits 0.15 of a spacing past
3325
- the lattice plane, a dark lattice of blotches. Without it, 0.1% and 0.6%.
3326
-
3327
- No SH and no radiance is read here.
3328
- 4. **Pick two**, the extracted `pick2_wrs_mat2x4`, which is Brick4's selection:
3329
- - one draw of `sample_discrete_wrs_mat2x4` over the eight weights with ξ₀;
3330
- - the drawn weight is zeroed, and a second draw is made with ξ₁;
3331
- - blend t = w₂ / (w₁ + w₂).
3332
- - A single non-zero weight is drawn twice, at t = 0.
3333
- 5. **In the blend skirt**, steps 3–4 run for c + 1 as well, and the two picks are lerped by b: four
3334
- probes there, and no noise across the cascade boundary.
3335
- 6. **Σ w = 0** tries the next cascade. With none left, the fallback is the caller's:
3336
- - pixel consumers take `get_ibl_irradiance(n)`;
3337
- - hit shading takes zero (§8.7).
3338
- A pixel's probes are wanted, admitted, traced and published in the same frame before shading reads
3339
- them. A pixel reaches the fallback only when NEW probes overflow U (a camera cut), or when it has
3340
- no depth of its own (a transparent surface in open air).
3341
-
3342
- Per pixel and cascade the visibility pass makes 8 indirection loads, 8 state words and 8 bilinear
3343
- depth taps. Then the pick reads two probes: two SH records and, for specular, two radiance taps. (a)
3344
- read eight 64 B records and resolved eight SH.
3345
-
3346
- **As built (S4f, 2026-10-28)**, `probe/chunk_probe_clipmap_pick.js`:
3347
- - **The cage is want's.** `probe_clipmap_cage` and `probe_clipmap_biased` live in
3348
- `probe/chunk_probe_clipmap_cage.js`, and want calls them too, so a pixel reads the cage it wanted.
3349
- - **A corner's weight**, `probe_clipmap_corner_weight`:
3350
- - No slot, or a state that is not ALIVE, weighs 0.
3351
- - The probe sits at its lattice point plus the relocation offset in its state's second word.
3352
- - The tap is toward the biased point, at its distance in the cascade's spacings, which is how
3353
- publish writes depth (§8.8). The wrap back-face term is taken from the point itself.
3354
- - **The weight is LPV's, extracted**: `probe_visibility_weight` and `probe_visibility_chebyshev` in
3355
- `shader/chunk/light/probe/chunk_probe_visibility_weight.js`. `chunk_lpv_mask_weights_by_visibility`
3356
- and `chunk_lpv_get_visibility_mask` call it, unchanged.
3357
- - **The pick is Brick4's, extracted**: `pick2_wrs_mat2x4` in
3358
- `shader/chunk/random/reservoir/chunk_pick2_wrs_mat2x4.js`, which `chunk_brick4_probe_meta_pick2`
3359
- now calls.
3360
- - It reads and clears the drawn weights through masks, because indexing the matrix by a value puts
3361
- it in local memory. That cost 3% of the diffuse on the iGPU: 7.29 → 7.06 ms, A/B in one frame
3362
- with the order alternating.
3363
- - Extracting it found a defect in the draw it is built on: `sample_discrete_wrs_mat2x4` and
3364
- `_vec4` drew NaN after leading zero weights, 0/0 in the first step. It is fixed and pinned in
3365
- `5a3c8e968`.
3366
- - **Reads** per cascade: 8 indirection loads, 8 state words and 8 depth taps. The pair's two slots
3367
- are loaded again by index rather than kept, so no array is indexed by a value.
3368
- - **A depth tile's place takes no integer division** since S4g: `index_to_grid2d_reciprocal`, through
3369
- the reciprocal the sampling uniform carries. RDNA2 emulates integer division. The iGPU's diffuse went
3370
- from 7.08 to 6.65 ms, paired, with the image the same to the bit on both GPUs.
3371
- - **The clipmap's shape is a uniform**, `PROBE_SAMPLING_STRUCT`: C_p, N_p, n_d, the depth atlas's
3372
- tiles a row and its texel size. `GPUProbeClipmap.write_sampling_bindings` puts it and the six
3373
- resources into a reader's bindings.
3374
- - **Tier 2**, `probe_clipmap_pick.spec.js`:
3375
- - picks as often as the weights, and dead, new and missing corners passed over;
3376
- - the depth read toward the point, at its direction's own texel, in the cascade's spacings;
3377
- - a relocated probe weighed from where it moved;
3378
- - the fallback cascade, the skirt's share, nothing found;
3379
- - two weighing corners blended in every draw;
3380
- - the SH mixed by the weights.
3381
-
3382
- `chunk_probe_visibility_weight.spec.js` and `chunk_pick2_wrs_mat2x4.spec.js` cover the extracted
3383
- pieces, and LPV's and Brick4's specs pass unchanged.
3384
-
3385
- ### 9.2 Diffuse: `probe_clipmap_sample_irradiance(position, normal, view_direction, ξ) -> E`
3386
-
3387
- - The two picked records' Metro L1 structs are mixed by t, then resolved once with
3388
- `resolve_irradiance_sh_to_diffuse(SH, n)`. Brick4 likewise mixes its SH before evaluating.
3389
- - The resolve returns E/π by its convention. The pass multiplies by π, because the resolve pass takes
3390
- irradiance E.
3391
-
3392
- **As built (S4f)**: there is no `probe_clipmap_sample_irradiance`. `probe_clipmap_sample_sh` returns the
3393
- mixed Metro L1 struct and whether anything was found, and the caller resolves at the normal it
3394
- chooses. The deferred pass uses two normals where this section has one n:
3395
- - it picks with the surface's own normal, as want chose its cage;
3396
- - it resolves at the bent normal, as IBL's pass does.
3397
-
3398
- ### 9.3 Specular: `probe_clipmap_sample_radiance(position, normal, view_direction, roughness, ξ) -> L`
3399
-
3400
- The two probes of the same pick are used. Per Brick4's fused pass, the pair's blend is re-weighted for
3401
- this lobe: the wrap term is taken against the sample vector instead of the normal, and the pick itself
3402
- is shared.
3403
- - **Sample vector**: `s = normalize(mix(r, n, α))`, with r the reflection of the view direction and
3404
- α = roughness². This is LPV's and Brick4's.
3405
- - **Radiance**: one hardware bilinear tap per probe along s, after a one-step parallax. The reflected
3406
- ray is intersected with a sphere around the probe whose radius is the probe's mean depth along s.
3407
- That is Lumen's sphere parallax, with the radius taken from the probe's own depth instead of a
3408
- constant. The two taps are blended by the re-weighted t.
3409
- - **GGX-convolved SH2**: `resolve_irradiance_sh_to_specular(SH, s, α)`, NEW in `l1i/`, on the mixed SH.
3410
- It is the diffuse resolve with the GGX zonal factor `g₁(α)` from `sh2_approximate_GGX_zh` in place
3411
- of the clamped cosine's 2/3:
3412
- - `Y = max(0, 2π·(0.28209·Y_w + g₁(α)·0.48860·(Y_xyz · s)))`;
3413
- - chroma rescaled by the ratio of directional to mean luma, as the diffuse resolve rescales it;
3414
- - at g₁ = 2/3 it is exactly the diffuse resolve, and constant radiance L returns L for every α.
3415
- Both are spec invariants.
3416
- - **Blend**: `L = mix(radiance, sh_ggx, roughness_to_mip_ratio(roughness))`, `chunk_lpv_sample_light`'s
3417
- blend. It is SH alone from roughness 0.7.
3418
- - Output: radiance before Fresnel, per the resolve's contract.
3419
-
3420
- **Why no prefiltered levels** (Alex's point). An atlas's hardware mips mix neighbouring tiles. Levels
3421
- per probe would be manual strips, with their own skirts and their own downsample pass, written one mip
3422
- per storage binding. Two SH bands hold the rough end of the lobe well, and a single tap holds the
3423
- smooth end.
3424
-
3425
- **As built (S6a, S6b, 2026-10-28)**:
3426
- - **The GGX resolve**, `resolve_irradiance_sh_to_specular` in `shader/chunk/spherical_harmonics/l1i/`
3427
- (S6a). Its spec holds both invariants above. 7 of 7 mutants were killed.
3428
- - **The sample**, `probe_clipmap_sample_radiance` in `probe/chunk_probe_clipmap_sample_radiance.js` (S6b).
3429
- It returns the radiance and whether anything was found, as the SH sample does.
3430
- - *The pick* is the diffuse's, `probe_clipmap_pick`.
3431
- - *The re-weight* takes each picked probe's whole weight again with the lobe in place of the normal,
3432
- at the visibility it was picked at. That visibility is recomputed, one depth tap toward the biased
3433
- point. The crush below 0.2 keeps a ratio of the wrap terms alone from standing in for it.
3434
- - *The parallax* takes the far root of the reflected ray against the sphere, so a point inside the
3435
- sphere always meets it. Where the ray misses the sphere, the tap is along the lobe itself.
3436
- - *The radiance tap* reads the radiance atlas through the depth's sampler, bilinear and clamped. Its
3437
- tiles are placed as the depth's are, and the sampling uniform now carries the radiance atlas's shape
3438
- as well (`radiance_resolution`, its tiles a row, its texel and the row's reciprocal).
3439
- - *The blend* is `roughness_to_mip_ratio`, between the renormalised taps and the SH. The SH is mixed
3440
- by the same shares and resolved for the lobe.
3441
- - *Reads a picked probe*: its record, its state word, two depth taps (visibility and parallax) and one
3442
- radiance tap.
3443
- - *The probes' sampling group* takes the radiance atlas, so every reader binds it in every mode. The
3444
- GI's placeholders gained a texel of radiance.
3445
- - *The deferred diffuse pass* listed seven of those declarations by hand. A frame graph pass must declare every
3446
- input it is given, so it threw under SSR on once the mode had probes. It declares the group whole since the
3447
- fix that followed S6c1, pinned by its spec running it in a frame graph on the GI's bindings.
3448
- - *Tier 2*: `probe/probe_clipmap_sample_radiance.spec.js` uses tiles whose interior texels hold their
3449
- own uv, so a tap shows what it read. It covers the parallax tap at a depth that varies with
3450
- direction, the GGX-resolved SH at the rough end, the mip ratio's blend between, the re-weighted
3451
- shares with a relocated probe, and nothing found. 16 of 16 mutants were killed.
3452
-
3453
- ### 9.4 Noise and its resolve
3454
-
3455
- - Image passes take ξ from STBN at (pixel, frame), `chunk_stbn_sample_vec2`, as Brick4 does.
3456
- - Forward passes and the irradiance clipmap take the extracted `jitter_from_vectors(position, normal)`,
3457
- a position-and-normal hash with the frame index.
3458
- - The pick's variance follows how much the eight probes disagree, which is largest across light
3459
- boundaries. TAA resolves it, as it resolves Brick4's specular.
3460
- - §12 measures the frame-to-frame change on an orbit and a dolly against the eight-probe evaluation,
3461
- as a one-off.
3462
-
3463
- ### 9.5 The five switch alternatives
3464
-
3465
- | Choice | This mode |
3466
- |---|---|
3467
- | `forward_sample_indirect(p, v, n, roughness, &radiance, &irradiance)` | one pick (hash noise); irradiance from §9.2, radiance from §9.3 (environment until S6) |
3468
- | `water_gi_irradiance(p, n)` / `water_gi_specular(p, n, v, roughness)` | §9.2 with v = n, since the irradiance call has no view direction / §9.3 (environment until S6); hash noise |
3469
- | `ssr_sample_radiance(p, n, v, roughness, coord)` | §9.3 with STBN at coord (environment until S6) |
3470
- | `ssr_add_hit_irradiance(&result, confidence, p, hit_vs, hit_n, coord)` | albedo·E/π at the hit when confidence > 0.05, E the deferred pass's own at the hit's texel (as built; LPV's body with §9.2 measured 41% of the resolve) |
3471
- | `irradiance_clipmap_gi_sample(p) -> array<vec3f,4>` | `probe_clipmap_sample_sh(p)` × `INDIRECT_LIGHT_INTENSITY`: the pick without the back-face term (there is no normal), returned as RGB SH2 |
3472
-
3473
- Each alternative declares `PROBE_CLIPMAP_RESOURCES` with `requireResource`:
3474
- - the levels uniform;
3475
- - the indirection texture;
3476
- - the states and records buffers;
3477
- - the depth atlas;
3478
- - the radiance atlas (from S6);
3479
- - a linear sampler.
3480
-
3481
- Union bindings were measured free in stage 2 of the switch proposal (p50 1.000).
3482
-
3483
- **As built (S5c, 2026-10-28)**:
3484
- - **The probes are bound in every mode.** A reader binds every mode's resources, and the GI is released
3485
- out of its mode. So `GPUProbeClipmapGI.write_sampling_bindings` hands out its probes' bindings once it
3486
- has probes. Before that, or out of the mode, it hands out a clipmap of no cascade: the sampling
3487
- uniform counts none, so the pick selects no cascade and reads nothing past the levels. These are six
3488
- objects of a few bytes, made on the first ask and destroyed with the GI. The seven declarations are
3489
- `PROBE_CLIPMAP_SAMPLING_GROUP`, in `probe/chunk_probe_clipmap_pick.js`; the sampler comes from its
3490
- declaration's descriptor.
3491
- - **SSR's hit**, `chunk_ssr_add_hit_irradiance_probe_clipmap`: albedo × the deferred pass's indirect
3492
- diffuse at the hit's texel / π, where the hit's confidence passes 0.05. That output is E with AO in,
3493
- so a reflection shows the indirect diffuse of the surface it hits, with its AO, its pick and its IBL
3494
- fallback.
3495
- - The renderer records the diffuse pass before SSR, which it reads nothing of, and hands it to SSR as
3496
- `input_indirect_diffuse` under this mode. The resolve's `tIndirectDiffuse` is black for the others.
3497
- - Tier 2: `chunk_ssr_add_hit_irradiance.spec.js` reads the hit's texel of both inputs, and nothing at
3498
- confidence 0.05. `SSR.spec.js` binds the diffuse under this mode alone. 7 of 7 mutants were killed.
3499
- - **Why not the pick at the hit** (§9.5's first form, LPV's body; built in `9208dd7cf`, replaced by Alex's
3500
- call the same day). Measured on Sponza at 1280×720, sun only, SSR on, the probes settled. The arms
3501
- alternated frame by frame over one set of probes, and each frame's resolve was also drawn by every
3502
- arm on the same inputs.
3503
- - 0.81% of the pixels have a confident hit.
3504
- - The pick's cost is its work at scattered hits, where a wave pays the whole pick for a few lanes. Its
3505
- code present but never run is about a sixth of it.
3506
-
3507
- | SSR resolve, p50 | RTX 4090 | AMD iGPU |
3508
- |---|---:|---:|
3509
- | no bounce (IBL's variant) | 39.9 µs | 2.06 ms |
3510
- | the pick's code present, never run | 42.0 µs | 2.19 ms |
3511
- | the pick at the hit | 54.3 µs | 2.91 ms |
3512
- | **as built**: the deferred pass's texel, against no bounce in the same run | × 1.019 | × 1.034 (2.03 → 2.10 ms) |
3513
-
3514
- The SSR trace, which no arm touches, reads 1.00 between arms on both GPUs.
3515
- - The pick lit 0.64% of the pixels and added 2.4% of the reflections' light.
3516
- - The texel lights 0.70%: where no probe weighs anything, the diffuse pass's IBL fallback lights the
3517
- hit too. It adds 1.9% of the reflections' light, with the hit's AO now applied.
3518
- - Neither darkens a pixel, and both leave the metadata the same to the bit.
3519
- - **Forward**, `rasterize/forward/chunk_forward_sample_indirect_probe_clipmap.js`: one pick by the
3520
- shading normal and the view, jittered by `jitter_from_vectors(p, n)`. That hash is extracted from
3521
- Brick4's forward sampling into `shader/chunk/random/`.
3522
- - E = π × the SH resolved at the normal, or the environment's where nothing is found.
3523
- - The specular is the environment's until S6.
3524
- - The AVBOIT draw and MBOIT's resolve add the probe declarations to their group 2, which both stages
3525
- see, and set those entries to the fragment stage alone. The probes' two storage buffers would
3526
- otherwise take the vertex stage to 10, the floor.
3527
- - **Water**, `chunk_water_gi_probe_clipmap` in `water/chunk_water_gi.js`: the forward pick with v = n,
3528
- since the irradiance call has no view, and the environment's specular until S6. The optics'
3529
- brick4/view group takes the probes, and `water_pipeline` keeps them fragment-only.
3530
- - **Bindings**: the owners write `write_sampling_bindings` into their hand-built bindings. The renderer
3531
- hands the GI to the transparency orchestrators, and AVBOIT's hands it on to the water's side channel
3532
- (`avboit_prepare`'s `probes`).
3533
- - Tier 2 is `chunk_forward_sample_indirect_probe_clipmap.spec.js` and `water/chunk_water_gi.spec.js`
3534
- against the pick and the environment map, the fragment-only test in
3535
- `indirect_lighting_consumers.spec.js`, and the AVBOIT orchestrator's water optics in every mode.
3536
- - 18 of 19 mutants were killed. MBOIT's resolve without the probes survives because no spec draws
3537
- MBOIT; the device check below draws it.
3538
- - **Measured** on Sponza at 1280×720, sun only, with five half-transparent boxes down the nave. Each
3539
- pass alternated frame by frame with IBL's variant over one set of probes.
3540
-
3541
- | p50, IBL → the probes | RTX 4090 | AMD iGPU |
3542
- |---|---:|---:|
3543
- | AVBOIT draw | 13.3 → 28.7 µs | 132 → 312 µs |
3544
- | MBOIT's resolve | 11.3 → 26.6 µs | 129 → 317 µs |
3545
-
3546
- - Per transparent fragment, that is about what the deferred pick costs a pixel.
3547
- - Averaged over 24 frames, 2.8% of the pixels, the boxes, move by 14 of 255 between the arms. The noise
3548
- floor, an arm against itself, is 0.1–0.25%. The boxes are lit brighter by the probes than by the
3549
- sky (luma 97 against 88), the same on both GPUs and in both techniques.
3550
- - The water optics compile under every mode on both GPUs.
3551
- - **The irradiance clipmap**, `chunk_irradiance_clipmap_gi_sample_probe_clipmap`: the pick at the voxel,
3552
- with no normal and no view, jittered by `jitter_from_vectors(p, 0)` and the view's frame. Where nothing
3553
- is found, the environment SH.
3554
- - The probes' Metro L1 is handed over as the radiance SH2 that `volumetrics_irradiance_sh_add_radiance`
3555
- turns back into it: [DC, y, z, x] = [YCoCg→RGB(2π·(Y.w, 0.28209·CoCg)), 2π·Y.y, 2π·Y.z, 2π·Y.x], the
3556
- band-1 terms grey.
3557
- - The build binds the probes as a group of its own, which brings it to 9 of the floor's 10 storage
3558
- buffers, and binds the view.
3559
- - The cascades are a cache, so a voxel's pick holds until its cascade is rebuilt: cascade 0 and the
3560
- ruler's each frame, the others as their windows move.
3561
- - Tier 2: `chunk_irradiance_clipmap_gi_sample.spec.js` accumulates the handed-over SH2 as the build
3562
- does and gets the pick back, with the frame's noise and the environment where nothing is found.
3563
- `graph_build_froxel_gi.spec.js` finds the GI's probes and the view bound to the build. 11 of 11
3564
- mutants were killed.
3565
- - **Measured** on Sponza at 1280×720, sun only. A sink kept the volume, which only lit particles read.
3566
- The build's p50 went from 7.2 to 15.4 µs on the 4090 and from 127 to 430 µs on the iGPU, for two
3567
- cascades of 34³ voxels a frame.
3568
- - The volume's band 0 moves by 0.0016 between the arms, 6× the probe arm's own change from one run to
3569
- the next (0.00026). The IBL arm is steady to the bit. The sun is most of the volume here, so its mean
3570
- moves by 0.5%.
3571
-
3572
- **As built (S6c2, 2026-10-28)**: SSR's fallback, `chunk_ssr_sample_radiance_probe_clipmap`, is §9.3 at the pixel:
3573
- the probes' specular picked by its normal and view, with STBN at the pixel and the resolve's frame index, as
3574
- Brick4's is. Where no probe weighs anything, it is the environment's.
3575
- - The resolve binds the probes' sampling group again, through SSR's `probes`, in every mode.
3576
- - The radiance chunk now declares what it reads itself. `switched_shaders_declare_bindings.spec.js` audits it
3577
- since SSR's resolve links it.
3578
- - Tier 2: `chunk_ssr_sample_radiance.spec.js` runs the fallback against `probe_clipmap_sample_radiance` with the
3579
- STBN texel at the pixel and frame, and against the environment where nothing is found. `SSR.spec.js` binds the
3580
- probes in every mode; `indirect_lighting_consumers.spec.js` finds the probes' specular in this mode's variant
3581
- alone. 7 of 7 mutants were killed.
3582
- - **Measured** on Sponza at 1280×720, sun only, SSR on, the probes settled. One of three arms a frame, in turn,
3583
- over one set of probes; 131 frames an arm, p50:
3584
-
3585
- | arm | deferred pass | SSR resolve | together, RTX 4090 | together, AMD iGPU |
3586
- |---|---|---|---:|---:|
3587
- | **as built**: the fallback picks again | 87 µs / 6.64 ms | 140 µs / 10.39 ms | 227 µs | 17.03 ms |
3588
- | S5c: IBL's fallback | 87 µs / 6.64 ms | 61 µs / 2.09 ms | 148 µs | 8.74 ms |
3589
- | the deferred pass's one pick writes the specular too, and the fallback reads its texel | 114 µs / 9.02 ms | 61 µs / 1.93 ms | 175 µs | 10.95 ms |
3590
-
3591
- - The SSR trace, which no arm touches, reads 0.98–1.01 between arms.
3592
- - The fallback runs at every foreground pixel, so its pick is the whole pick again: 4.96× the resolve on the
3593
- iGPU. The texel arm's light is the as-built arm's in the mean; 1% of the pixels differ, by the pick's noise.
3594
- - The probes' fallback lifts the reflections' mean luma 28× over the harness's dim sky. No pixel goes darker.
3595
- - **Alex kept the pick** (2026-10-28), as this section designs it, with these measurements before him.
3596
-
3597
- **As built (S6d, 2026-10-28)**: the forward passes' and the water's specular from the probes, §9.3.
3598
- - **Forward**, `chunk_forward_sample_indirect_probe_clipmap`: one pick for both lobes, as the fused deferred pass
3599
- takes it (S6c1). The irradiance is the pick's SH resolved at the normal, as before. The specular is
3600
- `probe_clipmap_radiance_of` the same pick, at the fragment's roughness. Where no probe weighs anything, both are
3601
- the environment's.
3602
- - **Water**, `water_gi_specular`: `probe_clipmap_sample_radiance` by the surface's normal and view, its noise the
3603
- irradiance's hash, or the environment's where nothing is found. The optics ask for the irradiance at other points
3604
- and by other normals (the in-scatter's column, a receiver's downwelling light), so each call picks for itself.
3605
- - **Bindings** are unchanged. Each of the three passes has declared the probes' group whole since S6b, the radiance
3606
- atlas with it, fragment-only.
3607
- - **Tier 2**: `chunk_forward_sample_indirect_probe_clipmap.spec.js` and `water/chunk_water_gi.spec.js` give each
3608
- probe a colour of its own over its radiance tile. They check the specular against `probe_clipmap_sample_radiance`
3609
- with the call's own noise, view and roughness, and against the environment where nothing is found.
3610
- `indirect_lighting_consumers.spec.js` finds the probes' radiance in this mode's forward and water variants alone.
3611
- 16 of 16 mutants were killed.
3612
- - **Measured** on Sponza at 1280×720, sun only, with S5c's five half-transparent boxes down the nave and S5c's
3613
- harness. Each pass alternated frame by frame with IBL's variant over one set of probes. IBL's arm reads within
3614
- 1 µs between S5c's run and this one.
3615
-
3616
- | p50, IBL → the probes | S5c: the irradiance | S6d: both lobes |
3617
- |---|---:|---:|
3618
- | AVBOIT draw, RTX 4090 | 13.3 → 28.7 µs | 12.3 → 31.7 µs |
3619
- | MBOIT's resolve, RTX 4090 | 11.3 → 26.6 µs | 11.3 → 30.7 µs |
3620
- | AVBOIT draw, AMD iGPU | 132 → 312 µs | 131 → 379 µs |
3621
- | MBOIT's resolve, AMD iGPU | 129 → 317 µs | 130 → 383 µs |
3622
-
3623
- - The specular adds 26–27% (4090) and 35–38% (iGPU) to what the probes' light costs each pass over IBL's.
3624
- - Averaged over 24 frames, 3.6–3.8% of the pixels move by more than 4 of 255 between the arms, against 2.8–3.0% in
3625
- S5c: the boxes' reflections now take the probes' light too. An arm against itself moves 0.2–0.7% of them
3626
- (S5c 0.1–0.25%), since the specular carries the pick's noise; TAA is off in this harness.
3627
- - The water optics compile under every mode on both GPUs.
3628
-
3629
- ### 9.6 Deferred
3630
-
3631
- A new case in `#graph_indirect_lighting`, shaped like Brick4's:
3632
- - **SSR off**: a fused image pass, `shader_probe_clipmap_indirect_lighting`. It picks once and
3633
- re-weights the pair for each lobe, as Brick4's fused pass does: one cost profile, the same two
3634
- probes.
3635
- - **SSR on**: `shader_probe_clipmap_diffuse` plus SSR, whose fallback is this mode's
3636
- `ssr_sample_radiance`.
3637
-
3638
- Until S6, specular is `shader_specular_IBL`, as LPV mode does today.
3639
-
3640
- **As built (S4f, 2026-10-28)**: `probe/shader_probe_clipmap_diffuse.js` serves SSR on and off alike.
3641
- The fused `shader_probe_clipmap_indirect_lighting` waits for S6, since until then there is no probe
3642
- specular to fuse with.
3643
- - `Renderer` swaps the pass in for `shader_diffuse_IBL` once `GPUProbeClipmapGI.has_probes`, that
3644
- is, once the mode has run a frame.
3645
- - A pixel with no probe found takes `get_ibl_irradiance(bent normal)` inside the pass.
3646
- - AO multiplies the result, as in the IBL pass.
3647
- - **Tier 2**: `shader_probe_clipmap_diffuse.spec.js` runs the pass whole on the emulator:
3648
- - π × the SH resolved at the bent normal × occlusion;
3649
- - IBL's output where nothing is found;
3650
- - the pick by the surface normal and the view, against `probe_clipmap_sample_sh` itself.
3651
- - **Mutants**: 32 of 32 killed (the pick 16, the visibility weight 3, the pick of two 2, the cage 1,
3652
- the host's uniform 2, the pass 8). Four were killed by tests added for them:
3653
- - a relocated probe weighed from where it moved;
3654
- - the pair's second slot and the blend's direction, both by two corners blended in every draw;
3655
- - the depth read at its direction's own texel.
3656
- - **Measured** on Sponza at 1280×720, a still camera, the caches baked. The frame's mean luma goes
3657
- from 2.6 under IBL to 66 under the probes (8-bit), the same on both GPUs.
3658
-
3659
- | p50 | AMD iGPU | RTX 4090 |
3660
- |---|---:|---:|
3661
- | the diffuse | 7.06 ms | 58 µs |
3662
-
3663
- The iGPU's time, split by twins drawn in the same frame:
3664
-
3665
- | part | iGPU |
3666
- |---|---:|
3667
- | depth taps and Chebyshev | 2.37 ms |
3668
- | the draw of two | 1.30 ms |
3669
- | SH reads and the resolve | 0.18 ms |
3670
- | the skirt | 0.04 ms |
3671
- | the pass without a pick | 0.42 ms |
3672
- | the rest of the corner weights: indirection loads, state words, the trilinear and back-face terms | ~3.0 ms |
3673
-
3674
- **As built (S6c1, 2026-10-28)**: `probe/shader_probe_clipmap_indirect_lighting.js`, the fused pass, with SSR off.
3675
- - `Renderer` draws it in place of the diffuse, specular and resolve passes once `has_probes`, as Brick4's fused
3676
- pass replaces its three. With SSR on, the diffuse pass and SSR stay.
3677
- - **One pick a pixel**, by its own normal and the view, with the frame's STBN, for both lobes.
3678
- `probe_clipmap_sh_of(pick)` and `probe_clipmap_radiance_of(pick, …)` take a pick already made;
3679
- `probe_clipmap_sample_sh` and `probe_clipmap_sample_radiance` make one and call them.
3680
- - **The diffuse** is the diffuse pass's: the pick's SH resolved at the bent normal, times π, times AO. Its
3681
- shares are not re-weighted for the bent normal, as Brick4's are. The diffuse pass takes them as picked (S4f),
3682
- so the fused pass lights the diffuse as SSR on does.
3683
- - **The specular** is `probe_clipmap_radiance_of` at the surface's roughness, as the IBL pass samples its own.
3684
- - A pixel no probe weighs anything for takes the environment's pair, as the IBL passes give it. The pair is
3685
- resolved as `shader_resolve_indirect_lighting` resolves it and added to the scene's colour.
3686
- - **Tier 2**: `shader_probe_clipmap_indirect_lighting.spec.js` runs the pass on the emulator against the
3687
- diffuse pass, `probe_clipmap_sample_radiance` and the resolve pass on the same pixel. Where nothing is found,
3688
- it runs against the IBL pair. The pixel's bent normal is placed so that its visibility cone ends across the
3689
- specular lobe, and the specular occlusion shows. 14 of 14 mutants were killed.
3690
- - **Measured** on Sponza at 1280×720, sun only, SSR off, the probes settled. The fused pass's node drew one of
3691
- three arms a frame, in turn, over one set of probes. 138 frames an arm; p50 of the indirect light's passes:
3692
-
3693
- | arm | RTX 4090 | AMD iGPU |
3694
- |---|---:|---:|
3695
- | **as built**: the fused pass | 265 µs | 9.66 ms |
3696
- | before: the probes' diffuse, IBL's specular, the resolve | 270 µs (208 + 31 + 33) | 8.17 ms (6.65 + 0.62 + 0.91) |
3697
- | the same light unfused: the probes' diffuse, a pass of their specular, the resolve | 480 µs (208 + 237 + 36) | 16.18 ms (6.65 + 8.62 + 0.92) |
3698
-
3699
- - The probe trace, which no arm touches, reads 0.97–1.00 between arms.
3700
- - One pick for both lobes costs 0.55× (4090) and 0.60× (iGPU) of the two picks unfused. Against the passes it
3701
- replaces, the probes' specular costs nothing measurable on the 4090 and 1.5 ms on the iGPU.
3702
- - The fused pass's light matches the unfused passes' to 0.6% of a pixel's luma, their f16 intermediates. The
3703
- probes' specular lifts the indirect light's mean luma 36% over the sky's, which is dim in this harness. Both
3704
- GPUs agree.
3705
-
3706
- ### 9.7 Every file the change touches
3707
-
3708
- The new mode value:
3709
- - **Definition**: `ShadeIndirectLightingMode.js`.
3710
- - **The five choice files** of §9.5, and the resource sets:
3711
- - `shader_avboit_draw.js` and/or `SHADING_LIGHT_RESOURCE_GROUP.js`;
3712
- - `shader_oit_resolve_moments.js`;
3713
- - `water/shader_water.js`;
3714
- - `ssr_resolve_shader.js`;
3715
- - `shader_irradiance_clipmap_build.js`.
3716
- - **Owners**: `graph_rasterize_meshes_transparent_avboit.js`, `graph_rasterize_meshes_transparent_oit.js`,
3717
- `GPUWaterRenderer.js`, `SSR.js`, and `GPUViewVolumetrics.js` → `GPUIrradianceClipmap.js`.
3718
- - **`Renderer.js`**: the switch at 1138, the hand-offs at 1266-1267 and 2791, the per-frame update
3719
- (beside `update_lpv`), `enquirePipelines`, `destroy` and `indicate_view_change`.
3720
- - **Specs that encode per-mode facts**: `indirect_lighting_consumers.spec.js`, `SSR.spec.js` and
3721
- `graph_build_froxel_gi.spec.js`.
3722
-
3723
- The caches, the membership bit and the extractions:
3724
- - `engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js` (the `SDFWorld` bit) and
3725
- `engine/graphics3/ShadedGeometrySystem.js` (forwarding it).
3726
- - `scene/rows/GPUSceneRows.js` (the bit, the `flags` word, `onMeshWritten`) and
3727
- `scene/MESH_INSTANCE_METADATA_STRUCT.js`.
3728
- - `geometry/sdf/GPUGeometrySDFManager.js`, `shader_build_sdf.js` and its spec, and the four readers of
3729
- the dense tiles, ported to `mesh_sdf_sample`.
3730
- - `material/resident/GPUResidentMaterialContext.js`: slot means, and the repack signal.
3731
- - Brick4's `chunk_brick4_probe_meta_pick2.js` and `chunk_brick4_sample_forward_indirect.js` (the pick
3732
- and the hash, extracted).
3733
- - `shader/chunk/spherical_harmonics/l1i/` (the specular resolve).
3734
-
3735
- ## 10. Settings
3736
-
3737
- `renderer.probe_clipmap`, a `ProbeClipmapSettings`: plain validated fields, pushed every frame, on
3738
- the shadow knobs' pattern.
3739
- - **Structural fields** (the cascade counts and resolutions, the voxel size, the spacing, the
3740
- radiance and depth resolutions): a change destroys and rebuilds the instance, and the caches warm
3741
- up again.
3742
- - **The mesh cache fields** (`mesh_sdf_voxel_size`, `mesh_sdf_max_bricks`): a change rebakes every
3743
- cached geometry.
3744
- - **Budgets and times** (`brick_build_budget`, `mesh_sdf_bake_budget`, `card_capture_budget`,
3745
- `rays_per_probe`, `ray_budget`, `response_frames`): uniforms and transient sizes, so
3746
- they take effect the next frame without a rebuild.
3747
-
3748
- | Setting | Default | Range | Changes |
3749
- |---|---|---|---|
3750
- | `brick_cascade_count` | 6 | 1–8 | brick map depth, initial capacity |
3751
- | `brick_cascade_resolution` | 32 | 16, 32, 64 | cells per axis per cascade |
3752
- | `brick_voxel_size` | 0.125 m | > 0 | finest voxel; every cascade doubles it |
3753
- | `brick_build_budget` | 4,096 | ≥ 64 | cells written a frame (a time knob, as `shadow_update_budget` is) |
3754
- | `mesh_sdf_voxel_size` | 0.125 | > 0 | mesh voxel, in the geometry's local units |
3755
- | `mesh_sdf_max_bricks` | 18 | even, 2–64 | bricks per axis before the voxel grows (a safety net; UE's per-mesh cap); 64 bounds the levels a thread walks |
3756
- | `mesh_sdf_bake_budget` | 23 | 1–8,191 | bricks evaluated a frame while caches fill (a time knob; derived in §6.4) |
3757
- | `card_capture_budget` | 16,384 | 1 – 65,535 | card pages captured a frame, new pairs first, then recaptures (a time knob; derived in S2, §6.5) |
3758
- | `probe_cascade_count` | 5 | 1–8 | probe levels |
3759
- | `probe_cascade_resolution` | 32 | 16, 32, 64 | lattice points per axis per cascade |
3760
- | `probe_spacing` | 1 m | > 0 | finest spacing; every cascade doubles it |
3761
- | `radiance_resolution` | 16 | 4–32, power of two | octahedral radiance texels per side |
3762
- | `depth_resolution` | 8 | 4–32, power of two | octahedral depth texels per side |
3763
- | `rays_per_probe` | 256 | a power of two in [n_r²/4, 4·n_r²] | rays per probe update; below n_r² an update covers a spread subset of texels, above it each texel averages several |
3764
- | `ray_budget` | 131,072 | ≥ `rays_per_probe` | rays per frame (a time knob) |
3765
- | `response_frames` | 30 | > 0 | the radiance's short mean's time constant τ in frames (§8.8), and the depth's blend's |
3766
-
3767
- `retention_time` (1 s) was removed on 2026-10-30: probes are evicted by the pressure on the slots (§8.3).
3768
-
3769
- The defaults:
3770
- - They sit between Enshrouded's low and mid tiers (100k and 250k rays; radiance 8 and 16).
3771
- - The probe cascade matches Lumen's 1.04 m and DDGI's usual 1–2 m.
3772
- - The brick voxel is UE's 19.8 cm order, and the mesh voxel matches it at unit scale.
3773
-
3774
- **Fixed, not settings**:
3775
- - the brick's 8³ / 7 layout, for world and mesh bricks alike;
3776
- - R = 4 voxels;
3777
- - the size-class step of two bricks;
3778
- - the hit threshold and minimum step, ½ voxel;
3779
- - 64 steps a ray;
3780
- - the mesh provider's 64 rows a batch and 512 nodes a level of a walk, past which the work goes on in more
3781
- batches and rounds (S9);
3782
- - 32 cards per geometry, and 4 alpha continuations per capture texel;
3783
- - the 2³-texel object cell;
3784
- - 2 probes per pick;
3785
- - 2 shadowed suns and 32 local lights per hit;
3786
- - the 4³-cell occupancy block;
3787
- - the 25% back-face threshold.
3788
-
3789
- Each is stated where it is used, with what is lost past it.
3790
-
3791
- ## 11. Budget
3792
-
3793
- Derived counts at the defaults. **No time here is measured.** §13 measures each stage on the 4090
3794
- and on the AMD iGPU before the next begins.
3795
-
3796
- **Memory**
3797
-
3798
- | Resource | Size |
3799
- |---|---|
3800
- | brick map + occupancy | 32³ × 6 × 4 B + 8³ × 6 × 4 B = 0.76 MiB |
3801
- | world bricks at initial capacity 12,288 | SDF 512 B + objects 256 B → 9 MiB |
3802
- | pending ring | 196,608 × 4 B = 768 KiB; as built, 262,144 × 4 B = 1 MiB (§7.4) |
3803
- | probe indirection + wanted bits | 32³ × 5 × 4 B + 20 KiB = 0.64 MiB |
3804
- | per probe | radiance 18² × 8 B + depth 10² × 4 B + record 64 B + state 4 B = 3,060 B |
3805
- | probes at initial capacity 16,384 | 48 MiB |
3806
- | geometry caches | per surface brick of a cached geometry: 512 B of SDF and about a seventh more for the levels, once however many instances draw it |
3807
- | card sets | per surface brick and per (geometry, material) pair: two to four pages of 512 B (a surface faces two of the six directions, and slabs add few). Measured in S2: Sponza's 103 card sets hold 154,138 pages, 79 MB. Most geometries draw with one material, so most have one card set |
3808
- | transients | build staging 4,096 × 768 B = 3 MiB; ray records 131,072 × 24 B = 3 MiB; probe maps 512 × 2.25 KiB = 1.2 MiB |
3809
-
3810
- The initial probe capacity is `2·N_p²·C_p` = 10,240 (a visible surface crosses each cascade as a
3811
- one-cell-thick shell, doubled for the cage), rounded up to a power of two. It matches the 16k that
3812
- Lumen and Enshrouded settled on.
3813
-
3814
- **Dense comparison** (D31): every lattice point of 5 × 32³ has a probe. That is 163,840 probes,
3815
- 478 MiB, and at the same ray budget a period ten times longer.
3816
-
3817
- **Work a frame**
3818
- - Rays ≤ 131,072, each ≤ 64 steps of 2–3 loads, plus up to 2 sun shadow marches.
3819
- - Shading: an object load, the card surface (§6.6), up to 32 lights, and a pick.
3820
- - World bricks ≤ 4,096 cells. An empty cell costs one TLAS level. A cell with instances costs, per
3821
- texel, ≤ 64 box tests, plus a transform, an indirection load and a tap per instance that passes.
3822
- - Pixels: 8 × (an indirection load, a state word, a depth tap), then 2 records and 2 radiance taps.
3823
- - Bakes: only while caches fill, ≤ `mesh_sdf_bake_budget` × 33k BLAS queries a frame.
3824
- - Captures: only for new pairs and changed materials, ≤ `card_capture_budget` × 64 texels a frame,
3825
- each a ray, up to 4 alpha draws and a material sample.
3826
- - **Passes**: about 20 dispatches a frame, counting the one-thread command builders. A pass on the
3827
- 4090 costs a measured 5–8 µs however small (2–3 µs for one thread), so the floor is near 0.1 ms
3828
- before any work. It is stated so the measurement can be read against it.
3829
-
3830
- ## 12. Verification
3831
-
3832
- **Tier 1, WGSL parse and validation**: every new module, through the existing `*_wgsl_validity`
3833
- specs. A real compile on a device catches what the validator misses.
3834
-
3835
- **Tier 2, emulator**: pure chunks against CPU oracles.
3836
- - **Mesh caches**:
3837
- - Size classes: every axis even, ≥ 2, grown in steps of two; the voxel grows past the cap.
3838
- - The bake's texels against `blas_query_sdf` evaluated on the CPU at the same points, for a sphere,
3839
- a box and an open plane. Exact distances within one LSB; signs right on the closed meshes.
3840
- - As built (S3): against UE's vote evaluated in doubles, which pins as well the shell behind an
3841
- open quad, a closed box drawn two-sided, and a texel past R/2 inside a large face.
3842
- - Classify never drops a brick that evaluate would keep.
3843
- - `mesh_sdf_sample` outside the grid box never exceeds the true distance (contract 4).
3844
- - Card placement covers every run start of a hollow box and of an L-shaped room within 32 cards.
3845
- - A capture of a textured quad matches `sample_material_data` at the same points under the same
3846
- seed: diffuse albedo and emissive, each within one LSB of its encoding.
3847
- - A recapture of an unchanged material writes the same bytes.
3848
- - The card depth tolerance passes a hit displaced by up to ½ voxel of its cascade, at every
3849
- cascade.
3850
- - **World bricks**:
3851
- - `r8unorm` encode/decode round trip within one LSB.
3852
- - The mesh provider's texels against a brute-force minimum over the same instances' CPU distances.
3853
- - The merge: two providers in either order produce the same bytes and, outside ties, the same
3854
- objects.
3855
- - `brick_clipmap_trace` against analytic spheres, boxes and planes built into bricks on the CPU:
3856
- the hit distance error is ≤ ½ voxel + 1 LSB.
3857
- - The occupancy skip never misses a hit that a brute-force march finds.
3858
- - UNKNOWN cells defer to the parent.
3859
- - The soft-shadow penumbra matches the analytic disk light at a sphere's edge.
3860
- - As built (S3e): against the disk past the surface the tracer hits, half a voxel out, within 0.1;
3861
- half at the edge within 0.1.
3862
- - Since 2026-10-30 the hits' shadows are rays: under a slit narrower than the sun's disk, each ray
3863
- sees all of the sun or none, and their mean the part of the disk the slit lets through (§8.7).
3864
- - `mesh_card_surface`: a hit on a card's surface returns that texel's diffuse albedo and emissive,
3865
- and a hit hidden from every card returns the material mean.
3866
- - **Probes**:
3867
- - Every generated direction encodes back into its own texel.
3868
- - SH2 projection of constant radiance L gives E = πL for every n.
3869
- - A single bright texel matches the SH2 of that cone.
3870
- - The update blend reproduces α for a known Δt.
3871
- - A ray from inside a solid is a back face at its first step.
3872
- - **Sampling**:
3873
- - The pick's frequencies converge to the normalised weights over many ξ.
3874
- - A probe behind a slab, per its depth moments, weighs under the crush and is never picked.
3875
- - The cascade blend is continuous across the skirt.
3876
- - `resolve_irradiance_sh_to_specular` at g₁ = 2/3 equals `resolve_irradiance_sh_to_diffuse`, and
3877
- returns L under constant radiance L at every α.
3878
- - **Extracted pieces** (probe weight, pick, hash, free-list pop/push): their
3879
- old callers' specs stay green. LPV's and Brick4's sampling are unchanged to the bit.
3880
-
3881
- **Tier 3, SoftwareGPUDevice**: host orchestration, black-box on what reaches the device.
3882
- - A one-cell anchor move produces exactly the scroll boxes' cells in the pending ring and frees the
3883
- vacated slots: host-written bytes and dispatch counts.
3884
- - Every dispatch is sized by a live count: indirect over a device count, or host-known for `scroll`,
3885
- `want`, `coarse` and the bake. None is sized by a capacity.
3886
- - A row whose `SDFWorld` bit is cleared reaches the device with bit 0 of its `flags` clear, and the
3887
- host pushes its box.
3888
- - A geometry is baked once however many rows draw it, and freed when the last row goes.
3889
- - A pair is captured once however many rows draw it.
3890
- - A row that changes material moves one pair's count to another.
3891
- - A material whose version moves recaptures exactly its own card sets, once.
3892
- - A pair still capturing reads null in the row → card set table.
3893
- - A provider registered after the mesh provider runs after it, over the same taken keys.
3894
- - A settings change reallocates to the new sizes.
3895
- - Entering and leaving the mode leaves `device.live_resources` as it was (the leak-spec pattern).
3896
- - The mover list is produced once a frame and read by both VSM and this mode.
3897
-
3898
- **Tier 4, device** (headless Chrome; few hundred frames at small sizes, never sweeps):
3899
- - **White furnace**: a closed box of albedo ρ and emission L_e converges to L = L_e/(1 − ρ). This
3900
- checks multi-bounce energy end to end.
3901
- - **Cornell box** against the accumulating path tracer, with an error metric.
3902
- - **Leak cases**: a sunlit wall thinner than a coarse voxel; a closed room beside daylight.
3903
- - **Absence**: a bright emissive mesh out of the SDF world lights nothing through GI.
3904
- - **Recapture**: an emissive material switched on lights the scene through GI after its card sets are
3905
- recaptured. The lag is measured in frames at the default budget.
3906
- - **Stability**: an orbit and a dolly, the same path for A and B, measuring frame-to-frame change of
3907
- the indirect term. A still camera proves nothing. The pick's noise is measured here against the
3908
- eight-probe evaluation, as a one-off.
3909
- - **Counters**: dropped instances and triangles, oversize geometries, unresolved rays, dead probes,
3910
- allocation failures, period T.
3911
- - **The storage-write rounding question of §8.8.**
3912
-
3913
- **As built, S4's done-when (S4g, 2026-10-28)**: one bounce, on both GPUs. The harness is the session's
3914
- scratchpad `device/s4g.js`.
3915
- - **How the runs were held.** The GI's clock is the caller's `performance.now()`, so α followed each run's
3916
- frame pacing, and a furnace read differently from run to run. Every run below holds the GI's clock at
3917
- 1/60 s a frame by wrapping `GPUProbeClipmapGI.graph_update`. The furnaces then let it settle 900
3918
- frames, 30 τ.
3919
- - **White furnace, one bounce.**
3920
- - *A closed room* of 8 × 4 × 8 m, every face glowing L, no light. Before AO, E/πL is 0.9991 at p50 and
3921
- 0.994 at p01, on both GPUs and at 60 or 240 Hz. Every ray hits, every texel holds L, and the
3922
- records' E/π is 0.4998 against 0.5. The 1.5% and 5.9% short before were the store's rounding (§8.8).
3923
- - GTAO's occlusion multiplies the probes' diffuse, as it does IBL's. The room's corners then read p10
3924
- 0.886 and p01 0.557: the probes see those walls glowing, and AO calls them dark. A furnace therefore
3925
- reads E before AO.
3926
- - *An open sky* of radiance L over a dark floor of 200 m, no light. L1 holds a lit hemisphere's
3927
- irradiance exactly at its pole, so πL is the floor's exact answer. E/πL is 0.997 on the iGPU and
3928
- 0.980 on the RTX 4090. **Open, not root-caused**: the shortfall is in the 4090's probes, not the
3929
- resolve, since the surface normal gives 0.983 against the bent normal's 0.981.
3930
- - Texel by texel, every probe holds L from 10° above the horizon up on both GPUs. At 2–10° the
3931
- 4090's texels hold 0.98 and 0.96 of L, against the iGPU's 0.996 and 0.993, on rays that resolve
3932
- alike.
3933
- - **D4's reversal condition is met here.** 64 steps leave 63% of the rays within 2° above the
3934
- horizon unresolved, 41% at 2–5°, 28% at 5–10° and 10% at 10–20°. Over Sponza it is 0.24–0.34% of
3935
- all rays.
3936
- - **Stability**, on Sponza with the sun: each pixel against the same surface point a frame before,
3937
- reprojected through the previous frame's view-projection. The two GPUs agree to the per-mille.
3938
- - Raw, the indirect diffuse before TAA:
3939
-
3940
- | p50 | still | orbit | dolly |
3941
- |---|---:|---:|---:|
3942
- | the eight corners blended (a twin) | 5.0% | 10.1% | 9.2% |
3943
- | the pick of two (shipped) | 16.9% | 26.4% | 17.6% |
3944
- | pixels matched | 99.6% | 95.8% | 98.3% |
3945
-
3946
- - With TAA, the final image:
3947
- - Still: the pick 0.95% against the eight's 0.65%.
3948
- - Orbiting, the medians are alike, 14.3% against 13.1%, which is texture detail resampled.
3949
- **The tails are not: p90 50% against 21%, mean 22.9% against 13.5%.** TAA does not resolve the
3950
- pick's noise under motion in a tenth of the frames, which §9.4 expected it to.
3951
- - The eight corners' cost, paired in one frame with the order alternating: iGPU 5.93 ms against the
3952
- pick's 6.64 ms, **0.72 ms less**; RTX 4090 65.5 µs against 58.4 µs.
3953
- - **Alex kept the pick of two** for the diffuse (2026-10-28), with these measurements before him.
3954
- - **Counters**, on Sponza:
3955
- - live probes: 769 still, 1,341 orbiting; dead: 13 and 54;
3956
- - the period 2, and nothing starved;
3957
- - rays: 3–5% miss, 0.24–0.34% unresolved, 2% back faces;
3958
- - all 103 geometries' fields coarsened; the bake starved 16 frames and skipped 90.
3959
- - **Timings**, Sponza at 1280 × 720, p50:
3960
-
3961
- | | AMD iGPU (orbit) | RTX 4090 (still) |
3962
- |---|---:|---:|
3963
- | want | 116 µs | 7 µs |
3964
- | relocate | 20 µs | 25 µs |
3965
- | trace, with the sun's shadow | 5.16 ms | 310 µs |
3966
- | shade | 1.04 ms | 26 µs |
3967
- | accumulate, the projection's rows and probes, publish | 0.75 ms | 33 µs |
3968
- | the diffuse | 6.65 ms (7.06 before S4g's tile address) | 58 µs |
3969
- | the rest, each | under 8 µs | under 8 µs |
3970
-
3971
- **As built, S5's done-when (S5d, 2026-10-28)**. The harness is the session's scratchpad `device/s5d2.js`.
3972
- - **The multi-bounce furnace** reads 0.994 once a hit wants its probes (S5a, §8.7).
3973
- - **Cornell box against the path tracer.**
3974
- - *The scene*: a box 4 m inside, open toward the camera, under a black sky, lit by a point light 0.4 m
3975
- below the ceiling. The walls have the classic reflectances (white 0.73, red (0.63, 0.065, 0.05),
3976
- green (0.14, 0.45, 0.091)), with a short and a tall white box inside.
3977
- - *The images*: 480×360, tone mapping NONE at a fixed exposure, the sRGB transfer undone on the CPU.
3978
- Each image's mean is taken per surface, the surface found by casting each pixel's ray on the CPU.
3979
- The probes' image is the mean of 64 frames after 900 frames of settling.
3980
- - *The reference is the accumulating path tracer through twins of its shader.* The engine's tracer
3981
- stops at three bounces (`BOUNCE_LIMIT = 3u`). In this box that leaves its total at 84–94% of the
3982
- converged one, and its indirect at 72–77%. So the reference traces 24 bounces at 512 samples a
3983
- pixel, and a twin at one bounce gives the tracer's own direct light.
3984
-
3985
- | surface | total, probes / traced | indirect, probes / traced | direct, raster / traced |
3986
- |---|---:|---:|---:|
3987
- | floor | 0.88 | 0.89 | 0.88 |
3988
- | ceiling | 0.90 | 0.94 | 0.89 |
3989
- | back | 1.03 | 1.07 | 0.98 |
3990
- | red wall | 1.03 | 1.15 (r 1.41, g 0.78, b 0.73) | 0.87 |
3991
- | green wall | 0.99 | 1.04 (r 0.82, g 1.09, b 0.76) | 0.92 |
3992
- | the boxes | 1.14 | 1.55 | 0.64 |
3993
-
3994
- The iGPU's probe image agrees with the 4090's within 0.5% on every surface.
3995
- - **The direct light differs before the GI does.** The rasterizer's point light is 2–13% under the
3996
- tracer's on the walls and 36% under on the boxes. That is the direct path's, not the probes'; it was
3997
- measured here and not looked into.
3998
- - **The indirect alone**, each image less its own direct, is within −11% to +7% on the floor, ceiling,
3999
- back and green wall, +15% on the red wall, and +55% on the boxes.
4000
- - **The colour bleed is too saturated** on the coloured walls: the red wall's indirect is redder than
4001
- traced, the green wall's greener. The probes keep chroma in the constant band alone (Metro L1), so a
4002
- probe beside a coloured wall lends its colour to every direction.
4003
- - **None of these gaps is a follow-up** (Alex, 2026-10-28). The tracer at three bounces is the engine's
4004
- reference, and a probe GI cannot match a path tracer: angular resolution, depth bias and the probes'
4005
- sampling offsets bound it. S5's done-when is the comparison, not parity with it.
4006
- - **Timings**, the probe clipmap's passes summed at their p50, Cornell at 480×360: **0.22 ms** on the 4090
4007
- and **5.6 ms** on the iGPU. The iGPU's trace is 2.8 ms of it, the light 0.62 ms, the diffuse 0.80 ms,
4008
- shade 0.34 ms and the hit wants 0.32 ms.
4009
-
4010
- **As built, S6's done-when (S6e, 2026-10-28)**. The harness is the session's scratchpad `device/s6e.js`.
4011
- - **The room** is S4g's, 8 × 4 × 8 m inside six slabs, with no light under a black sky. SSR is off, so the fused
4012
- pass draws, and the probes settle for 900 frames. Captures drawn beside the fused pass, on its bindings, write
4013
- the probes' specular at each pixel as the pass reads it: `probe_clipmap_sample_radiance` with the pixel's STBN
4014
- noise. A twin of the sample without the parallax, which taps along the lobe, is drawn beside it every frame.
4015
- - **Reflections in a closed room, as a furnace.** Every face glows L with albedo 0, so every probe texel holds L.
4016
- The specular over L at roughness 0.05, 0.3, 0.6 and 1 is 0.9995 at p50 and at least 0.996 at p01, on both GPUs,
4017
- with the parallax and without.
4018
- - **The parallax A/B.** The walls and the ceiling each glow a colour of their own with albedo 0; the floor is black
4019
- metal at roughness 0.05. Each floor pixel's specular, averaged over one cycle of the STBN (64 frames), is held
4020
- against the exact mirror: the face the pixel's mirror ray meets, cast on the CPU.
4021
- - *Right face* is the share of floor pixels whose nearest face colour is that face's.
4022
- - *The error* is |L − L_face|₁ / |L_face|₁.
4023
- - The table gives the range over two runs on each GPU; the two GPUs agree within half a percent.
4024
-
4025
- | the mirror ray's face is | with the parallax (as built) | without |
4026
- |---|---:|---:|
4027
- | under 2 m away (17% of the floor) | 87.5–87.8% right, error 0.200–0.202 | 97.0%, 0.105–0.107 |
4028
- | 2–4 m away (32%) | 97.2–97.3%, 0.146–0.148 | 95.5–95.9%, 0.141–0.144 |
4029
- | 4–8 m away (51%) | 87.8–88.4%, 0.255–0.258 | 79.6–79.9%, 0.245–0.246 |
4030
- | the whole floor | 90.7–91.0%; error p50 0.159–0.162, p90 0.384–0.390 | 87.6–87.9%; 0.140–0.143, 0.384–0.388 |
4031
-
4032
- - The parallax puts more of the reflections from 2 m out on their face, and fewer of those under 2 m. The
4033
- ceiling, 4 m and more above the floor, is the one face it does worse on as a whole: 85.7–87.0% right against
4034
- 100%.
4035
- - *Two other forms were measured and not taken.*
4036
- - Leaving the parallax out where the depth reads its √3 limit gives 86.1–86.4% right and a p90 error of
4037
- 0.44–0.45. Each probe decides alone, so a picked pair can tap along two inconsistent directions.
4038
- - Meeting the sphere where the ray enters, for a point outside it, is the same from 2 m out and worse under
4039
- it: 85.3% right, error 0.222.
4040
- - The parallax costs the capture 133 against 126–128 µs on the 4090 and 4.86 against 4.59 ms on the iGPU, at
4041
- 960 × 540 with every pixel the room's.
4042
- - **The parallax stays as built.** It places more of the floor's reflections, and the far ones most; the near ones
4043
- it misplaces about four times as often as a flat tap does.
4044
-
4045
- ## 13. Stages
4046
-
4047
- Each stage lands suite-green and is measured before the next.
4048
-
4049
- | Stage | Scope | Done when |
4050
- |---|---|---|
4051
- | S0 | enum value, `ProbeClipmapSettings`, lifecycle; the five alternatives and the deferred case behave as IBL | consumer specs green with the new value; leak spec |
4052
- | S1 | scene-level mover list with the skinned bit (D32), VSM migrated; the `SDFWorld` bit through `ShadedGeometrySystem` into the row's `flags`; `onMeshWritten` | VSM specs and playground unchanged; rows specs |
4053
- | S2 | geometry caches: `GPUGeometrySDFManager` sparse (classify, evaluate, store, levels, size classes); cards (placement, capture, recapture on the resident context's repack signal); the pair bookkeeping and the row → card set table; the bake and capture queues and budgets; the four readers ported; debug views of a mesh's bricks and cards | tier-2 oracles; bake and capture timings, which set `mesh_sdf_bake_budget` and `card_capture_budget` |
4054
- | S3 | brick clipmap: windows, map, atlas, free list, scroll, movers, clear, the provider contract, the mesh provider, store, occupancy, object cells, trace chunks, a ray-marched debug view | tier-2 oracles; device timings of build and trace |
4055
- | S4 | probe clipmap: lifetime, relocation, schedule, trace with sun shadow, shade (emissive, sun, sky; the card surface), accumulate, publish; deferred diffuse through the pick | furnace (one bounce), stability, timings |
4056
- | S5 | next bounce (E_ind); local lights via generalised world clusters; the forward, water, SSR-hit and irradiance-clipmap alternatives | furnace (multi-bounce), Cornell vs path tracer |
4057
- | S6 | specular: radiance ⊕ GGX-SH2, parallax, fused pass, SSR fallback, forward and water specular | parallax A/B, reflections in a closed room |
4058
- | S7 | the virtual geometry provider: box cut, streaming invalidation | Zorah lit by its own geometry; timings |
4059
- | S8 | virtual geometry's caches per geometry (Alex, 2026-10-29): a proxy cut from each, baked by the mesh path, adopted; the per-cell provider deleted; then measured follow-ups only (§17) | Zorah's holes, back faces and dead probes against S7e's and Sponza's; the frames and cost of the bake; the frame at rest |
4060
- | S9 | the mesh provider without its cap (Alex, 2026-10-29): walks that hand on what they cannot hold, batches of 64 spread over workgroups by count, an atomic merge, the resolve on the cells the walks listed; contract 7, retry | Zorah's holes against S8's; the provider's cost on both GPUs, paired with S8's |
4061
-
4062
- ## 14. Decisions taken here
4063
-
4064
- Each says what would reverse it.
4065
-
4066
- - **D1. Brick = 8³ texels on 7³ voxels (corner lattice)**, for world and mesh bricks alike. UE pages
4067
- and mesh distance fields agree. *Reverse:* none foreseen.
4068
- - **D2. Signed distance, 4-voxel band, ½-voxel hit.**
4069
- - (a)'s case for unsigned was that a per-cell triangle gather cannot tell inside from outside.
4070
- Whole-mesh bakes can, and the minimum of signed fields is their union.
4071
- - What the sign buys: relocation pushes probes out from inside, back faces are a gradient test, and
4072
- no triangle orientation is stored.
4073
- - The ½-voxel hit keeps walls thinner than a voxel, as UE's expand does.
4074
- - *Reverse:* if measured sign failures on real assets cost more than this.
4075
- - **D3. Allocate iff a staged texel has |d| under one voxel.** Fewer bricks than UE's
4076
- within-R-of-the-page-radius rule; the occupancy skip covers the cells left out.
4077
- *Reverse:* if trace steps measure high beside surfaces.
4078
- - **D4. Two-level occupancy (HDDA) instead of UE's coarse eikonal mip.** Incremental, toroidal, no
4079
- propagation passes. *Reverse:* if sky rays exhaust 64 steps in measurement.
4080
- - **D5. Material from surface cards with Lumen's payload** (Alex's answer on the card payload).
4081
- - Diffuse albedo and emissive are captured per (geometry, material) pair and recaptured when the
4082
- material's version moves.
4083
- - A hit does no material work.
4084
- - *Reverse:* none of mine; Alex chose it over cards that hold uvs.
4085
- - **D6. No triangle is read per frame.** Meshes are read at bakes (the SDF, per geometry) and at
4086
- captures (the cards, per pair and per material change). A virtual geometry's cut is read once, for
4087
- its proxy's bake (S8), never per frame. *Reverse:* none; it is the review's rule.
4088
- - **D7. Hits are diffuse only: `L = emissive + ρ_d·E/π`.**
4089
- - That is what Lumen's payload holds, and Lumen's surface cache lights diffusely too.
4090
- - (b) first had the full BRDF's directional albedo at the hit's NoV (`tSplitSum`), which needed
4091
- roughness and metalness at every hit.
4092
- - What is lost: a metal bounces only its emissive.
4093
- - *Reverse:* if metals measure dark in the bounce, capture the BRDF's hemispherical albedo in place
4094
- of the diffuse one. That needs the same storage and no hit-side change.
4095
- - **D8. Radiance `rgba16float`, two-pass update.** Hardware bilinear for every consumer.
4096
- *Reverse:* if the stall bias measures visible, f32 history in a buffer.
4097
- - **D9. Depth moments normalised by spacing, clamped at (1 + 0.45)·√3·s.**
4098
- - Chebyshev's bound asks only how many samples lie past the point, which a clamp past every point the
4099
- cage tests leaves as it was, and a larger clamp only loosens it. The clamp is one constant, so a
4100
- reader de-normalises with the spacing alone.
4101
- - **Corrected 2026-10-30**: it was √3·s, the cage diagonal, which holds only for a probe on its
4102
- lattice point. Relocation (§8.4) moves a probe up to 0.45 of a spacing on each axis, so a biased
4103
- point can be (1 + 0.45)·√3 ≈ 2.51 spacings from a corner probe. A direction that sees nothing read as
4104
- occluded past √3: visibility ≤ 0.41 at 1.8 spacings, 0.043 at 2.0. Over a cell, a corner moved 0.45
4105
- on every axis away from it lost up to 0.70% of the cage's trilinear weight; 0.25 on each, 0.013%.
4106
- - RTXGI clamps at `1.5·|spacing|` (`ProbeBlendingCS.hlsl`), the same reach over DDGI 2021's
4107
- half-spacing relocation bound; neither DDGI paper's text gives a value. RTXGI's own relocation is a
4108
- sphere of 0.45, so its clamp has headroom.
4109
- - **D10. SH2 projected from blended radiance, not blended separately.**
4110
- - **D11. Radiance by a multi-scale mean whose short mean follows a response time in seconds; depth
4111
- by an exponential blend; jittered stratified rays.**
4112
- - Lumen overwrites without jitter, but its screen probes filter downstream. Here consumers read
4113
- the probes directly.
4114
- - DDGI 2021's per-texel change boost is left out. A texel here sees one ray an update, so a change
4115
- test on it is a noise test.
4116
- - **Reversed for the radiance, 2026-10-29**: Alex judged the noise visible, and Enshrouded's
4117
- estimator (RT Gems ch. 25) was built (§8.8, as built). Measured on Sponza at τ = 0.5 s: a texel
4118
- moves 1.0% an update against 10.8%, the SH's band 0 0.27% against 1.54%; a doubled sun is half
4119
- taken in 55 frames against 29, and 90% in 389 against 105.
4120
- - A variant testing the means' gap against the short mean's own deviation, as SSR's accumulation
4121
- does, was modelled on the CPU and followed a step on a noisy texel more slowly still; not built.
4122
- - **The rays' half reversed too (2026-10-29).** Alex found the probes' noise visible, and asked for
4123
- Enshrouded's importance-sampled rays, rotating corner and all (§8.6, as built). The estimator's short
4124
- mean now takes a texel's period from its share of the updates that reach it (§8.8).
4125
- - **D12. Schedule: NEW first, then a slot-hashed phase with a period from the live count.**
4126
- - **D13. Pixels pick 2 of 8 by WRS after visibility-only weights** (the review). Brick4's sampler and
4127
- noise. *Reverse:* if the measured noise under TAA is visible, then a screen-space reservoir resolve,
4128
- argued from that measurement.
4129
- - **D14. Sun shadows at hits by shadow rays traced in the bricks**, toward a point of the disk.
4130
- Shadow maps read lit off screen; the cone soft shadow S3e built leaked through gaps narrower than
4131
- the disk, and was replaced on 2026-10-30 (§8.7).
4132
- - **D15. Local lights from the world clusters, one picked by WRS over the chance of its pick, its
4133
- shadow a ray traced in the bricks** (2026-10-30, §8.7). Until then every light of the cell, each
4134
- through its shadow map, a light with a cleared map unshadowed at hits.
4135
- - **D16. Settings sub-object, rebuild on mismatch.**
4136
- - **D17. Capacity learned from reported failures, never shrinks.**
4137
- - **D18. The mode is named `ProbeClipmap`**, after what shading samples, as Brick4 and LPV are.
4138
- - **D19. No bounce attenuation factor.**
4139
- - **D20. Specular blends a radiance tap with GGX-convolved SH2 by `roughness_to_mip_ratio`** (the
4140
- review), LPV's blend in Metro form. *Reverse:* none of mine; the review set it.
4141
- - **D21. One object per 2³-texel cell, written by the providers' own minimum.**
4142
- - UE's object grid has the same 4³ cells a page but keeps the 4 nearest objects per cell, 1 KiB a
4143
- page. It is filled by its own pass, and a hit blends their cards up to a weight of 0.9.
4144
- - Here the cell costs 256 B, and the argmin falls out of the minimum the providers compute anyway.
4145
- - Where two objects meet inside one cell, a hit can name the wrong one. Its card depth test then
4146
- fails, and the hit takes the material mean.
4147
- - *Reverse:* if seams measure visible, then UE's four candidates.
4148
- - **D22. Providers merge by a min of encoded bytes, objects by the cell minimum.** The order of
4149
- providers cannot change a distance.
4150
- - **D23. Cards per depth slab, placed by UE's clustering rule at brick granularity, ≤ 32 per
4151
- geometry.**
4152
- - Six bounds cards would miss every interior surface of a single-mesh room or atrium.
4153
- - UE places its near planes from 10 cm surfels with 32 rays a column. The brick occupancy the bake
4154
- already holds gives the same rule (m ≤ N ≤ Z) at 7-voxel granularity, with no rays.
4155
- - *Reverse:* if coverage measures short on real assets, then surfels from the capture rays.
4156
- - **D24. Card texels are two `rgba8unorm` planes, 8 B in all.**
4157
- - Diffuse albedo is stored as its square root, as UE stores it.
4158
- - Emissive is stored under the pair's scale s_e, which loses nothing that 8-bit resident textures
4159
- had.
4160
- - Depth is 8 bits over the slab, under 0.5 voxel at the 18-brick cap. That is well inside the depth
4161
- tolerance of 1.5 cascade voxels.
4162
- - *Reverse:* an emissive that measures banded; then `rgba16float` for that plane, 4 B more a texel.
4163
- - **D25. SDF membership is a `ShadedGeometryFlags` bit, on by default, mirrored into the row.**
4164
- - It is the entity's data, carried as `Skinned` is, and transparent materials stay out by rule.
4165
- - UE has a per-primitive distance-field lighting switch and leaves translucent sections out
4166
- too.
4167
- - **D26. A cascade samples the coarsest mesh level no coarser than itself.**
4168
- - Its voxel is within a factor of two of the cascade's, and its band reaches at least half the
4169
- cascade's. Past that band a sample under-estimates, which is safe.
4170
- - UE instead walks the resident mips from coarse to fine per sample, with mips streamed by distance.
4171
- Choosing per cascade is one decision per instance and cell, and it needs no residency.
4172
- - *Reverse:* if steps measure slow beside surfaces at the band's edge, then the next level up.
4173
- - **D27. The bake drops the open-mesh wrap.** The band stores nothing past R, which is what the wrap
4174
- was for.
4175
- - **D28. The caches live on the geometry manager, not on the GI instance.** They survive a mode switch
4176
- and serve any later SDF reader. The GI keeps only the bookkeeping that requests them.
4177
- - **D29. Recapture keys on `ShadeMaterial.version`, through the resident context's repack.**
4178
- - It is the change signal every material consumer already uses, and the repack is the one place it
4179
- is read. Nothing polls.
4180
- - UE tracks no material change at all; there a primitive is recaptured on an explicit
4181
- surface-cache invalidation or by the slow refresh of its oldest pages (§2.3).
4182
- - *Reverse:* none foreseen.
4183
- - **D30. Each passing card gives one texel, blended across cards by n².**
4184
- - UE also blends four depth-weighted taps per card.
4185
- - Here a probe's many rays do that averaging, and one texel per card halves the loads.
4186
- - *Reverse:* if card seams measure visible in the bounce, then UE's four taps.
4187
- - **D31. Probes are a sparse cache from the screen, not a dense lattice** (Alex, as recommended).
4188
- - "Also a 3D clipmap" holds either way.
4189
- - At the defaults the dense form is 10× the memory and 10× the update period (§11).
4190
- - The cost of sparse: no probes where nothing visible needs them, so off-screen bounces see only the
4191
- probes that exist.
4192
- - *Reverse:* if the off-screen bounce measures short, Enshrouded's world-cache probes (§17).
4193
- - **Reversed in part at S5a (Alex, 2026-10-28)**: the multi-bounce furnace measured the off-screen
4194
- bounce short (§8.7), so hits want the probes they read as well, one step from the screen, as the one
4195
- probe kind (§8.3). "If we get no indirect bounce, it is better to lose energy than to invent energy":
4196
- where no probe has data yet the bounce stays zero, and no other probe stands in.
4197
- - **D32. The mover pass moves from VSM to scene level, with a skinned bit** (Alex, as recommended).
4198
- - The kernels and the list stay the same. The scene context owns them and produces the list once a
4199
- frame for any reader.
4200
- - The skinned bit lets this mode skip boxes that only animation moved (§7.4).
4201
- - VSM's lifetime coupling goes away.
4202
- - *Reverse:* none foreseen.
4203
-
4204
- ## 15. Forks for Alex
4205
-
4206
- None is open. How each was settled:
4207
- - **(a)'s fork 1**, vgeo and DynamicMesh invisible to rays: by the geometry caches and the providers
4208
- of the review (§6, §7.6–7.8).
4209
- - **(a)'s fork 2**, unsigned distance: dissolved, because a whole-mesh bake knows the sign (D2).
4210
- - **(a)'s fork 3**, a sparse probe cache: taken as recommended (D31).
4211
- - **(a)'s fork 4**, the mover pass at scene level: taken as recommended, with the skinned bit (D32).
4212
- - **(b)'s card payload**: Lumen's, diffuse albedo and emissive captured per (geometry, material) and
4213
- recaptured on a material change (D5).
4214
-
4215
- ## 16. Duplication ledger
4216
-
4217
- Found during the audit. Items marked *(here)* are resolved by this proposal; the rest are listed,
4218
- not fixed.
4219
-
4220
- **Resolved here**
4221
- - *(here)* `GPUGeometrySDFManager`'s dense tiles, readers and all → sparse bricks in place.
4222
- - *(here)* The probe weighting lives only inside LPV's tetrahedral sampler → extracted.
4223
- - *(here)* Brick4's pick and its forward hash, private to Brick4 → extracted.
4224
- - *(here)* An SDF penumbra estimator buried in a dense-volume shader → not extracted: the brick
4225
- clipmap's estimate is the two-sided one (§4.2).
4226
- - *(here)* GPU slot stacks: particles' own → extracted and shared.
4227
- - *(here)* World clusters keyed to one consumer's struct → keyed by `CLIPMAP_LEVEL_ARRAY`.
4228
- - *(here)* The mover list owned by VSM → scene level.
4229
-
4230
- **Listed**
4231
- - `CascadedSceneSDF` duplicates what the brick clipmap does once S3 lands, and is a candidate for
4232
- deletion. Alex's call; the lightmap tree is the precedent for keeping things. The Brixelizer port
4233
- stays as a reference.
4234
- - Toroidal wrap in five places, and cascade placement in two (`IrradianceCascadePlacement` beside
4235
- `ClipmapWindowSet`).
4236
- - Finest-cascade selection in two forms (box, sphere), and cascade tables in four forms. The unused
4237
- `global_illumination/clipmap/CLIPMAP_METADATA.js` and `CLIPMAP_CASCADE_METADATA.js` are kept
4238
- surface.
4239
- - Eight radiance → SH projections; `sh2_color_get_irradiance_at` ≡ `sh2_color_irradiance` apart
4240
- from a clamp.
4241
- - Three manual octahedral bilinear samplers; two border painters (LPV's perimeter walk, whose
4242
- radiance border is never read, and the environment skirt).
4243
- - Two 64-ray volume-sign votes plus a CPU twin; three SDF-gradient variants; three ray-AABB slab
4244
- tests. S3 deleted the BLAS vote, with the bake's move to UE's.
4245
- - `chunk_lpv_mask_weights_by_visibility` and its `_no_depth` twin define the same WGSL function name.
4246
- - The irradiance clipmap cites a "Decision 6.3" document that exists nowhere in the tree or its
4247
- history.
4248
-
4249
- ## 17. Not in this proposal
4250
-
4251
- Each item is added only if a measurement asks for it, or, where marked, when Alex schedules it:
4252
- - **Skinned meshes as a dense animated patch** (Alex: later). The hooks are the clone's header alias
4253
- (§6.3), the mesh provider's accept (§7.7) and the skinned movers the GI skips today (§7.4).
4254
- - **DynamicMesh in the SDF world.** It has no row and changes every frame; a provider writing per-frame
4255
- patches is the same mechanism as the skinned one.
4256
- - **Cards for virtual geometry**, its own plan; until then vgeo hits take the material mean.
4257
- - **Lighting in the surface cache** (Lumen's direct lighting and radiosity per card texel). Here the
4258
- probes carry the bounce.
4259
- - **Specular at secondary hits.** Hits are diffuse only (D7), so a metal bounces nothing but its
4260
- emissive. The measured fix is the BRDF's hemispherical albedo captured in place of the diffuse
4261
- one.
4262
- - **Caches persisted across sessions**, an offline bake format.
4263
- - Ray and history reprojection between probes, if long-range propagation into enclosed spaces
4264
- measures short. History re-sampling is built as a switch, off, and measured quieter but no faster to
4265
- converge; weighed by the neighbour's confidence and age it is unbiased, blurred, and slower still (§8.8, as
4266
- built 2026-10-30); reprojecting the frame's own rays is not built.
4267
- - Foliage and translucency in the field, Enshrouded's open problem too.
4268
- - Alpha-tested geometry is in the SDF as its triangles, so canopies over-occlude. The `SDFWorld`
4269
- bit is the authored way out.
4270
- - UE marks voxels within the band of only two-sided meshes with zero coverage. There its trace
4271
- shrinks the surface expand from 1.0 to 0.6 and lengthens the minimum step fourfold. That is the
4272
- shape of the fix if it measures necessary: a coverage bit per brick cell beside the object.
4273
- - Ambient probes for fog. Fog has no GI in any mode.
4274
- - Sharing one clipmap between views.
4275
-
4276
- ## 18. Sources
4277
-
4278
- - J. Kolesik, *Realtime Global Illumination in Enshrouded*, GPC 2024 —
4279
- https://www.youtube.com/watch?v=57F1ezwH7Mk
4280
- - Unreal Engine 5.4 Lumen and Global Distance Field documentation:
4281
- https://dev.epicgames.com/documentation/en-us/unreal-engine/lumen-technical-details-in-unreal-engine
4282
- - Z. Majercik et al., *Dynamic Diffuse Global Illumination with Ray-Traced Irradiance Fields*,
4283
- JCGT 8(2), 2019 — https://jcgt.org/published/0008/02/01/ ; *Scaling Probe-Based Real-Time Dynamic
4284
- Global Illumination for Production*, JCGT 10(2), 2021 — https://jcgt.org/published/0010/02/01/
4285
- - NVIDIA RTXGI-DDGI, `rtxgi-sdk/include/rtxgi/ddgi/DDGIVolume.h` and `docs/DDGIVolume.md` —
4286
- https://github.com/NVIDIAGameWorks/RTXGI-DDGI
4287
- - AMD FidelityFX Brixelizer and Brixelizer GI —
4288
- https://gpuopen.com/manuals/fidelityfx_sdk/fidelityfx_sdk-page_techniques_brixelizer/ ,
4289
- https://gpuopen.com/manuals/fidelityfx_sdk/techniques/brixelizer-gi/ , FidelityFX-SDK v1.1.4
4290
- `sdk/include/FidelityFX/gpu/brixelizer/`, and
4291
- https://gpuopen.com/gdc-presentations/2024/GDC2024_GI_with_AMD_FidelityFX_Brixelizer.pdf
4292
- - Godot 4.3 SDFGI — https://docs.godotengine.org/en/stable/tutorials/3d/global_illumination/using_sdfgi.html ,
4293
- `servers/rendering/renderer_rd/environment/gi.cpp` and `shaders/environment/sdfgi_*.glsl`;
4294
- HDDAGI — https://github.com/godotengine/godot/pull/86267 , https://github.com/godotengine/godot/pull/119869
4295
- - kajiya GI overview — https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md
4296
- - K. Museth, *NanoVDB: A GPU-Friendly and Portable VDB Data Structure for Real-Time Rendering and
4297
- Simulation*, SIGGRAPH 2021 Talks.
4298
- - M. Fajardo et al., *Stochastic Texture Filtering*, 2023 — the WRS of `sample_discrete_wrs_mat2x4`.
4299
- - M. Pettineo, *SHforHLSL* — https://github.com/TheRealMJP/SHforHLSL (the GGX zonal fit).
4300
- - S. Lagarde and C. de Rousiers, *Moving Frostbite to Physically Based Rendering 3.0*, SIGGRAPH 2014
4301
- course notes (the dominant direction; the roughness-to-mip curve).
4302
- - *Exploring Ray-Traced Future in Metro Exodus*, GTC 2019 (the L1 YCoCg encoding; in-tree
4303
- `l1i/NOTES.md`).
4304
- - I. Quílez, distance-field gradients (the tetrahedral 4-tap form) and soft shadows.
4305
- - In-tree: `SHADER_SWITCH_PROPOSAL_2026_10_16.md`, `engine/physics/fluid/GPU_FLUID_CASCADES_PROPOSAL_2026_10_16.md`,
4306
- `shadow/vsm/PLAN.md`, `volumetrics/NOTES.md`, `geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md`.