@driftengine/core 3.62.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (428) hide show
  1. package/CHANGELOG.json +122 -0
  2. package/NOTICE +20 -0
  3. package/README.md +1 -1
  4. package/dist/core/loop.js +18 -2
  5. package/dist/core/storage.js +5 -0
  6. package/dist/geometry/meshBuilder.d.ts +1 -1
  7. package/dist/geometry/meshBuilder.js +6 -2
  8. package/dist/index.d.ts +70 -4
  9. package/dist/index.js +123 -2
  10. package/dist/input/input.js +6 -1
  11. package/dist/math/exact.d.ts +13 -3
  12. package/dist/math/exact.js +68 -3
  13. package/dist/math/frustum.d.ts +17 -2
  14. package/dist/math/frustum.js +24 -0
  15. package/dist/render/backend/api.d.ts +34 -1
  16. package/dist/render/backend/api.js +22 -1
  17. package/dist/render/backend/budget.d.ts +7 -6
  18. package/dist/render/backend/budget.js +7 -6
  19. package/dist/render/backend/createRenderer.d.ts +57 -0
  20. package/dist/render/backend/createRenderer.js +51 -2
  21. package/dist/render/backend/materialChanges.d.ts +41 -0
  22. package/dist/render/backend/materialChanges.js +38 -0
  23. package/dist/render/backend/select.js +8 -0
  24. package/dist/render/backend/webgl2/renderer.d.ts +80 -1
  25. package/dist/render/backend/webgl2/renderer.js +208 -10
  26. package/dist/render/backend/webgpu/buffers.d.ts +20 -1
  27. package/dist/render/backend/webgpu/buffers.js +116 -58
  28. package/dist/render/backend/webgpu/fieldCompose.d.ts +156 -0
  29. package/dist/render/backend/webgpu/fieldCompose.js +396 -0
  30. package/dist/render/backend/webgpu/giFieldPass.d.ts +66 -0
  31. package/dist/render/backend/webgpu/giFieldPass.js +137 -0
  32. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +728 -0
  33. package/dist/render/backend/webgpu/gpuDrivenPass.js +2473 -0
  34. package/dist/render/backend/webgpu/linePass.d.ts +4 -14
  35. package/dist/render/backend/webgpu/linePass.js +4 -33
  36. package/dist/render/backend/webgpu/probeBake.d.ts +148 -0
  37. package/dist/render/backend/webgpu/probeBake.js +493 -0
  38. package/dist/render/backend/webgpu/renderer.d.ts +379 -24
  39. package/dist/render/backend/webgpu/renderer.js +1369 -76
  40. package/dist/render/backend/webgpu/scatterPass.js +15 -1
  41. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +47 -14
  42. package/dist/render/backend/webgpu/surfaceTexturePass.js +128 -80
  43. package/dist/render/causticsRenderer.d.ts +3 -1
  44. package/dist/render/causticsRenderer.js +4 -1
  45. package/dist/render/depthConvention.d.ts +12 -0
  46. package/dist/render/depthConvention.js +14 -0
  47. package/dist/render/deviceFeatures.d.ts +32 -0
  48. package/dist/render/deviceFeatures.js +41 -0
  49. package/dist/render/fog.d.ts +79 -0
  50. package/dist/render/fog.js +138 -0
  51. package/dist/render/frame/alias.d.ts +30 -0
  52. package/dist/render/frame/alias.js +61 -0
  53. package/dist/render/frame/deps.d.ts +37 -0
  54. package/dist/render/frame/deps.js +122 -0
  55. package/dist/render/frame/flushGraph.d.ts +43 -0
  56. package/dist/render/frame/flushGraph.js +84 -0
  57. package/dist/render/frame/graphSchedule.d.ts +49 -0
  58. package/dist/render/frame/graphSchedule.js +120 -0
  59. package/dist/render/frame/index.d.ts +15 -0
  60. package/dist/render/frame/index.js +8 -0
  61. package/dist/render/frame/lifetime.d.ts +24 -0
  62. package/dist/render/frame/lifetime.js +36 -0
  63. package/dist/render/frame/passTimings.d.ts +42 -0
  64. package/dist/render/frame/passTimings.js +38 -0
  65. package/dist/render/frame/validate.d.ts +15 -0
  66. package/dist/render/frame/validate.js +22 -0
  67. package/dist/render/frame/virtual.d.ts +44 -0
  68. package/dist/render/frame/virtual.js +61 -0
  69. package/dist/render/gi/chain.d.ts +82 -0
  70. package/dist/render/gi/chain.js +135 -0
  71. package/dist/render/gi/denoise.d.ts +114 -0
  72. package/dist/render/gi/denoise.js +169 -0
  73. package/dist/render/gi/fieldScene.d.ts +125 -0
  74. package/dist/render/gi/fieldScene.js +219 -0
  75. package/dist/render/gi/globalField.d.ts +157 -0
  76. package/dist/render/gi/globalField.js +373 -0
  77. package/dist/render/gi/probeGridFit.d.ts +24 -0
  78. package/dist/render/gi/probeGridFit.js +71 -0
  79. package/dist/render/gi/probeTrace.d.ts +82 -0
  80. package/dist/render/gi/probeTrace.js +128 -0
  81. package/dist/render/gi/probeVolume.d.ts +136 -0
  82. package/dist/render/gi/probeVolume.js +191 -0
  83. package/dist/render/gi/reflection.d.ts +73 -0
  84. package/dist/render/gi/reflection.js +109 -0
  85. package/dist/render/gi/traceField.d.ts +91 -0
  86. package/dist/render/gi/traceField.js +129 -0
  87. package/dist/render/gi/traceScreen.d.ts +68 -0
  88. package/dist/render/gi/traceScreen.js +82 -0
  89. package/dist/render/gpuScene.d.ts +38 -0
  90. package/dist/render/gpuScene.js +41 -0
  91. package/dist/render/gpudriven/alphaTest.d.ts +28 -0
  92. package/dist/render/gpudriven/alphaTest.js +32 -0
  93. package/dist/render/gpudriven/clusterUpload.d.ts +59 -0
  94. package/dist/render/gpudriven/clusterUpload.js +75 -0
  95. package/dist/render/gpudriven/clusterWorld.d.ts +57 -0
  96. package/dist/render/gpudriven/clusterWorld.js +155 -0
  97. package/dist/render/gpudriven/compact.d.ts +51 -0
  98. package/dist/render/gpudriven/compact.js +68 -0
  99. package/dist/render/gpudriven/cullClusters.d.ts +129 -0
  100. package/dist/render/gpudriven/cullClusters.js +237 -0
  101. package/dist/render/gpudriven/decodeTables.d.ts +64 -0
  102. package/dist/render/gpudriven/decodeTables.js +219 -0
  103. package/dist/render/gpudriven/frustum.d.ts +25 -0
  104. package/dist/render/gpudriven/frustum.js +59 -0
  105. package/dist/render/gpudriven/geometryHarness.d.ts +15 -0
  106. package/dist/render/gpudriven/geometryHarness.js +31 -0
  107. package/dist/render/gpudriven/hzb.d.ts +60 -0
  108. package/dist/render/gpudriven/hzb.js +97 -0
  109. package/dist/render/gpudriven/ibl.d.ts +85 -0
  110. package/dist/render/gpudriven/ibl.js +129 -0
  111. package/dist/render/gpudriven/indirect.d.ts +67 -0
  112. package/dist/render/gpudriven/indirect.js +77 -0
  113. package/dist/render/gpudriven/instances.d.ts +43 -0
  114. package/dist/render/gpudriven/instances.js +172 -0
  115. package/dist/render/gpudriven/lit.d.ts +165 -0
  116. package/dist/render/gpudriven/lit.js +165 -0
  117. package/dist/render/gpudriven/lodCut.d.ts +28 -0
  118. package/dist/render/gpudriven/lodCut.js +38 -0
  119. package/dist/render/gpudriven/materialBin.d.ts +60 -0
  120. package/dist/render/gpudriven/materialBin.js +108 -0
  121. package/dist/render/gpudriven/materialTable.d.ts +88 -0
  122. package/dist/render/gpudriven/materialTable.js +115 -0
  123. package/dist/render/gpudriven/pipeline.d.ts +135 -0
  124. package/dist/render/gpudriven/pipeline.js +237 -0
  125. package/dist/render/gpudriven/rangeAlloc.d.ts +50 -0
  126. package/dist/render/gpudriven/rangeAlloc.js +112 -0
  127. package/dist/render/gpudriven/sceneUpload.d.ts +96 -0
  128. package/dist/render/gpudriven/sceneUpload.js +115 -0
  129. package/dist/render/gpudriven/shadeBins.d.ts +92 -0
  130. package/dist/render/gpudriven/shadeBins.js +182 -0
  131. package/dist/render/gpudriven/shadow.d.ts +136 -0
  132. package/dist/render/gpudriven/shadow.js +224 -0
  133. package/dist/render/gpudriven/shadowCamera.d.ts +87 -0
  134. package/dist/render/gpudriven/shadowCamera.js +345 -0
  135. package/dist/render/gpudriven/streamScene.d.ts +194 -0
  136. package/dist/render/gpudriven/streamScene.js +459 -0
  137. package/dist/render/gpudriven/surfaceFrame.d.ts +25 -0
  138. package/dist/render/gpudriven/surfaceFrame.js +51 -0
  139. package/dist/render/gpudriven/twoPhase.d.ts +122 -0
  140. package/dist/render/gpudriven/twoPhase.js +160 -0
  141. package/dist/render/gpudriven/visbuffer.d.ts +25 -0
  142. package/dist/render/gpudriven/visbuffer.js +37 -0
  143. package/dist/render/halfFloat.d.ts +10 -0
  144. package/dist/render/halfFloat.js +23 -4
  145. package/dist/render/inference/attentionTiled.d.ts +27 -0
  146. package/dist/render/inference/attentionTiled.js +117 -0
  147. package/dist/render/inference/denseKernels.d.ts +29 -0
  148. package/dist/render/inference/denseKernels.js +312 -0
  149. package/dist/render/inference/device.d.ts +21 -0
  150. package/dist/render/inference/device.js +32 -0
  151. package/dist/render/inference/deviceGraph.d.ts +34 -0
  152. package/dist/render/inference/deviceGraph.js +17 -0
  153. package/dist/render/inference/fuse.d.ts +15 -0
  154. package/dist/render/inference/fuse.js +37 -0
  155. package/dist/render/inference/kernelKit.d.ts +52 -0
  156. package/dist/render/inference/kernelKit.js +70 -0
  157. package/dist/render/inference/kernels.d.ts +3 -0
  158. package/dist/render/inference/kernels.js +11 -0
  159. package/dist/render/inference/plan.d.ts +13 -0
  160. package/dist/render/inference/plan.js +18 -0
  161. package/dist/render/inference/reuse.d.ts +39 -0
  162. package/dist/render/inference/reuse.js +65 -0
  163. package/dist/render/inference/runner.d.ts +31 -0
  164. package/dist/render/inference/runner.js +175 -0
  165. package/dist/render/inference/schedule.d.ts +39 -0
  166. package/dist/render/inference/schedule.js +59 -0
  167. package/dist/render/inference/shapeKernels.d.ts +10 -0
  168. package/dist/render/inference/shapeKernels.js +80 -0
  169. package/dist/render/inference/spatialKernels.d.ts +21 -0
  170. package/dist/render/inference/spatialKernels.js +179 -0
  171. package/dist/render/inference/splatRaster.wgsl.d.ts +75 -0
  172. package/dist/render/inference/splatRaster.wgsl.js +337 -0
  173. package/dist/render/inference/splatTiles.d.ts +79 -0
  174. package/dist/render/inference/splatTiles.js +161 -0
  175. package/dist/render/lineBatch.d.ts +2 -5
  176. package/dist/render/lineBatch.js +4 -24
  177. package/dist/render/mesh.d.ts +4 -0
  178. package/dist/render/mesh.js +45 -1
  179. package/dist/render/pass.d.ts +145 -8
  180. package/dist/render/prefilterEnvMap.d.ts +22 -0
  181. package/dist/render/prefilterEnvMap.js +31 -0
  182. package/dist/render/readback.d.ts +30 -0
  183. package/dist/render/readback.js +47 -0
  184. package/dist/render/recon/clamp.d.ts +36 -0
  185. package/dist/render/recon/clamp.js +92 -0
  186. package/dist/render/recon/disocclusion.d.ts +60 -0
  187. package/dist/render/recon/disocclusion.js +109 -0
  188. package/dist/render/recon/frameGen.d.ts +81 -0
  189. package/dist/render/recon/frameGen.js +150 -0
  190. package/dist/render/recon/frameSizes.d.ts +34 -0
  191. package/dist/render/recon/frameSizes.js +58 -0
  192. package/dist/render/recon/jitter.d.ts +60 -0
  193. package/dist/render/recon/jitter.js +118 -0
  194. package/dist/render/recon/motionVectors.d.ts +55 -0
  195. package/dist/render/recon/motionVectors.js +107 -0
  196. package/dist/render/recon/reproject.d.ts +41 -0
  197. package/dist/render/recon/reproject.js +131 -0
  198. package/dist/render/recon/resolve.d.ts +90 -0
  199. package/dist/render/recon/resolve.js +313 -0
  200. package/dist/render/recon/tier.d.ts +24 -0
  201. package/dist/render/recon/tier.js +8 -0
  202. package/dist/render/reflectionProbe.d.ts +26 -0
  203. package/dist/render/reflectionProbe.js +33 -0
  204. package/dist/render/renderQuality.d.ts +69 -0
  205. package/dist/render/renderQuality.js +22 -0
  206. package/dist/render/screenSpaceReflection.d.ts +23 -0
  207. package/dist/render/screenSpaceReflection.js +22 -3
  208. package/dist/render/sdfFont.js +2 -0
  209. package/dist/render/segmentQuads.d.ts +13 -0
  210. package/dist/render/segmentQuads.js +32 -0
  211. package/dist/render/shaders/flat/lobes.d.ts +27 -0
  212. package/dist/render/shaders/flat/lobes.js +33 -3
  213. package/dist/render/shaders/flat/preamble.d.ts +7 -0
  214. package/dist/render/shaders/flat/preamble.js +24 -1
  215. package/dist/render/shaders/flat/probeGrid.d.ts +1 -1
  216. package/dist/render/shaders/flat/probeGrid.js +71 -2
  217. package/dist/render/shaders/generated/flat.wgsl.d.ts +48 -8
  218. package/dist/render/shaders/generated/flat.wgsl.js +64 -24
  219. package/dist/render/shaders/gi/composeField.wgsl.d.ts +56 -0
  220. package/dist/render/shaders/gi/composeField.wgsl.js +231 -0
  221. package/dist/render/shaders/gi/marchField.wgsl.d.ts +30 -0
  222. package/dist/render/shaders/gi/marchField.wgsl.js +141 -0
  223. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +113 -0
  224. package/dist/render/shaders/gi/probeBake.wgsl.js +635 -0
  225. package/dist/render/shaders/gi/probeTrace.wgsl.d.ts +72 -0
  226. package/dist/render/shaders/gi/probeTrace.wgsl.js +261 -0
  227. package/dist/render/shaders/gi/probeVolume.wgsl.d.ts +49 -0
  228. package/dist/render/shaders/gi/probeVolume.wgsl.js +242 -0
  229. package/dist/render/shaders/gi/sampleField.wgsl.d.ts +32 -0
  230. package/dist/render/shaders/gi/sampleField.wgsl.js +134 -0
  231. package/dist/render/shaders/gpudriven/blendRaster.wgsl.d.ts +26 -0
  232. package/dist/render/shaders/gpudriven/blendRaster.wgsl.js +339 -0
  233. package/dist/render/shaders/gpudriven/compact.wgsl.d.ts +29 -0
  234. package/dist/render/shaders/gpudriven/compact.wgsl.js +58 -0
  235. package/dist/render/shaders/gpudriven/cull.wgsl.d.ts +90 -0
  236. package/dist/render/shaders/gpudriven/cull.wgsl.js +423 -0
  237. package/dist/render/shaders/gpudriven/decode.wgsl.d.ts +72 -0
  238. package/dist/render/shaders/gpudriven/decode.wgsl.js +349 -0
  239. package/dist/render/shaders/gpudriven/environment.wgsl.d.ts +44 -0
  240. package/dist/render/shaders/gpudriven/environment.wgsl.js +137 -0
  241. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +28 -0
  242. package/dist/render/shaders/gpudriven/fog.wgsl.js +107 -0
  243. package/dist/render/shaders/gpudriven/frameBlock.wgsl.d.ts +13 -0
  244. package/dist/render/shaders/gpudriven/frameBlock.wgsl.js +62 -0
  245. package/dist/render/shaders/gpudriven/materialBin.wgsl.d.ts +51 -0
  246. package/dist/render/shaders/gpudriven/materialBin.wgsl.js +112 -0
  247. package/dist/render/shaders/gpudriven/materialTable.wgsl.d.ts +25 -0
  248. package/dist/render/shaders/gpudriven/materialTable.wgsl.js +43 -0
  249. package/dist/render/shaders/gpudriven/meshTransform.wgsl.d.ts +18 -0
  250. package/dist/render/shaders/gpudriven/meshTransform.wgsl.js +37 -0
  251. package/dist/render/shaders/gpudriven/present.wgsl.d.ts +43 -0
  252. package/dist/render/shaders/gpudriven/present.wgsl.js +127 -0
  253. package/dist/render/shaders/gpudriven/shade.wgsl.d.ts +127 -0
  254. package/dist/render/shaders/gpudriven/shade.wgsl.js +874 -0
  255. package/dist/render/shaders/gpudriven/shadow.wgsl.d.ts +44 -0
  256. package/dist/render/shaders/gpudriven/shadow.wgsl.js +308 -0
  257. package/dist/render/shaders/gpudriven/visbufferRaster.wgsl.d.ts +28 -0
  258. package/dist/render/shaders/gpudriven/visbufferRaster.wgsl.js +245 -0
  259. package/dist/render/shaders/network.wgsl.d.ts +101 -0
  260. package/dist/render/shaders/network.wgsl.js +206 -0
  261. package/dist/render/shaders/recon/motion.wgsl.d.ts +44 -0
  262. package/dist/render/shaders/recon/motion.wgsl.js +96 -0
  263. package/dist/render/shaders/recon/resolve.wgsl.d.ts +51 -0
  264. package/dist/render/shaders/recon/resolve.wgsl.js +733 -0
  265. package/dist/render/temporalAa.js +8 -33
  266. package/dist/render/textLayout.js +12 -6
  267. package/dist/render/textRenderer.d.ts +3 -1
  268. package/dist/render/textRenderer.js +4 -1
  269. package/dist/render/translucentQueue.d.ts +6 -0
  270. package/dist/render/translucentQueue.js +11 -0
  271. package/dist/render/windStreakRenderer.d.ts +2 -1
  272. package/dist/render/windStreakRenderer.js +3 -1
  273. package/dist/world/cell.d.ts +63 -0
  274. package/dist/world/cell.js +181 -0
  275. package/dist/world/cellStream.d.ts +103 -0
  276. package/dist/world/cellStream.js +198 -0
  277. package/dist/world/freeze.d.ts +81 -0
  278. package/dist/world/freeze.js +114 -0
  279. package/dist/world/rebase.d.ts +29 -0
  280. package/dist/world/rebase.js +41 -0
  281. package/package.json +8 -3
  282. package/scripts/frames.mjs +92 -0
  283. package/scripts/gpuCompute.mjs +323 -0
  284. package/scripts/png.d.mts +16 -0
  285. package/scripts/png.mjs +25 -0
  286. package/src/core/loop.ts +19 -2
  287. package/src/core/storage.ts +5 -0
  288. package/src/geometry/meshBuilder.ts +5 -1
  289. package/src/index.ts +261 -4
  290. package/src/input/input.ts +6 -1
  291. package/src/math/exact.ts +69 -3
  292. package/src/math/frustum.ts +52 -3
  293. package/src/render/backend/api.ts +42 -1
  294. package/src/render/backend/budget.ts +7 -6
  295. package/src/render/backend/createRenderer.ts +92 -3
  296. package/src/render/backend/materialChanges.ts +54 -0
  297. package/src/render/backend/select.ts +8 -0
  298. package/src/render/backend/webgl2/renderer.ts +211 -9
  299. package/src/render/backend/webgpu/buffers.ts +182 -69
  300. package/src/render/backend/webgpu/fieldCompose.ts +482 -0
  301. package/src/render/backend/webgpu/giFieldPass.ts +200 -0
  302. package/src/render/backend/webgpu/gpuDrivenPass.ts +2993 -0
  303. package/src/render/backend/webgpu/linePass.ts +4 -39
  304. package/src/render/backend/webgpu/probeBake.ts +588 -0
  305. package/src/render/backend/webgpu/renderer.ts +1481 -76
  306. package/src/render/backend/webgpu/scatterPass.ts +15 -1
  307. package/src/render/backend/webgpu/surfaceTexturePass.ts +158 -88
  308. package/src/render/causticsRenderer.ts +5 -2
  309. package/src/render/depthConvention.ts +15 -0
  310. package/src/render/deviceFeatures.ts +77 -0
  311. package/src/render/fog.ts +185 -0
  312. package/src/render/frame/alias.ts +97 -0
  313. package/src/render/frame/deps.ts +155 -0
  314. package/src/render/frame/flushGraph.ts +135 -0
  315. package/src/render/frame/graphSchedule.ts +170 -0
  316. package/src/render/frame/index.ts +52 -0
  317. package/src/render/frame/lifetime.ts +61 -0
  318. package/src/render/frame/passTimings.ts +69 -0
  319. package/src/render/frame/validate.ts +40 -0
  320. package/src/render/frame/virtual.ts +91 -0
  321. package/src/render/gi/chain.ts +225 -0
  322. package/src/render/gi/denoise.ts +276 -0
  323. package/src/render/gi/fieldScene.ts +264 -0
  324. package/src/render/gi/globalField.ts +543 -0
  325. package/src/render/gi/probeGridFit.ts +89 -0
  326. package/src/render/gi/probeTrace.ts +143 -0
  327. package/src/render/gi/probeVolume.ts +288 -0
  328. package/src/render/gi/reflection.ts +163 -0
  329. package/src/render/gi/traceField.ts +223 -0
  330. package/src/render/gi/traceScreen.ts +106 -0
  331. package/src/render/gpuScene.ts +80 -0
  332. package/src/render/gpudriven/alphaTest.ts +33 -0
  333. package/src/render/gpudriven/clusterUpload.ts +123 -0
  334. package/src/render/gpudriven/clusterWorld.ts +239 -0
  335. package/src/render/gpudriven/compact.ts +80 -0
  336. package/src/render/gpudriven/cullClusters.ts +333 -0
  337. package/src/render/gpudriven/decodeTables.ts +332 -0
  338. package/src/render/gpudriven/frustum.ts +70 -0
  339. package/src/render/gpudriven/geometryHarness.ts +51 -0
  340. package/src/render/gpudriven/hzb.ts +113 -0
  341. package/src/render/gpudriven/ibl.ts +147 -0
  342. package/src/render/gpudriven/indirect.ts +89 -0
  343. package/src/render/gpudriven/instances.ts +234 -0
  344. package/src/render/gpudriven/lit.ts +329 -0
  345. package/src/render/gpudriven/lodCut.ts +62 -0
  346. package/src/render/gpudriven/materialBin.ts +131 -0
  347. package/src/render/gpudriven/materialTable.ts +164 -0
  348. package/src/render/gpudriven/pipeline.ts +336 -0
  349. package/src/render/gpudriven/rangeAlloc.ts +122 -0
  350. package/src/render/gpudriven/sceneUpload.ts +173 -0
  351. package/src/render/gpudriven/shadeBins.ts +222 -0
  352. package/src/render/gpudriven/shadow.ts +285 -0
  353. package/src/render/gpudriven/shadowCamera.ts +406 -0
  354. package/src/render/gpudriven/streamScene.ts +575 -0
  355. package/src/render/gpudriven/surfaceFrame.ts +68 -0
  356. package/src/render/gpudriven/twoPhase.ts +214 -0
  357. package/src/render/gpudriven/visbuffer.ts +41 -0
  358. package/src/render/halfFloat.ts +22 -4
  359. package/src/render/inference/attentionTiled.ts +132 -0
  360. package/src/render/inference/denseKernels.ts +369 -0
  361. package/src/render/inference/device.ts +44 -0
  362. package/src/render/inference/deviceGraph.ts +39 -0
  363. package/src/render/inference/fuse.ts +47 -0
  364. package/src/render/inference/kernelKit.ts +120 -0
  365. package/src/render/inference/kernels.ts +18 -0
  366. package/src/render/inference/plan.ts +26 -0
  367. package/src/render/inference/reuse.ts +79 -0
  368. package/src/render/inference/runner.ts +204 -0
  369. package/src/render/inference/schedule.ts +80 -0
  370. package/src/render/inference/shapeKernels.ts +112 -0
  371. package/src/render/inference/spatialKernels.ts +220 -0
  372. package/src/render/inference/splatRaster.wgsl.ts +348 -0
  373. package/src/render/inference/splatTiles.ts +186 -0
  374. package/src/render/lineBatch.ts +4 -24
  375. package/src/render/mesh.ts +46 -1
  376. package/src/render/pass.ts +155 -10
  377. package/src/render/prefilterEnvMap.ts +33 -0
  378. package/src/render/readback.ts +73 -0
  379. package/src/render/recon/clamp.ts +113 -0
  380. package/src/render/recon/disocclusion.ts +143 -0
  381. package/src/render/recon/frameGen.ts +191 -0
  382. package/src/render/recon/frameSizes.ts +64 -0
  383. package/src/render/recon/jitter.ts +126 -0
  384. package/src/render/recon/motionVectors.ts +138 -0
  385. package/src/render/recon/reproject.ts +179 -0
  386. package/src/render/recon/resolve.ts +444 -0
  387. package/src/render/recon/tier.ts +31 -0
  388. package/src/render/reflectionProbe.ts +34 -0
  389. package/src/render/renderQuality.ts +87 -0
  390. package/src/render/screenSpaceReflection.ts +37 -3
  391. package/src/render/sdfFont.ts +2 -0
  392. package/src/render/segmentQuads.ts +39 -0
  393. package/src/render/shaders/flat/lobes.ts +35 -3
  394. package/src/render/shaders/flat/preamble.ts +25 -1
  395. package/src/render/shaders/flat/probeGrid.ts +77 -2
  396. package/src/render/shaders/generated/flat.wgsl.ts +64 -24
  397. package/src/render/shaders/gi/composeField.wgsl.ts +239 -0
  398. package/src/render/shaders/gi/marchField.wgsl.ts +146 -0
  399. package/src/render/shaders/gi/probeBake.wgsl.ts +649 -0
  400. package/src/render/shaders/gi/probeTrace.wgsl.ts +269 -0
  401. package/src/render/shaders/gi/probeVolume.wgsl.ts +246 -0
  402. package/src/render/shaders/gi/sampleField.wgsl.ts +140 -0
  403. package/src/render/shaders/gpudriven/blendRaster.wgsl.ts +343 -0
  404. package/src/render/shaders/gpudriven/compact.wgsl.ts +59 -0
  405. package/src/render/shaders/gpudriven/cull.wgsl.ts +433 -0
  406. package/src/render/shaders/gpudriven/decode.wgsl.ts +373 -0
  407. package/src/render/shaders/gpudriven/environment.wgsl.ts +141 -0
  408. package/src/render/shaders/gpudriven/fog.wgsl.ts +110 -0
  409. package/src/render/shaders/gpudriven/frameBlock.wgsl.ts +62 -0
  410. package/src/render/shaders/gpudriven/materialBin.wgsl.ts +116 -0
  411. package/src/render/shaders/gpudriven/materialTable.wgsl.ts +46 -0
  412. package/src/render/shaders/gpudriven/meshTransform.wgsl.ts +37 -0
  413. package/src/render/shaders/gpudriven/present.wgsl.ts +129 -0
  414. package/src/render/shaders/gpudriven/shade.wgsl.ts +884 -0
  415. package/src/render/shaders/gpudriven/shadow.wgsl.ts +322 -0
  416. package/src/render/shaders/gpudriven/visbufferRaster.wgsl.ts +251 -0
  417. package/src/render/shaders/network.wgsl.ts +238 -0
  418. package/src/render/shaders/recon/motion.wgsl.ts +101 -0
  419. package/src/render/shaders/recon/resolve.wgsl.ts +741 -0
  420. package/src/render/temporalAa.ts +9 -33
  421. package/src/render/textLayout.ts +12 -6
  422. package/src/render/textRenderer.ts +5 -2
  423. package/src/render/translucentQueue.ts +11 -0
  424. package/src/render/windStreakRenderer.ts +4 -2
  425. package/src/world/cell.ts +231 -0
  426. package/src/world/cellStream.ts +278 -0
  427. package/src/world/freeze.ts +157 -0
  428. package/src/world/rebase.ts +56 -0
@@ -8,7 +8,29 @@ import {
8
8
  } from '../../temporalAa.ts';
9
9
  import { OIT_MULTISAMPLE_REFUSAL } from '../../orderIndependent.ts';
10
10
  import { TranslucentQueue } from '../../translucentQueue.ts';
11
- import { DEPTH_CLEAR, MAX_DEPTH_LAYER, REVERSED_DEPTH } from '../../depthConvention.ts';
11
+ import {
12
+ DEPTH_CLEAR,
13
+ DEPTH_COMPARE_EQUAL,
14
+ MAX_DEPTH_LAYER,
15
+ REVERSED_DEPTH,
16
+ } from '../../depthConvention.ts';
17
+ import { RECONSTRUCTION_MULTISAMPLE_REFUSAL, reconRenderSize } from '../../recon/frameSizes.ts';
18
+ import { jitterOffset as jitterOffsetFor, reconJitterPhases } from '../../recon/jitter.ts';
19
+ import {
20
+ RECON_HISTORY_FORMAT,
21
+ RECON_PARAM_FLOATS,
22
+ RECON_WORKGROUP,
23
+ reconResolveWgsl,
24
+ } from '../../shaders/recon/resolve.wgsl.ts';
25
+ import {
26
+ MOTION_DRAW_FLOATS,
27
+ MOTION_DRAW_STRIDE,
28
+ MOTION_FRAME_FLOATS,
29
+ MOTION_TARGET_FORMAT,
30
+ RECON_MOTION_WGSL,
31
+ } from '../../shaders/recon/motion.wgsl.ts';
32
+ import { DEFAULT_DISOCCLUSION } from '../../recon/disocclusion.ts';
33
+ import { DEFAULT_RECON_QUALITY } from '../../recon/resolve.ts';
12
34
  import type { ReadonlyMat4 } from 'gl-matrix';
13
35
 
14
36
  import type { Vec3 } from '../../../math/color.ts';
@@ -58,11 +80,18 @@ import {
58
80
  import type { WindStreakOptions } from '../../windStreakRenderer.ts';
59
81
  import type { WindField } from '../../windField.ts';
60
82
  import type { FlockParams } from '../../flockRenderer.ts';
61
- import { expandBoltSegments } from '../../segmentQuads.ts';
83
+ import { expandBoltSegments, expandLineSegments } from '../../segmentQuads.ts';
62
84
  import { LIVE_POINT_SHADOW_MAPS, MAX_POINT_LIGHTS, POINT_SHADOW_POOL } from '../../lightBudget.ts';
63
85
  import { roughnessForLevel } from '../../prefilterEnvMap.ts';
64
86
  import { equirectToCubeFaces } from '../../equirectToCube.ts';
65
- import { ProbeGrid, SINGLE_PROBE, UNIT_STEP, WORLD_ORIGIN, sameGrid } from '../../probeGrid.ts';
87
+ import {
88
+ MAX_ENV_PROBES,
89
+ ProbeGrid,
90
+ SINGLE_PROBE,
91
+ UNIT_STEP,
92
+ WORLD_ORIGIN,
93
+ sameGrid,
94
+ } from '../../probeGrid.ts';
66
95
  import type { ProbeGridOptions } from '../../probeGrid.ts';
67
96
  import { ggxMaxLevelFor, irradianceLevelFor, octahedralEdgeFor } from '../../prefilterEnvMap.ts';
68
97
  import { environmentTexels } from './environmentTexels.ts';
@@ -413,7 +442,6 @@ import {
413
442
  createGpuLines,
414
443
  createLineBindGroup,
415
444
  createLineBindGroupLayout,
416
- expandLineSegments,
417
445
  linePipeline,
418
446
  type GpuLines,
419
447
  } from './linePass.ts';
@@ -428,8 +456,29 @@ import {
428
456
  } from './lightVolumePass.ts';
429
457
  import { aimReflection, createGpuReflection, type GpuReflection } from './reflectionPass.ts';
430
458
  import { FrameBudget } from '../budget.ts';
459
+ import { MaterialChanges, ownsMaterial } from '../materialChanges.ts';
431
460
  import { DYNAMIC_ALIGNMENT as DYNAMIC_UNIFORM_ALIGNMENT, UniformRing } from './uniformRing.ts';
432
461
  import { DecalQueue, MAX_DRAWN_DECALS } from '../../decalQueue.ts';
462
+ import { DistanceFieldScene } from '../../gi/fieldScene.ts';
463
+ import { DEFAULT_FIELD_COMPOSE, FieldComposer } from './fieldCompose.ts';
464
+ import { ProbeBaker } from './probeBake.ts';
465
+ import { distanceFieldBounds } from '../../gi/fieldScene.ts';
466
+ import { fitProbeGrid } from '../../gi/probeGridFit.ts';
467
+
468
+ /**
469
+ * Metres between probes when the renderer fits a grid itself.
470
+ *
471
+ * **Two, which is a room rather than a building.** The spacing is what decides how local the light
472
+ * can be: a bounce off a wall reaches a surface two metres away and no nearer detail than that.
473
+ * `fitProbeGrid` widens it uniformly when the budget bites, so a large scene gets a coarser grid
474
+ * rather than a truncated one.
475
+ */
476
+ const INDIRECT_PROBE_SPACING = 2;
477
+
478
+ /** What the trace is given when a frame never said what was lighting it. */
479
+ const NO_SUN: readonly number[] = [0, 0, 0];
480
+ import type { ComposedField } from './fieldCompose.ts';
481
+ import type { FieldSource } from '../../gi/globalField.ts';
433
482
  import {
434
483
  CLIP_Y_FLIP,
435
484
  MAX_REFLECTIVE_SURFACES,
@@ -444,6 +493,7 @@ import type { CommandPool, DrawCommand } from './drawCommand.ts';
444
493
  import {
445
494
  RESOURCE_COUNT,
446
495
  createArena,
496
+ createFlushSchedule,
447
497
  keptNodes,
448
498
  maskOf,
449
499
  nodeCount,
@@ -452,6 +502,7 @@ import {
452
502
  recordNode,
453
503
  resetArena,
454
504
  schedule,
505
+ scheduleFlush,
455
506
  scratchFor,
456
507
  } from '../../frame/index.ts';
457
508
  import { createFrustum, frustumFromViewProjection } from '../../../math/frustum.ts';
@@ -475,11 +526,12 @@ import type {
475
526
  PassContext,
476
527
  PassDefinition,
477
528
  PassDevice,
529
+ PassEnvironment,
478
530
  PassHandle,
479
531
  PassRegistry,
480
532
  PrepareContext,
481
533
  } from '../../pass.ts';
482
- import type { Arena, ScheduledPass } from '../../frame/index.ts';
534
+ import type { Arena, FlushSchedule, ScheduledPass } from '../../frame/index.ts';
483
535
  import { GpuInstancedBatch } from './instanced.ts';
484
536
  import type { MeshInstances } from '../../instances.ts';
485
537
 
@@ -799,20 +851,32 @@ function resolvedStoreOp(multisampled: boolean, terminal: boolean, allowed: bool
799
851
  }
800
852
 
801
853
  /**
802
- * Clip space differs between the two APIs, in two ways, and both must be corrected.
854
+ * Clip space differs between the two APIs in **one** way, and the other half of `CLIP_CORRECTION`
855
+ * cancels something this repository writes itself.
856
+ *
857
+ * **Depth lands in a different range, and that half is a real difference between the APIs.**
858
+ * OpenGL clips z to [-1, 1] and WebGPU to [0, 1], so an uncorrected matrix throws away the near
859
+ * half of the depth buffer and compares what remains against the wrong distances.
803
860
  *
804
- * **Y points the other way on screen.** WebGPU's framebuffer origin is the top-left corner
805
- * and OpenGL's is the bottom-left, so a projection built for WebGL2 draws the world upside
806
- * down here. The first frame this backend rendered was a colonnade hanging from the ceiling.
861
+ * **The Y negation cancels one the generator writes, and this comment used to say otherwise.**
862
+ * It claimed the flip was the framebuffer origin: WebGPU's is the top-left and OpenGL's is the
863
+ * bottom-left, so a projection built for WebGL2 "draws the world upside down here". That is not
864
+ * what either viewport transform does — both put clip `y = +1` at the top of the image, because
865
+ * OpenGL measures its window upward from the bottom-left and WebGPU measures its framebuffer
866
+ * downward from the top-left. An uncorrected projection lands the same way up on both.
807
867
  *
808
- * **Depth lands in a different range.** OpenGL clips z to [-1, 1] and WebGPU to [0, 1], so
809
- * an uncorrected matrix throws away the near half of the depth buffer and compares what
810
- * remains against the wrong distances.
868
+ * What does flip it is `naga`. Every module in `shaders/generated/` ends its vertex entry point
869
+ * with `gl_Position.y = -gl_Position.y`, which is how that translator adapts a GLSL shader to
870
+ * WebGPU — and the first frame this backend rendered, a colonnade hanging from the ceiling, was
871
+ * *that* line and not the API. So the pair is: the generator negates, this negates back, and the
872
+ * net effect on a generated shader is the depth remap alone. `shaders/generated.test.ts` pins the
873
+ * generator's half so the two cannot drift apart.
811
874
  *
812
- * Correcting the matrix rather than the shaders is deliberate: the shaders are generated
813
- * from the GLSL the WebGL2 path uses, and a Y flip written into them would be a difference
814
- * between the backends that no generator could keep honest. Here it is four numbers, applied
815
- * once a frame, in one place.
875
+ * **Which makes it the wrong matrix for a shader somebody wrote by hand**, since there is no
876
+ * negation to cancel — see `DEPTH_CORRECTION` below, and `PassDevice` for the seam where a
877
+ * contributed pass chooses. Wiring the GPU-driven pipeline's hand-written raster found it: the
878
+ * picture was an exact vertical mirror of the forward path's, and `frontFace: 'cw'` made it draw
879
+ * the right faces of a mirrored world.
816
880
  *
817
881
  * **This one is for a target that gets presented. `SHADOW_CLIP_CORRECTION` is for one that
818
882
  * gets sampled, and they are not the same transform.**
@@ -879,6 +943,37 @@ const CLIP_CORRECTION = new Float32Array([
879
943
  * against, applied by accident. Without the flip it culls what it says it culls, and matches
880
944
  * what `renderer.ts` does through the directional pass.
881
945
  */
946
+ /**
947
+ * `CLIP_CORRECTION` without the Y negation: the depth remap alone, reversed.
948
+ *
949
+ * **What a hand-written WGSL shader wants.** The negation in `CLIP_CORRECTION` is there to cancel
950
+ * the one `naga` writes into every generated vertex entry point; a shader that was not generated
951
+ * carries no such line, so taking that matrix mirrors its picture vertically — and mirrors its
952
+ * triangle winding with it, which is how it reads as a culling problem rather than as a flip.
953
+ *
954
+ * The depth half is not optional and is the reason this is a matrix rather than nothing: OpenGL
955
+ * clips z to [-1, 1] and WebGPU to [0, 1], and the row is `0.5 - 0.5z` rather than `0.5z + 0.5`
956
+ * because `depthConvention.ts` puts the near plane at one.
957
+ */
958
+ const DEPTH_CORRECTION = new Float32Array([
959
+ 1,
960
+ 0,
961
+ 0,
962
+ 0,
963
+ 0,
964
+ 1,
965
+ 0,
966
+ 0,
967
+ 0,
968
+ 0,
969
+ REVERSED_DEPTH ? -0.5 : 0.5,
970
+ 0,
971
+ 0,
972
+ 0,
973
+ 0.5,
974
+ 1,
975
+ ]);
976
+
882
977
  const SHADOW_CLIP_CORRECTION = new Float32Array([
883
978
  1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 1,
884
979
  ]);
@@ -1158,6 +1253,17 @@ const NO_CLIP_PLANE = new Float32Array(4);
1158
1253
  * a while telling consumers that working effects did nothing. All three are read by
1159
1254
  * `composite()` now, and each has been compared against WebGL2 on `demo/dev/probe.html`.
1160
1255
  */
1256
+ /**
1257
+ * The y negation every generated vertex stage ends with, as a matrix.
1258
+ *
1259
+ * **A hand-written shader has no such line, so it has to carry it.** `CLIP_CORRECTION` negates y
1260
+ * precisely to cancel the one `naga` writes, and the pair's net effect on a generated shader is the
1261
+ * depth remap alone — §3 rows 56 and 57. The motion pass has to put its vertices exactly where the
1262
+ * scene put them or its depth test admits nothing, so the renderer multiplies this through the
1263
+ * matrix the scene drew with and hands over the product.
1264
+ */
1265
+ const MOTION_CLIP_FLIP = mat4.fromValues(1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
1266
+
1161
1267
  export class WebGPURenderer implements RendererApi {
1162
1268
  /**
1163
1269
  * Real timestamp queries, where the adapter offers them.
@@ -1315,8 +1421,8 @@ export class WebGPURenderer implements RendererApi {
1315
1421
  private readonly perFrameStaging: ArrayBuffer;
1316
1422
  private readonly perFrameFloats: Float32Array;
1317
1423
  private readonly perFrameInts: Int32Array;
1318
- /** The slot the open material occupies, or -1 when the next draw must take a new one. */
1319
- private materialSlot = -1;
1424
+ /** The slot the open material occupies, and whether the next draw must take a new one. */
1425
+ private readonly materials = new MaterialChanges();
1320
1426
 
1321
1427
  /*
1322
1428
  * The frame graph's recording, and the machinery that replays it.
@@ -1369,6 +1475,7 @@ export class WebGPURenderer implements RendererApi {
1369
1475
  private readonly textBudget = this.budget.line('text draws', MAX_OVERLAYS);
1370
1476
  private readonly sdfTextBudget = this.budget.line('sdf text draws', MAX_OVERLAYS);
1371
1477
  private readonly lineBudget = this.budget.line('line draws', MAX_OVERLAYS);
1478
+ private readonly panelBudget = this.budget.line('panels', MAX_OVERLAYS);
1372
1479
 
1373
1480
  /**
1374
1481
  * What the frame just drawn asked for, and what it was refused.
@@ -1391,6 +1498,12 @@ export class WebGPURenderer implements RendererApi {
1391
1498
  * scatter, plumes and text reached it long before the draw ring was full.
1392
1499
  */
1393
1500
  private replayScratch: Int32Array = new Int32Array(MAX_DRAWS_PER_FRAME);
1501
+ /**
1502
+ * The identifier graph's working set, where `quality.identifierGraph` asks for it and not
1503
+ * otherwise: a renderer scheduling by masks carries none of it. Sized with the arena, and grown
1504
+ * by `scheduleFlush` the way the arena grows.
1505
+ */
1506
+ private readonly flushSchedule: FlushSchedule | null;
1394
1507
  private readonly scheduledPasses: ScheduledPass[] = Array.from({ length: 64 }, () => ({
1395
1508
  writes: 0,
1396
1509
  first: 0,
@@ -1663,6 +1776,26 @@ export class WebGPURenderer implements RendererApi {
1663
1776
  private bloomGroups: GPUBindGroup[] = [];
1664
1777
  private frameWidth = 1;
1665
1778
  private frameHeight = 1;
1779
+ /**
1780
+ * What the scene is drawn at, which is the drawing buffer unless a reconstruction is enlarging it.
1781
+ *
1782
+ * **Everything before the resolve is this size and everything after it is the drawing buffer's.**
1783
+ * The scene colour and its multisampled twin, the depth, the refraction snapshot, the resolved
1784
+ * depth, the occlusion pair, the medium, the translucent buffers, the reflection march, the
1785
+ * decals, the temporal history and the frame pass's own viewport are all before it; the composite
1786
+ * and the overlay are after it, and they are what the viewer's pixels are counted in.
1787
+ *
1788
+ * **The composite is what enlarges the picture, and it does so by existing.** It draws a
1789
+ * full-screen triangle into the swap chain sampling the scene by `uv` through a linear sampler, so
1790
+ * a smaller scene target is magnified by that sample and nothing else changes — which is why this
1791
+ * needs `screenEffects` and is refused without it: with no composite the world draws straight into
1792
+ * the swap chain and there is nothing to enlarge from.
1793
+ *
1794
+ * Equal to the drawing buffer whenever `quality.reconstruction` is 0, which is the default, and
1795
+ * `recon/frameSizes.ts` is the one place the two are related.
1796
+ */
1797
+ private renderWidth = 0;
1798
+ private renderHeight = 0;
1666
1799
  /** The view-projection this frame was drawn with, for the reprojection motion blur needs. */
1667
1800
  private readonly previousViewProj = new Float32Array(16);
1668
1801
  private readonly reprojection = new Float32Array(16);
@@ -1758,6 +1891,94 @@ export class WebGPURenderer implements RendererApi {
1758
1891
  private temporalJitterX = 0;
1759
1892
  private temporalJitterY = 0;
1760
1893
  private temporalJittering = false;
1894
+ /**
1895
+ * Whether this frame is being reconstructed, settled once in `bindMeshPass`.
1896
+ *
1897
+ * It is not `quality.reconstruction > 0` on its own: a mirror pass is skipped for the same reason
1898
+ * the temporal jitter skips one — a reflection is not the picture the history holds — and without
1899
+ * a composite there is nothing to enlarge the render from.
1900
+ */
1901
+ private reconstructing = false;
1902
+ /** Said once rather than every frame, as the translucent set's own refusal is. */
1903
+ private reconMultisampleSaid = false;
1904
+ private reconFrameIndex = -1;
1905
+ private readonly reconJitter = new Float32Array(2);
1906
+ private readonly reconPreviousJitter = new Float32Array(2);
1907
+ /**
1908
+ * `reconJitter` as a fraction of the clip square, which is what a contributed pass is handed.
1909
+ * See `PrepareContext.jitter`; zero on a frame that is not reconstructed.
1910
+ */
1911
+ private readonly passJitter = new Float32Array(2);
1912
+ /**
1913
+ * The reconstruction's own targets, allocated only while a consumer asks for one.
1914
+ *
1915
+ * **Two histories rather than one**, for the reason the temporal resolve keeps two: a dispatch
1916
+ * cannot read the texture it is writing. The shown picture is a third, because the history holds
1917
+ * the *unsharpened* result — sharpening into the history would sharpen a sharpened picture every
1918
+ * frame, which is the halo the reference's own clamp exists to prevent, compounded.
1919
+ *
1920
+ * The motion target is render size and carries a drawn object's own motion where something wrote
1921
+ * one. **Nothing writes it yet**, so it is cleared every frame and every pixel takes the camera's
1922
+ * motion, which the resolve derives from the depth and the reprojection. Static geometry is
1923
+ * exact that way and a moving object ghosts until the motion pass lands.
1924
+ */
1925
+ private reconHistories: (GPUTexture | null)[] = [null, null];
1926
+ private reconHistoryViews: (GPUTextureView | null)[] = [null, null];
1927
+ private reconWrite = 0;
1928
+ private reconHasHistory = false;
1929
+ private reconShown: GPUTexture | null = null;
1930
+ private reconShownView: GPUTextureView | null = null;
1931
+ private reconMotion: GPUTexture | null = null;
1932
+ private reconMotionView: GPUTextureView | null = null;
1933
+ private reconPreviousDepth: GPUTexture | null = null;
1934
+ private reconPreviousDepthView: GPUTextureView | null = null;
1935
+ private reconParams: GPUBuffer | null = null;
1936
+ private readonly reconStaging = new ArrayBuffer(RECON_PARAM_FLOATS * 4);
1937
+ private readonly reconFloats = new Float32Array(this.reconStaging);
1938
+ private readonly reconInts = new Uint32Array(this.reconStaging);
1939
+ private reconLayout: GPUBindGroupLayout | null = null;
1940
+ private reconResolvePipeline: GPUComputePipeline | null = null;
1941
+ private reconSharpenPipeline: GPUComputePipeline | null = null;
1942
+ private reconGroups: (GPUBindGroup | null)[] = [null, null];
1943
+ private reconSharpenGroups: (GPUBindGroup | null)[] = [null, null];
1944
+ private reconRushGroup: GPUBindGroup | null = null;
1945
+ /**
1946
+ * What the motion pass will draw, recorded by `drawMesh` and spent once a frame.
1947
+ *
1948
+ * A parallel array of meshes and one flat matrix buffer rather than an array of objects, because
1949
+ * this is written on the frame's hot path and the house rule about allocating there binds it: the
1950
+ * matrices are copied into a buffer that only ever grows, and the count is what resets.
1951
+ */
1952
+ private readonly motionMeshes: GpuMesh[] = [];
1953
+ private motionMatrices = new Float32Array(0);
1954
+ private motionCount = 0;
1955
+ private motionLayout: GPUBindGroupLayout | null = null;
1956
+ /**
1957
+ * One pipeline per vertex stride, because a stride is a property of the *mesh*.
1958
+ *
1959
+ * Which optional attributes a mesh supplied decides how far apart its positions are, and this
1960
+ * pass reads nothing but positions — so two meshes drawn by the same shader still need two
1961
+ * vertex layouts. A map rather than a cache key on the pipeline cache: there is one shader here
1962
+ * and a handful of strides in any scene.
1963
+ */
1964
+ private readonly motionPipelines = new Map<number, GPURenderPipeline>();
1965
+ private motionModule: GPUShaderModule | null = null;
1966
+ private motionPipelineLayout: GPUPipelineLayout | null = null;
1967
+ private motionFrameBuffer: GPUBuffer | null = null;
1968
+ private motionDrawBuffer: GPUBuffer | null = null;
1969
+ private motionDrawCapacity = 0;
1970
+ private motionGroup: GPUBindGroup | null = null;
1971
+ private readonly motionFrameStaging = new Float32Array(MOTION_FRAME_FLOATS);
1972
+ private motionDrawStaging = new Uint8Array(0);
1973
+ /** The scene's own clip transform with the generated shaders' y negation folded in. */
1974
+ private readonly motionRaster = mat4.create();
1975
+ private reconSizeKey = '';
1976
+ /** Last frame's raw view-projection and its inverse, and the two eyes. */
1977
+ private readonly reconPreviousRawViewProj = mat4.create();
1978
+ private readonly reconInverseViewProj = mat4.create();
1979
+ private readonly reconPreviousInverseViewProj = mat4.create();
1980
+ private readonly reconEye = new Float32Array(3);
1981
+ private readonly reconPreviousEye = new Float32Array(3);
1761
1982
  private temporalHistoryUsable = false;
1762
1983
 
1763
1984
  private readonly aoStaging = new ArrayBuffer(AO_FRAG_SIZE);
@@ -1799,6 +2020,23 @@ export class WebGPURenderer implements RendererApi {
1799
2020
  return this.quality.screenEffects;
1800
2021
  }
1801
2022
 
2023
+ /**
2024
+ * Whether this renderer is set up to reconstruct, asked in one place by everything that cares.
2025
+ *
2026
+ * **One condition and not three**, which §3 row 132 is the lesson of: the frame's flag, the size
2027
+ * the targets are built at and the size the world is drawn at all turn on the same question, and
2028
+ * three copies of it are three lines no test can fail — each covering the others, so breaking any
2029
+ * one of them changes nothing anybody can see.
2030
+ *
2031
+ * A composite, because the composite is what enlarges the render; one sample, because the motion
2032
+ * pass draws against the depth the scene left and every attachment in a pass must agree about its
2033
+ * sample count — `RECONSTRUCTION_MULTISAMPLE_REFUSAL` says the rest. Whether a *frame* is
2034
+ * reconstructed adds one more question, the mirror's, and that one belongs to the frame.
2035
+ */
2036
+ private get reconstructionWanted(): boolean {
2037
+ return this.quality.reconstruction > 0 && this.hasComposite && this.samples === 1;
2038
+ }
2039
+
1802
2040
  /** What a frame pass writes into: the scene target where there is one, the swap chain else. */
1803
2041
  /**
1804
2042
  * This frame's swap-chain view, taken on first use rather than at `beginFrame`.
@@ -1902,6 +2140,31 @@ export class WebGPURenderer implements RendererApi {
1902
2140
  * the frame has drawn, so it cannot run until the opaque world is finished. See `DecalQueue`.
1903
2141
  */
1904
2142
  private readonly decalQueue = new DecalQueue();
2143
+ /** The fields this frame declared, composed at `endFrame`. See `addDistanceField`. */
2144
+ private readonly distanceFields = new DistanceFieldScene();
2145
+ /**
2146
+ * The composition of them, built at the first frame that has both the flag and a field.
2147
+ *
2148
+ * **Not at construction**, because it is 1.4 MB of device buffers and a compute pipeline, and a
2149
+ * profile with `indirectLight` on is entitled to declare no fields at all — a scene that lights
2150
+ * itself entirely from a baked probe grid, for one. Nothing is allocated until something would
2151
+ * be composed.
2152
+ */
2153
+ private fieldComposer: FieldComposer | null = null;
2154
+ /**
2155
+ * The probes' own bake, built at the first frame that has the flag, a field and an array.
2156
+ *
2157
+ * Not at construction, for `fieldComposer`'s reason: it is two compute pipelines, a render
2158
+ * pipeline and a handful of buffers, and a profile with the flag on may never declare a field.
2159
+ */
2160
+ private probeBaker: ProbeBaker | null = null;
2161
+ /** Which array the baker's views were built against, so a replacement is noticed. */
2162
+ private bakerArray: GPUTexture | null = null;
2163
+ /** Reused, because deriving a grid from the declared fields runs in the frame path. */
2164
+ private readonly fieldBoundsMin = new Float32Array(3);
2165
+ private readonly fieldBoundsMax = new Float32Array(3);
2166
+ /** Said once rather than every frame, as the other refusals are. */
2167
+ private indirectGridRefused = false;
1905
2168
  private decalLayout!: GPUBindGroupLayout;
1906
2169
  private decalUniforms!: GPUBuffer;
1907
2170
  private readonly decalStaging = new ArrayBuffer(DECAL_SLOT * MAX_DRAWN_DECALS);
@@ -1937,7 +2200,10 @@ export class WebGPURenderer implements RendererApi {
1937
2200
  private readonly decalScissorRect = new Int32Array(4);
1938
2201
  private oitResolveLayout!: GPUBindGroupLayout;
1939
2202
  private oitResolveGroup: GPUBindGroup | null = null;
1940
- /** Reused, because the frame loop may not allocate. Mirrors the WebGL2 backend's own. */
2203
+ /**
2204
+ * Reused, because the frame loop may not allocate. Mirrors the WebGL2 backend's own, which it
2205
+ * had stopped doing: the other one carried a pane's refraction and this one did not.
2206
+ */
1941
2207
  private readonly oitReplayOptions: {
1942
2208
  lit: boolean;
1943
2209
  fog: boolean;
@@ -1945,7 +2211,20 @@ export class WebGPURenderer implements RendererApi {
1945
2211
  depthWrite: boolean;
1946
2212
  depthLayer: number;
1947
2213
  tint: Vec3 | null;
1948
- } = { lit: true, fog: true, toneMapped: true, depthWrite: false, depthLayer: 0, tint: null };
2214
+ refraction: number;
2215
+ refractTint: Vec3 | undefined;
2216
+ thicknessM: number;
2217
+ } = {
2218
+ lit: true,
2219
+ fog: true,
2220
+ toneMapped: true,
2221
+ depthWrite: false,
2222
+ depthLayer: 0,
2223
+ tint: null,
2224
+ refraction: 0,
2225
+ refractTint: undefined,
2226
+ thicknessM: 0,
2227
+ };
1949
2228
 
1950
2229
  private reflectionPassActive = false;
1951
2230
  /** Said once: a mirror asked for outside a frame is a call-order fault, not a device limit. */
@@ -2328,15 +2607,26 @@ export class WebGPURenderer implements RendererApi {
2328
2607
  return new GpuSurfaceTexture(this.surface.device, this.pipelines, source, options);
2329
2608
  }
2330
2609
 
2610
+ /**
2611
+ * GPU textures an `updateSurfaceTexture` replaced, destroyed when the next frame begins.
2612
+ *
2613
+ * Not at once, because a draw recorded earlier in the frame the update landed in may still read
2614
+ * one, and a destroyed texture in a submit loses the whole command buffer. Every encoder a frame
2615
+ * opens is submitted by the time the next one begins.
2616
+ */
2617
+ private readonly retiredTextures: GPUTexture[] = [];
2618
+
2331
2619
  updateSurfaceTexture(texture: GpuSurfaceTexture, source: TexImageSource): void {
2332
2620
  if (this.surface.lost) return;
2333
- texture.update(source);
2621
+ const replaced = texture.update(source);
2622
+ if (replaced === null) return;
2623
+ /* A new size is a new view, so every group holding the old one is dropped — see below. */
2624
+ this.retiredTextures.push(replaced);
2625
+ this.forgetBindingsOf(texture);
2334
2626
  }
2335
2627
 
2336
2628
  disposeSurfaceTexture(texture: GpuSurfaceTexture): void {
2337
2629
  if (this.surface.lost) return;
2338
- /* Same reasoning, for whichever SDF labels were bound against this atlas. */
2339
- this.sdfTextBindGroups.delete(texture);
2340
2630
  if (this.albedo === texture) {
2341
2631
  this.albedo = null;
2342
2632
  this.bindGroup = this.blankAlbedoBindGroup;
@@ -2362,6 +2652,19 @@ export class WebGPURenderer implements RendererApi {
2362
2652
  if (this.normalMap === texture) this.normalMap = null;
2363
2653
  if (this.ormMap === texture) this.ormMap = null;
2364
2654
  if (this.emissiveMap === texture) this.emissiveMap = null;
2655
+ this.forgetBindingsOf(texture);
2656
+ texture.dispose();
2657
+ }
2658
+
2659
+ /**
2660
+ * Drop every bind group that may hold `texture`'s view, and rebuild the ones bound now.
2661
+ *
2662
+ * For a texture going away and for one whose view has been replaced, which are the same problem:
2663
+ * a group holding the old view hands a destroyed texture to the next draw that asks.
2664
+ */
2665
+ private forgetBindingsOf(texture: GpuSurfaceTexture): void {
2666
+ /* Same reasoning, for whichever SDF labels were bound against this atlas. */
2667
+ this.sdfTextBindGroups.delete(texture);
2365
2668
  this.flatBindGroups.clear();
2366
2669
  /*
2367
2670
  * The blank one is built with the albedo held aside, because `buildFlatBindGroup` reads
@@ -2373,7 +2676,6 @@ export class WebGPURenderer implements RendererApi {
2373
2676
  this.blankAlbedoBindGroup = this.buildFlatBindGroup();
2374
2677
  this.albedo = held;
2375
2678
  this.bindGroup = this.flatGroupForMaps(held, this.normalMap, this.ormMap, this.emissiveMap);
2376
- texture.dispose();
2377
2679
  }
2378
2680
 
2379
2681
  /**
@@ -2537,7 +2839,7 @@ export class WebGPURenderer implements RendererApi {
2537
2839
  private material(write: (f: Float32Array, i: Int32Array) => void): void {
2538
2840
  if (this.surface.lost) return;
2539
2841
  write(this.perFrameFloats, this.perFrameInts);
2540
- this.materialSlot = -1;
2842
+ this.materials.dirty();
2541
2843
  }
2542
2844
 
2543
2845
  private materialField(name: string): number {
@@ -2628,7 +2930,19 @@ export class WebGPURenderer implements RendererApi {
2628
2930
  if (alpha <= 0 || rect.width <= 0 || rect.height <= 0) return;
2629
2931
  const vertexSlot = this.panelVerts.allocate();
2630
2932
  const fragmentSlot = this.panelFrags.allocate();
2631
- if (vertexSlot === null || fragmentSlot === null) return;
2933
+ if (vertexSlot === null || fragmentSlot === null) {
2934
+ /*
2935
+ * Said, as its text and line siblings say it: this returned without a word until 2026-09-19,
2936
+ * so an interface of panels lost everything past its sixty-fourth here and nothing on WebGL2.
2937
+ */
2938
+ if (!this.warnedPanelsFull) {
2939
+ this.warnedPanelsFull = true;
2940
+ console.warn(
2941
+ `WebGPU: more than ${MAX_OVERLAYS} panels in a frame; the rest are skipped. renderer.frameBudget names the line and the count.`,
2942
+ );
2943
+ }
2944
+ return;
2945
+ }
2632
2946
 
2633
2947
  const at = (name: string): number => PANEL_VERT_FIELDS[name]?.offset ?? 0;
2634
2948
  this.panelVerts.writeFloats(vertexSlot, at('uRect'), [
@@ -2669,6 +2983,9 @@ export class WebGPURenderer implements RendererApi {
2669
2983
  }
2670
2984
  }
2671
2985
 
2986
+ /** Said once rather than every frame, for the reason `warnedFull` gives. */
2987
+ private warnedPanelsFull = false;
2988
+
2672
2989
  /** A 3D text object. See `textPass.ts`; the layout it holds is shared with WebGL2. */
2673
2990
  createText(): GpuText {
2674
2991
  return new GpuText(this.surface.device);
@@ -3757,6 +4074,10 @@ export class WebGPURenderer implements RendererApi {
3757
4074
  before `takeVolumeDepth`, which is the one thing here that genuinely needs one open —
3758
4075
  it ends the current pass to copy the depth out and opens another in its place. */
3759
4076
  if (!this.canDraw()) return;
4077
+ /* Geometry that has not all arrived is not drawn, which is the surface's contract and what
4078
+ every other mesh verb here and the other backend's volume already kept. This one drew a
4079
+ beam from a buffer still filling, and counted it, until 2026-09-19. See `Mesh.complete`. */
4080
+ if (!mesh.complete) return;
3760
4081
  /* At zero it draws nothing rather than adding black, so a caller may keep the call in. That
3761
4082
  now covers a clear night too: a beam with no medium to light is not drawn at all. */
3762
4083
  const shown =
@@ -5036,6 +5357,44 @@ export class WebGPURenderer implements RendererApi {
5036
5357
  return this.surface.canvas;
5037
5358
  }
5038
5359
 
5360
+ /**
5361
+ * The size the world is drawn at this frame, which is **not** the canvas when a reconstruction
5362
+ * is enlarging it.
5363
+ *
5364
+ * **A contributed pass that fills a target of its own must size it from here.** The GPU-driven
5365
+ * rig sized its own targets from `canvas.width` and drew a full drawing-buffer picture into a
5366
+ * scene target two thirds as wide: the frame pass clipped it to the top-left corner and the
5367
+ * composite magnified that corner back over the screen, which is a plausible-looking picture of
5368
+ * the wrong part of the world. The device raises nothing — a viewport smaller than the thing
5369
+ * drawn is legal — so there is no failure to see, only a scene that has moved.
5370
+ *
5371
+ * Valid from `beginFrame`; before the first frame it is the canvas, which is what a pass sizing
5372
+ * itself at registration wants.
5373
+ */
5374
+ get sceneWidth(): number {
5375
+ return this.sceneSize().width;
5376
+ }
5377
+
5378
+ get sceneHeight(): number {
5379
+ return this.sceneSize().height;
5380
+ }
5381
+
5382
+ /**
5383
+ * The render size this frame will use, computed rather than remembered.
5384
+ *
5385
+ * **Computed, because a pass asks before the frame it is asking about.** `GpuDrivenPass.resize`
5386
+ * runs before `beginFrame` — that is when `prepare` records the pipeline — so a stored field
5387
+ * would answer with the previous frame's size, which is right every frame but the one where it
5388
+ * changed. The first frame after a reconstruction is switched on is exactly the frame that
5389
+ * matters.
5390
+ */
5391
+ private sceneSize(): { width: number; height: number } {
5392
+ const { canvas } = this.surface;
5393
+ return this.reconstructionWanted
5394
+ ? reconRenderSize(canvas.width, canvas.height, this.quality.reconstruction)
5395
+ : { width: Math.max(1, canvas.width), height: Math.max(1, canvas.height) };
5396
+ }
5397
+
5039
5398
  /**
5040
5399
  * Whether the device has gone.
5041
5400
  *
@@ -5162,6 +5521,10 @@ export class WebGPURenderer implements RendererApi {
5162
5521
  quality = this.quality;
5163
5522
  this.maxDrawingBufferPixels = quality.maxDrawingBufferPixels;
5164
5523
  this.exposure = quality.outputExposure;
5524
+ this.flushSchedule =
5525
+ quality.frameGraph && quality.identifierGraph
5526
+ ? createFlushSchedule(MAX_DRAWS_PER_FRAME)
5527
+ : null;
5165
5528
 
5166
5529
  const { device } = surface;
5167
5530
  /* Off unless a consumer asked: see `RenderQuality.gpuTiming`. A timer that can
@@ -6111,12 +6474,14 @@ export class WebGPURenderer implements RendererApi {
6111
6474
  */
6112
6475
  this.panelLayout = createPanelBindGroupLayout(device);
6113
6476
  this.panelCorners = createPanelCorners(device);
6477
+ /* The budget on one of the pair only: a panel is one ask, whichever ring runs out. */
6114
6478
  this.panelVerts = new UniformRing(
6115
6479
  device,
6116
6480
  PANEL_VERT_SIZE,
6117
6481
  MAX_OVERLAYS,
6118
6482
  USAGE_UNIFORM_DST,
6119
6483
  'panel.vertRing',
6484
+ this.panelBudget,
6120
6485
  );
6121
6486
  this.panelFrags = new UniformRing(
6122
6487
  device,
@@ -6409,6 +6774,9 @@ export class WebGPURenderer implements RendererApi {
6409
6774
  * uncaught exceptions a second in somebody's error reporting.
6410
6775
  */
6411
6776
  beginFrame(clearColor: Vec3): void {
6777
+ /* Whatever an update replaced during the last frame; nothing recorded can read it now. */
6778
+ for (const texture of this.retiredTextures) texture.destroy();
6779
+ this.retiredTextures.length = 0;
6412
6780
  /*
6413
6781
  * **Decided here and held for the frame**, so a draw cannot be recorded under one rule and
6414
6782
  * replayed under another. Multisampling is excluded: the two buffers are single-sampled and a
@@ -6418,6 +6786,8 @@ export class WebGPURenderer implements RendererApi {
6418
6786
  this.translucentQueue.reset();
6419
6787
  this.decalQueue.reset();
6420
6788
  this.reflectionQueue.reset();
6789
+ /* Redeclared every frame, so a field a consumer stops declaring stops lighting. */
6790
+ this.distanceFields.reset();
6421
6791
  this.oitActive =
6422
6792
  this.quality.orderIndependent && this.quality.screenEffects && this.samples === 1;
6423
6793
  /* Read off the line above rather than restating its last term: with the other two true, the
@@ -6492,7 +6862,9 @@ export class WebGPURenderer implements RendererApi {
6492
6862
  this.lineFrags.reset();
6493
6863
  /* The material ring, and the open slot with it: last frame's slots are gone. */
6494
6864
  this.perFrame.reset();
6495
- this.materialSlot = -1;
6865
+ this.materials.dirty();
6866
+ /* Spent by `runMotion` at the end of the last frame; a frame states its movers afresh. */
6867
+ this.motionCount = 0;
6496
6868
  /*
6497
6869
  * The canvas rather than the swap chain's texture, and the difference is that reading this
6498
6870
  * does not *acquire* anything. See `swapView`.
@@ -6503,8 +6875,22 @@ export class WebGPURenderer implements RendererApi {
6503
6875
  * without the target it draws into, and a scene is free never to call `resize`.
6504
6876
  */
6505
6877
  const { canvas } = this.surface;
6506
- this.ensureDepth(canvas.width, canvas.height);
6507
- this.ensureComposite(canvas.width, canvas.height);
6878
+ /*
6879
+ * **The render size first, because every target below is sized from it.** Off — and without a
6880
+ * composite to enlarge from — it is the drawing buffer exactly, which is what makes a frame
6881
+ * with no reconstruction byte-for-byte the frame this renderer has always drawn.
6882
+ */
6883
+ const render = this.sceneSize();
6884
+ this.renderWidth = render.width;
6885
+ this.renderHeight = render.height;
6886
+ this.ensureDepth(render.width, render.height);
6887
+ this.ensureComposite(render.width, render.height);
6888
+ this.ensureReconstruction(
6889
+ render.width,
6890
+ render.height,
6891
+ Math.max(1, canvas.width),
6892
+ Math.max(1, canvas.height),
6893
+ );
6508
6894
  /*
6509
6895
  * **Not acquired here, and that is the fix for a frame flashing black.**
6510
6896
  *
@@ -6522,6 +6908,7 @@ export class WebGPURenderer implements RendererApi {
6522
6908
  */
6523
6909
  this.frameSwapView = null;
6524
6910
  this.encoder = this.surface.device.createCommandEncoder();
6911
+ this.settleReconJitter();
6525
6912
  /*
6526
6913
  * Passes that own a target fill it here — after the encoder exists and before anything opens
6527
6914
  * the frame's render pass, including the eager path twenty lines below. See
@@ -6822,6 +7209,8 @@ export class WebGPURenderer implements RendererApi {
6822
7209
  */
6823
7210
  if (!this.reflectionPassActive) {
6824
7211
  mat4.copy(this.frameRawViewProj, camera.viewProjection);
7212
+ /* The eye in world space, which the resolve's normals are turned to face. */
7213
+ this.reconEye.set(camera.position);
6825
7214
  this.frameEye.set(camera.position);
6826
7215
  /* Held for the medium's march, which runs long after every draw and needs the sun the
6827
7216
  frame was lit by. Referenced rather than copied, exactly as `renderer.ts` holds its
@@ -6835,25 +7224,69 @@ export class WebGPURenderer implements RendererApi {
6835
7224
  * is skipped by the same guard that skips it for motion blur: it is not the picture the
6836
7225
  * history holds, and jittering it would resolve a reflection against the viewer's frame.
6837
7226
  */
7227
+ /*
7228
+ * **Reconstruction jitters instead of the temporal resolve, never as well as it.** Both
7229
+ * accumulate a history out of a jittered sequence, and running them together would resolve the
7230
+ * frame twice — once at the render size and again at the output size, against a history whose
7231
+ * own frames had already been mixed. So this is a choice rather than a pair, reconstruction
7232
+ * wins where a consumer asked for both, and `temporalResolve` is skipped while it runs.
7233
+ *
7234
+ * Its sequence is its own: `reconJitterPhases` takes eight positions for every output pixel a
7235
+ * render pixel covers, where the temporal resolve's eight are enough at one to one.
7236
+ */
7237
+ this.reconstructing = this.reconstructionWanted && !this.reflectionPassActive;
7238
+ if (this.quality.reconstruction > 0 && this.samples > 1 && !this.reconMultisampleSaid) {
7239
+ this.reconMultisampleSaid = true;
7240
+ console.warn(RECONSTRUCTION_MULTISAMPLE_REFUSAL);
7241
+ }
7242
+ if (this.reconstructing) {
7243
+ /*
7244
+ * The offset `settleReconJitter` chose for the frame at `beginFrame`, applied here and not
7245
+ * advanced here: a frame binding its mesh pass twice is one frame of the sequence.
7246
+ *
7247
+ * **The y offset is negated on the way in, and that is not a preference.** This shifts the
7248
+ * *corrected* matrix, whose y `CLIP_CORRECTION` has already negated so that the negation
7249
+ * every generated vertex stage ends with cancels out — §3 row 56. So an offset added here
7250
+ * arrives on screen with its sign reversed, while x, which nothing negates, arrives as
7251
+ * given. The temporal resolve cannot see this because its shader never reads the offset; the
7252
+ * reconstruction reads it at every texel it unprojects, and a sign error there moves every
7253
+ * sample two jitters away from where the depth says it is.
7254
+ */
7255
+ this.viewProj = jitterProjection(
7256
+ this.jitteredViewProj,
7257
+ this.correctedViewProj,
7258
+ this.reconJitter[0] as number,
7259
+ -(this.reconJitter[1] as number),
7260
+ this.renderWidth,
7261
+ this.renderHeight,
7262
+ ) as Float32Array;
7263
+ this.temporalJittering = false;
7264
+ this.temporalHistoryUsable = false;
7265
+ }
6838
7266
  this.temporalJittering =
6839
- this.quality.temporalAa && this.quality.screenEffects && !this.reflectionPassActive;
7267
+ !this.reconstructing &&
7268
+ this.quality.temporalAa &&
7269
+ this.quality.screenEffects &&
7270
+ !this.reflectionPassActive;
6840
7271
  if (this.temporalJittering) {
7272
+ /* The scene's size, not the drawing buffer's: the history is a picture of the render. */
6841
7273
  this.temporalHistoryUsable = this.temporalHistory.openFrame(
6842
- Math.max(1, this.surface.canvas.width),
6843
- Math.max(1, this.surface.canvas.height),
7274
+ this.renderWidth,
7275
+ this.renderHeight,
6844
7276
  );
6845
7277
  const [jx, jy] = jitterOffset(this.temporalHistory.frameIndex);
6846
7278
  this.temporalJitterX = jx;
6847
7279
  this.temporalJitterY = jy;
7280
+ /* A jitter is half a *render* texel, which is what the sequence is spread over. */
6848
7281
  this.viewProj = jitterProjection(
6849
7282
  this.jitteredViewProj,
6850
7283
  this.correctedViewProj,
6851
7284
  jx,
6852
7285
  jy,
6853
- Math.max(1, this.surface.canvas.width),
6854
- Math.max(1, this.surface.canvas.height),
7286
+ this.renderWidth,
7287
+ this.renderHeight,
6855
7288
  ) as Float32Array;
6856
- } else {
7289
+ } else if (!this.reconstructing) {
6857
7290
  this.temporalHistoryUsable = false;
6858
7291
  this.viewProj = this.correctedViewProj;
6859
7292
  }
@@ -7112,6 +7545,8 @@ export class WebGPURenderer implements RendererApi {
7112
7545
  * face size, and the array's stops one level below the diffuse term.
7113
7546
  */
7114
7547
  f[at('uEnvironmentEdge')] = this.probeEdge;
7548
+ /* Off until the indirect pass fills the moment layers. See `quality.indirectLight`. */
7549
+ f[at('uProbeVisibilityEnabled')] = 0;
7115
7550
  f[at('uEnvironmentIrradianceLevel')] = irradianceLevelFor(this.probeEdge);
7116
7551
  f[at('uProbeGridAmbient')] = usable && this.probeAmbient ? 1 : 0;
7117
7552
  }
@@ -7316,7 +7751,7 @@ export class WebGPURenderer implements RendererApi {
7316
7751
  * block rather than uploading it: the next draw takes the slot. `renderer.ts` resets the
7317
7752
  * same terms here, which is why a pass cannot inherit a material from the one before it.
7318
7753
  */
7319
- this.materialSlot = -1;
7754
+ this.materials.dirty();
7320
7755
  /* A pass starts with no material, exactly as `renderer.ts` resets these terms. */
7321
7756
  this.albedo = null;
7322
7757
  this.bindGroup = this.blankAlbedoBindGroup;
@@ -7332,7 +7767,7 @@ export class WebGPURenderer implements RendererApi {
7332
7767
  * each, which is what it asked for.
7333
7768
  */
7334
7769
  private materialSlotForDraw(): number | null {
7335
- if (this.materialSlot >= 0) return this.materialSlot;
7770
+ if (this.materials.open) return this.materials.slot;
7336
7771
  const slot = this.perFrame.allocate();
7337
7772
  if (slot === null) {
7338
7773
  if (!this.warnedMaterialsFull) {
@@ -7344,7 +7779,7 @@ export class WebGPURenderer implements RendererApi {
7344
7779
  return null;
7345
7780
  }
7346
7781
  this.perFrame.writeBlock(slot, this.perFrameInts);
7347
- this.materialSlot = slot;
7782
+ this.materials.slot = slot;
7348
7783
  return slot;
7349
7784
  }
7350
7785
 
@@ -7370,6 +7805,8 @@ export class WebGPURenderer implements RendererApi {
7370
7805
  model: ArrayLike<number>,
7371
7806
  depthLayer = 0,
7372
7807
  tint: Vec3 | null = null,
7808
+ /** Where it was last frame. See `RendererApi.drawMesh`, and `runMotion` for what reads it. */
7809
+ previousModel: ArrayLike<number> | null = null,
7373
7810
  ): void {
7374
7811
  /*
7375
7812
  * Skipped where the flag asks and the bounds say so.
@@ -7389,6 +7826,12 @@ export class WebGPURenderer implements RendererApi {
7389
7826
  * did before.
7390
7827
  */
7391
7828
  if (this.quality.cullDraws && this.occluded(mesh.bounds, model as ReadonlyMat4)) return;
7829
+ /*
7830
+ * Recorded after the two culls rather than before: a mesh the frame does not draw writes no
7831
+ * depth, so a motion pass drawing it would either be depth-rejected everywhere — wasted — or,
7832
+ * where it happened to pass, write motion for a surface no pixel of the frame belongs to.
7833
+ */
7834
+ if (previousModel !== null) this.recordMotion(mesh, model, previousModel);
7392
7835
  this.submitMesh(mesh, model, tint, 1, false, { depthLayer });
7393
7836
  }
7394
7837
 
@@ -7586,7 +8029,6 @@ export class WebGPURenderer implements RendererApi {
7586
8029
  /* Material state exactly like `uOpacity`, dirtied for the accumulation pass and restored
7587
8030
  below — the revealage pass draws with it at zero and its colour multiplied away. */
7588
8031
  this.perFrameFloats[this.materialField('uOitWeighted')] = this.oitMode === 'accum' ? 1 : 0;
7589
- this.materialSlot = -1;
7590
8032
  }
7591
8033
  /* Same shape as `dimmed`: a slot is only worth taking when either differs from the default
7592
8034
  `bindMeshPass` already wrote, and both are put back together once the draw is submitted. */
@@ -7597,13 +8039,11 @@ export class WebGPURenderer implements RendererApi {
7597
8039
  if (unlitOrUnfogged) {
7598
8040
  if (!lit) this.perFrameInts[this.materialField('uLightingEnabled')] = 0;
7599
8041
  if (!fog) this.perFrameInts[this.materialField('uFogEnabled')] = 0;
7600
- this.materialSlot = -1;
7601
8042
  }
7602
8043
  /* `1` is sRGB alone: the conversion without the curve. `Math.min` rather than a literal, so
7603
8044
  a renderer asked for `none` stays at none. See `TranslucentMeshOptions.toneMapped`. */
7604
8045
  if (!toneMapped) {
7605
8046
  this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.gradeCode(), 1);
7606
- this.materialSlot = -1;
7607
8047
  }
7608
8048
  /*
7609
8049
  * **Refraction, and the snapshot it reads is taken here or the draw does not refract.**
@@ -7624,8 +8064,11 @@ export class WebGPURenderer implements RendererApi {
7624
8064
  this.perFrameFloats[tintAt + 1] = refractTint?.[1] ?? 1;
7625
8065
  this.perFrameFloats[tintAt + 2] = refractTint?.[2] ?? 1;
7626
8066
  this.perFrameFloats[this.materialField('uRefractThickness')] = options.thicknessM ?? 0;
7627
- this.materialSlot = -1;
7628
8067
  }
8068
+ /* Taken for itself when any of those differs from the pass, and put back below: the rule both
8069
+ backends count material changes by. See `materialChanges.ts`. */
8070
+ const own = ownsMaterial({ opacity, lit, fog, toneMapped, refracting });
8071
+ if (own) this.materials.dirty();
7629
8072
  const base = (mesh as GpuMesh & { key?: string }).key ?? 'flat:s0:u0';
7630
8073
  /*
7631
8074
  * A rigged mesh takes the skinned vertex variant, an unrigged one the plain variant, and the
@@ -7758,23 +8201,20 @@ export class WebGPURenderer implements RendererApi {
7758
8201
  if (dimmed) {
7759
8202
  this.perFrameFloats[this.materialField('uOpacity')] = 1;
7760
8203
  this.perFrameFloats[this.materialField('uOitWeighted')] = 0;
7761
- this.materialSlot = -1;
7762
8204
  }
7763
8205
  if (unlitOrUnfogged) {
7764
8206
  if (!lit) this.perFrameInts[this.materialField('uLightingEnabled')] = 1;
7765
8207
  if (!fog) this.perFrameInts[this.materialField('uFogEnabled')] = 1;
7766
- this.materialSlot = -1;
7767
8208
  }
7768
8209
  if (refracting) {
7769
8210
  /* Back to off for every other draw in the frame, exactly as `uOpacity` is: this scratch is
7770
8211
  shared, so a strength left set is worn by everything drawn after it. */
7771
8212
  this.perFrameFloats[this.materialField('uRefractStrength')] = 0;
7772
- this.materialSlot = -1;
7773
8213
  }
7774
8214
  if (!toneMapped) {
7775
8215
  this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
7776
- this.materialSlot = -1;
7777
8216
  }
8217
+ if (own) this.materials.dirty();
7778
8218
  }
7779
8219
 
7780
8220
  /** Release geometry. */
@@ -7912,7 +8352,6 @@ export class WebGPURenderer implements RendererApi {
7912
8352
  const dimmed = opacity < 1;
7913
8353
  if (dimmed) {
7914
8354
  this.perFrameFloats[this.materialField('uOpacity')] = opacity;
7915
- this.materialSlot = -1;
7916
8355
  }
7917
8356
  const lit = options.lit ?? true;
7918
8357
  const fog = options.fog ?? true;
@@ -7921,15 +8360,16 @@ export class WebGPURenderer implements RendererApi {
7921
8360
  if (unlitOrUnfogged) {
7922
8361
  if (!lit) this.perFrameInts[this.materialField('uLightingEnabled')] = 0;
7923
8362
  if (!fog) this.perFrameInts[this.materialField('uFogEnabled')] = 0;
7924
- this.materialSlot = -1;
7925
8363
  }
7926
8364
  if (!toneMapped) {
7927
8365
  this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.gradeCode(), 1);
7928
- this.materialSlot = -1;
7929
8366
  }
7930
8367
 
7931
8368
  const depthWrite = options.depthWrite ?? true;
7932
8369
  const layer = Math.min(Math.max(Math.round(options.depthLayer ?? 0), 0), MAX_DEPTH_LAYER);
8370
+ /* An instanced draw does not refract. See `materialChanges.ts`. */
8371
+ const own = ownsMaterial({ opacity, lit, fog, toneMapped, refracting: false });
8372
+ if (own) this.materials.dirty();
7933
8373
  const base = (mesh as GpuMesh & { key?: string }).key ?? 'flat:s0:u0';
7934
8374
  /* `|inst` in the key, for the reason every other suffix is there: an instanced pipeline binds
7935
8375
  a third vertex buffer and a different vertex module, and sharing a cache entry with the
@@ -7999,17 +8439,15 @@ export class WebGPURenderer implements RendererApi {
7999
8439
  past the restore leaves the rest of the frame unlit, unfogged or undimmed. */
8000
8440
  if (dimmed) {
8001
8441
  this.perFrameFloats[this.materialField('uOpacity')] = 1;
8002
- this.materialSlot = -1;
8003
8442
  }
8004
8443
  if (unlitOrUnfogged) {
8005
8444
  this.perFrameInts[this.materialField('uLightingEnabled')] = 1;
8006
8445
  this.perFrameInts[this.materialField('uFogEnabled')] = 1;
8007
- this.materialSlot = -1;
8008
8446
  }
8009
8447
  if (!toneMapped) {
8010
8448
  this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
8011
- this.materialSlot = -1;
8012
8449
  }
8450
+ if (own) this.materials.dirty();
8013
8451
  }
8014
8452
 
8015
8453
  /* -- Scatter ------------------------------------------------------------------------ */
@@ -8826,7 +9264,7 @@ export class WebGPURenderer implements RendererApi {
8826
9264
  this.emissiveMap,
8827
9265
  );
8828
9266
  /* The open material lives in a slot of a buffer that no longer exists. */
8829
- this.materialSlot = -1;
9267
+ this.materials.dirty();
8830
9268
  }
8831
9269
 
8832
9270
  /**
@@ -8970,7 +9408,7 @@ export class WebGPURenderer implements RendererApi {
8970
9408
  * consumer running the temporal resolve without motion blur would have reprojected through a
8971
9409
  * `previousViewProj` nothing had ever written.
8972
9410
  */
8973
- const wantsReprojection = blur > 0 || this.temporalJittering;
9411
+ const wantsReprojection = blur > 0 || this.temporalJittering || this.reconstructing;
8974
9412
  if (wantsReprojection && this.viewProj !== null) {
8975
9413
  if (this.hasPreviousView) {
8976
9414
  mat4.invert(this.reprojection, this.correctedViewProj);
@@ -9025,6 +9463,15 @@ export class WebGPURenderer implements RendererApi {
9025
9463
  if (mediumActive(this.mediumOptions, this.quality.globalMediumSteps)) this.runMedium(encoder);
9026
9464
  /* After the depth it reprojects through, and before anything reads the scene. */
9027
9465
  this.temporalResolve(encoder);
9466
+ /*
9467
+ * **Reconstruction stands where the temporal resolve stands**, for the same reasons: after
9468
+ * everything that draws into the scene and after the depth it reprojects through, and before
9469
+ * bloom and the composite read the picture. It cannot copy its result back over the scene the
9470
+ * way the temporal resolve does — the two are different sizes — so the composite is given a
9471
+ * bind group of its own instead, which is the second group `temporalResolve` declined.
9472
+ */
9473
+ this.runMotion(encoder);
9474
+ this.runReconstruction(encoder);
9028
9475
  const aoStrength = this.quality.ambientOcclusion;
9029
9476
  if (aoStrength > 0 && this.frameProjection !== null) this.runOcclusion(encoder);
9030
9477
  const bloomStrength = this.quality.bloom * this.bloomScale;
@@ -9087,8 +9534,9 @@ export class WebGPURenderer implements RendererApi {
9087
9534
  this.surface.format,
9088
9535
  ),
9089
9536
  );
9090
- if (this.rushBindGroup === null) return;
9091
- pass.setBindGroup(0, this.rushBindGroup, [0]);
9537
+ const rush = this.reconstructing ? this.reconRushGroup : this.rushBindGroup;
9538
+ if (rush === null) return;
9539
+ pass.setBindGroup(0, rush, [0]);
9092
9540
  pass.draw(3);
9093
9541
  pass.end();
9094
9542
  }
@@ -9109,6 +9557,15 @@ export class WebGPURenderer implements RendererApi {
9109
9557
  if (this.sceneColorView === null || this.resolvedDepthView === null) return;
9110
9558
  if (this.aoTargetView === null) return;
9111
9559
  this.rushBindGroup = this.buildRushBindGroup('post.rushBindGroup', this.sceneColorView);
9560
+ /*
9561
+ * **And the reconstruction's twin, from the same three call sites.** A colour grade replaces
9562
+ * the lookup table and a resize replaces the depth and the occlusion target, and both groups
9563
+ * hold all three — so a second place that rebuilt only one of them is how the composite would
9564
+ * come to read a stale view on exactly the frames a consumer changed something.
9565
+ */
9566
+ if (this.reconShownView !== null) {
9567
+ this.reconRushGroup = this.buildRushBindGroup('recon.rushBindGroup', this.reconShownView);
9568
+ }
9112
9569
  }
9113
9570
 
9114
9571
  /** The composite's group over one scene source. See `rebuildRushBindGroup`. */
@@ -9162,6 +9619,506 @@ export class WebGPURenderer implements RendererApi {
9162
9619
  }
9163
9620
 
9164
9621
  /** Sample zero of the depth, into something the generated shaders can sample. */
9622
+ /**
9623
+ * Allocate what a reconstruction needs, and release it when nobody is asking.
9624
+ *
9625
+ * Sized by a key rather than by comparing four numbers, because three of the six targets are the
9626
+ * output's size and three are the render's, and a comparison that checked one pair would rebuild
9627
+ * half of them on a resize and keep the other half at the old size — which the device accepts,
9628
+ * a bind group holding views of two different sizes being perfectly legal, and which draws a
9629
+ * picture with a piece of it at the wrong scale.
9630
+ */
9631
+ private ensureReconstruction(
9632
+ renderWidth: number,
9633
+ renderHeight: number,
9634
+ outputWidth: number,
9635
+ outputHeight: number,
9636
+ ): void {
9637
+ const wanted = this.reconstructionWanted;
9638
+ const key = wanted ? `${renderWidth}x${renderHeight}:${outputWidth}x${outputHeight}` : '';
9639
+ if (key === this.reconSizeKey) return;
9640
+ this.reconSizeKey = key;
9641
+
9642
+ for (const dead of [
9643
+ ...this.reconHistories,
9644
+ this.reconShown,
9645
+ this.reconMotion,
9646
+ this.reconPreviousDepth,
9647
+ ]) {
9648
+ dead?.destroy();
9649
+ }
9650
+ this.reconHistories = [null, null];
9651
+ this.reconHistoryViews = [null, null];
9652
+ this.reconShown = null;
9653
+ this.reconShownView = null;
9654
+ this.reconMotion = null;
9655
+ this.reconMotionView = null;
9656
+ this.reconPreviousDepth = null;
9657
+ this.reconPreviousDepthView = null;
9658
+ this.reconGroups = [null, null];
9659
+ this.reconSharpenGroups = [null, null];
9660
+ this.reconRushGroup = null;
9661
+ /* A history of a size that no longer exists is not a history of this frame. */
9662
+ this.reconHasHistory = false;
9663
+ if (!wanted) return;
9664
+
9665
+ const { device } = this.surface;
9666
+ const HISTORY_USAGE = 0x4 | 0x8; // TEXTURE_BINDING | STORAGE_BINDING
9667
+ for (let i = 0; i < 2; i += 1) {
9668
+ const texture = device.createTexture({
9669
+ label: `recon.history${String(i)}`,
9670
+ size: [outputWidth, outputHeight],
9671
+ format: RECON_HISTORY_FORMAT,
9672
+ usage: HISTORY_USAGE,
9673
+ });
9674
+ this.reconHistories[i] = texture;
9675
+ this.reconHistoryViews[i] = texture.createView();
9676
+ }
9677
+ this.reconShown = device.createTexture({
9678
+ label: 'recon.shown',
9679
+ size: [outputWidth, outputHeight],
9680
+ format: RECON_HISTORY_FORMAT,
9681
+ usage: HISTORY_USAGE,
9682
+ });
9683
+ this.reconShownView = this.reconShown.createView();
9684
+ this.reconMotion = device.createTexture({
9685
+ label: 'recon.motion',
9686
+ size: [renderWidth, renderHeight],
9687
+ format: RECON_HISTORY_FORMAT,
9688
+ /* An attachment as well, because the pass that will write it is a draw. Nothing writes it
9689
+ today and nothing needs to clear it either: WebGPU zero-initialises a new texture, and
9690
+ a zero in the fourth channel is what tells the resolve to derive the camera's motion. */
9691
+ usage: 0x4 | 0x10,
9692
+ });
9693
+ this.reconMotionView = this.reconMotion.createView();
9694
+ this.reconPreviousDepth = device.createTexture({
9695
+ label: 'recon.previousDepth',
9696
+ size: [renderWidth, renderHeight],
9697
+ format: RESOLVED_DEPTH_FORMAT,
9698
+ usage: 0x4 | 0x2, // TEXTURE_BINDING | COPY_DST
9699
+ });
9700
+ this.reconPreviousDepthView = this.reconPreviousDepth.createView();
9701
+
9702
+ this.reconParams ??= device.createBuffer({
9703
+ label: 'recon.params',
9704
+ size: this.reconStaging.byteLength,
9705
+ usage: 0x40 | 0x8, // UNIFORM | COPY_DST
9706
+ });
9707
+ this.buildReconPipelines();
9708
+ this.rebuildReconGroups();
9709
+ }
9710
+
9711
+ /**
9712
+ * The two dispatches, over one explicit layout.
9713
+ *
9714
+ * **Explicit and not `layout: 'auto'`**, which §3 row 51 is about: an inferred layout names only
9715
+ * the bindings its own entry point reads, and these two read different subsets of one module —
9716
+ * the sharpen touches nothing but the history and the target. Two inferred layouts cannot share
9717
+ * a bind group, and building two groups over the same resources is two things to keep in step.
9718
+ */
9719
+ private buildReconPipelines(): void {
9720
+ if (this.reconLayout !== null) return;
9721
+ const { device } = this.surface;
9722
+ const COMPUTE = 0x4; // GPUShaderStage.COMPUTE
9723
+ const sampled = (binding: number, sampleType: GPUTextureSampleType) => ({
9724
+ binding,
9725
+ visibility: COMPUTE,
9726
+ texture: { sampleType, viewDimension: '2d' as const },
9727
+ });
9728
+ this.reconLayout = device.createBindGroupLayout({
9729
+ label: 'recon.layout',
9730
+ entries: [
9731
+ sampled(0, 'float'),
9732
+ /* r32float is not filterable, and the resolve only ever loads a depth texel. */
9733
+ sampled(1, 'unfilterable-float'),
9734
+ sampled(2, 'float'),
9735
+ sampled(3, 'unfilterable-float'),
9736
+ sampled(4, 'float'),
9737
+ { binding: 5, visibility: COMPUTE, sampler: { type: 'filtering' as const } },
9738
+ { binding: 6, visibility: COMPUTE, buffer: { type: 'uniform' as const } },
9739
+ {
9740
+ binding: 7,
9741
+ visibility: COMPUTE,
9742
+ storageTexture: {
9743
+ access: 'write-only' as const,
9744
+ format: RECON_HISTORY_FORMAT,
9745
+ viewDimension: '2d' as const,
9746
+ },
9747
+ },
9748
+ ],
9749
+ });
9750
+ const module = device.createShaderModule({ label: 'recon.resolve', code: reconResolveWgsl() });
9751
+ const layout = device.createPipelineLayout({ bindGroupLayouts: [this.reconLayout] });
9752
+ this.reconResolvePipeline = device.createComputePipeline({
9753
+ label: 'recon.resolve',
9754
+ layout,
9755
+ compute: { module, entryPoint: 'resolveMain' },
9756
+ });
9757
+ this.reconSharpenPipeline = device.createComputePipeline({
9758
+ label: 'recon.sharpen',
9759
+ layout,
9760
+ compute: { module, entryPoint: 'sharpenMain' },
9761
+ });
9762
+ }
9763
+
9764
+ /**
9765
+ * The four groups, one a direction of the ping-pong and one a dispatch.
9766
+ *
9767
+ * The resolve reads history `1 - write` and writes history `write`; the sharpen reads history
9768
+ * `write` and writes the shown picture. A group holds the views it was built with, so this is
9769
+ * called whenever any of them is replaced.
9770
+ */
9771
+ private rebuildReconGroups(): void {
9772
+ const layout = this.reconLayout;
9773
+ const params = this.reconParams;
9774
+ if (layout === null || params === null) return;
9775
+ if (this.sceneColorView === null || this.resolvedDepthView === null) return;
9776
+ if (this.reconMotionView === null || this.reconPreviousDepthView === null) return;
9777
+ if (this.reconShownView === null) return;
9778
+ const { device } = this.surface;
9779
+ const build = (label: string, history: GPUTextureView, target: GPUTextureView) =>
9780
+ device.createBindGroup({
9781
+ label,
9782
+ layout,
9783
+ entries: [
9784
+ { binding: 0, resource: this.sceneColorView as GPUTextureView },
9785
+ { binding: 1, resource: this.resolvedDepthView as GPUTextureView },
9786
+ { binding: 2, resource: this.reconMotionView as GPUTextureView },
9787
+ { binding: 3, resource: this.reconPreviousDepthView as GPUTextureView },
9788
+ { binding: 4, resource: history },
9789
+ { binding: 5, resource: this.postSampler },
9790
+ { binding: 6, resource: { buffer: params } },
9791
+ { binding: 7, resource: target },
9792
+ ],
9793
+ });
9794
+ for (let write = 0; write < 2; write += 1) {
9795
+ const read = this.reconHistoryViews[1 - write];
9796
+ const target = this.reconHistoryViews[write];
9797
+ if (read === null || target === null) continue;
9798
+ this.reconGroups[write] = build(`recon.resolve${String(write)}`, read, target);
9799
+ this.reconSharpenGroups[write] = build(
9800
+ `recon.sharpen${String(write)}`,
9801
+ target,
9802
+ this.reconShownView,
9803
+ );
9804
+ }
9805
+ /* The composite's own group, over the resolved picture instead of the scene target. */
9806
+ this.reconRushGroup = this.buildRushBindGroup('recon.rushBindGroup', this.reconShownView);
9807
+ /* And the scene's, because `ensureComposite` built it before this one existed. */
9808
+ this.rebuildRushBindGroup();
9809
+ }
9810
+
9811
+ /**
9812
+ * Remember that this mesh moved, so the motion pass can draw where it was.
9813
+ *
9814
+ * Silent unless a reconstruction is running: a scene that always states its previous transforms
9815
+ * — which is the right thing for a scene to do, the alternative being to know what the renderer
9816
+ * is configured as — must cost nothing at all when nobody is going to read them.
9817
+ */
9818
+ private recordMotion(
9819
+ mesh: GpuMesh,
9820
+ model: ArrayLike<number>,
9821
+ previousModel: ArrayLike<number>,
9822
+ ): void {
9823
+ if (!this.reconstructing) return;
9824
+ const at = this.motionCount;
9825
+ const wanted = (at + 1) * MOTION_DRAW_FLOATS;
9826
+ if (this.motionMatrices.length < wanted) {
9827
+ /* Doubling, so a scene settles on one allocation rather than one a draw. */
9828
+ const grown = new Float32Array(Math.max(wanted, this.motionMatrices.length * 2, 64));
9829
+ grown.set(this.motionMatrices);
9830
+ this.motionMatrices = grown;
9831
+ }
9832
+ this.motionMatrices.set(model as ArrayLike<number> & Iterable<number>, at * MOTION_DRAW_FLOATS);
9833
+ this.motionMatrices.set(
9834
+ previousModel as ArrayLike<number> & Iterable<number>,
9835
+ at * MOTION_DRAW_FLOATS + 16,
9836
+ );
9837
+ this.motionMeshes[at] = mesh;
9838
+ this.motionCount = at + 1;
9839
+ }
9840
+
9841
+ /**
9842
+ * The pipeline the motion pass draws with, built once.
9843
+ *
9844
+ * **The position buffer alone**, because that is all the shader reads and a mesh keeps one buffer
9845
+ * an attribute — so this binds buffer zero and nothing else, whatever else the mesh carries.
9846
+ */
9847
+ private buildMotionPipeline(): void {
9848
+ if (this.motionLayout !== null) return;
9849
+ const { device } = this.surface;
9850
+ this.motionLayout = device.createBindGroupLayout({
9851
+ label: 'recon.motion.layout',
9852
+ entries: [
9853
+ { binding: 0, visibility: 0x1, buffer: { type: 'uniform' as const } },
9854
+ {
9855
+ binding: 1,
9856
+ visibility: 0x1,
9857
+ buffer: { type: 'uniform' as const, hasDynamicOffset: true },
9858
+ },
9859
+ ],
9860
+ });
9861
+ this.motionModule = device.createShaderModule({
9862
+ label: 'recon.motion',
9863
+ code: RECON_MOTION_WGSL,
9864
+ });
9865
+ this.motionPipelineLayout = device.createPipelineLayout({
9866
+ bindGroupLayouts: [this.motionLayout],
9867
+ });
9868
+ }
9869
+
9870
+ /** The pipeline for one vertex stride, built on first sight of it. */
9871
+ private motionPipelineFor(stride: number): GPURenderPipeline | null {
9872
+ const held = this.motionPipelines.get(stride);
9873
+ if (held !== undefined) return held;
9874
+ const module = this.motionModule;
9875
+ const layout = this.motionPipelineLayout;
9876
+ if (module === null || layout === null) return null;
9877
+ const { device } = this.surface;
9878
+ const pipeline = device.createRenderPipeline({
9879
+ label: `recon.motion.${String(stride)}`,
9880
+ layout,
9881
+ vertex: {
9882
+ module,
9883
+ entryPoint: 'motionVert',
9884
+ /* Positions are the layout's first attribute and are never optional, so they are at offset
9885
+ zero of every stride this can be handed. */
9886
+ buffers: [
9887
+ {
9888
+ arrayStride: stride,
9889
+ attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }],
9890
+ },
9891
+ ],
9892
+ },
9893
+ fragment: { module, entryPoint: 'motionFrag', targets: [{ format: MOTION_TARGET_FORMAT }] },
9894
+ /*
9895
+ * **The scene's own winding**, because this draws the scene's geometry through the scene's
9896
+ * clip transform — the product the renderer hands over already carries the negation that
9897
+ * `naga` writes into every generated vertex stage, so the faces come out the way they did
9898
+ * and a `cw` front face here would draw the inside of every mesh. §3 row 57.
9899
+ */
9900
+ primitive: { topology: 'triangle-list', cullMode: 'back' },
9901
+ depthStencil: {
9902
+ format: DEPTH_FORMAT,
9903
+ /*
9904
+ * **Tested and not written.** The frame's depth is finished; this pass only wants the
9905
+ * pixels the scene actually kept, and `DEPTH_COMPARE_EQUAL` is the comparison that admits
9906
+ * exactly them — the same transform on the same positions gives the same depth, which is
9907
+ * why the rasterisation matrix is handed over whole rather than rebuilt here.
9908
+ *
9909
+ * **And the equality is not fragile here**, which was worth measuring rather than
9910
+ * assuming: this driver contracts multiply-adds (§3 row 107) and two shader modules can
9911
+ * contract a matrix product differently, so an equality across them could have admitted
9912
+ * only some of a surface. Run with `greater-equal` instead, the ghost measured the same
9913
+ * to the pixel — 5,942 either way — so nothing is being dropped.
9914
+ */
9915
+ depthWriteEnabled: false,
9916
+ depthCompare: DEPTH_COMPARE_EQUAL,
9917
+ },
9918
+ });
9919
+ this.motionPipelines.set(stride, pipeline);
9920
+ return pipeline;
9921
+ }
9922
+
9923
+ /**
9924
+ * Where every mover was last frame, into the motion target.
9925
+ *
9926
+ * **After everything that draws and before the resolve reads it.** The frame's depth has to be
9927
+ * finished for the equality test to mean anything, and the resolve is what consumes the result.
9928
+ */
9929
+ private runMotion(encoder: GPUCommandEncoder): void {
9930
+ const target = this.reconMotionView;
9931
+ const depth = this.depthView;
9932
+ if (!this.reconstructing || target === null || depth === null) return;
9933
+ const { device } = this.surface;
9934
+ this.buildMotionPipeline();
9935
+ const layout = this.motionLayout;
9936
+ if (layout === null) return;
9937
+
9938
+ /*
9939
+ * **Cleared every frame, even when nothing moved.** A texel left from last frame is a flag
9940
+ * saying "a draw wrote this", and the resolve would take a motion belonging to a surface that
9941
+ * is no longer there. The clear is the pass's own load operation, so an empty queue still runs
9942
+ * it — which is why this is not returned from before the pass is opened.
9943
+ */
9944
+ const pass = encoder.beginRenderPass({
9945
+ label: 'recon.motion',
9946
+ timestampWrites: this.gpuTimer.writesFor(),
9947
+ colorAttachments: [
9948
+ { view: target, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
9949
+ ],
9950
+ depthStencilAttachment: {
9951
+ view: depth,
9952
+ depthLoadOp: 'load',
9953
+ depthStoreOp: 'store',
9954
+ depthReadOnly: false,
9955
+ },
9956
+ });
9957
+
9958
+ const count = this.motionCount;
9959
+ if (count > 0) {
9960
+ const bytes = count * MOTION_DRAW_STRIDE;
9961
+ if (this.motionDrawCapacity < count) {
9962
+ this.motionDrawBuffer?.destroy();
9963
+ this.motionDrawCapacity = Math.max(count, this.motionDrawCapacity * 2, 16);
9964
+ this.motionDrawBuffer = device.createBuffer({
9965
+ label: 'recon.motion.draws',
9966
+ size: this.motionDrawCapacity * MOTION_DRAW_STRIDE,
9967
+ usage: 0x40 | 0x8, // UNIFORM | COPY_DST
9968
+ });
9969
+ this.motionDrawStaging = new Uint8Array(this.motionDrawCapacity * MOTION_DRAW_STRIDE);
9970
+ this.motionGroup = null;
9971
+ }
9972
+ this.motionFrameBuffer ??= device.createBuffer({
9973
+ label: 'recon.motion.frame',
9974
+ size: MOTION_FRAME_FLOATS * 4,
9975
+ usage: 0x40 | 0x8,
9976
+ });
9977
+ this.motionGroup ??= device.createBindGroup({
9978
+ label: 'recon.motion.group',
9979
+ layout,
9980
+ entries: [
9981
+ { binding: 0, resource: { buffer: this.motionFrameBuffer } },
9982
+ {
9983
+ binding: 1,
9984
+ resource: { buffer: this.motionDrawBuffer as GPUBuffer, size: MOTION_DRAW_STRIDE },
9985
+ },
9986
+ ],
9987
+ });
9988
+
9989
+ /* The scene's clip transform, with the generated stages' y negation folded in. */
9990
+ mat4.multiply(this.motionRaster, MOTION_CLIP_FLIP, this.viewProj ?? this.correctedViewProj);
9991
+ this.motionFrameStaging.set(this.motionRaster as Float32Array, 0);
9992
+ this.motionFrameStaging.set(this.frameRawViewProj as Float32Array, 16);
9993
+ this.motionFrameStaging.set(this.reconPreviousRawViewProj as Float32Array, 32);
9994
+ device.queue.writeBuffer(this.motionFrameBuffer, 0, this.motionFrameStaging);
9995
+
9996
+ const draws = new Float32Array(this.motionDrawStaging.buffer);
9997
+ for (let i = 0; i < count; i += 1) {
9998
+ draws.set(
9999
+ this.motionMatrices.subarray(i * MOTION_DRAW_FLOATS, (i + 1) * MOTION_DRAW_FLOATS),
10000
+ (i * MOTION_DRAW_STRIDE) / 4,
10001
+ );
10002
+ }
10003
+ device.queue.writeBuffer(
10004
+ this.motionDrawBuffer as GPUBuffer,
10005
+ 0,
10006
+ this.motionDrawStaging,
10007
+ 0,
10008
+ bytes,
10009
+ );
10010
+
10011
+ let bound: GPURenderPipeline | null = null;
10012
+ for (let i = 0; i < count; i += 1) {
10013
+ const mesh = this.motionMeshes[i];
10014
+ if (mesh === undefined) continue;
10015
+ const pipeline = this.motionPipelineFor(mesh.vertexStride);
10016
+ if (pipeline === null) continue;
10017
+ if (pipeline !== bound) {
10018
+ pass.setPipeline(pipeline);
10019
+ bound = pipeline;
10020
+ }
10021
+ pass.setBindGroup(0, this.motionGroup, [i * MOTION_DRAW_STRIDE]);
10022
+ pass.setVertexBuffer(0, mesh.vertexBuffers[0] as GPUBuffer);
10023
+ pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
10024
+ pass.drawIndexed(mesh.indexCount);
10025
+ }
10026
+ }
10027
+ pass.end();
10028
+ }
10029
+
10030
+ /**
10031
+ * DriftTR tier 0: the jittered render at the render size into the picture at the output size.
10032
+ *
10033
+ * `recon/resolve.ts` is the reference this is held to and `scripts/recon-parity.mjs` compares the
10034
+ * two; nothing here decides anything the reference does not.
10035
+ */
10036
+ private runReconstruction(encoder: GPUCommandEncoder): void {
10037
+ if (!this.reconstructing) return;
10038
+ const resolve = this.reconResolvePipeline;
10039
+ const sharpen = this.reconSharpenPipeline;
10040
+ const params = this.reconParams;
10041
+ const write = this.reconWrite;
10042
+ const group = this.reconGroups[write];
10043
+ const sharpenGroup = this.reconSharpenGroups[write];
10044
+ if (resolve === null || sharpen === null || params === null) return;
10045
+ if (group === null || sharpenGroup === null) return;
10046
+ const { device } = this.surface;
10047
+ const outputWidth = Math.max(1, this.surface.canvas.width);
10048
+ const outputHeight = Math.max(1, this.surface.canvas.height);
10049
+
10050
+ /*
10051
+ * **The unjittered matrices**, which is what the reference's own header insists on: the
10052
+ * history stands for the scene as it would have been drawn with no jitter, and the motion the
10053
+ * resolve derives carries none. `frameRawViewProj` is the camera's own view-projection, in
10054
+ * OpenGL's depth convention — which is the convention the shader's accessor converts the
10055
+ * renderer's reversed buffer into.
10056
+ */
10057
+ mat4.invert(this.reconInverseViewProj, this.frameRawViewProj);
10058
+ const f = this.reconFloats;
10059
+ const u = this.reconInts;
10060
+ u[0] = this.renderWidth;
10061
+ u[1] = this.renderHeight;
10062
+ u[2] = outputWidth;
10063
+ u[3] = outputHeight;
10064
+ f[4] = this.reconJitter[0] as number;
10065
+ f[5] = this.reconJitter[1] as number;
10066
+ f[6] = this.reconPreviousJitter[0] as number;
10067
+ f[7] = this.reconPreviousJitter[1] as number;
10068
+ f.set(this.reconInverseViewProj as Float32Array, 8);
10069
+ f.set(this.reconPreviousRawViewProj as Float32Array, 24);
10070
+ f.set(this.reconPreviousInverseViewProj as Float32Array, 40);
10071
+ f.set(this.reconEye, 56);
10072
+ f.set(this.reconPreviousEye, 60);
10073
+ f[64] = DEFAULT_RECON_QUALITY.alpha;
10074
+ f[65] = DEFAULT_RECON_QUALITY.sharpen;
10075
+ f[66] = DEFAULT_RECON_QUALITY.clampGamma;
10076
+ u[67] = this.reconHasHistory ? 1 : 0;
10077
+ f[68] = DEFAULT_DISOCCLUSION.depthTolerance;
10078
+ f[69] = DEFAULT_DISOCCLUSION.motionScale;
10079
+ f[70] = DEFAULT_DISOCCLUSION.normalFloor;
10080
+ f[71] = DEFAULT_DISOCCLUSION.normalCeiling;
10081
+ device.queue.writeBuffer(params, 0, this.reconStaging);
10082
+
10083
+ const groupsX = Math.ceil(outputWidth / RECON_WORKGROUP);
10084
+ const groupsY = Math.ceil(outputHeight / RECON_WORKGROUP);
10085
+ const pass = encoder.beginComputePass({
10086
+ label: 'recon.resolve',
10087
+ timestampWrites: this.gpuTimer.writesFor(),
10088
+ });
10089
+ pass.setPipeline(resolve);
10090
+ pass.setBindGroup(0, group);
10091
+ pass.dispatchWorkgroups(groupsX, groupsY, 1);
10092
+ /*
10093
+ * **The sharpen in the same pass, and that is legal because it reads a different texture.**
10094
+ * The resolve wrote history `write`; the sharpen reads it and writes the shown picture. Two
10095
+ * dispatches in one compute pass see each other's writes in recording order, and the rule
10096
+ * §3 row 52 is about — one buffer writable and readable in a single synchronisation scope —
10097
+ * does not bite, because no resource here is both.
10098
+ */
10099
+ pass.setPipeline(sharpen);
10100
+ pass.setBindGroup(0, sharpenGroup);
10101
+ pass.dispatchWorkgroups(groupsX, groupsY, 1);
10102
+ pass.end();
10103
+
10104
+ /* Last frame's depth, for the disocclusion test and the normals derived from it. */
10105
+ const depth = this.resolvedDepth;
10106
+ const previous = this.reconPreviousDepth;
10107
+ if (depth !== null && previous !== null) {
10108
+ encoder.copyTextureToTexture({ texture: depth }, { texture: previous }, [
10109
+ previous.width,
10110
+ previous.height,
10111
+ 1,
10112
+ ]);
10113
+ }
10114
+
10115
+ this.reconWrite = 1 - write;
10116
+ this.reconHasHistory = true;
10117
+ mat4.copy(this.reconPreviousRawViewProj, this.frameRawViewProj);
10118
+ mat4.copy(this.reconPreviousInverseViewProj, this.reconInverseViewProj);
10119
+ this.reconPreviousEye.set(this.reconEye);
10120
+ }
10121
+
9165
10122
  private resolveDepth(encoder: GPUCommandEncoder): void {
9166
10123
  const target = this.resolvedDepthView;
9167
10124
  const source = this.depthView;
@@ -9204,6 +10161,12 @@ export class WebGPURenderer implements RendererApi {
9204
10161
  * must still write, or the history it leaves for the next frame is whatever was in the texture.
9205
10162
  */
9206
10163
  private temporalResolve(encoder: GPUCommandEncoder): void {
10164
+ /*
10165
+ * **One guard, not two.** A second test on `reconstructing` here read as belt and braces and
10166
+ * was worse than nothing: it and the `!reconstructing` in `temporalJittering`'s own condition
10167
+ * each covered the other, so breaking either one changed no behaviour and no test could see
10168
+ * it. `temporalJittering` is where the choice is made, and it is made once.
10169
+ */
9207
10170
  if (!this.temporalJittering) return;
9208
10171
  const write = this.taaWrite;
9209
10172
  const target = this.taaViews[write];
@@ -9214,8 +10177,8 @@ export class WebGPURenderer implements RendererApi {
9214
10177
  const f = this.taaFloats;
9215
10178
  const at = (name: string): number => this.postField(TAA_FRAG_FIELDS, name);
9216
10179
  f.set(this.reprojection, at('uReprojection'));
9217
- f[at('uTexel')] = 1 / Math.max(1, this.surface.canvas.width);
9218
- f[at('uTexel') + 1] = 1 / Math.max(1, this.surface.canvas.height);
10180
+ f[at('uTexel')] = 1 / this.renderWidth;
10181
+ f[at('uTexel') + 1] = 1 / this.renderHeight;
9219
10182
  f[at('uHistoryBlend')] = this.temporalHistoryUsable ? TEMPORAL_HISTORY_BLEND : 0;
9220
10183
  device.queue.writeBuffer(this.taaUniforms, 0, this.taaStaging);
9221
10184
 
@@ -9296,8 +10259,9 @@ export class WebGPURenderer implements RendererApi {
9296
10259
  }
9297
10260
  if (!this.frameCameraSeen) return;
9298
10261
  const { device } = this.surface;
9299
- const width = Math.max(1, this.surface.canvas.width);
9300
- const height = Math.max(1, this.surface.canvas.height);
10262
+ /* The scene's size: this marches through the picture's own texels, not the viewer's. */
10263
+ const width = this.renderWidth;
10264
+ const height = this.renderHeight;
9301
10265
  const view = this.frameRawViewProj as Float32Array;
9302
10266
 
9303
10267
  mat4.invert(this.ssrDepthToWorld, view);
@@ -9406,8 +10370,9 @@ export class WebGPURenderer implements RendererApi {
9406
10370
  }
9407
10371
  if (!this.frameCameraSeen) return;
9408
10372
  const { device } = this.surface;
9409
- const width = Math.max(1, this.surface.canvas.width);
9410
- const height = Math.max(1, this.surface.canvas.height);
10373
+ /* The scene's size: a mark is projected onto the depth this frame drew, at that depth's size. */
10374
+ const width = this.renderWidth;
10375
+ const height = this.renderHeight;
9411
10376
  const view = this.frameRawViewProj as Float32Array;
9412
10377
 
9413
10378
  mat4.invert(this.decalDepthToWorld, view);
@@ -9493,6 +10458,23 @@ export class WebGPURenderer implements RendererApi {
9493
10458
  }
9494
10459
  const { device } = this.surface;
9495
10460
  const options = this.oitReplayOptions;
10461
+ /*
10462
+ * **A refracting pane reads the frame behind it, and here that frame is finished.** So the
10463
+ * copy it reads is taken now, from the resolved scene, once for the whole set — the immediate
10464
+ * path takes it by ending the scene's pass, and that pass has already ended. Only when
10465
+ * something in the set refracts, because a copy of the frame is not free.
10466
+ */
10467
+ if (this.translucentQueue.refracts && !this.refractSnapshotTaken) {
10468
+ const snapshot = this.refractSnapshot;
10469
+ const source = this.sceneColor;
10470
+ if (snapshot !== null && source !== null) {
10471
+ encoder.copyTextureToTexture({ texture: source }, { texture: snapshot }, [
10472
+ snapshot.width,
10473
+ snapshot.height,
10474
+ ]);
10475
+ this.refractSnapshotTaken = true;
10476
+ }
10477
+ }
9496
10478
 
9497
10479
  const buffers = [
9498
10480
  { mode: 'accum' as const, view: accum, clear: [0, 0, 0, 0] as const },
@@ -9524,6 +10506,12 @@ export class WebGPURenderer implements RendererApi {
9524
10506
  options.depthWrite = false;
9525
10507
  options.depthLayer = draw.depthLayer;
9526
10508
  options.tint = draw.tint as unknown as Vec3 | null;
10509
+ /* Replayed too, as the other backend replays it: a pane that refracts under sorted
10510
+ blending and stands clear under this effect reads as a bug in the effect. It did stand
10511
+ clear here until 2026-09-19. */
10512
+ options.refraction = draw.refraction;
10513
+ options.refractTint = draw.refractTint as unknown as Vec3 | undefined;
10514
+ options.thicknessM = draw.thicknessM;
9527
10515
  this.drawTranslucentMesh(draw.mesh as GpuMesh, draw.model, draw.opacity, options);
9528
10516
  });
9529
10517
  this.oitReplaying = false;
@@ -9980,6 +10968,9 @@ export class WebGPURenderer implements RendererApi {
9980
10968
  private takeRefractSnapshot(): boolean {
9981
10969
  if (this.reflectionPassActive || this.probePassActive) return false;
9982
10970
  if (this.refractSnapshotTaken) return true;
10971
+ /* The replay of an order-independent set runs after the scene's pass has ended, and `runOit`
10972
+ took the copy for it before it began; there is no pass here to end and reopen. */
10973
+ if (this.oitReplaying) return false;
9983
10974
  const snapshot = this.refractSnapshot;
9984
10975
  const source = this.sceneColor;
9985
10976
  const encoder = this.encoder;
@@ -10139,7 +11130,22 @@ export class WebGPURenderer implements RendererApi {
10139
11130
  * recorded so far writes the scene target, so this must come out as exactly one pass;
10140
11131
  * anything else means a node declared a write it does not perform.
10141
11132
  */
10142
- const passes = schedule(this.arena, this.pendingClearMask, liveOut, this.scheduledPasses);
11133
+ /*
11134
+ * **Either scheduler, into the same records**, so nothing below knows which ran. The identifier
11135
+ * graph is the one that can also express the second pipeline's frame; running it here is what
11136
+ * shows it is a generalisation of this one rather than a different renderer — see
11137
+ * `quality.identifierGraph` for the captures that say so.
11138
+ */
11139
+ const passes =
11140
+ this.flushSchedule === null
11141
+ ? schedule(this.arena, this.pendingClearMask, liveOut, this.scheduledPasses)
11142
+ : scheduleFlush(
11143
+ this.arena,
11144
+ this.pendingClearMask,
11145
+ liveOut,
11146
+ this.flushSchedule,
11147
+ this.scheduledPasses,
11148
+ );
10143
11149
  this.passesLastFlush = passes;
10144
11150
  let discards = 0;
10145
11151
  let clears = 0;
@@ -10261,6 +11267,14 @@ export class WebGPURenderer implements RendererApi {
10261
11267
  once and ran against a discarded attachment that read zero everywhere — which is why
10262
11268
  there is one of these and why this line is in it. */
10263
11269
  (this.quality.temporalAa && this.quality.screenEffects) ||
11270
+ /*
11271
+ * **And so does a reconstruction, for the same reason, and it was missing for the same
11272
+ * reason.** Without it the resolve read a discarded attachment as zero — the far plane
11273
+ * reversed — so every surface stood at infinity: a turning camera reprojected correctly,
11274
+ * since a turn moves every depth alike, and a sliding one moved nothing, and the history
11275
+ * trailed every slide by the parallax it never saw.
11276
+ */
11277
+ this.reconstructing ||
10264
11278
  /*
10265
11279
  * **The order-independent passes attach the depth and test against it**, so a frame running
10266
11280
  * them needs it to survive the main pass. Without this it is discarded, those passes test
@@ -10614,6 +11628,15 @@ export class WebGPURenderer implements RendererApi {
10614
11628
  this.flushRings();
10615
11629
  framePass.end();
10616
11630
  this.pass = null;
11631
+ /*
11632
+ * **The one window in the frame where every declaration is in and no render pass is open.**
11633
+ * A consumer declares its fields wherever in its own frame the objects live, so the earliest
11634
+ * the set is complete is after the last verb; and a compute pass cannot be recorded inside a
11635
+ * render pass, so the latest it can be recorded is before the composite opens its own. The
11636
+ * probes traced from it are read by the next frame's shading, which is what a probe volume
11637
+ * does anyway — it accumulates.
11638
+ */
11639
+ this.runIndirectLight(this.encoder);
10617
11640
  if (this.hasComposite) this.composite(this.encoder);
10618
11641
  /* And where there is no composite there is no depth to read, so a frame that submitted marks
10619
11642
  is told once rather than losing them quietly. See `refuseDecals`. */
@@ -10674,7 +11697,17 @@ export class WebGPURenderer implements RendererApi {
10674
11697
  const width = Math.max(1, this.budgeted.width);
10675
11698
  const height = Math.max(1, this.budgeted.height);
10676
11699
  this.surface.configure(width, height);
10677
- this.ensureComposite(width, height);
11700
+ /*
11701
+ * **The same split `beginFrame` makes, and it has to be made twice.** This path resized the
11702
+ * composite to the drawing buffer while the frame resized it to the render size, so the two
11703
+ * fought every time a canvas changed — and the reconstruction's bind groups, built here
11704
+ * against the previous scene depth, held a view of a texture this call had just destroyed.
11705
+ * The device's words for that are "destroyed texture used in a submit", on the next frame,
11706
+ * after a resize, which is as far from the cause as a message gets.
11707
+ */
11708
+ const render = this.sceneSize();
11709
+ this.ensureComposite(render.width, render.height);
11710
+ this.ensureReconstruction(render.width, render.height, width, height);
10678
11711
  this.ensureReflection(width, height);
10679
11712
  }
10680
11713
 
@@ -10749,6 +11782,206 @@ export class WebGPURenderer implements RendererApi {
10749
11782
  this.occlusion?.addOccluder(min as Float32Array, max as Float32Array, model);
10750
11783
  }
10751
11784
 
11785
+ /**
11786
+ * Declare a baked distance field that indirect light may be traced against.
11787
+ *
11788
+ * **The seam is `addOccluder`'s, and for `addOccluder`'s reason.** A distance field is the shape
11789
+ * of a thing it is safe to be *traced against*, and only a consumer knows which of its objects
11790
+ * that is true of — the walls and the terrain, not the leaves, not the water, not the thing it
11791
+ * is about to delete. A renderer that derived the field from whatever was drawn would trace
11792
+ * against the foliage and light the room with a hedge.
11793
+ *
11794
+ * **Declared anywhere in the frame and composed at its end**, which is the one window where
11795
+ * every declaration is in and the encoder has no render pass open. Cleared at `beginFrame` with
11796
+ * the decal and reflection queues, so a consumer redeclares every frame and a field it stops
11797
+ * declaring stops lighting.
11798
+ *
11799
+ * The field is in the model matrix's own space and the matrix must carry uniform scale only —
11800
+ * `composeGlobalField` gives the reason: a distance is not preserved by a non-uniform scale, and
11801
+ * the factor to correct by depends on the direction to the nearest surface, which is exactly
11802
+ * what a distance field does not record.
11803
+ *
11804
+ * **`albedo` is what colour this placement's surface is, and it decides what the bounce carries.**
11805
+ * What leaves a surface is the light arriving times its albedo, so a ray that lands on a wall
11806
+ * and does not know its colour returns the same answer for a red wall and a white one — measured
11807
+ * on `demo/dev/bounce.html` before this argument existed. It is a placement's rather than a
11808
+ * field's, because the same baked shape is placed many times and two pillars cut from one mould
11809
+ * may be painted differently. White where it is left out, so every existing caller is unchanged.
11810
+ */
11811
+ addDistanceField(field: FieldSource, model: ReadonlyMat4, albedo?: ArrayLike<number>): void {
11812
+ this.distanceFields.record(field, model, albedo);
11813
+ }
11814
+
11815
+ /** Forget the fields declared this frame, for a consumer that rebuilds its list mid-frame. */
11816
+ clearDistanceFields(): void {
11817
+ this.distanceFields.reset();
11818
+ }
11819
+
11820
+ /**
11821
+ * Compose what the frame declared, or nothing at all.
11822
+ *
11823
+ * **Nothing at all is the ordinary case and must cost nothing.** `quality.indirectLight` is off
11824
+ * by default and a frame that declared no fields has nothing to compose, so both are answered
11825
+ * before the composer exists — which is what keeps the eighteen scenes byte-identical with the
11826
+ * flag off rather than merely close.
11827
+ */
11828
+ private runIndirectLight(encoder: GPUCommandEncoder): void {
11829
+ /*
11830
+ * **The flag is read here and in no other place.** It used to be read again by each of the two
11831
+ * steps, and a perturbation proved neither of those could fail: the bake needs a composed
11832
+ * field, and only the composition makes one. A guard another guard covers is a line no test
11833
+ * can hold — this session has had to remove three of them.
11834
+ */
11835
+ if (!this.quality.indirectLight) return;
11836
+ this.composeDistanceField(encoder);
11837
+ this.bakeIndirectProbes(encoder);
11838
+ }
11839
+
11840
+ private composeDistanceField(encoder: GPUCommandEncoder): void {
11841
+ /*
11842
+ * **An empty frame still reaches the composer, and a test had to find that.** The early return
11843
+ * used to cover the whole method, so a frame that declared nothing left `composed` true from
11844
+ * the frame before — and the bake traced against a field built out of geometry the consumer
11845
+ * had stopped declaring. What the guard is actually for is not *building* a composer for a
11846
+ * profile that never declares anything, so that is all it now guards.
11847
+ */
11848
+ if (this.fieldComposer === null) {
11849
+ if (this.distanceFields.length === 0) return;
11850
+ this.fieldComposer = new FieldComposer(this.surface.device, DEFAULT_FIELD_COMPOSE);
11851
+ }
11852
+ this.fieldComposer.compose(encoder, this.distanceFields, this.frameEye);
11853
+ }
11854
+
11855
+ /** The world field this frame composed, or null where none was. For a pass that marches it. */
11856
+ get distanceField(): ComposedField | null {
11857
+ return this.fieldComposer?.field ?? null;
11858
+ }
11859
+
11860
+ /**
11861
+ * Refresh this frame's share of the probe grid from the world field, or do nothing at all.
11862
+ *
11863
+ * **Doing nothing is the ordinary case and must cost nothing**, which is why every condition is
11864
+ * answered before the baker exists: a frame may have composed no field — including a frame that
11865
+ * declared none after a frame that did, where the composer exists and holds nothing — and a
11866
+ * profile may have asked for no probe array. With either false this returns having allocated
11867
+ * nothing and recorded nothing, which is what keeps the scenes byte-identical.
11868
+ *
11869
+ * **The grid is the declared one, or one fitted to the fields themselves.** A consumer that
11870
+ * placed its probes always wins. One that did not gets a grid around whatever it said its light
11871
+ * may be traced against — which is the right extent rather than a convenient one, because a grid
11872
+ * sized to everything drawn would spend its probes on the inside of the sky.
11873
+ */
11874
+ private bakeIndirectProbes(encoder: GPUCommandEncoder): void {
11875
+ const field = this.fieldComposer?.field;
11876
+ if (field === undefined || field === null) return;
11877
+
11878
+ /*
11879
+ * **The grid is settled before the array is read, and the first version read it first.**
11880
+ * Fitting a grid calls `setProbeGrid`, which reallocates the array for the new layer count —
11881
+ * so a baker built from the array captured a line earlier held the one-layer array the
11882
+ * renderer starts with, and asked it for a view of layer 1. The device refused in those words.
11883
+ * It is the trap `setProbeGrid` already documents for the flat bind groups: a group holds
11884
+ * whatever texture existed when it was built, for ever, with no error anywhere until the
11885
+ * sizes disagree.
11886
+ */
11887
+ const grid = this.indirectGrid();
11888
+ if (grid === null) return;
11889
+ const array = this.probeArray;
11890
+ const view = this.probeView;
11891
+ if (array === null || view === null) return;
11892
+
11893
+ if (this.probeBaker !== null && this.bakerArray !== array) {
11894
+ this.probeBaker.dispose();
11895
+ this.probeBaker = null;
11896
+ }
11897
+ this.probeBaker ??= new ProbeBaker(this.surface.device, {
11898
+ array,
11899
+ view,
11900
+ sampler: this.probeSampler,
11901
+ edge: this.probeEdge,
11902
+ format: this.pipelines.format,
11903
+ });
11904
+ this.bakerArray = array;
11905
+ /*
11906
+ * **Whether the scene rasterised this grid first**, which decides two things: that the
11907
+ * roughness chain is already valid and must not be overwritten by a diffuse map, and that the
11908
+ * probes hold direct light for the trace to bounce. A grid that was never rasterised is traced
11909
+ * anyway and converges on whatever it started with — see `ProbeBaker`'s header.
11910
+ */
11911
+ /*
11912
+ * The frame's own sun, taken from the environment `bindMeshPass` was given rather than from a
11913
+ * capture — which is what makes a bounce follow a light that moved. A frame that drew no mesh
11914
+ * has no environment and traces with the sun off, which is the honest answer for a frame that
11915
+ * never said what was lighting it.
11916
+ */
11917
+ const env = this.frameEnv;
11918
+ this.probeBaker.bake(encoder, field, grid, this.probeBaked, {
11919
+ direction: env?.directionalDir ?? NO_SUN,
11920
+ colour: env?.directionalColor ?? NO_SUN,
11921
+ /* What a ray that left the world finds. The frame's own ambient, not a capture. */
11922
+ sky: env?.ambient ?? NO_SUN,
11923
+ });
11924
+ /*
11925
+ * **The bake is what fills the layers now, so it is what marks them filled.** `probeBaked`
11926
+ * gates the whole array, and a grid nothing rasterised would otherwise never be sampled — the
11927
+ * lit pass would read the hemispheric gradient and the traced light would go nowhere.
11928
+ */
11929
+ if (this.probeBaker.complete) {
11930
+ for (let layer = 0; layer < grid.layers; layer += 1) this.markProbeFilled(layer);
11931
+ }
11932
+ }
11933
+
11934
+ /** The declared grid, or one fitted to this frame's fields, or null where neither is possible. */
11935
+ private indirectGrid(): ProbeGrid | null {
11936
+ if (this.probes !== null) return this.probes;
11937
+ if (!distanceFieldBounds(this.distanceFields, this.fieldBoundsMin, this.fieldBoundsMax)) {
11938
+ return null;
11939
+ }
11940
+ const fitted = fitProbeGrid(
11941
+ { min: this.fieldBoundsMin, max: this.fieldBoundsMax },
11942
+ INDIRECT_PROBE_SPACING,
11943
+ MAX_ENV_PROBES,
11944
+ );
11945
+ if (!this.setProbeGrid(fitted)) {
11946
+ if (!this.indirectGridRefused) {
11947
+ this.indirectGridRefused = true;
11948
+ console.warn(
11949
+ '[driftengine] indirect light needs a probe grid and this profile has no probe array, ' +
11950
+ 'so nothing is traced. Ask for a quality profile with the environment probe enabled.',
11951
+ );
11952
+ }
11953
+ return null;
11954
+ }
11955
+ return this.probes;
11956
+ }
11957
+
11958
+ /**
11959
+ * The device milliseconds the last composed field took, or null where nothing measured it.
11960
+ *
11961
+ * One frame behind, because a timestamp is read back and a readback that blocked would be the
11962
+ * measurement changing what it measures. Null also where no field has ever been composed, and
11963
+ * where the device was not asked for `timestamp-query`.
11964
+ */
11965
+ get distanceFieldMs(): number | null {
11966
+ return this.fieldComposer?.composeMs ?? null;
11967
+ }
11968
+
11969
+ /**
11970
+ * The device milliseconds the last traced probe refresh took, or null where nothing measured it.
11971
+ *
11972
+ * **A refresh rather than a grid**, which is `PROBES_PER_FRAME` probes and is what a frame
11973
+ * actually pays. One frame behind, for the reason `distanceFieldMs` gives.
11974
+ */
11975
+ get indirectBakeMs(): number | null {
11976
+ return this.probeBaker?.bakeMs ?? null;
11977
+ }
11978
+
11979
+ /** Ask for the last frame's figures, once the frame that recorded them has been submitted. */
11980
+ readDistanceFieldTimings(): void {
11981
+ this.fieldComposer?.readTimings();
11982
+ this.probeBaker?.readTimings();
11983
+ }
11984
+
10752
11985
  /**
10753
11986
  * Whether a mesh is entirely behind the occluders declared this frame.
10754
11987
  *
@@ -10791,11 +12024,13 @@ export class WebGPURenderer implements RendererApi {
10791
12024
  pass: GPURenderPassEncoder | null;
10792
12025
  outputTransform: number;
10793
12026
  outputExposure: number;
12027
+ jitter: Float32Array;
10794
12028
  } = {
10795
12029
  backend: 'webgpu',
10796
12030
  pass: null,
10797
12031
  outputTransform: 0,
10798
12032
  outputExposure: 1,
12033
+ jitter: this.passJitter,
10799
12034
  };
10800
12035
 
10801
12036
  /**
@@ -10823,11 +12058,76 @@ export class WebGPURenderer implements RendererApi {
10823
12058
  */
10824
12059
  private preparingPasses = 0;
10825
12060
  /** Mutated in place, because `beginFrame` is a per-frame path. See `PassDefinition.prepare`. */
10826
- private readonly prepareContext: { backend: 'webgpu'; encoder: GPUCommandEncoder | null } = {
12061
+ private readonly prepareContext: {
12062
+ backend: 'webgpu';
12063
+ encoder: GPUCommandEncoder | null;
12064
+ environment: PassEnvironment | null;
12065
+ distanceField: ComposedField | null;
12066
+ jitter: Float32Array;
12067
+ } = {
10827
12068
  backend: 'webgpu',
10828
12069
  encoder: null,
12070
+ environment: null,
12071
+ distanceField: null,
12072
+ jitter: this.passJitter,
12073
+ };
12074
+ /**
12075
+ * The object handed to a pass as `PrepareContext.environment`, rebuilt only when it changes.
12076
+ *
12077
+ * **Mutated in place for the reason the context above is**: this runs once a frame for every
12078
+ * registered pass, and the house rule about per-frame allocation binds the seam as much as the
12079
+ * verbs. The view is the only field that can change identity, and it changes when the grid's
12080
+ * layer count does.
12081
+ */
12082
+ private readonly passEnvironment: {
12083
+ view: GPUTextureView | null;
12084
+ sampler: GPUSampler | null;
12085
+ edge: number;
12086
+ maxLod: number;
12087
+ irradianceLevel: number;
12088
+ irradiance: boolean;
12089
+ layers: number;
12090
+ } = {
12091
+ view: null,
12092
+ sampler: null,
12093
+ edge: 0,
12094
+ maxLod: 0,
12095
+ irradianceLevel: 0,
12096
+ irradiance: false,
12097
+ layers: 0,
10829
12098
  };
10830
12099
 
12100
+ /**
12101
+ * The frame's reconstruction jitter: one step of the sequence a frame, taken before any pass
12102
+ * prepares.
12103
+ *
12104
+ * **Here and not in `bindMeshPass`, for two reasons that were both defects.** A registered pass
12105
+ * prepares at `beginFrame`, before any mesh pass is bound, so a jitter chosen there could not
12106
+ * reach it — and the GPU-driven pipeline draws its whole world in `prepare`, unjittered, into a
12107
+ * resolve that un-jitters everything. And a frame that bound its mesh pass twice took two steps
12108
+ * of the sequence and resolved against the second.
12109
+ *
12110
+ * The sizes are the frame's by now: `ensureReconstruction` has run.
12111
+ */
12112
+ private settleReconJitter(): void {
12113
+ if (!this.reconstructionWanted) {
12114
+ this.passJitter[0] = 0;
12115
+ this.passJitter[1] = 0;
12116
+ return;
12117
+ }
12118
+ this.reconFrameIndex += 1;
12119
+ this.reconPreviousJitter.set(this.reconJitter);
12120
+ jitterOffsetFor(
12121
+ this.reconFrameIndex,
12122
+ reconJitterPhases(this.renderWidth, this.surface.canvas.width),
12123
+ this.reconJitter,
12124
+ );
12125
+ /* A render pixel is two over the render's size of the clip square; y upward, as the resolve's
12126
+ rows and the camera's own matrix both run. */
12127
+ this.passJitter[0] = (2 * (this.reconJitter[0] as number)) / this.renderWidth;
12128
+ this.passJitter[1] = (2 * (this.reconJitter[1] as number)) / this.renderHeight;
12129
+ }
12130
+
10831
12131
  /**
10832
12132
  * Give every pass that owns a target its chance to fill it, before the frame's own opens.
10833
12133
  *
@@ -10841,6 +12141,32 @@ export class WebGPURenderer implements RendererApi {
10841
12141
  private runPreparePasses(encoder: GPUCommandEncoder): void {
10842
12142
  if (this.preparingPasses === 0) return;
10843
12143
  this.prepareContext.encoder = encoder;
12144
+ /*
12145
+ * **The same test the lit pass applies to its own binding**: a view exists from the moment the
12146
+ * array is created, and what makes it *readable* is a completed bake that is not the one
12147
+ * currently open. Offering it earlier hands a pass a texture of undefined contents, which is
12148
+ * the failure `probeBaked` was introduced for on the forward path.
12149
+ */
12150
+ const usable = this.probeView !== null && this.probeBaked && !this.probePassActive;
12151
+ if (usable) {
12152
+ const environment = this.passEnvironment;
12153
+ environment.view = this.probeView;
12154
+ environment.sampler = this.probeSampler;
12155
+ environment.edge = this.probeEdge;
12156
+ environment.maxLod = this.probeMaxLod;
12157
+ environment.irradianceLevel = irradianceLevelFor(this.probeEdge);
12158
+ environment.irradiance = this.probeAmbient;
12159
+ environment.layers = this.probes?.layers ?? 1;
12160
+ this.prepareContext.environment = environment as PassEnvironment;
12161
+ } else {
12162
+ this.prepareContext.environment = null;
12163
+ }
12164
+ /*
12165
+ * The field the *previous* frame composed, because this runs at `beginFrame` and the
12166
+ * composition runs at `endFrame`. `PrepareContext.distanceField` says why that is right rather
12167
+ * than merely what happens.
12168
+ */
12169
+ this.prepareContext.distanceField = this.fieldComposer?.field ?? null;
10844
12170
  for (const definition of this.passes.definitions) {
10845
12171
  definition?.prepare?.(this.prepareContext as PrepareContext);
10846
12172
  }
@@ -11060,7 +12386,12 @@ export class WebGPURenderer implements RendererApi {
11060
12386
  depthFormat: DEPTH_FORMAT,
11061
12387
  /* The same four numbers this backend's own verbs are projected through. See `PassDevice`. */
11062
12388
  clipCorrection: CLIP_CORRECTION,
12389
+ /* And the one a pass that wrote its own WGSL wants, which is the same without the Y
12390
+ negation — that negation cancels `naga`'s, and a hand-written shader has none. */
12391
+ depthCorrection: DEPTH_CORRECTION,
11063
12392
  samples: this.samples,
12393
+ /* Whether a pass drawing the world owes the frame its jitter and its depth. See `PassDevice`. */
12394
+ reconstruction: this.reconstructionWanted,
11064
12395
  };
11065
12396
  }
11066
12397
 
@@ -11113,8 +12444,13 @@ export class WebGPURenderer implements RendererApi {
11113
12444
  const cssWidth = Math.max(canvas.clientWidth, 1);
11114
12445
  const cssHeight = Math.max(canvas.clientHeight, 1);
11115
12446
  const origin = canvas.getBoundingClientRect();
11116
- const scaleX = canvas.width / cssWidth;
11117
- const scaleY = canvas.height / cssHeight;
12447
+ /*
12448
+ * **CSS pixels to the scene's pixels, not the viewer's.** A viewport is state on the frame's
12449
+ * own pass, which draws into the render-size targets, so a box measured in the drawing buffer
12450
+ * would be scaled by the reconstruction ratio and land off the side of a smaller attachment.
12451
+ */
12452
+ const scaleX = this.renderWidth / cssWidth;
12453
+ const scaleY = this.renderHeight / cssHeight;
11118
12454
  const left = rect.left - origin.left;
11119
12455
  const top = rect.top - origin.top;
11120
12456
  const w = Math.max(1, Math.round(rect.width * scaleX));
@@ -11142,8 +12478,9 @@ export class WebGPURenderer implements RendererApi {
11142
12478
  * edge for the frames it is half-off. That asymmetry is the cost of the clamp, it is
11143
12479
  * recorded in the parity ledger, and the alternative was an exception.
11144
12480
  */
11145
- const targetWidth = canvas.width;
11146
- const targetHeight = canvas.height;
12481
+ /* The attachment this pass draws into, which is the scene's size — see `renderWidth`. */
12482
+ const targetWidth = this.renderWidth;
12483
+ const targetHeight = this.renderHeight;
11147
12484
  const clampedX = Math.max(0, Math.min(x, targetWidth));
11148
12485
  const clampedY = Math.max(0, Math.min(y, targetHeight));
11149
12486
  const clampedW = Math.max(0, Math.min(x + w, targetWidth) - clampedX);
@@ -11220,9 +12557,9 @@ export class WebGPURenderer implements RendererApi {
11220
12557
  if (this.quality.frameGraph) this.flushGraph();
11221
12558
  const pass = this.pass;
11222
12559
  if (this.surface.lost || pass === null) return;
11223
- const canvas = this.surface.context.canvas as HTMLCanvasElement;
11224
- pass.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
11225
- pass.setScissorRect(0, 0, canvas.width, canvas.height);
12560
+ /* The whole of what this pass draws into, which is the scene's size and not the viewer's. */
12561
+ pass.setViewport(0, 0, this.renderWidth, this.renderHeight, 0, 1);
12562
+ pass.setScissorRect(0, 0, this.renderWidth, this.renderHeight);
11226
12563
  }
11227
12564
 
11228
12565
  /**
@@ -11742,12 +13079,28 @@ export class WebGPURenderer implements RendererApi {
11742
13079
  return this.probes !== null && this.probeFilledCount === this.probes.layers;
11743
13080
  }
11744
13081
 
11745
- /** Record that one layer now holds a convolution, without counting a rebake twice. */
13082
+ /**
13083
+ * Record that one layer now holds a convolution, without counting a rebake twice.
13084
+ *
13085
+ * **And rebuild the flat groups on the last one**, which is the third time this backend has paid
13086
+ * for a bind group holding whatever texture existed when it was built. `flatTextures` resolves
13087
+ * `uEnvironment` to a one-texel **white** stand-in while `probeBaked` is false, so a grid filled
13088
+ * by the trace rather than by `bakeProbeGrid` — which runs before any frame and therefore before
13089
+ * any group — leaves every flat group holding that white for the life of the renderer. The
13090
+ * uniform beside it flips to "use the grid" and the grid the shader reads is pure white.
13091
+ *
13092
+ * Measured on `demo/dev/bounce.html?seed=0`: every surface came back at exactly its own albedo
13093
+ * times 255, with the sun switched off and the trace pinned to a constant. It had been recorded
13094
+ * as undefined memory in the probe array; it is a deliberate white texture, read on purpose.
13095
+ * The refraction snapshot's own note two hundred lines below says the same thing about the same
13096
+ * mistake, and `setProbeGrid` says it about the array being replaced.
13097
+ */
11746
13098
  private markProbeFilled(layer: number): void {
11747
13099
  if (layer < 0 || layer >= this.probeFilled.length) return;
11748
13100
  if (this.probeFilled[layer] === 1) return;
11749
13101
  this.probeFilled[layer] = 1;
11750
13102
  this.probeFilledCount++;
13103
+ if (this.probeBaked) this.rebuildFlatGroupsForNewRings();
11751
13104
  }
11752
13105
 
11753
13106
  /**
@@ -11769,6 +13122,47 @@ export class WebGPURenderer implements RendererApi {
11769
13122
  this.probeView = this.probeArray.createView({ dimension: '2d-array' });
11770
13123
  this.probeFilled = new Uint8Array(layers);
11771
13124
  this.probeFilledCount = 0;
13125
+
13126
+ /*
13127
+ * **Cleared here, because a texture no pass has written holds undefined contents** — the same
13128
+ * sentence the directional shadow maps are cleared under, arrived at from the other end.
13129
+ *
13130
+ * A traced probe reads the array's own irradiance level back as the light that has already
13131
+ * bounced, so an unwritten array is not "nothing has bounced yet": it is whatever the
13132
+ * allocator left, amplified by every refresh. Measured on `demo/dev/bounce.html` with
13133
+ * `?seed=0`, a grid nothing had rasterised settled on a uniform **209 of 255** in every
13134
+ * channel, which reads as a working ambient term and is memory.
13135
+ *
13136
+ * **Every level, not only the one the bake writes.** The roughness chain is sampled by
13137
+ * reflective surfaces long before any bake has filled it, and a first fill writes the whole
13138
+ * chain only for a layer the scheduler has reached.
13139
+ */
13140
+ const clear = device.createCommandEncoder({ label: 'probe.clear' });
13141
+ const levels = irradianceLevelFor(this.probeEdge) + 1;
13142
+ for (let layer = 0; layer < layers; layer += 1) {
13143
+ for (let level = 0; level < levels; level += 1) {
13144
+ clear
13145
+ .beginRenderPass({
13146
+ label: 'probe.clear',
13147
+ colorAttachments: [
13148
+ {
13149
+ view: this.probeArray.createView({
13150
+ dimension: '2d',
13151
+ baseArrayLayer: layer,
13152
+ arrayLayerCount: 1,
13153
+ baseMipLevel: level,
13154
+ mipLevelCount: 1,
13155
+ }),
13156
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
13157
+ loadOp: 'clear',
13158
+ storeOp: 'store',
13159
+ },
13160
+ ],
13161
+ })
13162
+ .end();
13163
+ }
13164
+ }
13165
+ device.queue.submit([clear.finish()]);
11772
13166
  }
11773
13167
 
11774
13168
  /**
@@ -11933,7 +13327,7 @@ export class WebGPURenderer implements RendererApi {
11933
13327
  const heldExposure = this.perFrameFloats[this.materialField('uOutputExposure')] ?? 1;
11934
13328
  this.perFrameInts[this.materialField('uOutputTransform')] = 0;
11935
13329
  this.perFrameFloats[this.materialField('uOutputExposure')] = 1;
11936
- this.materialSlot = -1;
13330
+ this.materials.dirty();
11937
13331
 
11938
13332
  for (let face = 0; face < PROBE_FACES.length; face++) {
11939
13333
  const aim = PROBE_FACES[face];
@@ -12012,7 +13406,7 @@ export class WebGPURenderer implements RendererApi {
12012
13406
  this.markProbeFilled(layer);
12013
13407
  this.perFrameInts[this.materialField('uOutputTransform')] = heldGrade;
12014
13408
  this.perFrameFloats[this.materialField('uOutputExposure')] = heldExposure;
12015
- this.materialSlot = -1;
13409
+ this.materials.dirty();
12016
13410
  this.probePassActive = false;
12017
13411
  this.rebuildFlatBindGroup();
12018
13412
  return true;
@@ -12514,7 +13908,13 @@ export class WebGPURenderer implements RendererApi {
12514
13908
  label: 'post.resolvedDepth',
12515
13909
  size: [width, height],
12516
13910
  format: RESOLVED_DEPTH_FORMAT,
12517
- usage: USAGE,
13911
+ /*
13912
+ * **`COPY_SRC` only where a reconstruction is going to keep it**, because a usage is a
13913
+ * promise about the whole life of the texture and every frame that does not reconstruct
13914
+ * would be paying for one it never makes. A reconstruction copies this into its own target
13915
+ * at the end of the frame, which is what its disocclusion tests against next frame.
13916
+ */
13917
+ usage: this.quality.reconstruction > 0 ? USAGE | 0x1 : USAGE,
12518
13918
  });
12519
13919
  this.resolvedDepthView = this.resolvedDepth.createView();
12520
13920
  /*
@@ -12983,6 +14383,8 @@ export class WebGPURenderer implements RendererApi {
12983
14383
  * the 2026-08-13 rule asks for — one decision, bound twice.
12984
14384
  */
12985
14385
  dispose(): void {
14386
+ for (const texture of this.retiredTextures) texture.destroy();
14387
+ this.retiredTextures.length = 0;
12986
14388
  const passDevice = this.passDevice();
12987
14389
  drainRegistry(this.passes, (definition) => definition.dispose?.(passDevice));
12988
14390
  this.passReads.clear();
@@ -12993,6 +14395,9 @@ export class WebGPURenderer implements RendererApi {
12993
14395
  const computeDevice = this.computeDevice();
12994
14396
  drainRegistry(this.computes, (definition) => definition.dispose?.(computeDevice));
12995
14397
 
14398
+ this.fieldComposer?.dispose();
14399
+ this.fieldComposer = null;
14400
+
12996
14401
  this.pass = null;
12997
14402
  this.encoder = null;
12998
14403
  this.skinPalettes.dispose();