@woosh/meep-engine 3.11.1 → 3.11.3

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 (225) hide show
  1. package/build/bundle-worker-terrain.js +1 -1
  2. package/package.json +1 -1
  3. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  4. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +294 -290
  5. package/src/engine/graphics3/ParticipatingMedia.d.ts +111 -0
  6. package/src/engine/graphics3/ParticipatingMedia.d.ts.map +1 -0
  7. package/src/engine/graphics3/ParticipatingMedia.js +203 -0
  8. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts +34 -0
  9. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts.map +1 -0
  10. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.js +73 -0
  11. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +87 -0
  12. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -0
  13. package/src/engine/graphics3/ParticipatingMediaSystem3.js +184 -0
  14. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  15. package/src/shade/descriptor/pipeline/PipelineLayoutDescriptor.d.ts +16 -2
  16. package/src/shade/descriptor/pipeline/PipelineLayoutDescriptor.d.ts.map +1 -1
  17. package/src/shade/descriptor/pipeline/PipelineLayoutDescriptor.js +28 -4
  18. package/src/shade/descriptor/pipeline/compute/ComputePipelineDescriptor.d.ts +1 -1
  19. package/src/shade/descriptor/pipeline/compute/ComputePipelineDescriptor.d.ts.map +1 -1
  20. package/src/shade/descriptor/pipeline/compute/ComputePipelineDescriptor.js +5 -2
  21. package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +564 -451
  22. package/src/shade/device/ShadeGPUCommandContext.d.ts +18 -2
  23. package/src/shade/device/ShadeGPUCommandContext.d.ts.map +1 -1
  24. package/src/shade/device/ShadeGPUCommandContext.js +994 -940
  25. package/src/shade/device/immediate_data_supported.d.ts +33 -0
  26. package/src/shade/device/immediate_data_supported.d.ts.map +1 -0
  27. package/src/shade/device/immediate_data_supported.js +53 -0
  28. package/src/shade/device/mock/SoftwareGPUCommandEncoder.d.ts +18 -0
  29. package/src/shade/device/mock/SoftwareGPUCommandEncoder.d.ts.map +1 -1
  30. package/src/shade/device/mock/SoftwareGPUCommandEncoder.js +24 -0
  31. package/src/shade/device/mock/SoftwareGPUComputePassEncoder.d.ts +7 -0
  32. package/src/shade/device/mock/SoftwareGPUComputePassEncoder.d.ts.map +1 -1
  33. package/src/shade/device/mock/SoftwareGPUComputePassEncoder.js +49 -1
  34. package/src/shade/device/mock/SoftwareGPUDevice.d.ts.map +1 -1
  35. package/src/shade/device/mock/SoftwareGPUDevice.js +20 -2
  36. package/src/shade/device/mock/SoftwareGPUImmediateData.d.ts +65 -0
  37. package/src/shade/device/mock/SoftwareGPUImmediateData.d.ts.map +1 -0
  38. package/src/shade/device/mock/SoftwareGPUImmediateData.js +160 -0
  39. package/src/shade/device/mock/SoftwareGPURenderPassEncoder.d.ts +7 -0
  40. package/src/shade/device/mock/SoftwareGPURenderPassEncoder.d.ts.map +1 -1
  41. package/src/shade/device/mock/SoftwareGPURenderPassEncoder.js +50 -0
  42. package/src/shade/device/mock/pipeline_immediate_size.d.ts +11 -0
  43. package/src/shade/device/mock/pipeline_immediate_size.d.ts.map +1 -0
  44. package/src/shade/device/mock/pipeline_immediate_size.js +18 -0
  45. package/src/shade/device/pipeline/PipelineLayoutManager.d.ts.map +1 -1
  46. package/src/shade/device/pipeline/PipelineLayoutManager.js +1 -0
  47. package/src/shade/playground/volumetrics_froxel/README.md +104 -1
  48. package/src/shade/playground/volumetrics_froxel/index.html +20 -1
  49. package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -1
  50. package/src/shade/playground/volumetrics_froxel/main.js +298 -0
  51. package/src/shade/playground/volumetrics_froxel/measure.d.ts +119 -0
  52. package/src/shade/playground/volumetrics_froxel/measure.d.ts.map +1 -0
  53. package/src/shade/playground/volumetrics_froxel/measure.js +559 -0
  54. package/src/shade/renderer/GraphicsContext.d.ts +9 -2
  55. package/src/shade/renderer/GraphicsContext.d.ts.map +1 -1
  56. package/src/shade/renderer/GraphicsContext.js +13 -1
  57. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  58. package/src/shade/renderer/Renderer.js +22 -18
  59. package/src/shade/renderer/atmosphere/GPUSky.d.ts.map +1 -1
  60. package/src/shade/renderer/atmosphere/GPUSky.js +145 -142
  61. package/src/shade/renderer/atmosphere/MULTISCATTER_LUT_CONFIG.d.ts +34 -0
  62. package/src/shade/renderer/atmosphere/MULTISCATTER_LUT_CONFIG.d.ts.map +1 -0
  63. package/src/shade/renderer/atmosphere/MULTISCATTER_LUT_CONFIG.js +36 -0
  64. package/src/shade/renderer/atmosphere/SAMPLER_SKY_LUT.d.ts +20 -0
  65. package/src/shade/renderer/atmosphere/SAMPLER_SKY_LUT.d.ts.map +1 -0
  66. package/src/shade/renderer/atmosphere/SAMPLER_SKY_LUT.js +25 -0
  67. package/src/shade/renderer/atmosphere/chunk_get_multiscatter_values.d.ts +14 -1
  68. package/src/shade/renderer/atmosphere/chunk_get_multiscatter_values.d.ts.map +1 -1
  69. package/src/shade/renderer/atmosphere/chunk_get_multiscatter_values.js +163 -177
  70. package/src/shade/renderer/atmosphere/chunk_multiscatter_sample_direction.d.ts +20 -0
  71. package/src/shade/renderer/atmosphere/chunk_multiscatter_sample_direction.d.ts.map +1 -0
  72. package/src/shade/renderer/atmosphere/chunk_multiscatter_sample_direction.js +46 -0
  73. package/src/shade/renderer/atmosphere/chunk_sample_multiscatter_lut.d.ts +11 -1
  74. package/src/shade/renderer/atmosphere/chunk_sample_multiscatter_lut.d.ts.map +1 -1
  75. package/src/shade/renderer/atmosphere/chunk_sample_multiscatter_lut.js +57 -27
  76. package/src/shade/renderer/atmosphere/chunk_sample_transmittance_lut.d.ts +19 -1
  77. package/src/shade/renderer/atmosphere/chunk_sample_transmittance_lut.d.ts.map +1 -1
  78. package/src/shade/renderer/atmosphere/chunk_sample_transmittance_lut.js +69 -33
  79. package/src/shade/renderer/atmosphere/chunk_sky_lut_compute_uv.d.ts +13 -0
  80. package/src/shade/renderer/atmosphere/chunk_sky_lut_compute_uv.d.ts.map +1 -1
  81. package/src/shade/renderer/atmosphere/chunk_sky_lut_compute_uv.js +35 -35
  82. package/src/shade/renderer/atmosphere/chunk_sky_lut_params.d.ts +13 -0
  83. package/src/shade/renderer/atmosphere/chunk_sky_lut_params.d.ts.map +1 -0
  84. package/src/shade/renderer/atmosphere/chunk_sky_lut_params.js +38 -0
  85. package/src/shade/renderer/atmosphere/chunk_sky_lut_texel_to_unit.d.ts +17 -0
  86. package/src/shade/renderer/atmosphere/chunk_sky_lut_texel_to_unit.d.ts.map +1 -0
  87. package/src/shade/renderer/atmosphere/chunk_sky_lut_texel_to_unit.js +29 -0
  88. package/src/shade/renderer/atmosphere/chunk_sky_lut_uv.d.ts +21 -0
  89. package/src/shade/renderer/atmosphere/chunk_sky_lut_uv.d.ts.map +1 -0
  90. package/src/shade/renderer/atmosphere/chunk_sky_lut_uv.js +42 -0
  91. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.d.ts +2 -2
  92. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.d.ts.map +1 -1
  93. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.js +165 -84
  94. package/src/shade/renderer/atmosphere/shader_sky_irradiance_lut.d.ts.map +1 -1
  95. package/src/shade/renderer/atmosphere/shader_sky_irradiance_lut.js +215 -204
  96. package/src/shade/renderer/atmosphere/shader_transmittance_lut.d.ts.map +1 -1
  97. package/src/shade/renderer/atmosphere/shader_transmittance_lut.js +79 -67
  98. package/src/shade/renderer/light/cluster/graph_assign_clsuters.d.ts.map +1 -1
  99. package/src/shade/renderer/light/cluster/graph_assign_clsuters.js +6 -2
  100. package/src/shade/renderer/postprocess/gtao/GTAO.d.ts.map +1 -1
  101. package/src/shade/renderer/postprocess/gtao/GTAO.js +4 -0
  102. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  103. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.d.ts.map +1 -1
  104. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.js +341 -345
  105. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.d.ts +0 -2
  106. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.d.ts.map +1 -1
  107. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.js +58 -57
  108. package/src/shade/renderer/rasterize/expand/mesh/graph_expand_meshes_to_meshlets.d.ts.map +1 -1
  109. package/src/shade/renderer/rasterize/expand/mesh/graph_expand_meshes_to_meshlets.js +7 -3
  110. package/src/shade/renderer/rasterize/fast/graph_extract_meshes_from_bucket.d.ts.map +1 -1
  111. package/src/shade/renderer/rasterize/fast/graph_extract_meshes_from_bucket.js +77 -81
  112. package/src/shade/renderer/rasterize/standard/graph_rasterize_scene.d.ts.map +1 -1
  113. package/src/shade/renderer/rasterize/standard/graph_rasterize_scene.js +5 -1
  114. package/src/shade/renderer/shader/ComputeShader.d.ts.map +1 -1
  115. package/src/shade/renderer/shader/ComputeShader.js +6 -2
  116. package/src/shade/renderer/shader/ImageShader.d.ts.map +1 -1
  117. package/src/shade/renderer/shader/ImageShader.js +4 -1
  118. package/src/shade/renderer/shader/ShaderDescriptor.d.ts.map +1 -1
  119. package/src/shade/renderer/shader/ShaderDescriptor.js +26 -0
  120. package/src/shade/renderer/shader/chunk/atmosphere/ATMOSPHERE_RADII_DECLARATION.d.ts +18 -0
  121. package/src/shade/renderer/shader/chunk/atmosphere/ATMOSPHERE_RADII_DECLARATION.d.ts.map +1 -0
  122. package/src/shade/renderer/shader/chunk/atmosphere/ATMOSPHERE_RADII_DECLARATION.js +32 -0
  123. package/src/shade/renderer/shader/chunk/atmosphere/PLANET_CENTER_DECLARATION.d.ts +7 -0
  124. package/src/shade/renderer/shader/chunk/atmosphere/PLANET_CENTER_DECLARATION.d.ts.map +1 -1
  125. package/src/shade/renderer/shader/chunk/atmosphere/PLANET_CENTER_DECLARATION.js +17 -6
  126. package/src/shade/renderer/shader/chunk/atmosphere/chunk_atmosphere_ray_bounds.d.ts +21 -0
  127. package/src/shade/renderer/shader/chunk/atmosphere/chunk_atmosphere_ray_bounds.d.ts.map +1 -0
  128. package/src/shade/renderer/shader/chunk/atmosphere/chunk_atmosphere_ray_bounds.js +69 -0
  129. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_contains_aabb3.d.ts +9 -0
  130. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_contains_aabb3.d.ts.map +1 -0
  131. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_contains_aabb3.js +22 -0
  132. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_from_transformed_corners.d.ts +16 -0
  133. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_from_transformed_corners.d.ts.map +1 -0
  134. package/src/shade/renderer/shader/chunk/geometry/aabb3/chunk_aabb3_from_transformed_corners.js +41 -0
  135. package/src/shade/renderer/shader/chunk/geometry/frustum/chunk_frustum_transform_m4.d.ts +11 -0
  136. package/src/shade/renderer/shader/chunk/geometry/frustum/chunk_frustum_transform_m4.d.ts.map +1 -1
  137. package/src/shade/renderer/shader/chunk/geometry/frustum/chunk_frustum_transform_m4.js +15 -19
  138. package/src/shade/renderer/shader/chunk/geometry/frustum/chunk_frustum_transform_plane_matrix.d.ts +23 -0
  139. package/src/shade/renderer/shader/chunk/geometry/frustum/chunk_frustum_transform_plane_matrix.d.ts.map +1 -0
  140. package/src/shade/renderer/shader/chunk/geometry/frustum/chunk_frustum_transform_plane_matrix.js +44 -0
  141. package/src/shade/renderer/shader/chunk/geometry/octahedron/chunk_uv_octahedral_unit_decode.d.ts.map +1 -1
  142. package/src/shade/renderer/shader/chunk/geometry/octahedron/chunk_uv_octahedral_unit_decode.js +1 -4
  143. package/src/shade/renderer/shader/compiler/CodeChunk.d.ts +7 -1
  144. package/src/shade/renderer/shader/compiler/CodeChunk.d.ts.map +1 -1
  145. package/src/shade/renderer/shader/compiler/CodeChunk.js +37 -2
  146. package/src/shade/renderer/shader/graph/compute/compute_binding_read_names.d.ts.map +1 -1
  147. package/src/shade/renderer/shader/graph/compute/compute_binding_read_names.js +6 -0
  148. package/src/shade/renderer/shader/graph/compute/graph_compute_indirect_pass.js +1 -1
  149. package/src/shade/renderer/shader/graph/compute/graph_compute_pass.js +1 -1
  150. package/src/shade/renderer/shader/graph/graph_image_pass.js +1 -1
  151. package/src/shade/renderer/shader/graph/graph_prepare_bindings.d.ts.map +1 -1
  152. package/src/shade/renderer/shader/graph/graph_prepare_bindings.js +7 -0
  153. package/src/shade/renderer/shader/graph/graph_prepare_pass_input_data.d.ts +3 -1
  154. package/src/shade/renderer/shader/graph/graph_prepare_pass_input_data.d.ts.map +1 -1
  155. package/src/shade/renderer/shader/graph/graph_prepare_pass_input_data.js +12 -1
  156. package/src/shade/renderer/shader/pass/RenderPassDescriptor.d.ts.map +1 -1
  157. package/src/shade/renderer/shader/pass/RenderPassDescriptor.js +1 -0
  158. package/src/shade/renderer/shader/resource/IMMEDIATE_DATA.d.ts +21 -0
  159. package/src/shade/renderer/shader/resource/IMMEDIATE_DATA.d.ts.map +1 -0
  160. package/src/shade/renderer/shader/resource/IMMEDIATE_DATA.js +21 -0
  161. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.d.ts +59 -1
  162. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.d.ts.map +1 -1
  163. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.js +195 -2
  164. package/src/shade/renderer/shader/type/type_has_arrays.d.ts +14 -0
  165. package/src/shade/renderer/shader/type/type_has_arrays.d.ts.map +1 -0
  166. package/src/shade/renderer/shader/type/type_has_arrays.js +33 -0
  167. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts +14 -13
  168. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts.map +1 -1
  169. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.js +133 -129
  170. package/src/shade/renderer/view/GPUViewSkyContext.d.ts.map +1 -1
  171. package/src/shade/renderer/view/GPUViewSkyContext.js +169 -173
  172. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  173. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +480 -315
  174. package/src/shade/renderer/volumetrics/NOTES.md +887 -8
  175. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts +5 -0
  176. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts.map +1 -1
  177. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.js +21 -2
  178. package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts +26 -0
  179. package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts.map +1 -1
  180. package/src/shade/renderer/volumetrics/SceneVolumetrics.js +64 -21
  181. package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.d.ts.map +1 -1
  182. package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.js +84 -28
  183. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts +98 -0
  184. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts.map +1 -0
  185. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.js +111 -0
  186. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts +84 -0
  187. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts.map +1 -0
  188. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.js +252 -0
  189. package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.js +75 -75
  190. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts +43 -0
  191. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts.map +1 -0
  192. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.js +145 -0
  193. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts +20 -0
  194. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts.map +1 -0
  195. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.js +48 -0
  196. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts +38 -0
  197. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts.map +1 -0
  198. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.js +130 -0
  199. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +18 -3
  200. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  201. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +193 -70
  202. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts +16 -0
  203. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  204. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +83 -15
  205. package/src/shade/renderer/volumetrics/shader_volumetrics_build_participating_media.d.ts.map +1 -1
  206. package/src/shade/renderer/volumetrics/shader_volumetrics_build_participating_media.js +29 -18
  207. package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.js +260 -260
  208. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts +58 -0
  209. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts.map +1 -0
  210. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.js +290 -0
  211. package/src/shade/wgsl/emulator/CPUBitmapData.d.ts +25 -0
  212. package/src/shade/wgsl/emulator/CPUBitmapData.d.ts.map +1 -1
  213. package/src/shade/wgsl/emulator/CPUBitmapData.js +33 -0
  214. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +2228 -2215
  215. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  216. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +210 -2
  217. package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts +69 -0
  218. package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts.map +1 -0
  219. package/src/shade/wgsl/emulator/cpu_texture_gather.js +148 -0
  220. package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts +16 -0
  221. package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts.map +1 -1
  222. package/src/shade/wgsl/emulator/cpu_texture_sample.js +176 -172
  223. package/src/shade/renderer/volumetrics/VOLUMETRICS_WORKGROUP_RESOLUTION.d.ts +0 -2
  224. package/src/shade/renderer/volumetrics/VOLUMETRICS_WORKGROUP_RESOLUTION.d.ts.map +0 -1
  225. package/src/shade/renderer/volumetrics/VOLUMETRICS_WORKGROUP_RESOLUTION.js +0 -1
@@ -1,11 +1,11 @@
1
- * https://shaderbits.com/blog/creating-volumetric-ray-marcher
2
- * "Physically Based and Unified Volumetric Rendering in Frostbite" SIGGRAPH 2015
3
- * "Creating the Atmospheric World of Red Dead Redemption 2: A Complete and Integrated Solution" SIGGRAPH 2019
4
- * "Volumetric Fog in Enshrouded" GPC 2024 (https://www.youtube.com/watch?v=OR8HbFnQdlk)
5
-
6
-
7
-
8
- Check out "A Novel Sampling Algorithm for Fast and Stable Real-Time Volume Rendering" SIGGRAPH 2015
1
+ * https://shaderbits.com/blog/creating-volumetric-ray-marcher
2
+ * "Physically Based and Unified Volumetric Rendering in Frostbite" SIGGRAPH 2015
3
+ * "Creating the Atmospheric World of Red Dead Redemption 2: A Complete and Integrated Solution" SIGGRAPH 2019
4
+ * "Volumetric Fog in Enshrouded" GPC 2024 (https://www.youtube.com/watch?v=OR8HbFnQdlk)
5
+
6
+
7
+
8
+ Check out "A Novel Sampling Algorithm for Fast and Stable Real-Time Volume Rendering" SIGGRAPH 2015
9
9
  * https://github.com/huwb/volsample/blob/master/src/shadertoy/clouds.shader
10
10
  ## Blue noise for the froxel column jitter — measured, 2026-08-29
11
11
 
@@ -48,3 +48,882 @@ away, and the obvious fix aggravates the stated mechanism. Genuinely removing it
48
48
  XY jitter (the TAA's 3×3×3 Blackman–Harris resample already does spatial reconstruction), not
49
49
  changing the noise source. Adopt `stbn_vec1` when cutting `SAMPLE_COUNT` (P-01), where better
50
50
  per-pixel temporal distribution is the actual goal — and never `stbn_vec3`, for the reason above.
51
+
52
+ ## Shadowing the clustered lights — measured, 2026-08-29
53
+
54
+ Q-01. The point and spot loops in `shader_volumetrics_build_lighting` never applied a shadow map, so
55
+ geometry did not occlude a local light in the volume at all; only the medium's own optical depth
56
+ did. The directional loop always had. Fixed by `chunk_volumetrics_local_light_shadow`, one nearest
57
+ atlas tap per light.
58
+
59
+ Two things about it were expected to be problems and measured not to be. The numbers below came from
60
+ a `local_light_shadow_mode` word on the metadata that switched between off, that tap, and the
61
+ deferred path's filter kernels; it and the two playground checkboxes over it have since been removed,
62
+ which is why nothing in the tree reproduces these rows any more. Restoring the comparison means
63
+ re-adding that switch.
64
+
65
+ **The cost is nearly nothing, even at 130 shadow-casting lights.** Whole-frame median over 18
66
+ interleaved rounds in the playground scene at 3840x2160 (the froxel grid is 4x the 1080p one, so
67
+ this magnifies any per-froxel cost by 4x):
68
+
69
+ | sampling | frame (ms) | vs. off |
70
+ |---|---|---|
71
+ | off | 10.29 | — |
72
+ | one atlas tap (what ships) | 10.42 | +0.13 (+1.2%) |
73
+ | deferred kernels (8 cone taps / 9-gather PCF) | 10.71 | +0.42 (+4.0%) |
74
+
75
+ Scaled back to 1080p that is +0.03 ms and +0.10 ms, both far below the run-to-run spread. Which
76
+ means the thing P-02 exists to make affordable was already affordable — do not reach for a
77
+ pre-filtered ESM/VSM target on Q-01's behalf. It also means the "skip the shadow test for lights
78
+ whose contribution is negligible" idea has nothing left to buy and would only add a popping risk.
79
+
80
+ **The cheap tap is not a quality compromise here — it is indistinguishable, and marginally
81
+ quieter.** Under the measurement protocol in the playground README (auto exposure off, 45 frames of
82
+ re-convergence, 32-frame average, noise floor from an identical no-op toggle):
83
+
84
+ | comparison | signed mean | mean abs |
85
+ |---|---|---|
86
+ | noise floor (mode 1 vs itself) | -0.053 | 1.110 |
87
+ | mode 1 vs off | **-0.852** | 1.826 |
88
+ | mode 2 vs off | **-0.853** | 2.010 |
89
+ | mode 1 vs mode 2 | +0.0004 | 1.439 |
90
+
91
+ The two paths remove the same light to four decimal places — 0.0004 against a floor of 0.05 — and
92
+ their per-pixel difference sits at the floor. Residual noise after the TAA (mean abs of two settled
93
+ captures of the same setting) is 1.100 off, 1.343 for one tap, 1.381 for the kernels: the 8-tap cone
94
+ is *noisier*, because its taps are drawn stochastically per frame. Repeated with a shadow-casting
95
+ spot light added to the scene: mode 1 vs mode 2 signed mean -0.061, mean abs 1.092, against that
96
+ run's floor of +0.016 / 1.124 — below the floor on both statistics.
97
+
98
+ So the PCF kernel really was filtering something about to be filtered three more times, and the
99
+ deferred path keeps it for surfaces, where it belongs.
100
+
101
+ One caveat came out of this, recorded because it bit the spec, and since fixed — though not the way
102
+ it first looked. `shadowmap_sample_point` rounds its taps stochastically from
103
+ `uv * face_size - 0.5`, and an axis-aligned light-to-sample direction encodes to an octahedral uv of
104
+ exactly 0 or 1, so some taps land at texel -1 or `face_size`, one texel outside the rect the record
105
+ publishes. Latent in the deferred path (mode 2), not introduced here; mode 1 clamps and never had it.
106
+ Not an exotic input either: the xy axes are the four edge midpoints of the square and the -Z pole is
107
+ its corner, so a lamp above the floor beneath it overshoots on roughly half its taps.
108
+
109
+ **That overshoot is provided for, and the first read of this was wrong.** `shadow_point` publishes
110
+ the INNER octahedral surface — `make_octahedral_point_shadow_record` subtracts
111
+ `SHADOWMAP_ATLAS_BORDER` (4) from every side of the slot — and the cube → octahedral remap fills the
112
+ ring around it with `inner[texture_octahedral_wrap_texel_coordinates(local)]`, folding each border
113
+ fragment through the wrap before decoding a direction from it. One texel of overshoot therefore
114
+ reads this light's own wrapped data, four texels inside its slot. Nothing was ever reading a
115
+ neighbouring light, and the `face_size` overshoot was never a defect at all.
116
+
117
+ What *was* wrong is narrower: the sampler reached the skirt through
118
+ `vec2<u32>(rounded_local_texel) + vec2<u32>(atlas_aabb.xy)`, and `vec2<u32>` of `-1.0` is a
119
+ negative-to-`u32` conversion WGSL leaves indeterminate. Land on `0xFFFFFFFF` and the wrapping add
120
+ gets to `inner - 1`, which is right; land on `0`, equally legal, and the tap silently reads the
121
+ opposite edge of the inner surface. Fixed by doing the add in `i32` — exact for an integral `f32` in
122
+ `[-1, size]`, and the inner origin is at least `SHADOWMAP_ATLAS_BORDER`, so the `u32` at the end is
123
+ defined.
124
+
125
+ `shadow/map/shader/shadowmap_sample_point_seam.spec.js` builds a synthetic atlas with the skirt the
126
+ remap would have written, and drives the shader under *both* legal readings of that conversion,
127
+ requiring the same answer. That is the assertion that failed before; a checkerboard inner surface
128
+ makes the two readings disagree on every affected tap, because the skirt texel at local `(x, -1)`
129
+ holds `inner[size - 1 - x, 0]`, of opposite parity to `inner[x, 0]`.
130
+
131
+ Worth knowing if the border is ever revisited: `chunk_shadow2d_pcf_multiple_tap_pcf_contact_hardened`,
132
+ the DPCF disk kernel `SHADOWMAP_ATLAS_BORDER`'s doc names as its reason to exist, is imported by no
133
+ shader — `chunk_shadowmap_sample_point2` replaced it with cone sampling. So the skirt's only current
134
+ reader is that one-texel overshoot, and 8 texels per slot (half of a 16-slot) are otherwise idle.
135
+ Cutting the border is therefore possible but not free: the overshoot needs at least one texel of it.
136
+
137
+ ## Amortizing the sun's optical depth into a light-aligned volume — measured, 2026-08-29
138
+
139
+ > **Superseded in two places by the last section of this file.** What kept this switched off is not
140
+ > the frustum-clipped medium; the field-of-view measurement that argued it was is confounded, because
141
+ > widening the lens also coarsens the box. And the structure is cascaded now, not a single volume.
142
+
143
+ Q-04. `integrate_optical_depth` marches 16 steps from every froxel toward every light. For two
144
+ froxels on the same light ray the answers differ by one increment, and the pass recomputes the whole
145
+ integral for each: a prefix sum evaluated as N independent sums.
146
+ `shader_volumetrics_build_sun_shadow_volume` computes it once instead, into a voxel grid whose third
147
+ axis is the sun, and `chunk_volumetrics_sun_optical_depth` reads it back in a single fetch.
148
+ `__VOL_SUN_SHADOW` switches between them. **Off by default**, for the one reason recorded at the end.
149
+
150
+ ### What the literature actually does, including a correction
151
+
152
+ The report this came from credited both RDR2 and Enshrouded with amortizing the per-froxel march
153
+ into a volumetric shadow representation. Read against the released material, one of those is right
154
+ and the other is not.
155
+
156
+ **Enshrouded (GPC 2024) is doing exactly this, cascaded.** From the talk: "for rendering shadows …
157
+ we are using 3D volumes in a cascaded fashion and align their z-axis to the direction of the primary
158
+ light source, so in a first pass we would just initialize them with participating media properties
159
+ and in a second pass integrate from the light source down … and that gives us information about how
160
+ much shadowing there is from the fog for any position inside those cascades." And on the shipping
161
+ version: "we're initializing that one in one 3D dispatch and then do a 2D dispatch for each cascade
162
+ in the direction of the light … each cascade as an initial shadow value reads the upper cascade, so
163
+ in the end we can just sample the one cascade and with one sample we get the full shadow volume, and
164
+ the outer cascade is initialized with approximate volumetric shadows for clouds." Same structure,
165
+ same build shape — a 2D dispatch walking columns along the light — with cascades on top and the
166
+ outermost seeded from a cloud approximation. Their fog lives in a world-space voxel grid at 1 m per
167
+ voxel, which is why they do not have the caveat below.
168
+
169
+ **RDR2 (SIGGRAPH 2019) is not.** It does have a *Shadow Volume* — R16F, one of three volumes in a
170
+ 160x88x64 frustum grid, temporally filtered — but the slide and its speaker notes say what it holds:
171
+ "the directional light shadow term sampled from the shadow map cascade as well as cloud and terrain
172
+ shadow map". That is the **shadow map** lookup amortized, not the medium's own optical depth. The
173
+ medium's self-shadowing there is a short secondary sample on cloud material ("we also sample
174
+ extinction towards the sun or moon to generate high frequency shadowing") plus the cloud shadow map,
175
+ which is a light-space ESM built by ray-marching the cloudscape from the light — a genuine
176
+ light-space transmittance structure, but for clouds only. The talk's own Future Outlook slide lists
177
+ **"Volumetric shadows"** as work not done. The review document is corrected accordingly.
178
+
179
+ Costs from that talk, for calibration, on PS4: cloud ESM 0.156 ms, shadow volume 0.204 ms, material
180
+ volume 0.33 ms, scattering volume 0.34 ms, far-field raymarch 1.31 ms.
181
+
182
+ **Fourier Opacity Mapping is the famous answer and the wrong one here.** FOM (Jansen & Bavoil, I3D
183
+ 2010) projects log-transmittance along each light-space texel onto a truncated Fourier basis. It
184
+ exists because the medium usually arrives as *unordered rasterized particles*, where the only thing
185
+ available is a fixed-size, order-independent, additively blendable summary — and it pays for that
186
+ with ringing at sharp density transitions, which is why Adaptive Volumetric Shadow Maps (Salvi et
187
+ al., EGSR 2010) answer the same problem with a variable-error compressed curve instead. This engine's
188
+ medium is already a 3D texture with a defined order along any axis, so the scan is available, exact
189
+ for the piecewise-constant field a voxel grid *is*, and has nothing to ring. In the shadow-map
190
+ taxonomy what is implemented here is an Opacity Shadow Map (Kim & Neumann 2001) with enough slices
191
+ and hardware trilinear reconstruction that the slice artefacts it is remembered for do not appear.
192
+
193
+ ### The structure, and why it does not swim
194
+
195
+ A cube in sun space, sized from the camera frustum's **bounding sphere** and snapped to whole voxels.
196
+ Both halves matter and neither is decoration:
197
+
198
+ - The sphere's radius is a function of `fov`, `aspect`, `near` and `far` only, so the extent does not
199
+ change when the camera turns. A tight fit of the frustum corners in sun space — which is what this
200
+ engine's CSM deliberately does, for reasons its own comment gives — would breathe under rotation,
201
+ moving every voxel centre continuously. The CSM can afford that because it is two orders of
202
+ magnitude denser and its consumer is a filtered depth comparison; this is neither.
203
+ - With a fixed extent, snapping the origin makes the world-space voxel lattice *the same set of
204
+ points* every frame. Only the indexing changes. `build_volumetrics_sun_shadow_transform.spec.js`
205
+ asserts that directly: voxel centres generated for one camera pose land on voxel centres of the
206
+ grid generated for another, under translation, rotation and both together.
207
+
208
+ Confirmed on the device, with the volumetric TAA off so nothing could hide a discontinuity. Creeping
209
+ the camera across three voxels of translation, and separately across 20 degrees of yaw, the volume
210
+ path's mean absolute second difference of frame luminance is *lower* than the march's — 0.0140 vs
211
+ 0.0165 translating, 0.0051 vs 0.0253 rotating. It does not snap.
212
+
213
+ Six scripted motions (yaw, pitch, forward, backward, strafe, combined), 90 frames each, at two
214
+ viewpoints, comparing the volume against the march on flicker while moving, on flicker and drift
215
+ while settling, and on the converged image after the motion against the converged image from a
216
+ standing start at the same pose. Every pair agrees to within a few percent; the largest gap in
217
+ "flown vs still" is 0.06 mean luminance against a floor of 0.05. The structure adds no temporal
218
+ instability of its own. `playground/volumetrics_froxel/measure.js` is that harness.
219
+
220
+ ### Cost, from the profiler rather than from frame time
221
+
222
+ Per pass, `WORKLOAD` capture decoded in the page, 50 frames, two runs each:
223
+
224
+ | pass | 1920x1080, march -> volume | 3840x2160, march -> volume |
225
+ |---|---|---|
226
+ | build sun shadow volume | 0.003 -> **0.048** | 0.003 -> **0.053** |
227
+ | build lighting | 0.675 -> **0.499** | 1.857 -> **1.274** |
228
+ | net | **-0.13 ms** | **-0.53 ms** |
229
+
230
+ The whole-frame delta at 2160p is -0.55 ms with disjoint spreads over seven interleaved rounds,
231
+ matching the per-pass sum. At 1080p whole-frame timing could not resolve it at all: the run-to-run
232
+ spread is about +-0.5 ms and three interleaved runs read +0.11, +0.31 and +0.37 — the *wrong sign*.
233
+ The README's advice to attribute cost to passes rather than infer it from frame time earned itself
234
+ here.
235
+
236
+ The shape is the point. The build is fixed at ~0.05 ms, and the same at 64^3 as at 128^3 — it is not
237
+ where the time goes — while what it removes scales with the froxel count. Double the resolution and
238
+ the saving doubles while the cost does not move.
239
+
240
+ ### Why it is off by default
241
+
242
+ At seven of the nine playground viewpoints the two paths agree to within about four times the noise
243
+ floor (|signed mean| <= 0.20 against a floor of 0.01-0.09). "Under the island" reads -0.73. **"Inside
244
+ the fog" reads +1.92**, and carries a single **+3.8 pop** around 8 degrees into a yaw, which the
245
+ 0.95-blend TAA passes straight through rather than absorbing.
246
+
247
+ That is not resolution, and the measurement that settles it is the field of view. At that same
248
+ viewpoint, widening to 110 degrees brings the disagreement to -0.05 against a floor of -0.06 — gone —
249
+ while *raising* the volume's resolution does not converge at all (32^3 through 192^3 oscillates
250
+ between +7.0 and -1.9). The pop moves when `__VOL_FROXEL_FAR` moves and does not move with the box's
251
+ snapping: the transform is bit-identical either side of it.
252
+
253
+ > **Read the next section before trusting the paragraph below.** It calls the march "the converged
254
+ > one", on the strength of `STEP_COUNT = 256` moving it by only -0.24 at *that* viewpoint. That is
255
+ > true there and false in general — sixteen steps step clean over a thin fog slab, and there are
256
+ > viewpoints in this same scene where the march is 58 luminance from its own converged answer while
257
+ > the volume is 29 from it. Which of the two is nearer the truth is a property of the geometry, not
258
+ > of the method.
259
+
260
+ All of which points at the medium rather than the structure. `tParticipatingMedia1` is hard clipped
261
+ to the camera frustum (Q-05), so the field being scanned has an artificial discontinuity in it. The
262
+ march resolves that edge at its own step size, which is anchored at the froxel and shrinks to nothing
263
+ exactly where the exit is near; a world-space lattice resolves it at a fixed 2.3 units. Where the sun
264
+ ray leaves the frustum within a voxel or two of the froxel — inside dense fog, looking toward a low
265
+ sun — that edge *is* the integral, and the two disagree. Widening the FOV pushes it away and they
266
+ agree again. For a reference: the march at `STEP_COUNT = 256` differs from the shipping 16 by only
267
+ -0.24 mean luminance, so the march is the converged one and the volume is the one in error there.
268
+
269
+ So the fix is not more voxels and not a smarter fit. It is to stop sampling a frustum-shaped medium —
270
+ build the volume from the scene's participating-media volumes directly, which removes the
271
+ discontinuity, closes Q-05, and is the arrangement Enshrouded has. That is one shader's worth of work
272
+ now that the structure and the measurements exist, and flipping the default is a line.
273
+
274
+ Cascades are the other half of what Enshrouded ships, and this is shaped to take them: an inner
275
+ cascade's scan starts from the outer cascade's value at its entry face instead of from zero. Worth it
276
+ when the near field needs resolution the uniform grid cannot give — which, on this evidence, is not
277
+ the binding constraint yet.
278
+
279
+ ## The sun volume strobed, and the march was not the reference — measured, 2026-08-29
280
+
281
+ > **Read the last section of this file for what closed this.** The strobe diagnosis here is right
282
+ > and the fix for it stands. The paragraph at the end proposing temporal accumulation is answered
283
+ > there, and the answer is no; so is the claim that the box is thin in two axes.
284
+
285
+ Reported from the playground: at a camera parked beside one of the small fog boxes, with the sun
286
+ ahead, `__VOL_SUN_SHADOW = true` made the light pulse. Two poses, both reproducible, both with the
287
+ camera *stationary* — which rules out everything the section above establishes about the box moving,
288
+ since a still camera means a bit-identical transform every frame.
289
+
290
+ Frame-to-frame mean luminance, 24 frames, camera static:
291
+
292
+ | | march (16 steps) | volume, 1 sample per voxel |
293
+ |---|---|---|
294
+ | mean | 120.19 | 91.59 |
295
+ | mean absolute frame-to-frame step | 0.0023 | 0.131 |
296
+ | largest single-frame step | 0.004 | **0.623** |
297
+
298
+ At the second pose the largest step was **1.81**. That is a visible pulse, and the temporal filter
299
+ passes it through rather than absorbing it.
300
+
301
+ ### The march was not the reference, and the earlier note leant on it as if it were
302
+
303
+ Raising `STEP_COUNT` to 512 to get a converged answer at these poses:
304
+
305
+ | | pose 1 | pose 2 |
306
+ |---|---|---|
307
+ | march, 16 steps (shipping) | 120.18 | 149.47 |
308
+ | **march, 512 steps** | **62.19** | **79.03** |
309
+ | volume, 128^3, 1 sample per voxel | 91.59 | 119.23 |
310
+ | volume, 256^3 | 63.20 | — |
311
+
312
+ So at these poses the shipping march is 58 luminance from its own converged answer and the volume is
313
+ 29 from it: the amortized path is the *more* accurate of the two, and the resolution sweep that
314
+ looked like divergence — 64^3 giving 120.3, 128^3 giving 91.8, 256^3 giving 63.2 — was convergence
315
+ all along, toward a reference the earlier work never computed at this geometry. It read as
316
+ divergence only because the shipping march was being used as ground truth, and here it is nowhere
317
+ near it.
318
+
319
+ Worth saying plainly, because it inverts the last paragraph of the section above: **the march at 16
320
+ steps is converged when the medium is thick and spread out, and badly wrong when it is thin.** Its
321
+ step is the distance to the frustum exit over sixteen, which at these poses is about 8 units, and
322
+ the fog box it is meant to find is 3 units thick. It steps over it. A stable wrong answer does not
323
+ strobe, which is the only reason the march looks better on the flicker column.
324
+
325
+ ### Why it strobed, and the fix
326
+
327
+ The fog box here is 10x3x20 and the camera sits about a third of a unit outside its face. The sun
328
+ travels roughly along -X, so what the ray toward the sun has to cross is the 3-unit dimension — and
329
+ the 128^3 volume's voxel is 2.28 units. Walking the column that passes through the camera and asking
330
+ which lattice points fall inside any scene volume: **two of them.** The entire shadow rested on two
331
+ samples, and the froxel grid's per-frame column jitter moves the medium under both.
332
+
333
+ The fix is to stop conflating the sampling rate with the storage resolution. A voxel has to hold the
334
+ medium's *integral across its slab*, and one point sample at its centre is not that. The build pass
335
+ now takes four sub-steps per voxel along the sun axis and stores the running integral at the voxel
336
+ centre as before — 0.57-unit sample spacing behind a 2.28-unit voxel, one loop rather than 64x the
337
+ memory:
338
+
339
+ | | 1 sample/voxel | 4 sub-steps/voxel |
340
+ |---|---|---|
341
+ | pose 1, largest frame step | 0.623 | **0.083** |
342
+ | pose 2, largest frame step | 1.81 | **0.151** |
343
+ | pose 1, mean abs step | 0.131 | 0.020 |
344
+ | pose 2, mean abs step | 0.468 | 0.037 |
345
+
346
+ Six to thirteen times quieter, and it improves the static disagreement at *Inside the fog* as well,
347
+ +1.92 -> +1.30. The cost, per pass at `WORKLOAD`:
348
+
349
+ | | 1920x1080 | 3840x2160 |
350
+ |---|---|---|
351
+ | build sun shadow volume | 0.008 -> 0.115 | 0.003 -> 0.133 |
352
+ | build lighting | 0.427 -> 0.318 | 1.772 -> 1.068 |
353
+ | net | **~0.00 ms** | **-0.57 ms** |
354
+
355
+ So the strobe fix spends the 1080p win — it is now a wash there — and keeps the 2160p one. The build
356
+ grew 2.7x rather than 4x, the rest being fixed overhead.
357
+
358
+ ### The switch is gone, and what that costs
359
+
360
+ Q-04 is approved, so the cascades are the sun's only path and the A/B that measured them has been
361
+ removed: the `__VOL_SUN_SHADOW` global, the playground checkbox over it, and the `sun_shadow_mode`
362
+ word in the metadata, along with the branch in `shader_volumetrics_build_lighting` that read it.
363
+ Same treatment Q-01 got, and it has the same consequence — **nothing in the tree reproduces the
364
+ tables above any more.** Restoring the comparison means re-adding that word and its branch.
365
+
366
+ Restoring the rest of it means more than that, and it is worth writing down while it is fresh:
367
+
368
+ * the converged reference needs `STEP_COUNT` in `chunk_integrate_optical_depth` raised to 512;
369
+ * the single-box rows need `VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO` set to 1, which makes the three
370
+ cascades coincide and the structure exactly one box, built by the same code;
371
+ * the resolution ladder needs `VOLUMETRICS_SUN_SHADOW_RESOLUTION` edited. There was a
372
+ `__VOL_SUN_SHADOW_RESOLUTION` override for it; it went with the rest, because a global that only
373
+ exists to A/B a constant is the same thing as the switch and it was inconsistent to keep one
374
+ without the other — the ratio never had one.
375
+
376
+ What replaces the mode word is one field rather than two. `sun_shadow_light_index` already said
377
+ which directional light the cascades were built for, so that a second sun degrades to the march
378
+ instead of being shadowed by the first one's boxes; it now also carries
379
+ `VOLUMETRICS_SUN_SHADOW_LIGHT_NONE` for the two cases that can place no box at all — a scene with no
380
+ directional light, and a camera with no field of view to derive a bounding sphere from. The sentinel
381
+ matches no index, so those degrade every directional light the same way. "Not built this frame" is a
382
+ property of the light, not a mode to select, and saying it that way removes the failure mode a mode
383
+ word has: left at zero it selects the march, silently, everywhere.
384
+
385
+ The build passes still run in those cases, collapsed to one voxel per cascade. Not for measurement
386
+ symmetry any more — that argument went with the A/B — but because the lighting pass binds their
387
+ outputs unconditionally and WebGPU has no optional bindings.
388
+
389
+ ### What is left, and it is not more sub-steps
390
+
391
+ Sub-stepping fixed the *temporal* aliasing and left most of the *magnitude* gap: pose 1 went 91.59 ->
392
+ 92.97 against a reference of 62.19. That is because sub-stepping refines one axis and the fog box is
393
+ thin in two of them — perpendicular to the sun its cross-section is the 3x20 face, so the 3-unit
394
+ dimension is under-sampled laterally as well, and only 256^3, which refines all three, converges.
395
+ Lateral sub-sampling would blur the shadow rather than sharpen it, so that is not the answer either.
396
+
397
+ The answer is *not* what the first draft of this section said, which was to build the volume from
398
+ the scene's media volumes and integrate each box analytically, "since the chord a sun ray cuts
399
+ through a box has a closed form". That assumes the medium inside a box is uniform, and it is not.
400
+
401
+ `compute_volume_fade_factor` ramps the density near every face:
402
+ `smoothstep(0, fade_distance, d.x) * smoothstep(0, fade_distance, d.y) * smoothstep(0, fade_distance, d.z)`
403
+ over the world-space distance to each face. So a box's interior is a separable cubic-Hermite ramp,
404
+ and `chord_length * sigma` is wrong by whatever the fade profile does along that chord. The volumes
405
+ overlap too, though that half is harmless on its own — optical depth is additive, so
406
+ `tau = integral of sum(sigma_i) = sum of integral of sigma_i`, and each volume could in principle be
407
+ integrated separately. It is the non-uniformity inside each one that kills it.
408
+
409
+ And there is a worse problem underneath, which is that **the participating-media pass does not
410
+ discretise a field — it evaluates an estimator**. `compute_volume_fade_factor` takes the *froxel's
411
+ frustum*, intersects it with the box, and samples the fade at the midpoint of that intersection
412
+ segment; `frustum_unit_box_coverage` multisamples the froxel/box overlap to get a coverage fraction.
413
+ Both answers depend on the shape of the froxel, not only on where it is. There is therefore no
414
+ `sigma(x)` that the PM grid is sampling and that something else could integrate exactly: an analytic
415
+ sun-volume build would be computing a different quantity from the one the froxel grid holds, and the
416
+ two would disagree by construction, everywhere, for a reason no measurement would attribute
417
+ correctly.
418
+
419
+ `ParticipatingMediaVolume` also carries `noise_scale`, `noise_amplitude` and `noise_evolution_speed`.
420
+ They are not in `PARTICIPATING_MEDIA_VOLUME_STRUCT`, so nothing reaches the GPU and no noise is
421
+ applied today — but the data model plainly intends it, and an analytic scheme would die the day it is
422
+ wired.
423
+
424
+ So: sample the medium, do not try to solve it. What makes that affordable is the one property this
425
+ structure already has and the froxel grid does not — **the world-space voxel lattice does not move**,
426
+ which the transform spec asserts and the device measurements confirm. A lattice that does not move
427
+ can be accumulated in place: one *jittered* sample per voxel per frame, a ping-pong pair of volumes,
428
+ and a blend. No reprojection, because there is nothing to reproject; the effective sample count grows
429
+ with the frame count instead of with the per-frame cost, and it converges on the true cell average of
430
+ whatever the medium turns out to be, fade profiles and noise included. The sub-steps this commit adds
431
+ would then drop back toward one.
432
+
433
+ It is not free of conditions, and they are the usual ones for anything temporal:
434
+
435
+ * the lattice is invariant to *camera* motion, not to the sun turning (the basis changes) nor to
436
+ `fov`/`near`/`far` changing (the extent changes) — both need a reset, and both are rare;
437
+ * a volume that moves or animates needs the accumulation invalidated, which `SceneVolumetrics` can
438
+ now signal since it gained `invalidate`;
439
+ * it buys accuracy with lag, which is the trade the froxel TAA already makes one pass downstream.
440
+
441
+ What none of this fixes is the *spatial* resolution of the stored shadow: a three-unit box behind a
442
+ 2.3-unit voxel gives a blurry shadow no matter how well each voxel's content is estimated. That is
443
+ what cascades are for, and it is a separate axis from this.
444
+
445
+ ## The sun volume needed cascades, and nothing else would do — measured, 2026-08-29
446
+
447
+ Q-04, third pass. The section above ends by proposing temporal accumulation, and blames the
448
+ frustum-clipped medium (Q-05) for the disagreement that kept the structure switched off. Both are
449
+ wrong, and the measurements that show it also show what the structure actually needed. It now ships
450
+ **on by default**, as three cascades.
451
+
452
+ Everything below is against a converged reference: `chunk_integrate_optical_depth` with `STEP_COUNT`
453
+ raised to 512, rendered by the same code path as the shipping march so the two differ in nothing but
454
+ the step count. 1280x720, auto exposure off, 45 frames of re-convergence and a 32-frame average,
455
+ noise floor from a repeat of the reference inside the same run — the protocol in the playground
456
+ README. Numbers are signed mean luminance against that reference.
457
+
458
+ ### Where the three paths stand
459
+
460
+ | viewpoint | reference | march, 16 steps | one box, 128^3 | **cascades, 3 x 96^3** |
461
+ |---|---|---|---|---|
462
+ | beside the fog bank, pose 1 | 62.39 | **+58.2** | +30.5 | **-1.13** |
463
+ | beside the fog bank, pose 2 | 79.05 | **+70.5** | +37.6 | **-0.53** |
464
+ | Inside the fog | 114.68 | +0.29 | +1.51 | +0.04 |
465
+ | Fog bank, wide | 110.22 | +0.01 | 0.00 | -0.02 |
466
+ | Under the island | 92.02 | +0.62 | -0.19 | -0.20 |
467
+ | Dead overview | 100.88 | +0.12 | -0.16 | -0.08 |
468
+
469
+ Floors 0.01 to 0.16. The amortized path is not a cheaper approximation of the march any more. It is
470
+ the accurate one, by a factor of fifty at the two viewpoints where the two disagree at all, and it
471
+ is also the cheaper one.
472
+
473
+ ### The error was resolution, on one axis, and the axis is not the sun's
474
+
475
+ Isolating axes at pose 1, one box, four sub-steps throughout so the quadrature is held fixed. The
476
+ sun travels along -X at 12 degrees of elevation; the volume's basis puts `u` horizontal and `v`
477
+ within 12 degrees of vertical, and the fog bank is a slab three units thick in world Y.
478
+
479
+ | grid | lateral voxel (u, v) | sun voxel | signed |
480
+ |---|---|---|---|
481
+ | `[128,128,128]` | 2.28, 2.28 | 2.28 | +30.5 |
482
+ | `[128,128,256]` — sun axis x2 | 2.28, 2.28 | 1.14 | +30.3 |
483
+ | `[256,128,128]` — u x2 | 1.14, 2.28 | 2.28 | +30.9 |
484
+ | **`[128,256,128]` — v x2** | 2.28, **1.14** | 2.28 | **+6.1** |
485
+ | `[128,512,128]` — v x4 | 2.28, 0.57 | 2.28 | +4.8 |
486
+ | `[256,256,128]` | 1.14, 1.14 | 2.28 | +3.5 |
487
+
488
+ Doubling the sun axis buys nothing. Doubling the *one* lateral axis that runs across the slab's
489
+ three-unit dimension removes 80% of the error. That is a Nyquist threshold and it sits where one
490
+ would predict: 1.52 units (a `[192,192,*]` grid) still reads +21.9, and 1.14 units reads +6.1.
491
+
492
+ So the previous section's account — "it refines one axis and the box is thin in two of them", and
493
+ "only 256^3, which refines all three, converges" — is wrong on both counts. As the sun sees it the
494
+ box is thin in *one* axis, and `[256,256,32]` converges at exactly the memory `[128,128,128]` was
495
+ already spending.
496
+
497
+ ### Prefiltering cannot fix it, and neither can temporal accumulation
498
+
499
+ This is the measurement that closes the proposal the section above ends on. Replace the single
500
+ lateral sample per sub-step with a 4x4 stratified average over the voxel's lateral footprint —
501
+ sixteen taps, deterministic, and therefore the *converged limit* of any scheme that reaches the same
502
+ cell average stochastically over time. Quadrature held at a constant 0.6 unit spacing.
503
+
504
+ | | 1 lateral sample | 4x4 lateral |
505
+ |---|---|---|
506
+ | `[128,128,128]`, pose 1 | +30.5 | **+25.1** |
507
+ | `[128,128,128]`, pose 2 | +37.5 | **+29.7** |
508
+ | `[256,256,32]`, pose 1 | +0.35 | +4.09 |
509
+ | `[256,256,32]`, pose 2 | +7.59 | +10.05 |
510
+
511
+ Sixteen times the sampling recovers 18% of the error, and at a resolution that already resolves the
512
+ medium it makes matters *worse*, because there it blurs a shadow that was resolved. That is what a
513
+ prefilter is for and all it is for: it removes aliasing, not the resolution that was aliased away. A
514
+ box filter preserves the integral of `tau` and lowers its peak, and the froxels that carry the image
515
+ sit at the peak.
516
+
517
+ **One jittered sample per voxel per frame, accumulated in place, converges on that same 4x4 column.**
518
+ The world-space lattice really does not move — that half of the proposal is sound and its spec still
519
+ asserts it — but what it does not move *toward* is the reference. Temporal accumulation would spend
520
+ a settling window, a reset on every sun rotation and projection change, and an invalidation on every
521
+ moving volume, to arrive at +25.1 instead of +30.5. It is the wrong instrument for this defect. It
522
+ stays the right instrument for the residual per-frame noise, which is a different and much smaller
523
+ problem — the stability table below puts that noise at 0.03 to 0.05.
524
+
525
+ The literature says the same thing from the other end. Optical depth is *linear* in `sigma`, which is
526
+ why every light-space structure in this family stores it: Fourier Opacity Mapping (Jansen & Bavoil,
527
+ I3D 2010) projects absorption along the ray onto a Fourier basis and exponentiates on read, and its
528
+ own framing is that it is for media whose spatial opacity variations are smooth. Deep Shadow Maps
529
+ (Lokovic & Veach, SIGGRAPH 2000) prefilter in *transmittance* instead, because their medium is hair
530
+ and the sub-texel variation is binary. And averaging the extinction of a heterogeneous medium to
531
+ downsample it is known to be wrong in a specific direction — Zhao et al., "Downsampling Scattering
532
+ Parameters for Rendering Anisotropic Media" (SIGGRAPH Asia 2016), put it as linear downsampling
533
+ weakening the object's intrinsic shadowing structures, which is precisely the +25.1 above.
534
+
535
+ ### And the frustum clip is not what kept it switched off
536
+
537
+ The section above attributes the *Inside the fog* disagreement to Q-05, on the strength of widening
538
+ the field of view to 110 degrees making it vanish while raising the resolution did not. That
539
+ inference does not hold, because **widening the field of view changes the resolution**: the box is
540
+ sized from the frustum's bounding sphere, whose radius runs 93 units at 40 degrees, 141 at 60, 245
541
+ at 90 and 350 at 110. Opening the lens by a factor of 2.5 coarsens every voxel by the same factor.
542
+ The disagreement went away at 110 degrees because the box happened to land on a size that suited
543
+ that geometry, which the next table makes plain.
544
+
545
+ ### Why no single box could ever have been right
546
+
547
+ Pose 2, against a converged reference computed at each field of view. The cascade ratio is forced to
548
+ 1 so the three cascades coincide and the structure is exactly a single box, built by the same code
549
+ with the same sub-steps — the only variable is the shape.
550
+
551
+ | field of view | reference | one box 96^3 | one box 128^3 | one box `[256,256,32]` | 3 x 64^3 | **3 x 96^3** | 3 x 128^3 |
552
+ |---|---|---|---|---|---|---|---|
553
+ | 40 deg | 84.96 | **+49.97** | +6.43 | +28.09 | -0.61 | **-1.35** | -1.77 |
554
+ | 60 deg | 79.05 | +5.87 | **+37.68** | +2.47 | +1.01 | **-0.53** | -1.14 |
555
+ | 90 deg | 76.21 | +5.13 | +1.43 | **+38.96** | +1.29 | **+0.32** | -0.41 |
556
+ | 110 deg | 74.94 | **+39.46** | +2.07 | **+39.46** | +5.86 | **+1.77** | +0.13 |
557
+
558
+ Floor ±0.12 throughout. **Every single-box configuration measured has a field of view at which it is
559
+ 28 to 50 luminance from the truth, and which one that is moves with the resolution.** That is not
560
+ tuning, it is arithmetic: the box has to contain the frustum under any rotation, so its side is
561
+ proportional to `far` and to `tan(fov/2)`, while a three-unit fog slab is three units whatever the
562
+ camera is doing. The ratio between what the structure can resolve and what it has to resolve is a
563
+ free parameter of the *camera*, and no constant pins it.
564
+
565
+ Buying the near field uniformly is not an option either. A 0.28 unit voxel over a 283 unit box is
566
+ 1024 per axis: 268 MB.
567
+
568
+ ### Cascades, which is what both shipped references do
569
+
570
+ Three boxes, each sized from the bounding sphere of the sub-frustum out to `far / 3^(2-i)` — 13.3,
571
+ 40 and 120 units at the playground's projection, so 0.34, 1.02 and 3.07 unit voxels at 96 per axis.
572
+ 7.1 MB each, 21.2 MB in total, against the 16.8 MB the single 128^3 box was already spending.
573
+
574
+ Every property the single box had transfers, because each cascade is placed by the same call with a
575
+ different `far`: the extent is a function of the projection alone, so it cannot breathe under
576
+ rotation, and the snapped origin makes each cascade's world-space voxel lattice the same set of
577
+ points from frame to frame. `build_volumetrics_sun_shadow_transform.spec.js` now asserts that per
578
+ cascade, along with the containment the seeding depends on.
579
+
580
+ Enshrouded (GPC 2024) ships exactly this: "each cascade as an initial shadow value reads the upper
581
+ cascade, so in the end we can just sample the one cascade and with one sample we get the full shadow
582
+ volume". So does Frostbite (SIGGRAPH 2015) one level down the stack — its extinction volume is a
583
+ three-level clip-map and its volumetric shadow maps are 32^3 3D textures storing *transmittance*,
584
+ built at 0.04 ms for a spot light and 0.14 ms for a point light on PS4. Both cascade, and neither
585
+ has this engine's problem of a medium that exists only inside the camera frustum.
586
+
587
+ Three things about the construction are decisions rather than transcription:
588
+
589
+ - **The seed is one fetch of the outer cascade at the world position where this column enters this
590
+ box**, not at the box centre and not a re-march of the approach. Everything between the sun and
591
+ that plane has already been integrated out there. It is what makes an inner cascade a *refinement*
592
+ of the outer rather than a truncation of the medium — without it every near-field froxel would be
593
+ lit as if the fog behind it did not exist, and the volume would still come out perfectly smooth,
594
+ with the correct slope, and wrong.
595
+ - **A texture per cascade, not one texture with the cascades stacked along w.** Stacking puts a
596
+ pass's own output and its input in the same texture, which WebGPU rejects: a subresource cannot be
597
+ bound as writable storage and as a sampled texture at once, and a 3D texture has no subresource
598
+ finer than the whole thing to split it on.
599
+ - **Selection is on view depth, with a blend band over the last 10% of each cascade's range.** The
600
+ two cascades do not agree where they meet — one is more accurate, which is the reason there are
601
+ two — so a hard switch is a ring at a fixed view depth, and the 0.95-blend froxel TAA smears such
602
+ an edge rather than removing it. Depth also makes the branch uniform: the lighting pass dispatches
603
+ one froxel slice per workgroup in z, and a slice is a surface of constant depth.
604
+
605
+ The boxes are **cubic**, and that too is measured rather than assumed. `[256,256,32]` is tempting —
606
+ at 60 degrees it beats `[128,128,128]` by an order of magnitude at identical memory, for the good
607
+ reason that this scene's medium is eight times finer across the sun than along it. The table above
608
+ is what that costs at other fields of view. Two axes, two Nyquist limits, and which one is starved
609
+ is a property of the fog and the sun's elevation rather than of the method.
610
+
611
+ The sub-step count is per cascade — 4, 2, 2 from the outermost inward — so that the *world-space
612
+ sample spacing* is held constant across cascades rather than the count per voxel. It has to be even:
613
+ the build stores its running integral at sub-step `count / 2`, which is the voxel's centre only when
614
+ the halves are equal, and a count of one stores it at the near face, half a slab of missing shadow
615
+ on whichever cascade drew the short straw.
616
+
617
+ ### Cost
618
+
619
+ Per pass, `WORKLOAD` capture decoded in the page — `measure.js` grew a `profile` for it, so this no
620
+ longer needs a downloaded `.sgpt` — medians over interleaved rounds at pose 1.
621
+
622
+ | 1920x1080 | build | build lighting | total |
623
+ |---|---|---|---|
624
+ | march, 16 steps | 0.010 | 0.521 | **0.531** |
625
+ | cascades 3 x 64^3 | 0.120 | 0.282 | **0.422** |
626
+ | cascades 3 x 96^3 | 0.187 | 0.190 | **0.370** |
627
+
628
+ | 3840x2160 | build | build lighting | total |
629
+ |---|---|---|---|
630
+ | march, 16 steps | 0.011 | 1.503 | **1.515** |
631
+ | cascades 3 x 64^3 | 0.131 | 0.866 | **0.997** |
632
+ | cascades 3 x 96^3 | 0.188 | 0.617 | **0.805** |
633
+
634
+ The single box with four sub-steps was break-even at 1080p; three cascades are 0.16 ms *ahead* of
635
+ the march there and 0.71 ahead at 2160p. Not a paradox: the build is bound by the length of its
636
+ serial dependency chain rather than by its sample count, so splitting one 128-slice column into
637
+ three 96-slice ones with three times as many columns is a better shape for the machine even at 1.7x
638
+ the samples. Measured directly on the single box, `[128,128,128]` and `[256,256,32]` carry the same
639
+ 8.4M samples and cost 0.094 ms and 0.032 ms.
640
+
641
+ One caveat in those tables. The lighting pass does the same work for 64^3 and 96^3 — one fetch
642
+ either way — and reads 0.282 against 0.190. Every round put 64^3 immediately after the march, so
643
+ read that gap as ordering within the round rather than as a property of the change.
644
+
645
+ ### Temporal stability
646
+
647
+ Static camera, 24 frames, largest single-frame step in mean luminance:
648
+
649
+ | | pose 1 | pose 2 | Inside the fog |
650
+ |---|---|---|---|
651
+ | march (512 steps) | 0.012 | 0.011 | 0.004 |
652
+ | cascades 3 x 96^3 | 0.051 | 0.033 | 0.003 |
653
+ | cascades 3 x 64^3 | 0.023 | 0.049 | 0.004 |
654
+ | *one box, one sample per voxel — the reported strobe* | *0.595* | *0.636* | — |
655
+
656
+ An order of magnitude below the pulse that was reported as visible, and three to four times the
657
+ march's own floor.
658
+
659
+ Six scripted motions — yaw, pitch, forward, backward, strafe, and all three together — 90 frames of
660
+ motion plus 40 of hold, at two viewpoints, comparing the cascades against the march on flicker while
661
+ moving, on flicker and drift while settling, and on the converged image after the motion against the
662
+ converged image from a standing start at the same pose. At pose 1:
663
+
664
+ | motion | settling flicker, cascades / march | flown vs still, cascades / march |
665
+ |---|---|---|
666
+ | yaw | 0.011 / 0.008 | +0.056 / +0.004 |
667
+ | pitch | 0.005 / 0.003 | -0.007 / +0.013 |
668
+ | forward | 0.043 / 0.042 | +0.037 / +0.013 |
669
+ | backward | 0.007 / 0.008 | -0.047 / -0.043 |
670
+ | strafe | 0.022 / 0.022 | +0.024 / +0.016 |
671
+ | combined | 0.012 / 0.013 | +0.009 / -0.016 |
672
+
673
+ At *Inside the fog* the two agree to within 0.01 on every motion and every statistic. The forward
674
+ and backward runs are the ones to look at: they sweep the cascade boundaries eight units through the
675
+ fog, which is the one instability this change could have introduced, and they read identically to
676
+ the march. The **+3.8 pop** the section above reports around 8 degrees into a yaw at that viewpoint
677
+ is gone — the largest single-frame step across that motion is 0.233 for the cascades against 0.235
678
+ for the march.
679
+
680
+ ### What is left
681
+
682
+ - **Q-05 still stands, and is now the largest defect here.** The medium exists only inside the camera
683
+ frustum, so fog outside it casts no shadow. Both paths have that, so it appears nowhere in the
684
+ tables above: every one of them compares two readings of the same clipped medium.
685
+ - A systematic **-1.2 luminance** at the higher resolutions, converging rather than shrinking — 64^3
686
+ reads -0.32 at pose 1, 96^3 -1.13, 112^3 -1.23. Small, and part of it is the reference's own bias
687
+ (the march's first sample carries 0.9 of a step's weight), but it is not noise.
688
+ - The cascade count is capped at 4 because the per-cascade scalars are packed into a `vec4f`. Past
689
+ that the metadata wants a real array, whose uniform-space stride is 16 bytes per `f32`.
690
+ - The build is latency-bound, not bandwidth-bound. If it ever has to be cheaper, the lever is the
691
+ serial chain — fewer slices per cascade, or a cooperative scan — and not fewer samples.
692
+
693
+ ## The lighting workgroup, and a premise that did not survive contact — measured, 2026-08-30
694
+
695
+ S-01. The pass declared `@workgroup_size(16,16)`, dispatched one workgroup per froxel z slice. The
696
+ study's argument for reshaping it: a froxel is `TILE_SIZE` = 8 pixels, so a 16x16 group covers
697
+ 128x128 pixels — sixteen separate 32-pixel light clusters — and every lane of a wave can be walking
698
+ a different light list, which is the worst case for the divergent light-record reads in the point
699
+ and spot loops and for shadow atlas tile locality. 4x4 in xy is exactly one cluster tile.
700
+
701
+ Mean over the build lighting span:
702
+
703
+ | shape | invocations | clusters spanned | mean |
704
+ |---|---|---|---|
705
+ | 16x16x1 | 256 | 16 in xy | **247 us** |
706
+ | 8x8x1 | 64 | 4 in xy | 368 us |
707
+ | 4x4x4 | 64 | 1 in xy, 4 in z | 386 us |
708
+
709
+ **The premise was false.** The shape spanning the most clusters is the fastest by 50%, so
710
+ cross-cluster divergence is not what governs this pass's time — cutting the group's cluster
711
+ footprint from sixteen tiles to one made it *slower*, and the xy alignment the whole finding rested
712
+ on bought nothing. Whatever dominates here, it is group size, not light-list coherence.
713
+
714
+ **Spanning z is separately wrong, and that part holds.** At equal occupancy, 4x4x4 is slower than
715
+ 8x8x1. Lights are binned into 3D clusters and the largest distance between neighbouring clusters is
716
+ along depth, so a group that spans z spans the most unrelated light lists — the axis the study
717
+ picked to grow was the one axis to leave alone. The reshape is 2D for the same reason the pass was
718
+ 2D to begin with.
719
+
720
+ Shipping **8x8x1**: 64 invocations, one wave64 or two warps, flat in z. That is 121 us of measured
721
+ cost against the original, paid deliberately, and the reason is **portability**.
722
+
723
+ Both numbers above were taken on an RTX 4090. Its register file is unusually large, and a 256-invocation
724
+ group winning there says more about that part than about the shape: the wider the group, the more
725
+ registers it needs live at once, and a 4090 has the headroom to keep 256 of them resident where a
726
+ smaller part would drop occupancy instead. AMD hardware in particular does better with smaller
727
+ groups. So 16x16x1's 247 us is a local result, not a general one, and shipping the shape that wins
728
+ on one machine would be tuning the engine to this desk.
729
+
730
+ Do not read the table as an instruction to put 16x16x1 back. That would need measurements from more
731
+ than one vendor, and it is a different question from the one measured here.
732
+
733
+ The shape is now `VOLUMETRICS_LIGHTING_WORKGROUP_RESOLUTION`, read by both the `@workgroup_size` and
734
+ the dispatch, following the sun shadow build. Dead `VOLUMETRICS_WORKGROUP_RESOLUTION` (`[8, 8, 4]`,
735
+ zero importers, matching one pass's literal by coincidence) is deleted.
736
+
737
+ ### Regrouping cannot change the output, and did not
738
+
739
+ Every invocation computes from `global_invocation_id` alone — no shared memory, no subgroup op, no
740
+ cross-invocation state — so the question reduces to which ids get dispatched. Driven through the
741
+ emulator over both id sets, `tLighting` compared equal byte for byte at grids with a partial group
742
+ on one axis, on all three, and on none, with no voxel left unwritten; coverage at 1080p's 240x135x64
743
+ is exactly once each. That harness was throwaway — it compares against shapes that no longer exist —
744
+ and what stayed is a test pinning `@workgroup_size` to the constant the dispatch divides by.
745
+
746
+ Two things worth keeping from building its negative controls:
747
+
748
+ - Under-dispatching fails loudly: a stale divisor drops froxels and the volume comes back holed.
749
+ - **Over-dispatching is invisible.** A `group_count_z` left at the slice count under a 4-deep group
750
+ still visits every voxel exactly once — `gz * 4 + lz` stays a bijection — so the volume is
751
+ bit-identical while four times the groups launch to bounds-check and return. Only an assertion
752
+ about the dispatch being tight catches it.
753
+
754
+ ## Hoisting the local-light shadow test above the march — measured and NOT shipped, 2026-08-30
755
+
756
+ F-01 and F-03, together, because separately neither is worth doing. Both local-light loops in
757
+ `shader_volumetrics_build_lighting` ran march -> multiscatter -> shadow:
758
+
759
+ ```
760
+ let optical_depth = integrate_optical_depth(shadow_ray); // up to 16 filtered 3D fetches
761
+ let ms = calculate_multiscattering(...); // 6 octaves, pow + exp per octave
762
+ let shadow_term = volumetrics_point_light_shadow(...); // 1 textureLoad, returns 0 or 1
763
+ inScatteringSum += shadow_term * ms * direct_light.color;
764
+ ```
765
+
766
+ The shadow term is binary by construction — `chunk_volumetrics_local_light_shadow` takes one nearest
767
+ texel with no filter kernel and ends in a `select` between 0.0 and 1.0 — so a zero throws away
768
+ everything above it. Testing it first and `continue`ing on zero skips that work (F-01). And
769
+ `volumetrics_point_light_shadow(database, index, position)` re-read the record
770
+ `get_point_light_info_by_index` had just unmarshalled, *before* its `CASTS_SHADOW_BIT` early-out
771
+ could fire, so the reorder alone would have added a storage read for every non-shadowing light;
772
+ passing the struct instead removes the duplicate and makes the flag test free (F-03).
773
+
774
+ **The reorder is exact, and that was verified rather than argued.** Both orderings were driven
775
+ through the WGSL emulator over thirteen fixtures — every exit of both loops, plus a case with real
776
+ per-froxel extinction where the old order computed a genuine optical depth and multiscatter and then
777
+ multiplied the pair by zero — and `tLighting` compared **byte for byte**, on the raw bits so that
778
+ `+0`/`-0` and any NaN payload would count as a difference. It is identical everywhere. NaN is the one
779
+ input for which the two are not equivalent (`0 * NaN` propagates, a skipped term does not), so the
780
+ spec fails on any non-finite value in its own right instead of folding it into the comparison.
781
+
782
+ **It is not faster.** Median of the `Volumetrics / build lighting` span over 18 interleaved rounds at
783
+ 3840x2160, ABBA-ordered so neither arm systematically takes the warm half of a round, 48 recorded
784
+ frames per capture after a 40-frame ramp:
785
+
786
+ | arm | median (ms) |
787
+ |---|---|
788
+ | shadow test first (F-01/F-03) | 1.3573 |
789
+ | march first (shipping) | 1.3471 |
790
+ | **median paired difference** | **+0.0108** |
791
+
792
+ and the same protocol run with the *same build on both arms*, which is what that number has to beat:
793
+
794
+ | | median paired difference | median abs difference |
795
+ |---|---|---|
796
+ | A/B, 18 rounds | +0.0108 | 0.0998 |
797
+ | **A/A floor, 10 rounds** | **+0.0092** | 0.0456 |
798
+
799
+ The measured effect and the noise floor agree to 1.6 us. There is no signal, and the sign is the
800
+ wrong one anyway.
801
+
802
+ **Why there was nothing to win, which is the part worth keeping.** The local-light loops are not a
803
+ measurable part of this pass in this scene at all. Removing all 130 shadow-casting point lights from
804
+ the playground scene and re-measuring:
805
+
806
+ | | median (ms) |
807
+ |---|---|
808
+ | 130 shadow-casting point lights | 1.2585 |
809
+ | the same scene with all of them removed | 1.3343 |
810
+
811
+ Deleting the entire workload the finding proposed to optimize does not make the pass faster — it
812
+ reads a shade slower, which is to say the difference is noise. Whatever the 1.3 ms is spent on, it is
813
+ the per-froxel work outside those loops: the STBN fetch, the two participating-media samples, the
814
+ cluster lookup, and the directional loop's cascade fetch. That also retires the premise the finding
815
+ rested on, the same way S-01's did: 130 shadow-casting lights in the scene does not mean 130 lights
816
+ in the cluster any froxel actually reads.
817
+
818
+ So this joins Q-01's "the cost is nearly nothing" as a cost claim the profiler refused. Not shipped;
819
+ the tree keeps the march-first order.
820
+
821
+ What would have to change before it is worth revisiting: a scene where the froxel volume genuinely
822
+ overlaps many clustered lights, shown by that removal test moving the number. Reach for the removal
823
+ test *first* next time — it is three captures and it would have settled this before any code was
824
+ written.
825
+
826
+ Two things did survive, both about the shipping code rather than the change:
827
+
828
+ - `shader_volumetrics_build_lighting_local_shadow.spec.js` now covers the exits it did not: a light
829
+ over an atlas region that was never rendered (`stored == 0`, must not occlude), a froxel behind a
830
+ spot's apex (`clip_light.w <= 0`), that the optical-depth march still runs and still attenuates
831
+ when the shadow test passes, and that no NaN or infinity reaches `tLighting` in any fixture. The
832
+ apex case needs a deliberately inconsistent fixture and says so: in the shipping arrangement a
833
+ froxel behind the apex is also outside the cone, so the range cull `continue`s before the shadow
834
+ function is reached, and the branch has to be built rather than found.
835
+
836
+ - The dev server no longer hot-reloads (`server.hmr: false`). A reload drops the WebGPU device and
837
+ resets the temporal accumulation, and an edit anywhere in the module graph — including one made by
838
+ someone else working in the tree at the same time, which is exactly what happened here — destroys
839
+ an interleaved A/B mid-run.
840
+
841
+ Measurement conditions, because one of them is unusual: RTX 4090, canvas 1280x720 at pixel ratio 3.
842
+ The pass was confirmed resolution-bound first (0.4618 ms at 1280x720 rising to 1.878 ms at 3840x2160)
843
+ rather than presentation-throttled. Another session was building and running GPU work on the same
844
+ machine throughout, which put occasional 2x outliers in both arms; medians absorb them, and the A/A
845
+ floor was taken under the same conditions so it prices them in.
846
+
847
+ ## The participating-media coverage skip — counted and NOT shipped, 2026-08-30
848
+
849
+ `shader_volumetrics_build_participating_media` carried a TODO: if a froxel is entirely inside a
850
+ volume, coverage is 1.0 and `frustum_unit_box_coverage` need not run. The TODO proposed getting there
851
+ by replacing `aabb3_intersects_frustum` with something that returns an intersection *degree*.
852
+
853
+ ### The degree answers the other question
854
+
855
+ `aabb3_intersects_frustum(unit_bounds, local_frustum)` classifies the **volume box** against the
856
+ **froxel's planes**, and `aabb3_intersects_frustum_degree` already returns 0/1/2 for exactly that.
857
+ Its `2` means the box is inside the froxel — the arrangement where the froxel is *larger* than the
858
+ volume, where coverage is a small fraction. Reading it as "coverage is 1.0" would flood the far field
859
+ with fog, which `volumetrics_froxel_containment.spec.js` pins as a test.
860
+
861
+ What coverage of 1.0 needs is the dual: **froxel ⊆ box**. Both bodies are convex, so it holds exactly
862
+ when the froxel's 8 vertices are inside the box, and `frustum_to_corners` produces the same points
863
+ `frustum_sample_point` reaches at `uvw ∈ {0,1}³` — the sign flips in `plane_convex_intersection`
864
+ cancel, so a plane and its negation give the same corner. The test is exact, not conservative, and
865
+ the same bound gives a second exact skip: if the froxel clears `fade_distance` from every face in
866
+ world units, all three of `compute_volume_fade_factor`'s smoothsteps saturate and the fade is 1.0.
867
+
868
+ ### It is exact and it still loses
869
+
870
+ Corners cost 8 `plane_convex_intersection` — 3 crosses, a dot and a divide each — which is what
871
+ `frustum_unit_box_coverage` spends on its 4 rays. Deriving them per volume pays for precisely the
872
+ work it skips, so the only arrangement that can win builds them once per froxel in world space and
873
+ maps them into each volume with a matrix multiply.
874
+
875
+ Hit rates over the playground's six volumes at 240x135x64 (every 4th column in X and Y, every slice
876
+ in Z, froxel extents kept at the true 1/240 and 1/135):
877
+
878
+ | pose | froxels touching a volume | survivors per froxel | contained | fade saturated |
879
+ |---|---|---|---|---|
880
+ | overview (default) | 100.0% | 1.223 | 7.0% | 3.3% |
881
+ | fog bank, wide | 91.2% | 0.912 | 8.1% | 4.1% |
882
+ | inside the fog | 97.6% | 1.565 | 33.2% | 27.2% |
883
+
884
+ Per froxel, in three-plane intersections:
885
+
886
+ | pose | paid | saved | |
887
+ |---|---|---|---|
888
+ | overview | 8.00 | 0.77 | **10.4x loss** |
889
+ | fog bank | 8.00 | 0.67 | **12.0x loss** |
890
+ | inside the fog | 8.00 | 5.00 | **1.6x loss** |
891
+
892
+ Two refinements do not rescue it. Building the corners **lazily**, on the first volume that survives
893
+ the plane test, saves nothing: the 400x20x400 cloud deck is a huge thin slab that defeats the
894
+ conservative frustum/AABB test almost everywhere, so 91-100% of froxels touch a volume anyway.
895
+ Gating on a **1-intersection necessary condition** — the froxel's own centre must be inside the box,
896
+ or containment is impossible — is very tight (centre-inside is 8.7% against contained's 7.0%) and
897
+ still loses: 2.7x, 2.5x and 1.3x on the three poses.
898
+
899
+ So the geometry is right and the arithmetic is against it, at every pose measured, including the one
900
+ where a third of froxels qualify. Not shipped. `volumetrics_froxel_containment.spec.js` keeps the
901
+ proof — containment implies coverage of exactly 1.0, and the fade core implies a fade of exactly
902
+ 1.0 — alongside `chunk_aabb3_contains_aabb3` and `chunk_aabb3_from_transformed_corners`, so if the
903
+ grid, the volume mix or the estimator's cost moves, restoring the early-out is a few lines rather
904
+ than a rediscovery.
905
+
906
+ This one was settled by counting rather than by the profiler, which is unusual here. The hit rate is
907
+ a property of the scene's geometry and not of the device, and the cost ratio it implies is 2.5x to
908
+ 12x on the two poses that matter — far outside anything a capture would overturn. A GPU A/B is the
909
+ confirmation, not the decision.
910
+
911
+ ### What did ship
912
+
913
+ The line above the TODO was doing real redundant work. `frustum_transform_m4(froxel_frustum,
914
+ volume.transform_inverse)` derives its plane matrix as `transpose(mat4_inverse(transform))`, so
915
+ handing it `transform_inverse` made it compute `transpose(mat4_inverse(transform_inverse))` — which
916
+ is `transpose(volume.transform)`, a matrix already resident in the struct. Every froxel/volume pair
917
+ was running the 85-line generic cofactor `mat4_inverse` to recover a matrix it had been given: at
918
+ 1080p with six volumes, 12.4M of them per frame.
919
+
920
+ `chunk_frustum_transform_plane_matrix` is that function with the plane matrix passed in, and
921
+ `frustum_transform_m4` is now the wrapper that derives one. The shader calls the former with
922
+ `transpose(volume.transform)`. Strictly less work, and marginally more accurate for dropping an
923
+ inverse round-trip. It is a full 4x4, not a 3x3 normal basis: the last row carries the translation,
924
+ which is what supplies each transformed plane's distance term, and
925
+ `chunk_frustum_transform_plane_matrix.spec.js` asserts both that and the equality with the old route.
926
+
927
+ `transpose` was missing from the WGSL emulator's language core, which is why `frustum_transform_m4`
928
+ had never been exercised there; it is implemented now for the square matrix sizes, on the same
929
+ footing as `determinant`.