@woosh/meep-engine 3.12.0 → 3.13.1

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 (301) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -1
  3. package/src/core/geom/3d/shape/ConvexHullShape3D.js +41 -25
  4. package/src/core/geom/3d/shape/PosedShape3D.d.ts.map +1 -1
  5. package/src/core/geom/3d/shape/PosedShape3D.js +7 -2
  6. package/src/engine/graphics3/decal/GPUDecalRenderer.js +3 -3
  7. package/src/engine/graphics3/decal/shader_decal_composite.d.ts.map +1 -1
  8. package/src/engine/graphics3/decal/shader_decal_composite.js +4 -2
  9. package/src/engine/graphics3/highlight/GPUHighlightRenderer.d.ts.map +1 -1
  10. package/src/engine/graphics3/highlight/GPUHighlightRenderer.js +2 -3
  11. package/src/engine/graphics3/highlight/shader_highlight_outline.js +150 -150
  12. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  13. package/src/shade/RENDERER_CONTRACT.md +2 -1
  14. package/src/shade/descriptor/pipeline/compute/ComputePipelineDescriptor.d.ts +18 -0
  15. package/src/shade/descriptor/pipeline/compute/ComputePipelineDescriptor.d.ts.map +1 -1
  16. package/src/shade/descriptor/pipeline/compute/ComputePipelineDescriptor.js +73 -0
  17. package/src/shade/device/PASS_ENCODER_PROPOSAL_2026_08_31.md +755 -0
  18. package/src/shade/device/ShadeGPUCommandContext.d.ts +10 -16
  19. package/src/shade/device/ShadeGPUCommandContext.d.ts.map +1 -1
  20. package/src/shade/device/ShadeGPUCommandContext.js +801 -994
  21. package/src/shade/device/pass/ShadeGPUComputePassEncoder.d.ts +107 -0
  22. package/src/shade/device/pass/ShadeGPUComputePassEncoder.d.ts.map +1 -0
  23. package/src/shade/device/pass/ShadeGPUComputePassEncoder.js +222 -0
  24. package/src/shade/device/pass/ShadeGPURenderPassEncoder.d.ts +168 -0
  25. package/src/shade/device/pass/ShadeGPURenderPassEncoder.d.ts.map +1 -0
  26. package/src/shade/device/pass/ShadeGPURenderPassEncoder.js +317 -0
  27. package/src/shade/device/timing/GPUTimerArray.d.ts +4 -9
  28. package/src/shade/device/timing/GPUTimerArray.d.ts.map +1 -1
  29. package/src/shade/device/timing/GPUTimerArray.js +261 -289
  30. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +20 -2
  31. package/src/shade/device/timing/ShadeGPUContextProfiler.d.ts +59 -0
  32. package/src/shade/device/timing/ShadeGPUContextProfiler.d.ts.map +1 -0
  33. package/src/shade/device/timing/ShadeGPUContextProfiler.js +182 -0
  34. package/src/shade/device/timing/profile/GPUProfileSession.d.ts +4 -4
  35. package/src/shade/device/timing/profile/GPUProfileSession.js +4 -4
  36. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.d.ts.map +1 -1
  37. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.js +3 -1
  38. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.d.ts.map +1 -1
  39. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.js +3 -1
  40. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.d.ts.map +1 -1
  41. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.js +3 -1
  42. package/src/shade/playground/skinned_blas_refit/verify_leaf_triangles.js +2 -2
  43. package/src/shade/playground/skinned_blas_refit/verify_traversal_reachability.js +2 -2
  44. package/src/shade/renderer/Renderer.d.ts +0 -6
  45. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  46. package/src/shade/renderer/Renderer.js +0 -29
  47. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  48. package/src/shade/renderer/animation/GPUAnimationManager.js +1 -3
  49. package/src/shade/renderer/animation/shader_animation_tick.d.ts.map +1 -1
  50. package/src/shade/renderer/animation/shader_animation_tick.js +128 -125
  51. package/src/shade/renderer/animation/skinning/GPUMeshSkinningContext.d.ts.map +1 -1
  52. package/src/shade/renderer/animation/skinning/GPUMeshSkinningContext.js +8 -18
  53. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_geometry_sphere_derive.d.ts.map +1 -1
  54. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_geometry_sphere_derive.js +100 -98
  55. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_reduce.d.ts.map +1 -1
  56. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_reduce.js +215 -212
  57. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.d.ts +1 -1
  58. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.d.ts.map +1 -1
  59. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.js +135 -120
  60. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_skinned_mesh_bounds_refresh.d.ts.map +1 -1
  61. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_skinned_mesh_bounds_refresh.js +77 -75
  62. package/src/shade/renderer/animation/skinning/shader_meshlet_apply_skinning.d.ts.map +1 -1
  63. package/src/shade/renderer/animation/skinning/shader_meshlet_apply_skinning.js +375 -371
  64. package/src/shade/renderer/atmosphere/shader_transmittance_lut.js +3 -3
  65. package/src/shade/renderer/buffer/graph_inspect_gpu_buffer.js +1 -1
  66. package/src/shade/renderer/buffer/table/GPUDatabase.d.ts.map +1 -1
  67. package/src/shade/renderer/buffer/table/GPUDatabase.js +14 -19
  68. package/src/shade/renderer/buffer/table/gpu_table_element_upload_shader.d.ts.map +1 -1
  69. package/src/shade/renderer/buffer/table/gpu_table_element_upload_shader.js +7 -2
  70. package/src/shade/renderer/buffer/table/single/GPUSingleTypeTable.d.ts.map +1 -1
  71. package/src/shade/renderer/buffer/table/single/GPUSingleTypeTable.js +325 -319
  72. package/src/shade/renderer/buffer/util/shader_buffer_copy_storage_to_storage.d.ts.map +1 -1
  73. package/src/shade/renderer/buffer/util/shader_buffer_copy_storage_to_storage.js +3 -1
  74. package/src/shade/renderer/deferred/shader_tonemap_HDR.d.ts.map +1 -1
  75. package/src/shade/renderer/deferred/shader_tonemap_HDR.js +4 -1
  76. package/src/shade/renderer/dynamic/GPUDynamicMeshResidency.d.ts +2 -2
  77. package/src/shade/renderer/dynamic/GPUDynamicMeshResidency.d.ts.map +1 -1
  78. package/src/shade/renderer/dynamic/GPUDynamicMeshResidency.js +167 -167
  79. package/src/shade/renderer/fow/GPUFogOfWarRenderer.js +3 -3
  80. package/src/shade/renderer/fow/shader_fog_of_war.d.ts.map +1 -1
  81. package/src/shade/renderer/fow/shader_fog_of_war.js +3 -1
  82. package/src/shade/renderer/geometry/bvh/record_blas_refit.js +102 -102
  83. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.d.ts.map +1 -1
  84. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.js +125 -121
  85. package/src/shade/renderer/geometry/meshlet/GPUMeshletManager.d.ts.map +1 -1
  86. package/src/shade/renderer/geometry/meshlet/GPUMeshletManager.js +766 -774
  87. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.d.ts.map +1 -1
  88. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.js +72 -69
  89. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.d.ts.map +1 -1
  90. package/src/shade/renderer/geometry/sdf/GPUGeometrySDFManager.js +5 -10
  91. package/src/shade/renderer/geometry/sdf/shader_build_sdf.js +129 -129
  92. package/src/shade/renderer/geometry/sdf/shader_sdf_visualize.d.ts.map +1 -1
  93. package/src/shade/renderer/geometry/sdf/shader_sdf_visualize.js +5 -3
  94. package/src/shade/renderer/global_illumination/brick4/gpu/bake/shader_brick4_bake_probes.d.ts.map +1 -1
  95. package/src/shade/renderer/global_illumination/brick4/gpu/bake/shader_brick4_bake_probes.js +4 -1
  96. package/src/shade/renderer/global_illumination/brick4/gpu/bake/shader_brick4_bake_resolve_probes.d.ts.map +1 -1
  97. package/src/shade/renderer/global_illumination/brick4/gpu/bake/shader_brick4_bake_resolve_probes.js +3 -1
  98. package/src/shade/renderer/global_illumination/clipmap/shader_update_clipmap_cascade.d.ts.map +1 -1
  99. package/src/shade/renderer/global_illumination/clipmap/shader_update_clipmap_cascade.js +114 -112
  100. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_lpv_store_sh.d.ts.map +1 -1
  101. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_lpv_store_sh.js +4 -1
  102. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_lvp_atlas_to_sh.d.ts.map +1 -1
  103. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_lvp_atlas_to_sh.js +187 -184
  104. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_reduce_atlas_to_sh.d.ts.map +1 -1
  105. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_reduce_atlas_to_sh.js +4 -1
  106. package/src/shade/renderer/global_illumination/sharc/SpatialHashRadianceCache.d.ts.map +1 -1
  107. package/src/shade/renderer/global_illumination/sharc/SpatialHashRadianceCache.js +6 -8
  108. package/src/shade/renderer/global_illumination/sharc/shader/shader_sharc_evict.d.ts.map +1 -1
  109. package/src/shade/renderer/global_illumination/sharc/shader/shader_sharc_evict.js +3 -1
  110. package/src/shade/renderer/global_illumination/sharc/shader/shader_sharc_update.d.ts.map +1 -1
  111. package/src/shade/renderer/global_illumination/sharc/shader/shader_sharc_update.js +3 -1
  112. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +3 -3
  113. package/src/shade/renderer/gpu_primitive/bvh/PHASE1_HANDOVER.md +3 -3
  114. package/src/shade/renderer/gpu_primitive/bvh/graph_bvh_build_lbvh.js +217 -217
  115. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.d.ts.map +1 -1
  116. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.js +145 -142
  117. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.d.ts.map +1 -1
  118. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.js +87 -82
  119. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.d.ts.map +1 -1
  120. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.js +143 -140
  121. package/src/shade/renderer/gpu_primitive/bvh/verify_gpu_bvh_build.d.ts +2 -2
  122. package/src/shade/renderer/gpu_primitive/bvh/verify_gpu_bvh_build.d.ts.map +1 -1
  123. package/src/shade/renderer/gpu_primitive/bvh/verify_gpu_bvh_build.js +43 -8
  124. package/src/shade/renderer/gpu_primitive/sort/graph_radix_sort.d.ts.map +1 -1
  125. package/src/shade/renderer/gpu_primitive/sort/graph_radix_sort.js +4 -5
  126. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.d.ts.map +1 -1
  127. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.js +88 -84
  128. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.d.ts.map +1 -1
  129. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.js +194 -191
  130. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  131. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +33 -69
  132. package/src/shade/renderer/hiz/shader_hzb_reduce_any.d.ts.map +1 -1
  133. package/src/shade/renderer/hiz/shader_hzb_reduce_any.js +6 -1
  134. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.d.ts.map +1 -1
  135. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.js +23 -7
  136. package/src/shade/renderer/light/environment/execute_filter_environment_map.d.ts.map +1 -1
  137. package/src/shade/renderer/light/environment/execute_filter_environment_map.js +1 -2
  138. package/src/shade/renderer/light/environment/shader_convolve_reflection_map.d.ts.map +1 -1
  139. package/src/shade/renderer/light/environment/shader_convolve_reflection_map.js +6 -1
  140. package/src/shade/renderer/lightmap/bake/raster/graph_draw_lightmap_viz_buffer.d.ts.map +1 -1
  141. package/src/shade/renderer/lightmap/bake/raster/graph_draw_lightmap_viz_buffer.js +234 -219
  142. package/src/shade/renderer/lightmap/bake/raster/shader_lightmap_to_viz_buffer.js +2 -2
  143. package/src/shade/renderer/lightmap/bake/shader_lightmap_denoise_sh.js +191 -191
  144. package/src/shade/renderer/lightmap/bake/tile/shader_lightmap_accummulate_tile.d.ts.map +1 -1
  145. package/src/shade/renderer/lightmap/bake/tile/shader_lightmap_accummulate_tile.js +85 -82
  146. package/src/shade/renderer/particles/graph_particles.d.ts.map +1 -1
  147. package/src/shade/renderer/particles/graph_particles.js +5 -3
  148. package/src/shade/renderer/particles/prototypeParticleSystem.js +3 -4
  149. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  150. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +132 -117
  151. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  152. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +79 -77
  153. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  154. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +111 -99
  155. package/src/shade/renderer/particles/sort/shader_particle_sort.d.ts.map +1 -1
  156. package/src/shade/renderer/particles/sort/shader_particle_sort.js +187 -158
  157. package/src/shade/renderer/path_tracer/accumulating/AccumulatingPathTracer.d.ts.map +1 -1
  158. package/src/shade/renderer/path_tracer/accumulating/AccumulatingPathTracer.js +7 -10
  159. package/src/shade/renderer/path_tracer/accumulating/shader_accumulating_path_tracer.d.ts.map +1 -1
  160. package/src/shade/renderer/path_tracer/accumulating/shader_accumulating_path_tracer.js +4 -1
  161. package/src/shade/renderer/postprocess/bloom/shader_bloom_mix.d.ts.map +1 -1
  162. package/src/shade/renderer/postprocess/bloom/shader_bloom_mix.js +3 -1
  163. package/src/shade/renderer/postprocess/cas/shader_ffx_rcas.d.ts.map +1 -1
  164. package/src/shade/renderer/postprocess/cas/shader_ffx_rcas.js +4 -2
  165. package/src/shade/renderer/postprocess/denoise/fragment_shader_denoise_ao.js +147 -147
  166. package/src/shade/renderer/postprocess/denoise/graph_atorus_denoise_luma.d.ts.map +1 -1
  167. package/src/shade/renderer/postprocess/denoise/graph_atorus_denoise_luma.js +121 -119
  168. package/src/shade/renderer/postprocess/dof/gather/graph_postprocess_dof2.d.ts.map +1 -1
  169. package/src/shade/renderer/postprocess/dof/gather/graph_postprocess_dof2.js +1 -2
  170. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_gather_far.d.ts.map +1 -1
  171. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_gather_far.js +4 -1
  172. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_gather_near.d.ts.map +1 -1
  173. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_gather_near.js +4 -1
  174. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_recombine.d.ts.map +1 -1
  175. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_recombine.js +4 -1
  176. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_setup.d.ts.map +1 -1
  177. package/src/shade/renderer/postprocess/dof/gather/shader_dof2_setup.js +4 -1
  178. package/src/shade/renderer/postprocess/dof/raymarch/graph_postprocess_dof.d.ts.map +1 -1
  179. package/src/shade/renderer/postprocess/dof/raymarch/graph_postprocess_dof.js +2 -4
  180. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_composite.d.ts.map +1 -1
  181. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_composite.js +4 -1
  182. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_prefilter.d.ts.map +1 -1
  183. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_prefilter.js +4 -1
  184. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch.d.ts.map +1 -1
  185. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch.js +4 -1
  186. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch_compute.d.ts.map +1 -1
  187. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch_compute.js +269 -266
  188. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_slice_compute.d.ts.map +1 -1
  189. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_slice_compute.js +273 -270
  190. package/src/shade/renderer/postprocess/dof/shader_dof_autofocus.d.ts.map +1 -1
  191. package/src/shade/renderer/postprocess/dof/shader_dof_autofocus.js +4 -1
  192. package/src/shade/renderer/postprocess/eye/GPUCameraExposureManager.d.ts.map +1 -1
  193. package/src/shade/renderer/postprocess/eye/GPUCameraExposureManager.js +8 -11
  194. package/src/shade/renderer/postprocess/eye/shader_autoexposure_adapt.d.ts.map +1 -1
  195. package/src/shade/renderer/postprocess/eye/shader_autoexposure_adapt.js +5 -1
  196. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.d.ts.map +1 -1
  197. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.js +11 -8
  198. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.d.ts.map +1 -1
  199. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.js +7 -8
  200. package/src/shade/renderer/postprocess/motion_blur/shader_motion_blur_reconstruct.d.ts.map +1 -1
  201. package/src/shade/renderer/postprocess/motion_blur/shader_motion_blur_reconstruct.js +3 -1
  202. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  203. package/src/shade/renderer/postprocess/nss/shader_nss_concat.d.ts.map +1 -1
  204. package/src/shade/renderer/postprocess/nss/shader_nss_concat.js +70 -68
  205. package/src/shade/renderer/postprocess/ssr/depth_max_shader.d.ts.map +1 -1
  206. package/src/shade/renderer/postprocess/ssr/depth_max_shader.js +70 -68
  207. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_IBL.d.ts.map +1 -1
  208. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_IBL.js +3 -1
  209. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_LPV.d.ts.map +1 -1
  210. package/src/shade/renderer/postprocess/ssr/resolve/ssr_resolve_shader_LPV.js +4 -1
  211. package/src/shade/renderer/postprocess/ssr/shader_downsample_encoded_normal.d.ts.map +1 -1
  212. package/src/shade/renderer/postprocess/ssr/shader_downsample_encoded_normal.js +103 -101
  213. package/src/shade/renderer/postprocess/ssr/ssr_spatial_denoise_shader.d.ts.map +1 -1
  214. package/src/shade/renderer/postprocess/ssr/ssr_spatial_denoise_shader.js +4 -2
  215. package/src/shade/renderer/postprocess/ssr/ssr_trace_shader.d.ts.map +1 -1
  216. package/src/shade/renderer/postprocess/ssr/ssr_trace_shader.js +3 -1
  217. package/src/shade/renderer/postprocess/taa/shader_taa.d.ts.map +1 -1
  218. package/src/shade/renderer/postprocess/taa/shader_taa.js +4 -1
  219. package/src/shade/renderer/postprocess/upscale-dither/fragment_shader_upscale_dither.js +5 -5
  220. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.d.ts.map +1 -1
  221. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.js +339 -341
  222. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.d.ts.map +1 -1
  223. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.js +114 -109
  224. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.d.ts.map +1 -1
  225. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.js +118 -115
  226. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.d.ts.map +1 -1
  227. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.js +69 -65
  228. package/src/shade/renderer/rasterize/native/oit/graph_rasterize_meshes_transparent_oit.js +722 -722
  229. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.d.ts.map +1 -1
  230. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.js +1 -9
  231. package/src/shade/renderer/rasterize/standard/graph_rasterize_remaining_opaque.d.ts.map +1 -1
  232. package/src/shade/renderer/rasterize/standard/graph_rasterize_remaining_opaque.js +1 -6
  233. package/src/shade/renderer/restir/di/graph_restir_di.d.ts.map +1 -1
  234. package/src/shade/renderer/restir/di/graph_restir_di.js +2 -3
  235. package/src/shade/renderer/restir/di/graph_restir_di_denoise.d.ts.map +1 -1
  236. package/src/shade/renderer/restir/di/graph_restir_di_denoise.js +1 -2
  237. package/src/shade/renderer/restir/di/shader_restir_di_denoise.d.ts.map +1 -1
  238. package/src/shade/renderer/restir/di/shader_restir_di_denoise.js +139 -136
  239. package/src/shade/renderer/restir/di/shader_restir_di_resample.d.ts.map +1 -1
  240. package/src/shade/renderer/restir/di/shader_restir_di_resample.js +131 -129
  241. package/src/shade/renderer/restir/di/shader_restir_di_resolve.d.ts.map +1 -1
  242. package/src/shade/renderer/restir/di/shader_restir_di_resolve.js +101 -99
  243. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.d.ts.map +1 -1
  244. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.js +379 -383
  245. package/src/shade/renderer/scene/Mesh.d.ts.map +1 -1
  246. package/src/shade/renderer/scene/Mesh.js +4 -6
  247. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.d.ts.map +1 -1
  248. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.js +147 -144
  249. package/src/shade/renderer/shader/ComputeShader.d.ts +2 -2
  250. package/src/shade/renderer/shader/ComputeShader.d.ts.map +1 -1
  251. package/src/shade/renderer/shader/ComputeShader.js +14 -17
  252. package/src/shade/renderer/shader/pass/RenderPassDescriptor.d.ts +2 -2
  253. package/src/shade/renderer/shader/pass/RenderPassDescriptor.d.ts.map +1 -1
  254. package/src/shade/renderer/shader/pass/RenderPassDescriptor.js +189 -189
  255. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.d.ts.map +1 -1
  256. package/src/shade/renderer/shader/resource/ShaderResourceSetDescriptor.js +3 -1
  257. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts +16 -7
  258. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.d.ts.map +1 -1
  259. package/src/shade/renderer/shadow/map/GPUSceneShadowmapContext.js +1342 -1326
  260. package/src/shade/renderer/shadow/map/Shadowmap.d.ts +13 -0
  261. package/src/shade/renderer/shadow/map/Shadowmap.d.ts.map +1 -1
  262. package/src/shade/renderer/shadow/map/Shadowmap.js +14 -0
  263. package/src/shade/renderer/shadow/map/ShadowmapDrawSelector.d.ts +79 -0
  264. package/src/shade/renderer/shadow/map/ShadowmapDrawSelector.d.ts.map +1 -0
  265. package/src/shade/renderer/shadow/map/ShadowmapDrawSelector.js +196 -0
  266. package/src/shade/renderer/shadow/map/ShadowmapRegistry.d.ts +91 -0
  267. package/src/shade/renderer/shadow/map/ShadowmapRegistry.d.ts.map +1 -0
  268. package/src/shade/renderer/shadow/map/ShadowmapRegistry.js +156 -0
  269. package/src/shade/renderer/shadow/map/shader/shader_shadowmap_remap_cube_to_octahedral.d.ts.map +1 -1
  270. package/src/shade/renderer/shadow/map/shader/shader_shadowmap_remap_cube_to_octahedral.js +5 -1
  271. package/src/shade/renderer/shadow/ray/shader_shadow_upscale.d.ts.map +1 -1
  272. package/src/shade/renderer/shadow/ray/shader_shadow_upscale.js +3 -1
  273. package/src/shade/renderer/shadow/ray/staged/gen/graph_gen_rays.js +73 -73
  274. package/src/shade/renderer/shadow/ray/staged/gen/shader_generate_rays.d.ts.map +1 -1
  275. package/src/shade/renderer/shadow/ray/staged/gen/shader_generate_rays.js +151 -148
  276. package/src/shade/renderer/shadow/ray/staged/trace/graph_trace_rays.d.ts.map +1 -1
  277. package/src/shade/renderer/shadow/ray/staged/trace/graph_trace_rays.js +4 -2
  278. package/src/shade/renderer/shadow/ray/staged/trace/shader_trace_rays.d.ts.map +1 -1
  279. package/src/shade/renderer/shadow/ray/staged/trace/shader_trace_rays.js +18 -4
  280. package/src/shade/renderer/shadow/sdf/cascade/CascadedSceneSDF.d.ts.map +1 -1
  281. package/src/shade/renderer/shadow/sdf/cascade/CascadedSceneSDF.js +7 -8
  282. package/src/shade/renderer/shadow/sdf/cascade/shader_build_scene_sdf_f32.d.ts.map +1 -1
  283. package/src/shade/renderer/shadow/sdf/cascade/shader_build_scene_sdf_f32.js +6 -2
  284. package/src/shade/renderer/shadow/sdf/cascade/shader_scene_sdf_debug_viz.d.ts.map +1 -1
  285. package/src/shade/renderer/shadow/sdf/cascade/shader_scene_sdf_debug_viz.js +129 -127
  286. package/src/shade/renderer/shadow/sdf/graph_draw_sdf_shadows.d.ts.map +1 -1
  287. package/src/shade/renderer/shadow/sdf/graph_draw_sdf_shadows.js +5 -3
  288. package/src/shade/renderer/texture/virtual/VirtualTextureManager.d.ts.map +1 -1
  289. package/src/shade/renderer/texture/virtual/VirtualTextureManager.js +2 -5
  290. package/src/shade/renderer/texture/virtual/build_shader_vt_feedback.d.ts.map +1 -1
  291. package/src/shade/renderer/texture/virtual/build_shader_vt_feedback.js +233 -231
  292. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  293. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +2 -4
  294. package/src/shade/wgsl/validate_wgsl_source.d.ts.map +1 -1
  295. package/src/shade/wgsl/validate_wgsl_source.js +8 -3
  296. package/src/shade/device/timing/profile/make_profiling_pass_encoder.d.ts +0 -21
  297. package/src/shade/device/timing/profile/make_profiling_pass_encoder.d.ts.map +0 -1
  298. package/src/shade/device/timing/profile/make_profiling_pass_encoder.js +0 -114
  299. package/src/shade/device/timing/profile/parse_workgroup_size.d.ts +0 -20
  300. package/src/shade/device/timing/profile/parse_workgroup_size.d.ts.map +0 -1
  301. package/src/shade/device/timing/profile/parse_workgroup_size.js +0 -43
@@ -1,1326 +1,1342 @@
1
- import { assert } from "../../../../core/assert.js";
2
- import AABB2 from "../../../../core/geom/2d/aabb/AABB2.js";
3
- import { MaxRectanglesPacker } from "../../../../core/geom/packing/max-rect/MaxRectanglesPacker.js";
4
- import { FrameGraph } from "../../../../engine/graphics/render/frame_graph/FrameGraph.js";
5
- import { TextureResourceDescriptor } from "../../../device/graph/TextureResourceDescriptor.js";
6
- import { CameraManager } from "../../camera/CameraManager.js";
7
- import { OrthographicCamera } from "../../camera/OrthographicCamera.js";
8
- import { PerspectiveCamera } from "../../camera/PerspectiveCamera.js";
9
- import { CullPolicy } from "../../rasterize/bucket/construct_primitive_state.js";
10
- import { graph_mesh_sphere_cull } from "../../rasterize/compute/graph_mesh_sphere_cull.js";
11
- import { execute_rasterization_jobs } from "../../rasterize/execute_rasterization_jobs.js";
12
- import {
13
- shadow_rasterization_alpha_tested_pass_descriptor
14
- } from "../../rasterize/native/depth/shadow_rasterization_alpha_tested_pass_descriptor.js";
15
- import {
16
- shadow_rasterization_pass_descriptor
17
- } from "../../rasterize/native/depth/shadow_rasterization_pass_descriptor.js";
18
- import { RasterizationJob, RasterizationPipelineType } from "../../rasterize/RasterizationJob.js";
19
- import { graph_rasterize_scene_fast_raw } from "../../rasterize/fast/graph_rasterize_scene_fast.js";
20
- import { graph_create_texture, graph_import_texture } from "../../shader/graph/graph_import_texture.js";
21
- import { GPUTextureContext } from "../../texture/GPUTextureContext.js";
22
- import { RenderTarget } from "../../texture/RenderTarget.js";
23
- import { View } from "../../view/View.js";
24
- import { ViewManager } from "../../view/ViewManager.js";
25
- import { SHADOWMAP_CSM_CASCADE_COUNT } from "./csm/SHADOWMAP_CSM_CASCADE_COUNT.js";
26
- import { DirectionalLightShadowmap } from "./DirectionalLightShadowmap.js";
27
- import { POINT_SHADOW_CUBE_COLS, POINT_SHADOW_CUBE_ROWS } from "./POINT_SHADOW_CUBE_LAYOUT.js";
28
- import {
29
- POINT_SHADOW_TETRA_COLS,
30
- POINT_SHADOW_TETRA_FACE_COUNT,
31
- POINT_SHADOW_TETRA_MAPPING_RESOLUTION_SCALE,
32
- POINT_SHADOW_TETRA_ROWS,
33
- } from "./POINT_SHADOW_TETRAHEDRON_LAYOUT.js";
34
- import { PointLightShadowmap } from "./PointLightShadowmap.js";
35
- import {
36
- POINT_SHADOW_REMAP_SETTINGS,
37
- shader_shadowmap_remap_cube_to_octahedral
38
- } from "./shader/shader_shadowmap_remap_cube_to_octahedral.js";
39
- import {
40
- POINT_SHADOW_TETRA_REMAP_SETTINGS,
41
- shader_shadowmap_remap_tetrahedron_to_octahedral
42
- } from "./shader/shader_shadowmap_remap_tetrahedron_to_octahedral.js";
43
- import { SHADOWMAP_ATLAS_BORDER } from "./SHADOWMAP_ATLAS_BORDER.js";
44
- import { TetrahedronPointLightShadowmap } from "./TetrahedronPointLightShadowmap.js";
45
- import { compute_light_projected_area_px, compute_shadowmap_update_score } from "./shadowmap_update_score.js";
46
- import { ShadowmapResolutionPolicy } from "./ShadowmapResolutionPolicy.js";
47
- import { SpotLightShadowmap } from "./SpotLightShadowmap.js";
48
-
49
- const DEFAULT_SHADOWMAP_ATLAS_RESOLUTION = 4096 * 2;
50
-
51
- /**
52
- * Resolution by cascade.
53
- * If the number of cascades is greater than the array length, the last resolution is used.
54
- * @type {number[]}
55
- */
56
- const DEFAULT_SHADOWMAP_DIRECTIONAL_RESOLUTION = [
57
- 1740, // first cascade
58
- 1440,
59
- ];
60
-
61
- /**
62
- * Placeholder resolution for local-light shadowmaps (spot/point).
63
- * A proper implementation will project the light's bounding volume into screen space to derive this at runtime.
64
- * @type {number}
65
- */
66
- const DEFAULT_SHADOWMAP_LOCAL_RESOLUTION = 128;
67
-
68
- /**
69
- * Per-frame shadow refresh budget, measured in **views** (one cube face counts as one view,
70
- * one CSM cascade counts as one view, etc.). A point light costs 6 views, a spot light 1, a
71
- * directional SHADOWMAP_CSM_CASCADE_COUNT. A bigger light consumes more of the budget, which
72
- * keeps the cost of the refresh step roughly proportional to how much work we're asking the
73
- * GPU to do a single point light running the meshlet rasterization pipeline 6 times is
74
- * equivalent work to 6 spot lights.
75
- *
76
- * Directional lights are always refreshed unconditionally (they drive the whole scene) and
77
- * their view cost is charged against the budget, so they effectively reduce the pool available
78
- * for local lights that frame.
79
- *
80
- * Unused budget is carried over between frames when demand exceeds supply — see
81
- * {@link GPUSceneShadowmapContext.#budget_carry_over}. This lets a light that's too expensive
82
- * to fit in a single frame (e.g. a point needing 6 views when only 2 are free) eventually get
83
- * its turn without permanently starving other lights.
84
- * @type {number}
85
- */
86
- const SHADOWMAP_UPDATE_BUDGET = 8;
87
-
88
- /**
89
- * Shadows for a single scene
90
- */
91
- export class GPUSceneShadowmapContext {
92
- atlas = new MaxRectanglesPacker(DEFAULT_SHADOWMAP_ATLAS_RESOLUTION, DEFAULT_SHADOWMAP_ATLAS_RESOLUTION);
93
-
94
- /**
95
- * Depth texture atlas used for shadowmaps.
96
- * @type {GPUTextureContext}
97
- */
98
- texture;
99
-
100
-
101
- /**
102
- * @type {GraphicsContext}
103
- */
104
- #graphics;
105
-
106
- /**
107
- * Dedicated ViewManager for the cube/cascade/perspective shadow views this context
108
- * creates and rasterizes into the atlas. Decoupled from the renderer-level ViewManager
109
- * so the ownership chain stays self-contained — the shadow context creates the views,
110
- * caches their GPU contexts, and disposes them with itself. The shared SceneManager
111
- * lookup is short-circuited with a tiny object that always returns our owning
112
- * GPUSceneContext (every shadow view here is for the same scene by construction).
113
- * @type {ViewManager}
114
- */
115
- shadow_view_manager;
116
-
117
- /**
118
- * Internal CameraManager backing {@link shadow_view_manager}. Shadow cameras are
119
- * created locally in {@link process_lights} and only referenced by this context, so
120
- * they don't need to share the renderer-level CameraManager.
121
- * @type {CameraManager}
122
- */
123
- #cameras;
124
-
125
- /**
126
- * @type {RenderTarget}
127
- */
128
- #render_target;
129
-
130
-
131
- /**
132
- *
133
- * @type {Shadowmap[]}
134
- */
135
- #maps = [];
136
-
137
- /**
138
- * Master kill switch for shadows on this scene. Defaults true; the renderer sets it from
139
- * its own `feature_shadows_enabled` flag before each frame's light update. When false,
140
- * {@link process_lights} evicts all maps so the next light-record build clears
141
- * CASTS_SHADOW_BIT on every light. {@link select_for_draw} and {@link draw} are still
142
- * gated externally by the renderer; this flag exists to keep the GPU light records in
143
- * sync with that gating without needing to thread the renderer's flag down further.
144
- * @type {boolean}
145
- */
146
- enabled = true;
147
-
148
- /**
149
- * Selects the tetrahedral point-light shadow generator (4 face frusta) over the default
150
- * cube generator (6 faces). The renderer pushes this from its
151
- * `feature_tetrahedron_point_shadows` flag before each light update, alongside {@link enabled}.
152
- *
153
- * Only affects how a point light's shadow is GENERATED — both paths remap into the same
154
- * octahedral atlas encoding, so the sampler is identical and this can be toggled at runtime.
155
- * A flip is picked up in {@link process_lights}: existing point maps whose generator no longer
156
- * matches this flag are evicted and rebuilt with the correct face count on the same pass.
157
- * @type {boolean}
158
- */
159
- use_tetrahedron_point_shadows = false;
160
-
161
- /**
162
- * Frame index of the most recent {@link select_for_draw} call. Passed to {@link draw} via
163
- * this field so we don't have to thread it through the call again — draw() stamps
164
- * `map.last_updated_frame_index` with this value after each successful rasterization.
165
- * @type {number}
166
- */
167
- #current_frame_index = -1;
168
-
169
- /**
170
- * View-budget surplus carried forward from prior frames. Accumulates whenever we had
171
- * non-directional candidates that couldn't fit in the per-frame budget (i.e. a point light
172
- * wanted 6 views but only 2 were free after directionals + earlier locals consumed their
173
- * share). Resets to 0 on any frame where the full demand was satisfied, so the carryover
174
- * doesn't grow unboundedly when the scene is quiet.
175
- * @type {number}
176
- */
177
- #budget_carry_over = 0;
178
-
179
- /**
180
- * Adjusts per-map atlas footprints each frame based on projected on-screen area. Lazily
181
- * tied to {@link atlas} in the constructor.
182
- * @type {ShadowmapResolutionPolicy}
183
- */
184
- #resolution_policy;
185
-
186
- /**
187
- * Number of shadowmaps actually rasterized by the most recent {@link draw} call. Reset at
188
- * the start of draw(), incremented once per map that went through the raster path. Exposed
189
- * for debugging scheduling behaviour a persistently oscillating value (e.g. 36/20/15
190
- * repeating) indicates some maps are being invalidated every frame and forcing a redraw,
191
- * which usually means the resolution policy or selector is churning.
192
- * @type {number}
193
- */
194
- #debug_render_count = 0;
195
-
196
- /**
197
- * @returns {number} number of shadowmaps rasterized in the most recent draw pass
198
- */
199
- get debug_render_count() {
200
- return this.#debug_render_count;
201
- }
202
-
203
- /**
204
- * Most recently measured atlas occupancy (placed area / total area, including pending
205
- * reservations), in [0, 1]. Forwards from the resolution policy. Useful for the debug
206
- * overlay alongside {@link debug_drop_size_scale}together they show whether the global
207
- * shrink/grow feedback has converged or is still chasing the target band.
208
- * @returns {number}
209
- */
210
- get debug_atlas_occupancy() {
211
- return this.#resolution_policy.last_occupancy;
212
- }
213
-
214
- /**
215
- * Current value of the global drop-size scale applied by the resolution policy. 1 means no
216
- * shrink; values below 1 mean atlas pressure has trimmed every light's target size
217
- * proportionally. Forwarded from the resolution policy for debug overlays.
218
- * @returns {number}
219
- */
220
- get debug_drop_size_scale() {
221
- return this.#resolution_policy.drop_size_scale;
222
- }
223
-
224
-
225
- /**
226
- * Per-type counts of shadow records written to the GPU database in the previous
227
- * {@link draw} pass. Used to identify trailing slots that must be zeroed when the
228
- * map count for a given type shrinks (e.g. a shadow-casting light is removed),
229
- * mirroring the bookkeeping in {@link GPULightCollection.#build}.
230
- */
231
- #prev_point_count = 0;
232
- #prev_spot_count = 0;
233
- #prev_directional_count = 0;
234
-
235
- /**
236
- * The Scene this context manages shadows for. Captured at construction so
237
- * {@link process_lights} doesn't need it threaded back through every frame.
238
- * @type {Scene}
239
- */
240
- #scene;
241
-
242
- /**
243
- * Owning light collection gives us access to the GPU database where shadow
244
- * records live and to the source LightCollection. Set in the constructor.
245
- * @type {GPULightCollection}
246
- */
247
- #owner;
248
-
249
- /**
250
- *
251
- * @param {GraphicsContext} graphics
252
- * @param {GPUSceneContext} scene_ctx the GPU-side context for the Scene we manage
253
- * shadows for. Used as the (single) destination of {@link shadow_view_manager}'s
254
- * scene lookup every shadow view is for this scene, so the lookup short-circuits.
255
- * @param {GPULightCollection} owner the parent light collection — gives us back-access
256
- * to the GPU database we publish records into during {@link draw}.
257
- */
258
- constructor(
259
- graphics,
260
- scene_ctx,
261
- owner,
262
- ) {
263
- assert.defined(graphics, 'graphics');
264
- assert.defined(scene_ctx, 'scene_ctx');
265
- assert.equal(scene_ctx.isGPUSceneContext, true, 'scene_ctx.isGPUSceneContext !== true');
266
- assert.defined(owner, 'owner');
267
-
268
-
269
- const initial_atlas_resolution = Math.min(
270
- graphics.device.limits.maxTextureDimension2D,
271
- DEFAULT_SHADOWMAP_ATLAS_RESOLUTION,
272
- );
273
-
274
- {
275
- const t = this.texture = new GPUTextureContext(graphics.device);
276
-
277
-
278
- const descriptor = t.descriptor;
279
-
280
- descriptor.format = "depth32float";
281
- descriptor.dimension = "2d";
282
- descriptor.label = "Shadowmap atlas";
283
- descriptor.usage = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC;
284
-
285
- t.resize(
286
- initial_atlas_resolution,
287
- initial_atlas_resolution
288
- );
289
-
290
- // ensure atlas size matches
291
- this.atlas.resize(
292
- initial_atlas_resolution,
293
- initial_atlas_resolution
294
- );
295
- }
296
-
297
- this.#graphics = graphics;
298
- this.#scene = scene_ctx.scene;
299
- this.#owner = owner;
300
-
301
- // Local CameraManager + ViewManager dedicated to shadow rasterization. We don't
302
- // share with the renderer-level managers because the cameras and views here are
303
- // owned by this context and only referenced from inside it. The "scenes" lookup
304
- // is a stub that always returns the owning scene context — every shadow view is
305
- // for `this.#scene` by construction.
306
- this.#cameras = new CameraManager(graphics.device);
307
- this.shadow_view_manager = new ViewManager(graphics, this.#cameras, {
308
- obtain: () => scene_ctx,
309
- });
310
-
311
- this.#render_target = RenderTarget.from({
312
- depth: this.texture,
313
- });
314
-
315
- this.#resolution_policy = new ShadowmapResolutionPolicy(this.atlas);
316
- }
317
-
318
- /**
319
- *
320
- * @param {Light} light
321
- * @returns {Shadowmap|undefined}
322
- */
323
- get_map(light) {
324
- return this.#maps.find(map => map.light === light);
325
- }
326
-
327
- /**
328
- * Decide which shadowmaps should be rasterized this frame.
329
- *
330
- * Runs at the top of every frame, before {@link draw}. Responsibilities:
331
- *
332
- * 1. **Reset per-frame signals.** `should_draw` is cleared on every map so a stale "yes"
333
- * from a prior frame can never cause a spurious extra raster.
334
- * 2. **Measure on-screen presence.** `projected_area_px` is computed once per map and stored
335
- * on the map itself — consumed by the resolution policy, the scorer, and the selector,
336
- * and exposed for debug tooling.
337
- * 3. **Reserve pending atlas footprints.** {@link ShadowmapResolutionPolicy.adjust} may
338
- * attach a `pending_layout` to maps whose projected area has shifted enough to warrant a
339
- * resize. The committed `layout` is left untouched so sampling remains valid; the
340
- * pending is consumed later by {@link draw} when the scheduler naturally picks this map
341
- * up (no forced redraw).
342
- * 4. **Score + select.** For each map:
343
- * - **Directional** → always selected (infinite score); view cost still charged.
344
- * - **Local, off-screen** (`projected_area_px <= 0`) → skipped entirely.
345
- * - **Local, in-view, invalid** MUST draw. `is_invalid` means the committed layout
346
- * has never held valid depth (freshly created map); sampling it would display
347
- * garbage, so we bypass the budget. Resizes no longer invalidate under the dual-
348
- * layout model — this flag fires only on first-draw.
349
- * - **Local, in-view, valid** compete for the remaining budget by score. Ordering
350
- * is descending score so the most important (largest-on-screen, stalest) maps win.
351
- * 5. **Commit selection.** On each winner we set `should_draw = true` and call
352
- * `map.update()` so its projection matrices reflect the current frame. The
353
- * `last_updated_frame_index` stamp is deferred to {@link draw} it records when the
354
- * atlas contents become current, not when the projection is fitted.
355
- *
356
- * Shadow records are NOT written here; they're written at the end of {@link draw} so the
357
- * shader reads slots that reflect any just-committed pending swaps within the same frame.
358
- *
359
- * See {@link compute_shadowmap_update_score} for the scoring formula and rationale.
360
- *
361
- * @param {Camera} main_camera used both for rendering the final frame and for projected-area
362
- * computation and directional CSM fit; must be callable for `.update()`
363
- * @param {GPUSceneContext} scene
364
- * @param {number} frame_index monotonically increasing frame counter, used for staleness
365
- * @param {ArrayLike<number>} view_resolution [width, height] of the main view in pixels; used
366
- * to convert projected sphere extents into a pixel-area score
367
- */
368
- select_for_draw(main_camera, scene, frame_index, view_resolution) {
369
- assert.defined(main_camera, 'main_camera');
370
- assert.isObject(main_camera, 'main_camera');
371
- assert.equal(scene.isGPUSceneContext, true, 'scene.isGPUSceneContext !== true');
372
- assert.isNumber(frame_index, 'frame_index');
373
- assert.defined(view_resolution, 'view_resolution');
374
-
375
- // make sure the camera is updated both our scoring (projected area) and any downstream
376
- // map.update() reads depend on view/projection matrices being current
377
- main_camera.update();
378
-
379
- this.#current_frame_index = frame_index;
380
-
381
- const shadowmaps = this.#maps;
382
- const n = shadowmaps.length;
383
-
384
- // Step 1: clear per-frame flags so a stale signal from a prior frame can't fire now.
385
- for (let i = 0; i < n; i++) {
386
- shadowmaps[i].should_draw = false;
387
- }
388
-
389
- // Step 2: recompute projected areas. Writing onto the map so the policy + scorer can
390
- // just read `map.projected_area_px` without threading a parallel array through.
391
- for (let i = 0; i < n; i++) {
392
- const m = shadowmaps[i];
393
- m.projected_area_px = compute_light_projected_area_px(m.light, main_camera, view_resolution);
394
- }
395
-
396
- // Step 3: policy reserves pending resize footprints alongside committed layouts. It
397
- // never mutates layout and never invalidates — sampling continues against the old
398
- // region until draw() commits the pending during a normal raster pass. Directionals and
399
- // off-screen locals are skipped (off-screen locals also get any stale pending released
400
- // so an idle reservation can't tie up atlas space).
401
- this.#resolution_policy.adjust(shadowmaps, frame_index);
402
-
403
- // Step 4: score every map. Directionals return Infinity; off-screen locals return 0.
404
- const scored = new Array(n);
405
- for (let i = 0; i < n; i++) {
406
- scored[i] = {
407
- map: shadowmaps[i],
408
- score: compute_shadowmap_update_score(shadowmaps[i], frame_index),
409
- };
410
- }
411
- scored.sort((a, b) => b.score - a.score);
412
-
413
- // Step 5: greedy-fill the view-budget in score order.
414
- const budget_total = SHADOWMAP_UPDATE_BUDGET + this.#budget_carry_over;
415
- let views_spent = 0;
416
- let had_unfit_candidates = false;
417
-
418
- for (let i = 0; i < n; i++) {
419
- const map = scored[i].map;
420
- const cost = map.views.length;
421
- const light = map.light;
422
-
423
- if (light.isDirectionalLight) {
424
- // Phase A: directionals always refresh. Infinite score puts them at the front of
425
- // the sorted list; their view cost still counts against the pool, so local lights
426
- // see a reduced budget this frame.
427
- map.should_draw = true;
428
- map.update(main_camera, scene);
429
- views_spent += cost;
430
- continue;
431
- }
432
-
433
- // Phase B: local lights (spot/point).
434
- if (map.projected_area_px <= 0) {
435
- // Off-screen. Not demand, not carryover fuel. Leave is_invalid / should_draw
436
- // alone so the map springs back to life the moment it returns to view.
437
- continue;
438
- }
439
-
440
- if (map.is_invalid) {
441
- // Phase B-override: invalid + in-view is a MUST-draw. Sampling garbage depth on
442
- // a visible light would be user-visible corruption, so we bypass the budget.
443
- // The view cost still counts so normal-priority locals see the deficit.
444
- map.should_draw = true;
445
- map.update(main_camera, scene);
446
- views_spent += cost;
447
- continue;
448
- }
449
-
450
- // Phase B normal: score-ordered greedy fill.
451
- if (views_spent + cost <= budget_total) {
452
- map.should_draw = true;
453
- map.update(main_camera, scene);
454
- views_spent += cost;
455
- } else {
456
- // wanted to refresh but the pool couldn't cover its view cost; flag so we carry
457
- // unused budget forward for next frame's attempt
458
- had_unfit_candidates = true;
459
- }
460
- // Skipped maps keep their prior metadata + atlas contents a never-refreshed map
461
- // samples the initial depth-cleared rect, which reads as "fully lit" under the
462
- // reverse-Z greater compare. Acceptable behavior until the map eventually wins a slot.
463
- }
464
-
465
- // Carry over the unused pool only when there was demand we couldn't meet. When the whole
466
- // scene's demand was satisfied, reset to 0 so idle frames don't inflate the pool. If
467
- // must-draw bypasses over-spent the pool (views_spent > budget_total), clamp at zero —
468
- // we can't owe future frames for past over-spend, that way lies runaway starvation.
469
- if (had_unfit_candidates) {
470
- this.#budget_carry_over = Math.max(0, budget_total - views_spent);
471
- } else {
472
- this.#budget_carry_over = 0;
473
- }
474
- }
475
-
476
- /**
477
- * Publish per-shadowmap records into the scene's GPU database. Each map writes its record
478
- * into the type-specific shadow_* table at the slot identified by `light._gpu_shadowmap_id`
479
- * (which is itself the per-type index, set by {@link process_lights}). Trailing slots from
480
- * previous frames are zeroed when the map count for a type shrinks, so stale entries don't
481
- * linger.
482
- *
483
- * Only maps with `metadata_changed === true` are re-uploaded; the flag is cleared after
484
- * the slot is queued so unchanged maps don't pay for an upload they don't need. Trim writes
485
- * always run regardless of the flag — they zero slots that no longer correspond to any map.
486
- *
487
- * The type-bucket order here MUST match the per-type id assignment in {@link process_lights}
488
- * (Point → Spot → Directional) so the shadow_id stamped onto each light record points at the
489
- * slot we write here.
490
- *
491
- * Caller must invoke `database.update(cmd)` afterwards to upload the writes — that is done
492
- * by {@link draw} once for both the new records and any zeroed trailing slots.
493
- *
494
- * @param {GPUDatabase} database
495
- */
496
- #write_shadow_records(database) {
497
- const t_point = database.get('shadow_point');
498
- const t_spot = database.get('shadow_spot');
499
- const t_directional = database.get('shadow_directional');
500
-
501
- const maps = this.#maps;
502
- let pc = 0;
503
- let sc = 0;
504
- let dc = 0;
505
-
506
- for (let i = 0; i < maps.length; i++) {
507
- const map = maps[i];
508
- const light = map.light;
509
-
510
- let table;
511
- let slot;
512
- if (light.isPointLight) {
513
- table = t_point;
514
- slot = pc++;
515
- } else if (light.isSpotLight) {
516
- table = t_spot;
517
- slot = sc++;
518
- } else if (light.isDirectionalLight) {
519
- table = t_directional;
520
- slot = dc++;
521
- } else {
522
- continue;
523
- }
524
-
525
- if (map.metadata_changed) {
526
- table.set(slot, map.make_record());
527
- map.metadata_changed = false;
528
- }
529
- }
530
-
531
- // trim trailing slots from previous frames so disused entries don't leak
532
- for (let i = pc; i < this.#prev_point_count; i++) {
533
- t_point.remove(i);
534
- }
535
- for (let i = sc; i < this.#prev_spot_count; i++) {
536
- t_spot.remove(i);
537
- }
538
- for (let i = dc; i < this.#prev_directional_count; i++) {
539
- t_directional.remove(i);
540
- }
541
-
542
- this.#prev_point_count = pc;
543
- this.#prev_spot_count = sc;
544
- this.#prev_directional_count = dc;
545
- }
546
-
547
- /**
548
- * Rasterize every shadowmap flagged by {@link select_for_draw} for a refresh this frame,
549
- * committing any pending resize reservations in the process, then publish per-shadow
550
- * metadata into the scene's GPU database.
551
- *
552
- * For each map with `should_draw` set:
553
- *
554
- * 1. If the map has a {@link Shadowmap.pending_layout} reservation, swap it into place
555
- * BEFORE rasterization so the draw targets the new region. The old committed rects
556
- * are remembered for step 3.
557
- * 2. Dispatch to the type-specific raster path:
558
- * - **Directional / spot**: direct raster into the atlas's sub-rects (one job per
559
- * cascade for directionals, one job per spot).
560
- * - **Point**: 6 cube-face depth passes into a shared intermediate texture, then
561
- * cube→octahedral remap into the atlas. Point lights are serialised so they can
562
- * reuse the intermediate texture.
563
- * 3. If a commit happened in step 1, release the old committed rects from the atlas now
564
- * that the new region holds valid depth.
565
- * 4. Clear per-frame flags (`should_draw`, `is_invalid`) and stamp
566
- * `last_updated_frame_index` with the current frame.
567
- *
568
- * After the loop, each map's metadata record is written into the corresponding
569
- * `shadow_point` / `shadow_spot` / `shadow_directional` table on the owning light
570
- * collection's database, then the database is uploaded this is where the committed
571
- * swaps become visible to the lighting shader. `#debug_render_count` is reset at entry
572
- * and incremented per map to expose scheduling behaviour to the debug overlay.
573
- *
574
- * No external job list: every path batches its own work internally via
575
- * {@link execute_rasterization_jobs}. Callers just pass a command context.
576
- *
577
- * @param {ShadeGPUCommandContext} command_ctx
578
- */
579
- draw(command_ctx) {
580
- assert.defined(command_ctx, 'command_ctx');
581
-
582
-
583
- this.#debug_render_count = 0;
584
-
585
- const frame_index = this.#current_frame_index;
586
- const maps = this.#maps;
587
-
588
- for (let i = 0; i < maps.length; i++) {
589
- const map = maps[i];
590
- if (!map.should_draw) {
591
- continue;
592
- }
593
-
594
- // Step 1: commit any pending-layout reservation before rasterizing. The draw helpers
595
- // read `map.layout` directly, so swapping here makes the raster target the new
596
- // region. We stash the old rect(s) locally for step 3's atlas release — once the
597
- // raster commands are enqueued, the old region's contents will no longer be sampled
598
- // (the next shadow-record write publishes the new layout), so returning that atlas
599
- // space to the packer is safe.
600
- let retired_rects = null;
601
- if (map.pending_layout !== null) {
602
- retired_rects = map.layout;
603
- map.layout = map.pending_layout;
604
- map.pending_layout = null;
605
- map.metadata_changed = true;
606
- }
607
-
608
- const light = map.light;
609
-
610
- if (light.isDirectionalLight || light.isSpotLight) {
611
- this.#draw_direct(map, command_ctx);
612
- } else if (light.isPointLight) {
613
- if (map.isTetrahedron) {
614
- this.#draw_point_tetrahedron(map, command_ctx);
615
- } else {
616
- this.#draw_point(map, command_ctx);
617
- }
618
- } else {
619
- throw new Error(`Unsupported light type ${light.constructor.name}`);
620
- }
621
-
622
- // Step 3: release the retired rects from the atlas. Must happen AFTER the raster
623
- // helpers finish reading map.layout (they may close over retired_rects as scratch
624
- // memory or view references in some paths; safest order is raster-first).
625
- if (retired_rects !== null) {
626
- for (let j = 0; j < retired_rects.length; j++) {
627
- this.atlas.remove(retired_rects[j]);
628
- }
629
- }
630
-
631
- // Step 4: atlas contents are now current. Clear per-frame flags and stamp the frame
632
- // index so the next frame's scorer computes staleness correctly.
633
- map.should_draw = false;
634
- map.is_invalid = false;
635
- map.last_updated_frame_index = frame_index;
636
- this.#debug_render_count++;
637
- }
638
-
639
-
640
- // Publish metadata AFTER all commits so the lighting shader sees atlas coordinates that
641
- // match the regions we just rasterised into. Never-drawn maps carry stale-but-valid
642
- // projection + layout; off-screen maps and skipped candidates publish their prior state.
643
- const database = this.#owner.database;
644
- this.#write_shadow_records(database);
645
- database.update(command_ctx);
646
-
647
-
648
- // finalize rendered views
649
- for (const map of this.#maps) {
650
- if (map.last_updated_frame_index !== frame_index) {
651
- // skip
652
- continue;
653
- }
654
-
655
- for (const view of map.views) {
656
- const view_ctx = this.shadow_view_manager.obtain(view);
657
- view_ctx.finish_frame(command_ctx);
658
- }
659
- }
660
- }
661
-
662
- /**
663
- * Rasterize a directional or spot shadowmap directly into its atlas rects. One job per
664
- * `map.views[i]` / `map.layout[i]` pair — directionals have N cascades, spots have one.
665
- *
666
- * @param {Shadowmap} map
667
- * @param {ShadeGPUCommandContext} command_ctx
668
- */
669
- #draw_direct(map, command_ctx) {
670
- // TODO pre-cull mesh set by sphere-casting the light volume against the main View Frustum
671
- // see ref: https://www.reddit.com/r/Unity3D/comments/1foe8qi/added_a_way_to_perform_frustum_culling_and_take
672
-
673
- const graph = new FrameGraph("shadows");
674
-
675
- const shadow_views = map.views;
676
- const jobs = new Array(shadow_views.length);
677
-
678
- for (let i = 0; i < shadow_views.length; i++) {
679
- const view = shadow_views[i];
680
- const view_ctx = this.shadow_view_manager.obtain(view);
681
-
682
- view_ctx.update(this.#graphics);
683
-
684
- const atlas_aabb = map.layout[i];
685
- view_ctx.setViewportSize(atlas_aabb.width, atlas_aabb.height);
686
-
687
- const job = new RasterizationJob();
688
- job.opaque_pass = shadow_rasterization_pass_descriptor;
689
- job.alpha_tested_pass = shadow_rasterization_alpha_tested_pass_descriptor;
690
- job.cull_policy = CullPolicy.Reversed;
691
- job.render_target = this.#render_target;
692
- job.view_ctx = view_ctx;
693
- job.viewport = [atlas_aabb.x0, atlas_aabb.y0, atlas_aabb.width, atlas_aabb.height];
694
-
695
-
696
- jobs[i] = job;
697
-
698
- // TODO reproject current scene view into shadow map for better occlusion culling,
699
- // see Assassin's Creed Unity talk on GPU-driven rendering,
700
- // see "RE ENGINE Meshlet Rendering Pipeline" https://youtu.be/nWgPtCDXlPc?t=2055
701
-
702
- // TODO need to linearize depth(?)
703
- // can linearize together with reprojection (TAA)
704
- }
705
-
706
- execute_rasterization_jobs({
707
- jobs,
708
- limits: this.#graphics.collection_limits,
709
- command_ctx,
710
- graph,
711
- });
712
-
713
- command_ctx.encodeGraph(graph);
714
- }
715
-
716
- /**
717
- * Rasterize a point light's 6 cube faces into the shared intermediate texture, then run the
718
- * cube→octahedral remap shader to write the encoded depth into this light's atlas rect.
719
- *
720
- * Sequencing matters: each point light fully completes (6 cube rasters + remap) before the
721
- * next one starts, so the intermediate texture can be reused across point lights without
722
- * cross-contamination.
723
- *
724
- * @param {PointLightShadowmap} map
725
- * @param {ShadeGPUCommandContext} command_ctx
726
- */
727
- #draw_point(
728
- map,
729
- command_ctx,
730
- ) {
731
-
732
- const graph = new FrameGraph("shadows");
733
-
734
- const gr_atlas_texture = graph_import_texture(graph, this.texture);
735
-
736
- const atlas_rect = map.layout[0];
737
-
738
- const octahedral_resolution = atlas_rect.width;
739
-
740
- /**
741
- * Controls the relationship between octahedral resolution and the cube map used during rendering
742
- * If this is larger - we get better quality at the cost of rendering more pixels.
743
- *
744
- * Solving for solid angle coverage, using the formula `N=POW(1 - SQRT(E/3), 3/2)` we get:
745
- *
746
- * 0% error -> 1
747
- * 1% error -> 0.915
748
- * 5% error -> 0.81299
749
- * 10% error -> 0.739
750
- * 20% error -> 0.639
751
- * 30% error -> 0.565
752
- * 40% error -> 0.506
753
- * 50% error -> 0.455
754
- *
755
- * @type {number}
756
- */
757
- const CUBE_MAPPING_RESOLUTION_SCALE = 1;
758
-
759
- const cube_face_resolution = Math.ceil(octahedral_resolution * CUBE_MAPPING_RESOLUTION_SCALE);
760
-
761
- // reserve texture
762
- let cube_packed_texture = graph_create_texture(graph, TextureResourceDescriptor.from({
763
- resolution: [
764
- cube_face_resolution * POINT_SHADOW_CUBE_COLS,
765
- cube_face_resolution * POINT_SHADOW_CUBE_ROWS,
766
- ],
767
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
768
- format: 'depth32float',
769
- enableMips: false,
770
- }));
771
-
772
- /**
773
- *
774
- * @type {PointLight}
775
- */
776
- const light = map.light;
777
-
778
- const scene_ctx = this.shadow_view_manager.obtain(map.views[0]).scene;
779
-
780
- /**
781
- * Sphere-culled meshes from the scene.
782
- * We could do per-face culling, but most point lights are local, and there's already meshlet culling happening later in the pipeline.
783
- * Good enough.
784
- * @type {number}
785
- */
786
- const input_meshes = graph_mesh_sphere_cull({
787
- graph,
788
- sphere: [...light.position, light.distance],
789
- scene: scene_ctx,
790
- limits: this.#graphics.collection_limits,
791
- });
792
-
793
- for (let face = 0; face < 6; face++) {
794
- const view = map.views[face];
795
- const view_ctx = this.shadow_view_manager.obtain(view);
796
-
797
- view_ctx.update(this.#graphics);
798
- view_ctx.setViewportSize(cube_face_resolution, cube_face_resolution);
799
-
800
- const col = face % POINT_SHADOW_CUBE_COLS;
801
- const row = Math.floor(face / POINT_SHADOW_CUBE_COLS);
802
-
803
- const job = new RasterizationJob();
804
-
805
- job.pipeline_type = RasterizationPipelineType.Fast;
806
- job.opaque_pass = shadow_rasterization_pass_descriptor;
807
- job.alpha_tested_pass = shadow_rasterization_alpha_tested_pass_descriptor;
808
- job.cull_policy = CullPolicy.Reversed;
809
- job.render_target = null;
810
- job.view_ctx = view_ctx;
811
- job.viewport = [
812
- col * cube_face_resolution,
813
- row * cube_face_resolution,
814
- cube_face_resolution,
815
- cube_face_resolution,
816
- ];
817
-
818
- cube_packed_texture = graph_rasterize_scene_fast_raw({
819
- graph,
820
- meshes: input_meshes,
821
- limits: this.#graphics.collection_limits,
822
- target_depth: cube_packed_texture,
823
- job,
824
- }).target_depth;
825
- }
826
-
827
- /**
828
- *
829
- * @param data
830
- * @param {FramePassResources} resources
831
- * @param {GraphicsExecutionContext} context
832
- */
833
- function remap_to_atlas(data, resources, context) {
834
- const atlas = resources.get(data.atlas);
835
- const cube_texture = resources.get(data.cube_texture);
836
-
837
- const settings = context.encoder.allocateTransientValueBuffer(
838
- POINT_SHADOW_REMAP_SETTINGS,
839
- {
840
- atlas_offset: [
841
- atlas_rect.x0 + SHADOWMAP_ATLAS_BORDER,
842
- atlas_rect.y0 + SHADOWMAP_ATLAS_BORDER,
843
- atlas_rect.width - 2 * SHADOWMAP_ATLAS_BORDER,
844
- atlas_rect.height - 2 * SHADOWMAP_ATLAS_BORDER,
845
- ],
846
- light_params: [
847
- map.light.distance,
848
- map.cube_near,
849
- 0,
850
- 0,
851
- ]
852
- }
853
- );
854
-
855
- shader_shadowmap_remap_cube_to_octahedral.draw({
856
- encoder: context.encoder,
857
- bindings: {
858
- settings: settings,
859
- cube_faces: cube_texture.obtainView(),
860
- },
861
- depthStencilAttachment: {
862
- view: atlas.obtainView(),
863
- depthLoadOp: "load",
864
- depthStoreOp: "store",
865
- },
866
- viewport: [
867
- atlas_rect.x0,
868
- atlas_rect.y0,
869
- atlas_rect.width,
870
- atlas_rect.height,
871
- ]
872
- })
873
- }
874
-
875
- const remap_data = {};
876
-
877
- const remap_pass = graph.add("remap to atals", remap_data, remap_to_atlas);
878
-
879
- remap_data.atlas = remap_pass.write(gr_atlas_texture);
880
- remap_data.cube_texture = remap_pass.read(cube_packed_texture);
881
-
882
-
883
- command_ctx.encodeGraph(graph);
884
- }
885
-
886
- /**
887
- * Rasterize a point light's 4 tetrahedron faces into a shared intermediate texture, then run
888
- * the tetrahedron→octahedral remap to write the encoded depth into this light's atlas rect.
889
- * The tetrahedral parallel to {@link #draw_point} — same shape, 4 faces in a 2x2 grid instead
890
- * of 6 in a 3x2 grid, and the remap is handed each face's view-projection matrix so it can
891
- * select faces and project directions in exact agreement with how the depth was rasterized.
892
- *
893
- * @param {TetrahedronPointLightShadowmap} map
894
- * @param {ShadeGPUCommandContext} command_ctx
895
- */
896
- #draw_point_tetrahedron(
897
- map,
898
- command_ctx,
899
- ) {
900
-
901
- const graph = new FrameGraph("shadows");
902
-
903
- const gr_atlas_texture = graph_import_texture(graph, this.texture);
904
-
905
- const atlas_rect = map.layout[0];
906
-
907
- const octahedral_resolution = atlas_rect.width;
908
-
909
- // Wider face fov than the cube path (see POINT_SHADOW_TETRA_FOV_DEGREES) needs a larger
910
- // per-face resolution to hold comparable on-axis density — the analogue of the cube
911
- // path's CUBE_MAPPING_RESOLUTION_SCALE.
912
- const face_resolution = Math.ceil(octahedral_resolution * POINT_SHADOW_TETRA_MAPPING_RESOLUTION_SCALE);
913
-
914
- // reserve intermediate depth texture 2x2 grid of faces
915
- let tetra_packed_texture = graph_create_texture(graph, TextureResourceDescriptor.from({
916
- resolution: [
917
- face_resolution * POINT_SHADOW_TETRA_COLS,
918
- face_resolution * POINT_SHADOW_TETRA_ROWS,
919
- ],
920
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
921
- format: 'depth32float',
922
- enableMips: false,
923
- }));
924
-
925
- /**
926
- *
927
- * @type {PointLight}
928
- */
929
- const light = map.light;
930
-
931
- const scene_ctx = this.shadow_view_manager.obtain(map.views[0]).scene;
932
-
933
- // One sphere cull for all 4 faces — most point lights are local and meshlet culling
934
- // refines later; per-face culling isn't worth it. Same trade as the cube path.
935
- const input_meshes = graph_mesh_sphere_cull({
936
- graph,
937
- sphere: [...light.position, light.distance],
938
- scene: scene_ctx,
939
- limits: this.#graphics.collection_limits,
940
- });
941
-
942
- for (let face = 0; face < POINT_SHADOW_TETRA_FACE_COUNT; face++) {
943
- const view = map.views[face];
944
- const view_ctx = this.shadow_view_manager.obtain(view);
945
-
946
- view_ctx.update(this.#graphics);
947
- view_ctx.setViewportSize(face_resolution, face_resolution);
948
-
949
- const col = face % POINT_SHADOW_TETRA_COLS;
950
- const row = Math.floor(face / POINT_SHADOW_TETRA_COLS);
951
-
952
- const job = new RasterizationJob();
953
-
954
- job.pipeline_type = RasterizationPipelineType.Fast;
955
- job.opaque_pass = shadow_rasterization_pass_descriptor;
956
- job.alpha_tested_pass = shadow_rasterization_alpha_tested_pass_descriptor;
957
- job.cull_policy = CullPolicy.Reversed;
958
- job.render_target = null;
959
- job.view_ctx = view_ctx;
960
- job.viewport = [
961
- col * face_resolution,
962
- row * face_resolution,
963
- face_resolution,
964
- face_resolution,
965
- ];
966
-
967
- tetra_packed_texture = graph_rasterize_scene_fast_raw({
968
- graph,
969
- meshes: input_meshes,
970
- limits: this.#graphics.collection_limits,
971
- target_depth: tetra_packed_texture,
972
- job,
973
- }).target_depth;
974
- }
975
-
976
- const views = map.views;
977
- const tetra_near = map.tetra_near;
978
- const light_max_distance = map.light.distance;
979
-
980
- /**
981
- *
982
- * @param data
983
- * @param {FramePassResources} resources
984
- * @param {GraphicsExecutionContext} context
985
- */
986
- function remap_to_atlas(data, resources, context) {
987
- const atlas = resources.get(data.atlas);
988
- const tetra_texture = resources.get(data.tetra_texture);
989
-
990
- // Read each face camera's view-projection at encode time — set in map.update() and
991
- // untouched since, and it is exactly the matrix the rasterizer used above.
992
- const face_view_proj = new Array(POINT_SHADOW_TETRA_FACE_COUNT);
993
- for (let f = 0; f < POINT_SHADOW_TETRA_FACE_COUNT; f++) {
994
- face_view_proj[f] = views[f].camera.view_projection_matrix;
995
- }
996
-
997
- const settings = context.encoder.allocateTransientValueBuffer(
998
- POINT_SHADOW_TETRA_REMAP_SETTINGS,
999
- {
1000
- atlas_offset: [
1001
- atlas_rect.x0 + SHADOWMAP_ATLAS_BORDER,
1002
- atlas_rect.y0 + SHADOWMAP_ATLAS_BORDER,
1003
- atlas_rect.width - 2 * SHADOWMAP_ATLAS_BORDER,
1004
- atlas_rect.height - 2 * SHADOWMAP_ATLAS_BORDER,
1005
- ],
1006
- light_params: [
1007
- light_max_distance,
1008
- tetra_near,
1009
- 0,
1010
- 0,
1011
- ],
1012
- face_view_proj,
1013
- }
1014
- );
1015
-
1016
- shader_shadowmap_remap_tetrahedron_to_octahedral.draw({
1017
- encoder: context.encoder,
1018
- bindings: {
1019
- settings: settings,
1020
- tetra_faces: tetra_texture.obtainView(),
1021
- },
1022
- depthStencilAttachment: {
1023
- view: atlas.obtainView(),
1024
- depthLoadOp: "load",
1025
- depthStoreOp: "store",
1026
- },
1027
- viewport: [
1028
- atlas_rect.x0,
1029
- atlas_rect.y0,
1030
- atlas_rect.width,
1031
- atlas_rect.height,
1032
- ]
1033
- })
1034
- }
1035
-
1036
- const remap_data = {};
1037
-
1038
- const remap_pass = graph.add("remap tetra to atlas", remap_data, remap_to_atlas);
1039
-
1040
- remap_data.atlas = remap_pass.write(gr_atlas_texture);
1041
- remap_data.tetra_texture = remap_pass.read(tetra_packed_texture);
1042
-
1043
-
1044
- command_ctx.encodeGraph(graph);
1045
- }
1046
-
1047
- /**
1048
- * Release the per-face GPU contexts (each view's uniform/HZB/volumetrics buffers + its camera
1049
- * uniform) backing a map. The dedicated {@link shadow_view_manager} / {@link #cameras} caches
1050
- * key on the View/Camera objects and have no GC path, so a map's contexts must be released
1051
- * explicitly here or their GPU buffers leak for the context's lifetime. Most visible when the
1052
- * point-shadow generator toggle rebuilds every point map (fresh Views each time), but it also
1053
- * closes the ordinary eviction leak (light removed / stops casting / shadows disabled).
1054
- *
1055
- * In this context Views and Cameras are 1:1 per map (each map creates its own), so releasing a
1056
- * view's camera alongside it can't strand a still-referenced camera.
1057
- *
1058
- * @param {Shadowmap} map
1059
- */
1060
- #release_map_gpu_contexts(map) {
1061
- const views = map.views;
1062
- for (let j = 0; j < views.length; j++) {
1063
- const view = views[j];
1064
- this.shadow_view_manager.release(view);
1065
- this.#cameras.release(view.camera);
1066
- }
1067
- }
1068
-
1069
- /**
1070
- * Release a map's atlas rects + GPU side-resources. Caller is responsible for splicing it
1071
- * out of {@link #maps}.
1072
- *
1073
- * @param {Shadowmap} map
1074
- */
1075
- #evict_map(map) {
1076
- for (let j = 0; j < map.layout.length; j++) {
1077
- this.atlas.remove(map.layout[j]);
1078
- }
1079
- if (map.pending_layout !== null) {
1080
- for (let j = 0; j < map.pending_layout.length; j++) {
1081
- this.atlas.remove(map.pending_layout[j]);
1082
- }
1083
- map.pending_layout = null;
1084
- }
1085
-
1086
- // free the per-view/-camera GPU contexts — without this an evicted map's buffers leak
1087
- this.#release_map_gpu_contexts(map);
1088
-
1089
- map.light._gpu_shadowmap_id = -1;
1090
- }
1091
-
1092
- /**
1093
- * Reconcile shadow allocations against the current scene state. Evicts maps for lights
1094
- * that no longer cast shadows or have left the scene, allocates maps for new shadow-
1095
- * casting lights, and reassigns per-type `_gpu_shadowmap_id` when the map ordering
1096
- * shifts.
1097
- *
1098
- * Called from {@link GPULightCollection.update} every frame, including frames where
1099
- * {@link LightCollection.version} hasn't moved — `light.casts_shadow` flips are not
1100
- * tracked by version and are detected here.
1101
- *
1102
- * @returns {boolean} true when at least one map was added/evicted or at least one light's
1103
- * per-type shadow_id moved. The caller uses this to decide whether the light
1104
- * records need to be re-published (their `shadow_id` and CASTS_SHADOW_BIT may
1105
- * have shifted).
1106
- */
1107
- process_lights() {
1108
- // When the renderer has shadows disabled, treat every map as evictable so the next
1109
- // build clears CASTS_SHADOW_BIT on every light. Re-enabling reconstructs everything
1110
- // from scratch on the following frame — that's a one-frame catch-up cost, considered
1111
- // an acceptable trade for the simpler invariant ("disabled" = no maps held).
1112
- if (!this.enabled) {
1113
- const had_maps = this.#maps.length > 0;
1114
- for (let i = this.#maps.length - 1; i >= 0; i--) {
1115
- this.#evict_map(this.#maps[i]);
1116
- }
1117
- this.#maps.length = 0;
1118
- return had_maps;
1119
- }
1120
-
1121
- const scene = this.#scene;
1122
- const lights = scene.lights.elements;
1123
- let changed = false;
1124
-
1125
- // Reclamation: drop maps whose lights left the scene or stopped casting shadows so a
1126
- // removed light's atlas rect doesn't stay pinned forever. Walk in reverse so splice
1127
- // indices stay stable.
1128
- const live_lights = new Set(lights);
1129
- for (let i = this.#maps.length - 1; i >= 0; i--) {
1130
- const map = this.#maps[i];
1131
- const light = map.light;
1132
-
1133
- // A point map built by the other generator (cube vs tetra) must be rebuilt when the
1134
- // flag flips its face count and draw path no longer match. Dropping it here lets
1135
- // add_light re-create it with the current generator later in this same pass.
1136
- const generator_mismatch = light.isPointLight
1137
- && Boolean(map.isTetrahedron) !== this.use_tetrahedron_point_shadows;
1138
-
1139
- if (live_lights.has(light) && light.casts_shadow && !generator_mismatch) {
1140
- continue;
1141
- }
1142
- this.#evict_map(map);
1143
- this.#maps.splice(i, 1);
1144
- changed = true;
1145
- }
1146
-
1147
- const add_directional = (light) => {
1148
- const map = new DirectionalLightShadowmap();
1149
- map.light = light;
1150
-
1151
- const bound = [];
1152
- try {
1153
- for (let j = 0; j < SHADOWMAP_CSM_CASCADE_COUNT; j++) {
1154
- const resolution_index = Math.min(j, DEFAULT_SHADOWMAP_DIRECTIONAL_RESOLUTION.length - 1);
1155
- const resolution = DEFAULT_SHADOWMAP_DIRECTIONAL_RESOLUTION[resolution_index];
1156
-
1157
- const rect = this.bind(resolution);
1158
- bound.push(rect);
1159
- map.layout[j] = rect;
1160
-
1161
- const camera = new OrthographicCamera();
1162
- camera.update();
1163
-
1164
- const view = new View(camera, scene);
1165
- view.label = `Shadow Cascade ${j}`;
1166
-
1167
- map.views[j] = view;
1168
- }
1169
- } catch (e) {
1170
- // Partial bind: roll back any cascade rects already inserted so the atlas isn't
1171
- // left holding orphans for a map that never got pushed to #maps.
1172
- for (let j = 0; j < bound.length; j++) {
1173
- this.atlas.remove(bound[j]);
1174
- }
1175
- throw e;
1176
- }
1177
-
1178
- this.#maps.push(map);
1179
- };
1180
-
1181
- const add_spot = (light) => {
1182
- const map = new SpotLightShadowmap();
1183
- map.light = light;
1184
-
1185
- const rect = this.bind(DEFAULT_SHADOWMAP_LOCAL_RESOLUTION);
1186
- map.layout[0] = rect;
1187
-
1188
- try {
1189
- const camera = new PerspectiveCamera();
1190
- camera.update();
1191
-
1192
- const view = new View(camera, scene);
1193
- view.label = `Spot Shadow`;
1194
-
1195
- map.views[0] = view;
1196
- } catch (e) {
1197
- this.atlas.remove(rect);
1198
- throw e;
1199
- }
1200
-
1201
- this.#maps.push(map);
1202
- };
1203
-
1204
- const add_point = (light) => {
1205
-
1206
- // Generator chosen by the current flag; both remap into one octahedral slot, they
1207
- // only differ in how many face frusta feed that remap (tetra 4, cube 6).
1208
- const use_tetra = this.use_tetrahedron_point_shadows;
1209
-
1210
- const map = use_tetra ? new TetrahedronPointLightShadowmap() : new PointLightShadowmap();
1211
- map.light = light;
1212
-
1213
- // single atlas rect holds the octahedral-encoded depth for all faces
1214
- const rect = this.bind(DEFAULT_SHADOWMAP_LOCAL_RESOLUTION);
1215
- map.layout[0] = rect;
1216
-
1217
- const face_count = use_tetra ? POINT_SHADOW_TETRA_FACE_COUNT : 6;
1218
-
1219
- for (let j = 0; j < face_count; j++) {
1220
- const camera = new PerspectiveCamera();
1221
- camera.update();
1222
-
1223
- const view = new View(camera, scene);
1224
- view.label = use_tetra ? `Point Shadow (tetra) face ${j}` : `Point Shadow face ${j}`;
1225
-
1226
- map.views[j] = view;
1227
- }
1228
-
1229
- this.#maps.push(map);
1230
- };
1231
-
1232
- const add_light = (light) => {
1233
- if (!light.casts_shadow) {
1234
- return;
1235
- }
1236
-
1237
- if (this.get_map(light) !== undefined) {
1238
- return;
1239
- }
1240
-
1241
- if (light.isDirectionalLight) {
1242
- add_directional(light);
1243
- changed = true;
1244
- } else if (light.isSpotLight) {
1245
- add_spot(light);
1246
- changed = true;
1247
- } else if (light.isPointLight) {
1248
- add_point(light);
1249
- changed = true;
1250
- }
1251
- };
1252
-
1253
- for (let i = 0; i < lights.length; i++) {
1254
- const light = lights[i];
1255
-
1256
- try {
1257
- add_light(light);
1258
- } catch (e) {
1259
- console.error(e);
1260
- }
1261
- }
1262
-
1263
- // Publish per-type shadowmap ids back onto the lights. Each shadow_* table is indexed
1264
- // independently, so a point light's shadow_id points into shadow_point, a spot's into
1265
- // shadow_spot, etc. Type-bucket order MUST match {@link #write_shadow_records} so each
1266
- // light record's stored shadow_id lines up with the slot we'll write into the table.
1267
- // When a map's slot index actually changes (eviction shifted neighbours up the array),
1268
- // mark its record dirty so the next #write_shadow_records re-publishes it into the new
1269
- // slot, and report `changed` so the caller re-publishes the light records too.
1270
- let pc = 0;
1271
- let sc = 0;
1272
- let dc = 0;
1273
- for (let i = 0; i < this.#maps.length; i++) {
1274
- const map = this.#maps[i];
1275
- const light = map.light;
1276
-
1277
- let new_id;
1278
- if (light.isPointLight) {
1279
- new_id = pc++;
1280
- } else if (light.isSpotLight) {
1281
- new_id = sc++;
1282
- } else if (light.isDirectionalLight) {
1283
- new_id = dc++;
1284
- } else {
1285
- continue;
1286
- }
1287
-
1288
- if (light._gpu_shadowmap_id !== new_id) {
1289
- light._gpu_shadowmap_id = new_id;
1290
- map.metadata_changed = true;
1291
- changed = true;
1292
- }
1293
- }
1294
-
1295
- return changed;
1296
- }
1297
-
1298
- /**
1299
- *
1300
- * @param {number} resolution
1301
- * @returns {AABB2}
1302
- */
1303
- bind(resolution) {
1304
-
1305
- const rect = new AABB2(0, 0, resolution, resolution);
1306
-
1307
- if (!this.atlas.add(rect)) {
1308
- throw new Error(`Failed to add shadowmap to atlas`);
1309
- }
1310
-
1311
- return rect;
1312
- }
1313
-
1314
- destroy() {
1315
- this.texture.destroy();
1316
-
1317
- for (let i = 0; i < this.#maps.length; i++) {
1318
- const map = this.#maps[i];
1319
- // release the per-view/-camera GPU contexts too — map.destroy() alone doesn't (the
1320
- // buffers live in the view/camera manager caches, not on the map)
1321
- this.#release_map_gpu_contexts(map);
1322
- map.destroy();
1323
- }
1324
- this.#maps.length = 0;
1325
- }
1326
- }
1
+ import { assert } from "../../../../core/assert.js";
2
+ import AABB2 from "../../../../core/geom/2d/aabb/AABB2.js";
3
+ import { MaxRectanglesPacker } from "../../../../core/geom/packing/max-rect/MaxRectanglesPacker.js";
4
+ import { FrameGraph } from "../../../../engine/graphics/render/frame_graph/FrameGraph.js";
5
+ import { TextureResourceDescriptor } from "../../../device/graph/TextureResourceDescriptor.js";
6
+ import { CameraManager } from "../../camera/CameraManager.js";
7
+ import { OrthographicCamera } from "../../camera/OrthographicCamera.js";
8
+ import { PerspectiveCamera } from "../../camera/PerspectiveCamera.js";
9
+ import { CullPolicy } from "../../rasterize/bucket/construct_primitive_state.js";
10
+ import { graph_mesh_sphere_cull } from "../../rasterize/compute/graph_mesh_sphere_cull.js";
11
+ import { execute_rasterization_jobs } from "../../rasterize/execute_rasterization_jobs.js";
12
+ import {
13
+ shadow_rasterization_alpha_tested_pass_descriptor
14
+ } from "../../rasterize/native/depth/shadow_rasterization_alpha_tested_pass_descriptor.js";
15
+ import {
16
+ shadow_rasterization_pass_descriptor
17
+ } from "../../rasterize/native/depth/shadow_rasterization_pass_descriptor.js";
18
+ import { RasterizationJob, RasterizationPipelineType } from "../../rasterize/RasterizationJob.js";
19
+ import { graph_rasterize_scene_fast_raw } from "../../rasterize/fast/graph_rasterize_scene_fast.js";
20
+ import { graph_create_texture, graph_import_texture } from "../../shader/graph/graph_import_texture.js";
21
+ import { GPUTextureContext } from "../../texture/GPUTextureContext.js";
22
+ import { RenderTarget } from "../../texture/RenderTarget.js";
23
+ import { View } from "../../view/View.js";
24
+ import { ViewManager } from "../../view/ViewManager.js";
25
+ import { SHADOWMAP_CSM_CASCADE_COUNT } from "./csm/SHADOWMAP_CSM_CASCADE_COUNT.js";
26
+ import { DirectionalLightShadowmap } from "./DirectionalLightShadowmap.js";
27
+ import { POINT_SHADOW_CUBE_COLS, POINT_SHADOW_CUBE_ROWS } from "./POINT_SHADOW_CUBE_LAYOUT.js";
28
+ import {
29
+ POINT_SHADOW_TETRA_COLS,
30
+ POINT_SHADOW_TETRA_FACE_COUNT,
31
+ POINT_SHADOW_TETRA_MAPPING_RESOLUTION_SCALE,
32
+ POINT_SHADOW_TETRA_ROWS,
33
+ } from "./POINT_SHADOW_TETRAHEDRON_LAYOUT.js";
34
+ import { PointLightShadowmap } from "./PointLightShadowmap.js";
35
+ import {
36
+ shader_shadowmap_remap_cube_to_octahedral
37
+ } from "./shader/shader_shadowmap_remap_cube_to_octahedral.js";
38
+ import {
39
+ POINT_SHADOW_TETRA_REMAP_SETTINGS,
40
+ shader_shadowmap_remap_tetrahedron_to_octahedral
41
+ } from "./shader/shader_shadowmap_remap_tetrahedron_to_octahedral.js";
42
+ import { SHADOWMAP_ATLAS_BORDER } from "./SHADOWMAP_ATLAS_BORDER.js";
43
+ import { TetrahedronPointLightShadowmap } from "./TetrahedronPointLightShadowmap.js";
44
+ import { compute_light_projected_area_px } from "./shadowmap_update_score.js";
45
+ import { ShadowmapDrawSelector } from "./ShadowmapDrawSelector.js";
46
+ import { ShadowmapRegistry } from "./ShadowmapRegistry.js";
47
+ import { ShadowmapResolutionPolicy } from "./ShadowmapResolutionPolicy.js";
48
+ import { SpotLightShadowmap } from "./SpotLightShadowmap.js";
49
+
50
+ const DEFAULT_SHADOWMAP_ATLAS_RESOLUTION = 4096 * 2;
51
+
52
+ /**
53
+ * Resolution by cascade.
54
+ * If the number of cascades is greater than the array length, the last resolution is used.
55
+ * @type {number[]}
56
+ */
57
+ const DEFAULT_SHADOWMAP_DIRECTIONAL_RESOLUTION = [
58
+ 1740, // first cascade
59
+ 1440,
60
+ ];
61
+
62
+ /**
63
+ * Placeholder resolution for local-light shadowmaps (spot/point).
64
+ * A proper implementation will project the light's bounding volume into screen space to derive this at runtime.
65
+ * @type {number}
66
+ */
67
+ const DEFAULT_SHADOWMAP_LOCAL_RESOLUTION = 128;
68
+
69
+ /**
70
+ * Per-frame shadow refresh budget, measured in **views** (one cube face counts as one view,
71
+ * one CSM cascade counts as one view, etc.). A point light costs 6 views, a spot light 1, a
72
+ * directional SHADOWMAP_CSM_CASCADE_COUNT. A bigger light consumes more of the budget, which
73
+ * keeps the cost of the refresh step roughly proportional to how much work we're asking the
74
+ * GPU to do — a single point light running the meshlet rasterization pipeline 6 times is
75
+ * equivalent work to 6 spot lights.
76
+ *
77
+ * Directional lights are always refreshed unconditionally (they drive the whole scene) and
78
+ * their view cost is charged against the budget, so they effectively reduce the pool available
79
+ * for local lights that frame.
80
+ *
81
+ * Unused budget is carried over between frames when demand exceeds supply — see
82
+ * {@link GPUSceneShadowmapContext.#budget_carry_over}. This lets a light that's too expensive
83
+ * to fit in a single frame (e.g. a point needing 6 views when only 2 are free) eventually get
84
+ * its turn without permanently starving other lights.
85
+ * @type {number}
86
+ */
87
+ const SHADOWMAP_UPDATE_BUDGET = 8;
88
+
89
+ /**
90
+ * Shadows for a single scene
91
+ */
92
+ export class GPUSceneShadowmapContext {
93
+ atlas = new MaxRectanglesPacker(DEFAULT_SHADOWMAP_ATLAS_RESOLUTION, DEFAULT_SHADOWMAP_ATLAS_RESOLUTION);
94
+
95
+ /**
96
+ * Depth texture atlas used for shadowmaps.
97
+ * @type {GPUTextureContext}
98
+ */
99
+ texture;
100
+
101
+
102
+ /**
103
+ * @type {GraphicsContext}
104
+ */
105
+ #graphics;
106
+
107
+ /**
108
+ * Dedicated ViewManager for the cube/cascade/perspective shadow views this context
109
+ * creates and rasterizes into the atlas. Decoupled from the renderer-level ViewManager
110
+ * so the ownership chain stays self-contained the shadow context creates the views,
111
+ * caches their GPU contexts, and disposes them with itself. The shared SceneManager
112
+ * lookup is short-circuited with a tiny object that always returns our owning
113
+ * GPUSceneContext (every shadow view here is for the same scene by construction).
114
+ * @type {ViewManager}
115
+ */
116
+ shadow_view_manager;
117
+
118
+ /**
119
+ * Internal CameraManager backing {@link shadow_view_manager}. Shadow cameras are
120
+ * created locally in {@link process_lights} and only referenced by this context, so
121
+ * they don't need to share the renderer-level CameraManager.
122
+ * @type {CameraManager}
123
+ */
124
+ #cameras;
125
+
126
+ /**
127
+ * @type {RenderTarget}
128
+ */
129
+ #render_target;
130
+
131
+
132
+ /**
133
+ * Every shadowmap this scene holds, plus the light -> map index and the reconciliation marks
134
+ * that make {@link process_lights} O(lights + maps) instead of O(lights x maps).
135
+ * @type {ShadowmapRegistry}
136
+ */
137
+ #registry = new ShadowmapRegistry();
138
+
139
+ /**
140
+ * Scores and picks the maps to refresh each frame. Holds the heap it selects with across
141
+ * frames, so the per-frame scoring allocates nothing.
142
+ * @type {ShadowmapDrawSelector}
143
+ */
144
+ #selector = new ShadowmapDrawSelector();
145
+
146
+ /**
147
+ * Scratch list of lights that need a shadowmap allocated, handed from pass 1 of
148
+ * {@link process_lights} to pass 3. Held across frames rather than allocated per call — it is
149
+ * empty on every frame where the light set didn't move, which is nearly all of them.
150
+ * @type {Light[]}
151
+ */
152
+ #reconcile_newcomers = [];
153
+
154
+ /**
155
+ * Master kill switch for shadows on this scene. Defaults true; the renderer sets it from
156
+ * its own `feature_shadows_enabled` flag before each frame's light update. When false,
157
+ * {@link process_lights} evicts all maps so the next light-record build clears
158
+ * CASTS_SHADOW_BIT on every light. {@link select_for_draw} and {@link draw} are still
159
+ * gated externally by the renderer; this flag exists to keep the GPU light records in
160
+ * sync with that gating without needing to thread the renderer's flag down further.
161
+ * @type {boolean}
162
+ */
163
+ enabled = true;
164
+
165
+ /**
166
+ * Selects the tetrahedral point-light shadow generator (4 face frusta) over the default
167
+ * cube generator (6 faces). The renderer pushes this from its
168
+ * `feature_tetrahedron_point_shadows` flag before each light update, alongside {@link enabled}.
169
+ *
170
+ * Only affects how a point light's shadow is GENERATED both paths remap into the same
171
+ * octahedral atlas encoding, so the sampler is identical and this can be toggled at runtime.
172
+ * A flip is picked up in {@link process_lights}: existing point maps whose generator no longer
173
+ * matches this flag are evicted and rebuilt with the correct face count on the same pass.
174
+ * @type {boolean}
175
+ */
176
+ use_tetrahedron_point_shadows = false;
177
+
178
+ /**
179
+ * Frame index of the most recent {@link select_for_draw} call. Passed to {@link draw} via
180
+ * this field so we don't have to thread it through the call again — draw() stamps
181
+ * `map.last_updated_frame_index` with this value after each successful rasterization.
182
+ * @type {number}
183
+ */
184
+ #current_frame_index = -1;
185
+
186
+ /**
187
+ * View-budget surplus carried forward from prior frames. Accumulates whenever we had
188
+ * non-directional candidates that couldn't fit in the per-frame budget (i.e. a point light
189
+ * wanted 6 views but only 2 were free after directionals + earlier locals consumed their
190
+ * share). Resets to 0 on any frame where the full demand was satisfied, so the carryover
191
+ * doesn't grow unboundedly when the scene is quiet.
192
+ * @type {number}
193
+ */
194
+ #budget_carry_over = 0;
195
+
196
+ /**
197
+ * Adjusts per-map atlas footprints each frame based on projected on-screen area. Lazily
198
+ * tied to {@link atlas} in the constructor.
199
+ * @type {ShadowmapResolutionPolicy}
200
+ */
201
+ #resolution_policy;
202
+
203
+ /**
204
+ * Number of shadowmaps actually rasterized by the most recent {@link draw} call. Reset at
205
+ * the start of draw(), incremented once per map that went through the raster path. Exposed
206
+ * for debugging scheduling behavioura persistently oscillating value (e.g. 36/20/15
207
+ * repeating) indicates some maps are being invalidated every frame and forcing a redraw,
208
+ * which usually means the resolution policy or selector is churning.
209
+ * @type {number}
210
+ */
211
+ #debug_render_count = 0;
212
+
213
+ /**
214
+ * @returns {number} number of shadowmaps rasterized in the most recent draw pass
215
+ */
216
+ get debug_render_count() {
217
+ return this.#debug_render_count;
218
+ }
219
+
220
+ /**
221
+ * Most recently measured atlas occupancy (placed area / total area, including pending
222
+ * reservations), in [0, 1]. Forwards from the resolution policy. Useful for the debug
223
+ * overlay alongside {@link debug_drop_size_scale} — together they show whether the global
224
+ * shrink/grow feedback has converged or is still chasing the target band.
225
+ * @returns {number}
226
+ */
227
+ get debug_atlas_occupancy() {
228
+ return this.#resolution_policy.last_occupancy;
229
+ }
230
+
231
+ /**
232
+ * Current value of the global drop-size scale applied by the resolution policy. 1 means no
233
+ * shrink; values below 1 mean atlas pressure has trimmed every light's target size
234
+ * proportionally. Forwarded from the resolution policy for debug overlays.
235
+ * @returns {number}
236
+ */
237
+ get debug_drop_size_scale() {
238
+ return this.#resolution_policy.drop_size_scale;
239
+ }
240
+
241
+
242
+ /**
243
+ * Per-type counts of shadow records written to the GPU database in the previous
244
+ * {@link draw} pass. Used to identify trailing slots that must be zeroed when the
245
+ * map count for a given type shrinks (e.g. a shadow-casting light is removed),
246
+ * mirroring the bookkeeping in {@link GPULightCollection.#build}.
247
+ */
248
+ #prev_point_count = 0;
249
+ #prev_spot_count = 0;
250
+ #prev_directional_count = 0;
251
+
252
+ /**
253
+ * The Scene this context manages shadows for. Captured at construction so
254
+ * {@link process_lights} doesn't need it threaded back through every frame.
255
+ * @type {Scene}
256
+ */
257
+ #scene;
258
+
259
+ /**
260
+ * Owning light collection — gives us access to the GPU database where shadow
261
+ * records live and to the source LightCollection. Set in the constructor.
262
+ * @type {GPULightCollection}
263
+ */
264
+ #owner;
265
+
266
+ /**
267
+ *
268
+ * @param {GraphicsContext} graphics
269
+ * @param {GPUSceneContext} scene_ctx the GPU-side context for the Scene we manage
270
+ * shadows for. Used as the (single) destination of {@link shadow_view_manager}'s
271
+ * scene lookup — every shadow view is for this scene, so the lookup short-circuits.
272
+ * @param {GPULightCollection} owner the parent light collection — gives us back-access
273
+ * to the GPU database we publish records into during {@link draw}.
274
+ */
275
+ constructor(
276
+ graphics,
277
+ scene_ctx,
278
+ owner,
279
+ ) {
280
+ assert.defined(graphics, 'graphics');
281
+ assert.defined(scene_ctx, 'scene_ctx');
282
+ assert.equal(scene_ctx.isGPUSceneContext, true, 'scene_ctx.isGPUSceneContext !== true');
283
+ assert.defined(owner, 'owner');
284
+
285
+
286
+ const initial_atlas_resolution = Math.min(
287
+ graphics.device.limits.maxTextureDimension2D,
288
+ DEFAULT_SHADOWMAP_ATLAS_RESOLUTION,
289
+ );
290
+
291
+ {
292
+ const t = this.texture = new GPUTextureContext(graphics.device);
293
+
294
+
295
+ const descriptor = t.descriptor;
296
+
297
+ descriptor.format = "depth32float";
298
+ descriptor.dimension = "2d";
299
+ descriptor.label = "Shadowmap atlas";
300
+ descriptor.usage = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC;
301
+
302
+ t.resize(
303
+ initial_atlas_resolution,
304
+ initial_atlas_resolution
305
+ );
306
+
307
+ // ensure atlas size matches
308
+ this.atlas.resize(
309
+ initial_atlas_resolution,
310
+ initial_atlas_resolution
311
+ );
312
+ }
313
+
314
+ this.#graphics = graphics;
315
+ this.#scene = scene_ctx.scene;
316
+ this.#owner = owner;
317
+
318
+ // Local CameraManager + ViewManager dedicated to shadow rasterization. We don't
319
+ // share with the renderer-level managers because the cameras and views here are
320
+ // owned by this context and only referenced from inside it. The "scenes" lookup
321
+ // is a stub that always returns the owning scene context — every shadow view is
322
+ // for `this.#scene` by construction.
323
+ this.#cameras = new CameraManager(graphics.device);
324
+ this.shadow_view_manager = new ViewManager(graphics, this.#cameras, {
325
+ obtain: () => scene_ctx,
326
+ });
327
+
328
+ this.#render_target = RenderTarget.from({
329
+ depth: this.texture,
330
+ });
331
+
332
+ this.#resolution_policy = new ShadowmapResolutionPolicy(this.atlas);
333
+ }
334
+
335
+ /**
336
+ *
337
+ * @param {Light} light
338
+ * @returns {Shadowmap|undefined}
339
+ */
340
+ get_map(light) {
341
+ return this.#registry.get(light);
342
+ }
343
+
344
+ /**
345
+ * Decide which shadowmaps should be rasterized this frame.
346
+ *
347
+ * Runs at the top of every frame, before {@link draw}. Responsibilities:
348
+ *
349
+ * 1. **Reset per-frame signals.** `should_draw` is cleared on every map so a stale "yes"
350
+ * from a prior frame can never cause a spurious extra raster. Folded into the selector's
351
+ * scoring pass, which walks every map anyway.
352
+ * 2. **Measure on-screen presence.** `projected_area_px` is computed once per map and stored
353
+ * on the map itself consumed by the resolution policy, the scorer, and the selector,
354
+ * and exposed for debug tooling.
355
+ * 3. **Reserve pending atlas footprints.** {@link ShadowmapResolutionPolicy.adjust} may
356
+ * attach a `pending_layout` to maps whose projected area has shifted enough to warrant a
357
+ * resize. The committed `layout` is left untouched so sampling remains valid; the
358
+ * pending is consumed later by {@link draw} when the scheduler naturally picks this map
359
+ * up (no forced redraw).
360
+ * 4. **Score + select.** Delegated to {@link ShadowmapDrawSelector}, which sets `should_draw`
361
+ * on the winners and reports the view spend and whether any candidate went unmet:
362
+ * - **Directional** always selected (infinite score); view cost still charged.
363
+ * - **Local, off-screen** (`projected_area_px <= 0`) → skipped entirely.
364
+ * - **Local, in-view, invalid** MUST draw. `is_invalid` means the committed layout
365
+ * has never held valid depth (freshly created map); sampling it would display
366
+ * garbage, so we bypass the budget. Resizes no longer invalidate under the dual-
367
+ * layout model — this flag fires only on first-draw.
368
+ * - **Local, in-view, valid** → compete for the remaining budget by score. Ordering
369
+ * is descending score so the most important (largest-on-screen, stalest) maps win.
370
+ * 5. **Fit the winners.** `map.update()` on each selected map, so its projection matrices
371
+ * reflect the current frame. The `last_updated_frame_index` stamp is deferred to
372
+ * {@link draw} — it records when the atlas contents become current, not when the
373
+ * projection is fitted.
374
+ *
375
+ * Shadow records are NOT written here; they're written at the end of {@link draw} so the
376
+ * shader reads slots that reflect any just-committed pending swaps within the same frame.
377
+ *
378
+ * See {@link compute_shadowmap_update_score} for the scoring formula and rationale, and
379
+ * {@link ShadowmapDrawSelector} for why the ordering is a heap drain rather than a sort.
380
+ *
381
+ * @param {Camera} main_camera used both for rendering the final frame and for projected-area
382
+ * computation and directional CSM fit; must be callable for `.update()`
383
+ * @param {GPUSceneContext} scene
384
+ * @param {number} frame_index monotonically increasing frame counter, used for staleness
385
+ * @param {ArrayLike<number>} view_resolution [width, height] of the main view in pixels; used
386
+ * to convert projected sphere extents into a pixel-area score
387
+ */
388
+ select_for_draw(main_camera, scene, frame_index, view_resolution) {
389
+ assert.defined(main_camera, 'main_camera');
390
+ assert.isObject(main_camera, 'main_camera');
391
+ assert.equal(scene.isGPUSceneContext, true, 'scene.isGPUSceneContext !== true');
392
+ assert.isNumber(frame_index, 'frame_index');
393
+ assert.defined(view_resolution, 'view_resolution');
394
+
395
+ // make sure the camera is updated — both our scoring (projected area) and any downstream
396
+ // map.update() reads depend on view/projection matrices being current
397
+ main_camera.update();
398
+
399
+ this.#current_frame_index = frame_index;
400
+
401
+ const shadowmaps = this.#registry.maps;
402
+ const n = shadowmaps.length;
403
+
404
+ // Step 1 (clearing the stale per-frame `should_draw`) happens inside the selector's own
405
+ // scoring pass, so there is no separate walk for it here.
406
+
407
+ // Step 2: recompute projected areas. Writing onto the map so the policy + scorer can
408
+ // just read `map.projected_area_px` without threading a parallel array through.
409
+ for (let i = 0; i < n; i++) {
410
+ const m = shadowmaps[i];
411
+ m.projected_area_px = compute_light_projected_area_px(m.light, main_camera, view_resolution);
412
+ }
413
+
414
+ // Step 3: policy reserves pending resize footprints alongside committed layouts. It
415
+ // never mutates layout and never invalidates — sampling continues against the old
416
+ // region until draw() commits the pending during a normal raster pass. Directionals and
417
+ // off-screen locals are skipped (off-screen locals also get any stale pending released
418
+ // so an idle reservation can't tie up atlas space).
419
+ this.#resolution_policy.adjust(shadowmaps, frame_index);
420
+
421
+ // Steps 4 and 5: score every map and greedy-fill the view budget in score order. See
422
+ // ShadowmapDrawSelector for why this is a heap drain rather than a sort — the budget
423
+ // consumes a handful of winners out of however many candidates the scene has.
424
+ const budget_total = SHADOWMAP_UPDATE_BUDGET + this.#budget_carry_over;
425
+ const selector = this.#selector;
426
+
427
+ selector.select(shadowmaps, frame_index, budget_total);
428
+
429
+ // Fit the winners' projections to the current frame. Kept out of the selector because it
430
+ // needs the camera and the scene, and the selector is deliberately free of both.
431
+ //
432
+ // Maps that lost keep their prior metadata + atlas contents — a never-refreshed map
433
+ // samples the initial depth-cleared rect, which reads as "fully lit" under the reverse-Z
434
+ // greater compare. Acceptable behavior until the map eventually wins a slot.
435
+ const selected = selector.selected;
436
+ for (let i = 0; i < selected.length; i++) {
437
+ selected[i].update(main_camera, scene);
438
+ }
439
+
440
+ // Carry over the unused pool only when there was demand we couldn't meet. When the whole
441
+ // scene's demand was satisfied, reset to 0 so idle frames don't inflate the pool. If
442
+ // must-draw bypasses over-spent the pool (views_spent > budget_total), clamp at zero
443
+ // we can't owe future frames for past over-spend, that way lies runaway starvation.
444
+ if (selector.had_unfit_candidates) {
445
+ this.#budget_carry_over = Math.max(0, budget_total - selector.views_spent);
446
+ } else {
447
+ this.#budget_carry_over = 0;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Publish per-shadowmap records into the scene's GPU database. Each map writes its record
453
+ * into the type-specific shadow_* table at the slot identified by `light._gpu_shadowmap_id`
454
+ * (which is itself the per-type index, set by {@link process_lights}). Trailing slots from
455
+ * previous frames are zeroed when the map count for a type shrinks, so stale entries don't
456
+ * linger.
457
+ *
458
+ * Only maps with `metadata_changed === true` are re-uploaded; the flag is cleared after
459
+ * the slot is queued so unchanged maps don't pay for an upload they don't need. Trim writes
460
+ * always run regardless of the flag they zero slots that no longer correspond to any map.
461
+ *
462
+ * The type-bucket order here MUST match the per-type id assignment in {@link process_lights}
463
+ * (Point → Spot → Directional) so the shadow_id stamped onto each light record points at the
464
+ * slot we write here.
465
+ *
466
+ * Caller must invoke `database.update(cmd)` afterwards to upload the writes that is done
467
+ * by {@link draw} once for both the new records and any zeroed trailing slots.
468
+ *
469
+ * @param {GPUDatabase} database
470
+ */
471
+ #write_shadow_records(database) {
472
+ const t_point = database.get('shadow_point');
473
+ const t_spot = database.get('shadow_spot');
474
+ const t_directional = database.get('shadow_directional');
475
+
476
+ const maps = this.#registry.maps;
477
+ let pc = 0;
478
+ let sc = 0;
479
+ let dc = 0;
480
+
481
+ for (let i = 0; i < maps.length; i++) {
482
+ const map = maps[i];
483
+ const light = map.light;
484
+
485
+ let table;
486
+ let slot;
487
+ if (light.isPointLight) {
488
+ table = t_point;
489
+ slot = pc++;
490
+ } else if (light.isSpotLight) {
491
+ table = t_spot;
492
+ slot = sc++;
493
+ } else if (light.isDirectionalLight) {
494
+ table = t_directional;
495
+ slot = dc++;
496
+ } else {
497
+ continue;
498
+ }
499
+
500
+ if (map.metadata_changed) {
501
+ table.set(slot, map.make_record());
502
+ map.metadata_changed = false;
503
+ }
504
+ }
505
+
506
+ // trim trailing slots from previous frames so disused entries don't leak
507
+ for (let i = pc; i < this.#prev_point_count; i++) {
508
+ t_point.remove(i);
509
+ }
510
+ for (let i = sc; i < this.#prev_spot_count; i++) {
511
+ t_spot.remove(i);
512
+ }
513
+ for (let i = dc; i < this.#prev_directional_count; i++) {
514
+ t_directional.remove(i);
515
+ }
516
+
517
+ this.#prev_point_count = pc;
518
+ this.#prev_spot_count = sc;
519
+ this.#prev_directional_count = dc;
520
+ }
521
+
522
+ /**
523
+ * Rasterize every shadowmap flagged by {@link select_for_draw} for a refresh this frame,
524
+ * committing any pending resize reservations in the process, then publish per-shadow
525
+ * metadata into the scene's GPU database.
526
+ *
527
+ * For each map with `should_draw` set:
528
+ *
529
+ * 1. If the map has a {@link Shadowmap.pending_layout} reservation, swap it into place
530
+ * BEFORE rasterization so the draw targets the new region. The old committed rects
531
+ * are remembered for step 3.
532
+ * 2. Dispatch to the type-specific raster path:
533
+ * - **Directional / spot**: direct raster into the atlas's sub-rects (one job per
534
+ * cascade for directionals, one job per spot).
535
+ * - **Point**: 6 cube-face depth passes into a shared intermediate texture, then
536
+ * cube→octahedral remap into the atlas. Point lights are serialised so they can
537
+ * reuse the intermediate texture.
538
+ * 3. If a commit happened in step 1, release the old committed rects from the atlas now
539
+ * that the new region holds valid depth.
540
+ * 4. Clear per-frame flags (`should_draw`, `is_invalid`) and stamp
541
+ * `last_updated_frame_index` with the current frame.
542
+ *
543
+ * After the loop, each map's metadata record is written into the corresponding
544
+ * `shadow_point` / `shadow_spot` / `shadow_directional` table on the owning light
545
+ * collection's database, then the database is uploaded — this is where the committed
546
+ * swaps become visible to the lighting shader. `#debug_render_count` is reset at entry
547
+ * and incremented per map to expose scheduling behaviour to the debug overlay.
548
+ *
549
+ * No external job list: every path batches its own work internally via
550
+ * {@link execute_rasterization_jobs}. Callers just pass a command context.
551
+ *
552
+ * @param {ShadeGPUCommandContext} command_ctx
553
+ */
554
+ draw(command_ctx) {
555
+ assert.defined(command_ctx, 'command_ctx');
556
+
557
+
558
+ this.#debug_render_count = 0;
559
+
560
+ const frame_index = this.#current_frame_index;
561
+ const maps = this.#registry.maps;
562
+
563
+ for (let i = 0; i < maps.length; i++) {
564
+ const map = maps[i];
565
+ if (!map.should_draw) {
566
+ continue;
567
+ }
568
+
569
+ // Step 1: commit any pending-layout reservation before rasterizing. The draw helpers
570
+ // read `map.layout` directly, so swapping here makes the raster target the new
571
+ // region. We stash the old rect(s) locally for step 3's atlas release — once the
572
+ // raster commands are enqueued, the old region's contents will no longer be sampled
573
+ // (the next shadow-record write publishes the new layout), so returning that atlas
574
+ // space to the packer is safe.
575
+ let retired_rects = null;
576
+ if (map.pending_layout !== null) {
577
+ retired_rects = map.layout;
578
+ map.layout = map.pending_layout;
579
+ map.pending_layout = null;
580
+ map.metadata_changed = true;
581
+ }
582
+
583
+ const light = map.light;
584
+
585
+ if (light.isDirectionalLight || light.isSpotLight) {
586
+ this.#draw_direct(map, command_ctx);
587
+ } else if (light.isPointLight) {
588
+ if (map.isTetrahedron) {
589
+ this.#draw_point_tetrahedron(map, command_ctx);
590
+ } else {
591
+ this.#draw_point(map, command_ctx);
592
+ }
593
+ } else {
594
+ throw new Error(`Unsupported light type ${light.constructor.name}`);
595
+ }
596
+
597
+ // Step 3: release the retired rects from the atlas. Must happen AFTER the raster
598
+ // helpers finish reading map.layout (they may close over retired_rects as scratch
599
+ // memory or view references in some paths; safest order is raster-first).
600
+ if (retired_rects !== null) {
601
+ for (let j = 0; j < retired_rects.length; j++) {
602
+ this.atlas.remove(retired_rects[j]);
603
+ }
604
+ }
605
+
606
+ // Step 4: atlas contents are now current. Clear per-frame flags and stamp the frame
607
+ // index so the next frame's scorer computes staleness correctly.
608
+ map.should_draw = false;
609
+ map.is_invalid = false;
610
+ map.last_updated_frame_index = frame_index;
611
+ this.#debug_render_count++;
612
+ }
613
+
614
+
615
+ // Publish metadata AFTER all commits so the lighting shader sees atlas coordinates that
616
+ // match the regions we just rasterised into. Never-drawn maps carry stale-but-valid
617
+ // projection + layout; off-screen maps and skipped candidates publish their prior state.
618
+ const database = this.#owner.database;
619
+ this.#write_shadow_records(database);
620
+ database.update(command_ctx);
621
+
622
+
623
+ // finalize rendered views
624
+ for (const map of this.#registry.maps) {
625
+ if (map.last_updated_frame_index !== frame_index) {
626
+ // skip
627
+ continue;
628
+ }
629
+
630
+ for (const view of map.views) {
631
+ const view_ctx = this.shadow_view_manager.obtain(view);
632
+ view_ctx.finish_frame(command_ctx);
633
+ }
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Rasterize a directional or spot shadowmap directly into its atlas rects. One job per
639
+ * `map.views[i]` / `map.layout[i]` pair — directionals have N cascades, spots have one.
640
+ *
641
+ * @param {Shadowmap} map
642
+ * @param {ShadeGPUCommandContext} command_ctx
643
+ */
644
+ #draw_direct(map, command_ctx) {
645
+ // TODO pre-cull mesh set by sphere-casting the light volume against the main View Frustum
646
+ // see ref: https://www.reddit.com/r/Unity3D/comments/1foe8qi/added_a_way_to_perform_frustum_culling_and_take
647
+
648
+ const graph = new FrameGraph("shadows");
649
+
650
+ const shadow_views = map.views;
651
+ const jobs = new Array(shadow_views.length);
652
+
653
+ for (let i = 0; i < shadow_views.length; i++) {
654
+ const view = shadow_views[i];
655
+ const view_ctx = this.shadow_view_manager.obtain(view);
656
+
657
+ view_ctx.update(this.#graphics);
658
+
659
+ const atlas_aabb = map.layout[i];
660
+ view_ctx.setViewportSize(atlas_aabb.width, atlas_aabb.height);
661
+
662
+ const job = new RasterizationJob();
663
+ job.opaque_pass = shadow_rasterization_pass_descriptor;
664
+ job.alpha_tested_pass = shadow_rasterization_alpha_tested_pass_descriptor;
665
+ job.cull_policy = CullPolicy.Reversed;
666
+ job.render_target = this.#render_target;
667
+ job.view_ctx = view_ctx;
668
+ job.viewport = [atlas_aabb.x0, atlas_aabb.y0, atlas_aabb.width, atlas_aabb.height];
669
+
670
+
671
+ jobs[i] = job;
672
+
673
+ // TODO reproject current scene view into shadow map for better occlusion culling,
674
+ // see Assassin's Creed Unity talk on GPU-driven rendering,
675
+ // see "RE ENGINE Meshlet Rendering Pipeline" https://youtu.be/nWgPtCDXlPc?t=2055
676
+
677
+ // TODO need to linearize depth(?)
678
+ // can linearize together with reprojection (TAA)
679
+ }
680
+
681
+ execute_rasterization_jobs({
682
+ jobs,
683
+ limits: this.#graphics.collection_limits,
684
+ command_ctx,
685
+ graph,
686
+ });
687
+
688
+ command_ctx.encodeGraph(graph);
689
+ }
690
+
691
+ /**
692
+ * Rasterize a point light's 6 cube faces into the shared intermediate texture, then run the
693
+ * cube→octahedral remap shader to write the encoded depth into this light's atlas rect.
694
+ *
695
+ * Sequencing matters: each point light fully completes (6 cube rasters + remap) before the
696
+ * next one starts, so the intermediate texture can be reused across point lights without
697
+ * cross-contamination.
698
+ *
699
+ * @param {PointLightShadowmap} map
700
+ * @param {ShadeGPUCommandContext} command_ctx
701
+ */
702
+ #draw_point(
703
+ map,
704
+ command_ctx,
705
+ ) {
706
+
707
+ const graph = new FrameGraph("shadows");
708
+
709
+ const gr_atlas_texture = graph_import_texture(graph, this.texture);
710
+
711
+ const atlas_rect = map.layout[0];
712
+
713
+ const octahedral_resolution = atlas_rect.width;
714
+
715
+ /**
716
+ * Controls the relationship between octahedral resolution and the cube map used during rendering
717
+ * If this is larger - we get better quality at the cost of rendering more pixels.
718
+ *
719
+ * Solving for solid angle coverage, using the formula `N=POW(1 - SQRT(E/3), 3/2)` we get:
720
+ *
721
+ * 0% error -> 1
722
+ * 1% error -> 0.915
723
+ * 5% error -> 0.81299
724
+ * 10% error -> 0.739
725
+ * 20% error -> 0.639
726
+ * 30% error -> 0.565
727
+ * 40% error -> 0.506
728
+ * 50% error -> 0.455
729
+ *
730
+ * @type {number}
731
+ */
732
+ const CUBE_MAPPING_RESOLUTION_SCALE = 1;
733
+
734
+ const cube_face_resolution = Math.ceil(octahedral_resolution * CUBE_MAPPING_RESOLUTION_SCALE);
735
+
736
+ // reserve texture
737
+ let cube_packed_texture = graph_create_texture(graph, TextureResourceDescriptor.from({
738
+ resolution: [
739
+ cube_face_resolution * POINT_SHADOW_CUBE_COLS,
740
+ cube_face_resolution * POINT_SHADOW_CUBE_ROWS,
741
+ ],
742
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
743
+ format: 'depth32float',
744
+ enableMips: false,
745
+ }));
746
+
747
+ /**
748
+ *
749
+ * @type {PointLight}
750
+ */
751
+ const light = map.light;
752
+
753
+ const scene_ctx = this.shadow_view_manager.obtain(map.views[0]).scene;
754
+
755
+ /**
756
+ * Sphere-culled meshes from the scene.
757
+ * We could do per-face culling, but most point lights are local, and there's already meshlet culling happening later in the pipeline.
758
+ * Good enough.
759
+ * @type {number}
760
+ */
761
+ const input_meshes = graph_mesh_sphere_cull({
762
+ graph,
763
+ sphere: [...light.position, light.distance],
764
+ scene: scene_ctx,
765
+ limits: this.#graphics.collection_limits,
766
+ });
767
+
768
+ for (let face = 0; face < 6; face++) {
769
+ const view = map.views[face];
770
+ const view_ctx = this.shadow_view_manager.obtain(view);
771
+
772
+ view_ctx.update(this.#graphics);
773
+ view_ctx.setViewportSize(cube_face_resolution, cube_face_resolution);
774
+
775
+ const col = face % POINT_SHADOW_CUBE_COLS;
776
+ const row = Math.floor(face / POINT_SHADOW_CUBE_COLS);
777
+
778
+ const job = new RasterizationJob();
779
+
780
+ job.pipeline_type = RasterizationPipelineType.Fast;
781
+ job.opaque_pass = shadow_rasterization_pass_descriptor;
782
+ job.alpha_tested_pass = shadow_rasterization_alpha_tested_pass_descriptor;
783
+ job.cull_policy = CullPolicy.Reversed;
784
+ job.render_target = null;
785
+ job.view_ctx = view_ctx;
786
+ job.viewport = [
787
+ col * cube_face_resolution,
788
+ row * cube_face_resolution,
789
+ cube_face_resolution,
790
+ cube_face_resolution,
791
+ ];
792
+
793
+ cube_packed_texture = graph_rasterize_scene_fast_raw({
794
+ graph,
795
+ meshes: input_meshes,
796
+ limits: this.#graphics.collection_limits,
797
+ target_depth: cube_packed_texture,
798
+ job,
799
+ }).target_depth;
800
+ }
801
+
802
+ /**
803
+ *
804
+ * @param data
805
+ * @param {FramePassResources} resources
806
+ * @param {GraphicsExecutionContext} context
807
+ */
808
+ function remap_to_atlas(data, resources, context) {
809
+ const atlas = resources.get(data.atlas);
810
+ const cube_texture = resources.get(data.cube_texture);
811
+
812
+ const settings = {
813
+ atlas_offset: [
814
+ atlas_rect.x0 + SHADOWMAP_ATLAS_BORDER,
815
+ atlas_rect.y0 + SHADOWMAP_ATLAS_BORDER,
816
+ atlas_rect.width - 2 * SHADOWMAP_ATLAS_BORDER,
817
+ atlas_rect.height - 2 * SHADOWMAP_ATLAS_BORDER,
818
+ ],
819
+ light_params: [
820
+ map.light.distance,
821
+ map.cube_near,
822
+ 0,
823
+ 0,
824
+ ]
825
+ };
826
+
827
+ shader_shadowmap_remap_cube_to_octahedral.draw({
828
+ encoder: context.encoder,
829
+ bindings: {
830
+ settings: settings,
831
+ cube_faces: cube_texture.obtainView(),
832
+ },
833
+ depthStencilAttachment: {
834
+ view: atlas.obtainView(),
835
+ depthLoadOp: "load",
836
+ depthStoreOp: "store",
837
+ },
838
+ viewport: [
839
+ atlas_rect.x0,
840
+ atlas_rect.y0,
841
+ atlas_rect.width,
842
+ atlas_rect.height,
843
+ ]
844
+ })
845
+ }
846
+
847
+ const remap_data = {};
848
+
849
+ const remap_pass = graph.add("remap to atals", remap_data, remap_to_atlas);
850
+
851
+ remap_data.atlas = remap_pass.write(gr_atlas_texture);
852
+ remap_data.cube_texture = remap_pass.read(cube_packed_texture);
853
+
854
+
855
+ command_ctx.encodeGraph(graph);
856
+ }
857
+
858
+ /**
859
+ * Rasterize a point light's 4 tetrahedron faces into a shared intermediate texture, then run
860
+ * the tetrahedron→octahedral remap to write the encoded depth into this light's atlas rect.
861
+ * The tetrahedral parallel to {@link #draw_point} — same shape, 4 faces in a 2x2 grid instead
862
+ * of 6 in a 3x2 grid, and the remap is handed each face's view-projection matrix so it can
863
+ * select faces and project directions in exact agreement with how the depth was rasterized.
864
+ *
865
+ * @param {TetrahedronPointLightShadowmap} map
866
+ * @param {ShadeGPUCommandContext} command_ctx
867
+ */
868
+ #draw_point_tetrahedron(
869
+ map,
870
+ command_ctx,
871
+ ) {
872
+
873
+ const graph = new FrameGraph("shadows");
874
+
875
+ const gr_atlas_texture = graph_import_texture(graph, this.texture);
876
+
877
+ const atlas_rect = map.layout[0];
878
+
879
+ const octahedral_resolution = atlas_rect.width;
880
+
881
+ // Wider face fov than the cube path (see POINT_SHADOW_TETRA_FOV_DEGREES) needs a larger
882
+ // per-face resolution to hold comparable on-axis density — the analogue of the cube
883
+ // path's CUBE_MAPPING_RESOLUTION_SCALE.
884
+ const face_resolution = Math.ceil(octahedral_resolution * POINT_SHADOW_TETRA_MAPPING_RESOLUTION_SCALE);
885
+
886
+ // reserve intermediate depth texture — 2x2 grid of faces
887
+ let tetra_packed_texture = graph_create_texture(graph, TextureResourceDescriptor.from({
888
+ resolution: [
889
+ face_resolution * POINT_SHADOW_TETRA_COLS,
890
+ face_resolution * POINT_SHADOW_TETRA_ROWS,
891
+ ],
892
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
893
+ format: 'depth32float',
894
+ enableMips: false,
895
+ }));
896
+
897
+ /**
898
+ *
899
+ * @type {PointLight}
900
+ */
901
+ const light = map.light;
902
+
903
+ const scene_ctx = this.shadow_view_manager.obtain(map.views[0]).scene;
904
+
905
+ // One sphere cull for all 4 faces — most point lights are local and meshlet culling
906
+ // refines later; per-face culling isn't worth it. Same trade as the cube path.
907
+ const input_meshes = graph_mesh_sphere_cull({
908
+ graph,
909
+ sphere: [...light.position, light.distance],
910
+ scene: scene_ctx,
911
+ limits: this.#graphics.collection_limits,
912
+ });
913
+
914
+ for (let face = 0; face < POINT_SHADOW_TETRA_FACE_COUNT; face++) {
915
+ const view = map.views[face];
916
+ const view_ctx = this.shadow_view_manager.obtain(view);
917
+
918
+ view_ctx.update(this.#graphics);
919
+ view_ctx.setViewportSize(face_resolution, face_resolution);
920
+
921
+ const col = face % POINT_SHADOW_TETRA_COLS;
922
+ const row = Math.floor(face / POINT_SHADOW_TETRA_COLS);
923
+
924
+ const job = new RasterizationJob();
925
+
926
+ job.pipeline_type = RasterizationPipelineType.Fast;
927
+ job.opaque_pass = shadow_rasterization_pass_descriptor;
928
+ job.alpha_tested_pass = shadow_rasterization_alpha_tested_pass_descriptor;
929
+ job.cull_policy = CullPolicy.Reversed;
930
+ job.render_target = null;
931
+ job.view_ctx = view_ctx;
932
+ job.viewport = [
933
+ col * face_resolution,
934
+ row * face_resolution,
935
+ face_resolution,
936
+ face_resolution,
937
+ ];
938
+
939
+ tetra_packed_texture = graph_rasterize_scene_fast_raw({
940
+ graph,
941
+ meshes: input_meshes,
942
+ limits: this.#graphics.collection_limits,
943
+ target_depth: tetra_packed_texture,
944
+ job,
945
+ }).target_depth;
946
+ }
947
+
948
+ const views = map.views;
949
+ const tetra_near = map.tetra_near;
950
+ const light_max_distance = map.light.distance;
951
+
952
+ /**
953
+ *
954
+ * @param data
955
+ * @param {FramePassResources} resources
956
+ * @param {GraphicsExecutionContext} context
957
+ */
958
+ function remap_to_atlas(data, resources, context) {
959
+ const atlas = resources.get(data.atlas);
960
+ const tetra_texture = resources.get(data.tetra_texture);
961
+
962
+ // Read each face camera's view-projection at encode time — set in map.update() and
963
+ // untouched since, and it is exactly the matrix the rasterizer used above.
964
+ const face_view_proj = new Array(POINT_SHADOW_TETRA_FACE_COUNT);
965
+ for (let f = 0; f < POINT_SHADOW_TETRA_FACE_COUNT; f++) {
966
+ face_view_proj[f] = views[f].camera.view_projection_matrix;
967
+ }
968
+
969
+ const settings = context.encoder.allocateTransientValueBuffer(
970
+ POINT_SHADOW_TETRA_REMAP_SETTINGS,
971
+ {
972
+ atlas_offset: [
973
+ atlas_rect.x0 + SHADOWMAP_ATLAS_BORDER,
974
+ atlas_rect.y0 + SHADOWMAP_ATLAS_BORDER,
975
+ atlas_rect.width - 2 * SHADOWMAP_ATLAS_BORDER,
976
+ atlas_rect.height - 2 * SHADOWMAP_ATLAS_BORDER,
977
+ ],
978
+ light_params: [
979
+ light_max_distance,
980
+ tetra_near,
981
+ 0,
982
+ 0,
983
+ ],
984
+ face_view_proj,
985
+ }
986
+ );
987
+
988
+ shader_shadowmap_remap_tetrahedron_to_octahedral.draw({
989
+ encoder: context.encoder,
990
+ bindings: {
991
+ settings: settings,
992
+ tetra_faces: tetra_texture.obtainView(),
993
+ },
994
+ depthStencilAttachment: {
995
+ view: atlas.obtainView(),
996
+ depthLoadOp: "load",
997
+ depthStoreOp: "store",
998
+ },
999
+ viewport: [
1000
+ atlas_rect.x0,
1001
+ atlas_rect.y0,
1002
+ atlas_rect.width,
1003
+ atlas_rect.height,
1004
+ ]
1005
+ })
1006
+ }
1007
+
1008
+ const remap_data = {};
1009
+
1010
+ const remap_pass = graph.add("remap tetra to atlas", remap_data, remap_to_atlas);
1011
+
1012
+ remap_data.atlas = remap_pass.write(gr_atlas_texture);
1013
+ remap_data.tetra_texture = remap_pass.read(tetra_packed_texture);
1014
+
1015
+
1016
+ command_ctx.encodeGraph(graph);
1017
+ }
1018
+
1019
+ /**
1020
+ * Release the per-face GPU contexts (each view's uniform/HZB/volumetrics buffers + its camera
1021
+ * uniform) backing a map. The dedicated {@link shadow_view_manager} / {@link #cameras} caches
1022
+ * key on the View/Camera objects and have no GC path, so a map's contexts must be released
1023
+ * explicitly here or their GPU buffers leak for the context's lifetime. Most visible when the
1024
+ * point-shadow generator toggle rebuilds every point map (fresh Views each time), but it also
1025
+ * closes the ordinary eviction leak (light removed / stops casting / shadows disabled).
1026
+ *
1027
+ * In this context Views and Cameras are 1:1 per map (each map creates its own), so releasing a
1028
+ * view's camera alongside it can't strand a still-referenced camera.
1029
+ *
1030
+ * @param {Shadowmap} map
1031
+ */
1032
+ #release_map_gpu_contexts(map) {
1033
+ const views = map.views;
1034
+ for (let j = 0; j < views.length; j++) {
1035
+ const view = views[j];
1036
+ this.shadow_view_manager.release(view);
1037
+ this.#cameras.release(view.camera);
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Release a map's atlas rects + GPU side-resources. Caller is responsible for splicing it
1043
+ * out of {@link #registry}.
1044
+ *
1045
+ * @param {Shadowmap} map
1046
+ */
1047
+ #evict_map(map) {
1048
+ for (let j = 0; j < map.layout.length; j++) {
1049
+ this.atlas.remove(map.layout[j]);
1050
+ }
1051
+ if (map.pending_layout !== null) {
1052
+ for (let j = 0; j < map.pending_layout.length; j++) {
1053
+ this.atlas.remove(map.pending_layout[j]);
1054
+ }
1055
+ map.pending_layout = null;
1056
+ }
1057
+
1058
+ // free the per-view/-camera GPU contexts — without this an evicted map's buffers leak
1059
+ this.#release_map_gpu_contexts(map);
1060
+
1061
+ map.light._gpu_shadowmap_id = -1;
1062
+ }
1063
+
1064
+ /**
1065
+ * Reconcile shadow allocations against the current scene state. Evicts maps for lights
1066
+ * that no longer cast shadows or have left the scene, allocates maps for new shadow-
1067
+ * casting lights, and reassigns per-type `_gpu_shadowmap_id` when the map ordering
1068
+ * shifts.
1069
+ *
1070
+ * Called from {@link GPULightCollection.update} every frame, including frames where
1071
+ * {@link LightCollection.version} hasn't moved — `light.casts_shadow` flips are not
1072
+ * tracked by version and are detected here.
1073
+ *
1074
+ * Runs as three linear passes — mark, reclaim, allocate — over {@link #registry}, which owns
1075
+ * the light -> map index and the marks. It used to scan the map array once per light to find
1076
+ * out whether that light already had one, which made the pass quadratic: 148us per frame at
1077
+ * 512 lights against 19us now, and 554us against 45us at 1024. See
1078
+ * benchmark/reconcile.spec.js.
1079
+ *
1080
+ * @returns {boolean} true when at least one map was added/evicted or at least one light's
1081
+ * per-type shadow_id moved. The caller uses this to decide whether the light
1082
+ * records need to be re-published (their `shadow_id` and CASTS_SHADOW_BIT may
1083
+ * have shifted).
1084
+ */
1085
+ process_lights() {
1086
+ const registry = this.#registry;
1087
+ const shadowmaps = registry.maps;
1088
+
1089
+ // When the renderer has shadows disabled, treat every map as evictable so the next
1090
+ // build clears CASTS_SHADOW_BIT on every light. Re-enabling reconstructs everything
1091
+ // from scratch on the following frame — that's a one-frame catch-up cost, considered
1092
+ // an acceptable trade for the simpler invariant ("disabled" = no maps held).
1093
+ if (!this.enabled) {
1094
+ const had_maps = shadowmaps.length > 0;
1095
+ for (let i = shadowmaps.length - 1; i >= 0; i--) {
1096
+ this.#evict_map(shadowmaps[i]);
1097
+ }
1098
+ registry.clear();
1099
+ return had_maps;
1100
+ }
1101
+
1102
+ const scene = this.#scene;
1103
+ const lights = scene.lights.elements;
1104
+ let changed = false;
1105
+
1106
+ const use_tetra = this.use_tetrahedron_point_shadows;
1107
+ const newcomers = this.#reconcile_newcomers;
1108
+
1109
+ registry.begin_reconcile();
1110
+ newcomers.length = 0;
1111
+
1112
+ // Pass 1 — mark. One index lookup per light both answers "does this light already have a
1113
+ // map" and stamps that map as belonging to a light still in the scene, which is what lets
1114
+ // the reclamation below run without building a set of live lights first.
1115
+ //
1116
+ // A light that stopped casting is simply not marked; its map falls out in pass 2 on the
1117
+ // strength of the stale mark alone.
1118
+ for (let i = 0; i < lights.length; i++) {
1119
+ const light = lights[i];
1120
+
1121
+ if (!light.casts_shadow) {
1122
+ continue;
1123
+ }
1124
+
1125
+ const map = registry.mark_live(light);
1126
+
1127
+ if (map === undefined) {
1128
+ newcomers.push(light);
1129
+ continue;
1130
+ }
1131
+
1132
+ // A point map built by the other generator (cube vs tetra) must be rebuilt when the
1133
+ // flag flips its face count and draw path no longer match. Pass 2 drops it on the
1134
+ // same test; queueing the rebuild here lands it in this pass rather than the next.
1135
+ if (light.isPointLight && Boolean(map.isTetrahedron) !== use_tetra) {
1136
+ newcomers.push(light);
1137
+ }
1138
+ }
1139
+
1140
+ // Pass 2 — reclaim. Anything not marked belongs to a light that left the scene or stopped
1141
+ // casting, and its atlas rect would otherwise stay pinned forever. Walk in reverse so
1142
+ // splice indices stay stable.
1143
+ for (let i = shadowmaps.length - 1; i >= 0; i--) {
1144
+ const map = shadowmaps[i];
1145
+ const light = map.light;
1146
+
1147
+ const generator_mismatch = light.isPointLight
1148
+ && Boolean(map.isTetrahedron) !== use_tetra;
1149
+
1150
+ if (registry.is_marked(map) && !generator_mismatch) {
1151
+ continue;
1152
+ }
1153
+ this.#evict_map(map);
1154
+ registry.remove_at(i);
1155
+ changed = true;
1156
+ }
1157
+
1158
+ const add_directional = (light) => {
1159
+ const map = new DirectionalLightShadowmap();
1160
+ map.light = light;
1161
+
1162
+ const bound = [];
1163
+ try {
1164
+ for (let j = 0; j < SHADOWMAP_CSM_CASCADE_COUNT; j++) {
1165
+ const resolution_index = Math.min(j, DEFAULT_SHADOWMAP_DIRECTIONAL_RESOLUTION.length - 1);
1166
+ const resolution = DEFAULT_SHADOWMAP_DIRECTIONAL_RESOLUTION[resolution_index];
1167
+
1168
+ const rect = this.bind(resolution);
1169
+ bound.push(rect);
1170
+ map.layout[j] = rect;
1171
+
1172
+ const camera = new OrthographicCamera();
1173
+ camera.update();
1174
+
1175
+ const view = new View(camera, scene);
1176
+ view.label = `Shadow Cascade ${j}`;
1177
+
1178
+ map.views[j] = view;
1179
+ }
1180
+ } catch (e) {
1181
+ // Partial bind: roll back any cascade rects already inserted so the atlas isn't
1182
+ // left holding orphans for a map that never got registered.
1183
+ for (let j = 0; j < bound.length; j++) {
1184
+ this.atlas.remove(bound[j]);
1185
+ }
1186
+ throw e;
1187
+ }
1188
+
1189
+ registry.add(map);
1190
+ };
1191
+
1192
+ const add_spot = (light) => {
1193
+ const map = new SpotLightShadowmap();
1194
+ map.light = light;
1195
+
1196
+ const rect = this.bind(DEFAULT_SHADOWMAP_LOCAL_RESOLUTION);
1197
+ map.layout[0] = rect;
1198
+
1199
+ try {
1200
+ const camera = new PerspectiveCamera();
1201
+ camera.update();
1202
+
1203
+ const view = new View(camera, scene);
1204
+ view.label = `Spot Shadow`;
1205
+
1206
+ map.views[0] = view;
1207
+ } catch (e) {
1208
+ this.atlas.remove(rect);
1209
+ throw e;
1210
+ }
1211
+
1212
+ registry.add(map);
1213
+ };
1214
+
1215
+ const add_point = (light) => {
1216
+
1217
+ // Generator picked by `use_tetra` above; both remap into one octahedral slot, they
1218
+ // only differ in how many face frusta feed that remap (tetra 4, cube 6).
1219
+ const map = use_tetra ? new TetrahedronPointLightShadowmap() : new PointLightShadowmap();
1220
+ map.light = light;
1221
+
1222
+ // single atlas rect holds the octahedral-encoded depth for all faces
1223
+ const rect = this.bind(DEFAULT_SHADOWMAP_LOCAL_RESOLUTION);
1224
+ map.layout[0] = rect;
1225
+
1226
+ const face_count = use_tetra ? POINT_SHADOW_TETRA_FACE_COUNT : 6;
1227
+
1228
+ for (let j = 0; j < face_count; j++) {
1229
+ const camera = new PerspectiveCamera();
1230
+ camera.update();
1231
+
1232
+ const view = new View(camera, scene);
1233
+ view.label = use_tetra ? `Point Shadow (tetra) face ${j}` : `Point Shadow face ${j}`;
1234
+
1235
+ map.views[j] = view;
1236
+ }
1237
+
1238
+ registry.add(map);
1239
+ };
1240
+
1241
+ const add_light = (light) => {
1242
+ // A light present twice in the scene's light array gets queued twice by pass 1, since
1243
+ // neither occurrence finds a map. LightCollection.add doesn't dedupe, so re-check here
1244
+ // rather than letting the registry's one-map-per-light assert fire every frame. Free
1245
+ // in the normal case — this loop only runs over lights that actually need a map.
1246
+ if (registry.get(light) !== undefined) {
1247
+ return;
1248
+ }
1249
+
1250
+ if (light.isDirectionalLight) {
1251
+ add_directional(light);
1252
+ changed = true;
1253
+ } else if (light.isSpotLight) {
1254
+ add_spot(light);
1255
+ changed = true;
1256
+ } else if (light.isPointLight) {
1257
+ add_point(light);
1258
+ changed = true;
1259
+ }
1260
+ };
1261
+
1262
+ // Pass 3 — allocate. Only the lights pass 1 flagged, so on the overwhelming majority of
1263
+ // frames (nothing arrived, nothing left) this loop does not run at all. The old version
1264
+ // walked every light here and re-tested casts_shadow on each; pass 1 already answered that.
1265
+ for (let i = 0; i < newcomers.length; i++) {
1266
+ try {
1267
+ add_light(newcomers[i]);
1268
+ } catch (e) {
1269
+ console.error(e);
1270
+ }
1271
+ }
1272
+
1273
+ // Drop the references rather than leaving them until the next call — a light that gets
1274
+ // removed from the scene right after being added here would otherwise stay reachable from
1275
+ // this scratch array for a frame.
1276
+ newcomers.length = 0;
1277
+
1278
+ // Publish per-type shadowmap ids back onto the lights. Each shadow_* table is indexed
1279
+ // independently, so a point light's shadow_id points into shadow_point, a spot's into
1280
+ // shadow_spot, etc. Type-bucket order MUST match {@link #write_shadow_records} so each
1281
+ // light record's stored shadow_id lines up with the slot we'll write into the table.
1282
+ // When a map's slot index actually changes (eviction shifted neighbours up the array),
1283
+ // mark its record dirty so the next #write_shadow_records re-publishes it into the new
1284
+ // slot, and report `changed` so the caller re-publishes the light records too.
1285
+ let pc = 0;
1286
+ let sc = 0;
1287
+ let dc = 0;
1288
+ for (let i = 0; i < shadowmaps.length; i++) {
1289
+ const map = shadowmaps[i];
1290
+ const light = map.light;
1291
+
1292
+ let new_id;
1293
+ if (light.isPointLight) {
1294
+ new_id = pc++;
1295
+ } else if (light.isSpotLight) {
1296
+ new_id = sc++;
1297
+ } else if (light.isDirectionalLight) {
1298
+ new_id = dc++;
1299
+ } else {
1300
+ continue;
1301
+ }
1302
+
1303
+ if (light._gpu_shadowmap_id !== new_id) {
1304
+ light._gpu_shadowmap_id = new_id;
1305
+ map.metadata_changed = true;
1306
+ changed = true;
1307
+ }
1308
+ }
1309
+
1310
+ return changed;
1311
+ }
1312
+
1313
+ /**
1314
+ *
1315
+ * @param {number} resolution
1316
+ * @returns {AABB2}
1317
+ */
1318
+ bind(resolution) {
1319
+
1320
+ const rect = new AABB2(0, 0, resolution, resolution);
1321
+
1322
+ if (!this.atlas.add(rect)) {
1323
+ throw new Error(`Failed to add shadowmap to atlas`);
1324
+ }
1325
+
1326
+ return rect;
1327
+ }
1328
+
1329
+ destroy() {
1330
+ this.texture.destroy();
1331
+
1332
+ const maps = this.#registry.maps;
1333
+ for (let i = 0; i < maps.length; i++) {
1334
+ const map = maps[i];
1335
+ // release the per-view/-camera GPU contexts too — map.destroy() alone doesn't (the
1336
+ // buffers live in the view/camera manager caches, not on the map)
1337
+ this.#release_map_gpu_contexts(map);
1338
+ map.destroy();
1339
+ }
1340
+ this.#registry.clear();
1341
+ }
1342
+ }