@driftengine/core 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/CHANGELOG.json +282 -0
  2. package/README.md +1 -1
  3. package/dist/cinematic/cameraPath.d.ts +53 -0
  4. package/dist/cinematic/cameraPath.js +118 -0
  5. package/dist/environment/daylight.d.ts +77 -0
  6. package/dist/environment/daylight.js +77 -0
  7. package/dist/geometry/meshConcat.d.ts +9 -0
  8. package/dist/geometry/meshConcat.js +101 -0
  9. package/dist/index.d.ts +12 -2
  10. package/dist/index.js +14 -1
  11. package/dist/render/ambientOcclusionPass.js +5 -1
  12. package/dist/render/backend/createRenderer.d.ts +13 -1
  13. package/dist/render/backend/createRenderer.js +6 -5
  14. package/dist/render/backend/webgl2/renderer.d.ts +147 -1
  15. package/dist/render/backend/webgl2/renderer.js +402 -23
  16. package/dist/render/backend/webgpu/buffers.js +46 -8
  17. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  18. package/dist/render/backend/webgpu/depthPass.js +75 -14
  19. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  20. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  21. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  22. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  23. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  24. package/dist/render/backend/webgpu/flatPass.js +12 -5
  25. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  26. package/dist/render/backend/webgpu/particlePass.js +1 -0
  27. package/dist/render/backend/webgpu/postPass.d.ts +13 -3
  28. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  29. package/dist/render/backend/webgpu/probeBake.js +97 -7
  30. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  31. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  32. package/dist/render/backend/webgpu/renderer.d.ts +174 -11
  33. package/dist/render/backend/webgpu/renderer.js +682 -252
  34. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  35. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  36. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  37. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  38. package/dist/render/backend/webgpu/skyPass.js +5 -1
  39. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  40. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  41. package/dist/render/bloomChain.d.ts +6 -0
  42. package/dist/render/bloomChain.js +8 -0
  43. package/dist/render/clusteredLights.d.ts +19 -11
  44. package/dist/render/clusteredLights.js +96 -45
  45. package/dist/render/cutoutCaster.d.ts +23 -0
  46. package/dist/render/cutoutCaster.js +20 -0
  47. package/dist/render/driftLight/bake.d.ts +34 -0
  48. package/dist/render/driftLight/bake.js +95 -0
  49. package/dist/render/driftLight/falloff.d.ts +16 -0
  50. package/dist/render/driftLight/falloff.js +23 -0
  51. package/dist/render/driftLight/layout.d.ts +59 -0
  52. package/dist/render/driftLight/layout.js +138 -0
  53. package/dist/render/driftLight/lightField.d.ts +108 -0
  54. package/dist/render/driftLight/lightField.js +179 -0
  55. package/dist/render/driftLight/uniforms.d.ts +28 -0
  56. package/dist/render/driftLight/uniforms.js +41 -0
  57. package/dist/render/driftLight/visibility.d.ts +18 -0
  58. package/dist/render/driftLight/visibility.js +47 -0
  59. package/dist/render/emptyTexture.d.ts +6 -0
  60. package/dist/render/emptyTexture.js +19 -0
  61. package/dist/render/exposurePass.d.ts +48 -0
  62. package/dist/render/exposurePass.js +172 -0
  63. package/dist/render/flameLight.d.ts +71 -0
  64. package/dist/render/flameLight.js +63 -0
  65. package/dist/render/gi/bounceLights.d.ts +34 -0
  66. package/dist/render/gi/bounceLights.js +66 -0
  67. package/dist/render/gi/fieldReach.d.ts +12 -0
  68. package/dist/render/gi/fieldReach.js +62 -0
  69. package/dist/render/gi/globalField.d.ts +8 -0
  70. package/dist/render/gi/globalField.js +31 -0
  71. package/dist/render/gi/probeTrace.d.ts +3 -1
  72. package/dist/render/gi/probeTrace.js +3 -1
  73. package/dist/render/gpuCapability.js +19 -4
  74. package/dist/render/lightBudget.d.ts +11 -0
  75. package/dist/render/lightBudget.js +11 -0
  76. package/dist/render/pointLightSelection.d.ts +16 -0
  77. package/dist/render/pointLightSelection.js +178 -83
  78. package/dist/render/pointShadowBudget.d.ts +12 -2
  79. package/dist/render/pointShadowBudget.js +16 -10
  80. package/dist/render/probeGrid.d.ts +11 -1
  81. package/dist/render/probeGrid.js +5 -2
  82. package/dist/render/probeSweeps.d.ts +45 -0
  83. package/dist/render/probeSweeps.js +77 -0
  84. package/dist/render/reflectionProbe.d.ts +43 -1
  85. package/dist/render/reflectionProbe.js +17 -3
  86. package/dist/render/renderQuality.d.ts +3 -1
  87. package/dist/render/rendererHarness.d.ts +9 -0
  88. package/dist/render/rendererHarness.js +18 -3
  89. package/dist/render/sceneTarget.d.ts +20 -0
  90. package/dist/render/sceneTarget.js +62 -1
  91. package/dist/render/shaders/ambientOcclusion.d.ts +63 -6
  92. package/dist/render/shaders/ambientOcclusion.js +188 -71
  93. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  94. package/dist/render/shaders/cutoutCoverage.js +40 -0
  95. package/dist/render/shaders/depth.d.ts +25 -0
  96. package/dist/render/shaders/depth.js +69 -3
  97. package/dist/render/shaders/exposure.d.ts +52 -0
  98. package/dist/render/shaders/exposure.js +124 -0
  99. package/dist/render/shaders/filmLook.d.ts +47 -0
  100. package/dist/render/shaders/filmLook.js +98 -0
  101. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  102. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  103. package/dist/render/shaders/flat/driftLight.js +75 -0
  104. package/dist/render/shaders/flat/index.js +5 -0
  105. package/dist/render/shaders/flat/lobes.js +13 -0
  106. package/dist/render/shaders/flat/main.d.ts +1 -1
  107. package/dist/render/shaders/flat/main.js +68 -9
  108. package/dist/render/shaders/flat/preamble.js +23 -5
  109. package/dist/render/shaders/flat/probeGrid.js +46 -3
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +8 -3
  111. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +8 -3
  112. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  113. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  115. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  116. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  117. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  118. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  119. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  120. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  121. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  123. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  124. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  125. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  126. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  127. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  128. package/dist/render/shaders/globalMedium.js +4 -0
  129. package/dist/render/shaders/localExposure.d.ts +35 -0
  130. package/dist/render/shaders/localExposure.js +138 -0
  131. package/dist/render/shaders/prefilter.d.ts +1 -1
  132. package/dist/render/shaders/prefilter.js +6 -0
  133. package/dist/render/shaders/rush.js +33 -5
  134. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  135. package/dist/render/shaders/tangentFrame.js +15 -3
  136. package/dist/render/shaders/temporalResolve.js +39 -1
  137. package/dist/render/shadowCasters.d.ts +5 -3
  138. package/dist/render/shadowMap.js +13 -0
  139. package/dist/render/surfaceTexture.d.ts +5 -0
  140. package/package.json +6 -6
  141. package/scripts/gpuCompute.mjs +24 -3
  142. package/scripts/png.mjs +23 -12
  143. package/src/cinematic/cameraPath.ts +178 -0
  144. package/src/environment/daylight.ts +152 -0
  145. package/src/geometry/meshConcat.ts +100 -0
  146. package/src/index.ts +32 -0
  147. package/src/render/ambientOcclusionPass.ts +5 -1
  148. package/src/render/backend/createRenderer.ts +22 -6
  149. package/src/render/backend/webgl2/renderer.ts +439 -20
  150. package/src/render/backend/webgpu/buffers.ts +51 -8
  151. package/src/render/backend/webgpu/depthPass.ts +78 -10
  152. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  153. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  154. package/src/render/backend/webgpu/flatPass.ts +10 -2
  155. package/src/render/backend/webgpu/particlePass.ts +9 -0
  156. package/src/render/backend/webgpu/probeBake.ts +143 -9
  157. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  158. package/src/render/backend/webgpu/renderer.ts +808 -273
  159. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  160. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  161. package/src/render/backend/webgpu/skyPass.ts +5 -1
  162. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  163. package/src/render/bloomChain.ts +9 -0
  164. package/src/render/clusteredLights.ts +108 -63
  165. package/src/render/cutoutCaster.ts +44 -0
  166. package/src/render/driftLight/bake.ts +125 -0
  167. package/src/render/driftLight/falloff.ts +26 -0
  168. package/src/render/driftLight/layout.ts +177 -0
  169. package/src/render/driftLight/lightField.ts +227 -0
  170. package/src/render/driftLight/uniforms.ts +61 -0
  171. package/src/render/driftLight/visibility.ts +58 -0
  172. package/src/render/emptyTexture.ts +41 -0
  173. package/src/render/exposurePass.ts +186 -0
  174. package/src/render/flameLight.ts +135 -0
  175. package/src/render/gi/bounceLights.ts +68 -0
  176. package/src/render/gi/fieldReach.ts +74 -0
  177. package/src/render/gi/globalField.ts +39 -0
  178. package/src/render/gi/probeTrace.ts +3 -1
  179. package/src/render/gpuCapability.ts +18 -5
  180. package/src/render/lightBudget.ts +12 -0
  181. package/src/render/pointLightSelection.ts +191 -79
  182. package/src/render/pointShadowBudget.ts +16 -8
  183. package/src/render/probeGrid.ts +13 -2
  184. package/src/render/probeSweeps.ts +80 -0
  185. package/src/render/reflectionProbe.ts +54 -2
  186. package/src/render/renderQuality.ts +3 -1
  187. package/src/render/rendererHarness.ts +25 -3
  188. package/src/render/sceneTarget.ts +75 -1
  189. package/src/render/shaders/ambientOcclusion.ts +205 -71
  190. package/src/render/shaders/cutoutCoverage.ts +43 -0
  191. package/src/render/shaders/depth.ts +80 -3
  192. package/src/render/shaders/exposure.ts +128 -0
  193. package/src/render/shaders/filmLook.ts +111 -0
  194. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  195. package/src/render/shaders/flat/driftLight.ts +75 -0
  196. package/src/render/shaders/flat/index.ts +5 -0
  197. package/src/render/shaders/flat/lobes.ts +13 -0
  198. package/src/render/shaders/flat/main.ts +68 -9
  199. package/src/render/shaders/flat/preamble.ts +23 -5
  200. package/src/render/shaders/flat/probeGrid.ts +46 -3
  201. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +8 -3
  202. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  203. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  204. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  205. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  206. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  207. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  208. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  209. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  210. package/src/render/shaders/globalMedium.ts +4 -0
  211. package/src/render/shaders/localExposure.ts +154 -0
  212. package/src/render/shaders/prefilter.ts +6 -0
  213. package/src/render/shaders/rush.ts +33 -5
  214. package/src/render/shaders/tangentFrame.ts +15 -3
  215. package/src/render/shaders/temporalResolve.ts +39 -1
  216. package/src/render/shadowCasters.ts +5 -3
  217. package/src/render/shadowMap.ts +14 -0
  218. package/src/render/surfaceTexture.ts +5 -0
@@ -1,5 +1,6 @@
1
1
  import { mat4 } from 'gl-matrix';
2
2
  import type { ProbeBakeOptions } from '../../reflectionProbe.ts';
3
+ import { probeFaceRange } from '../../reflectionProbe.ts';
3
4
  import {
4
5
  TEMPORAL_HISTORY_BLEND,
5
6
  TemporalHistory,
@@ -55,6 +56,7 @@ import {
55
56
  BLOOM_FILTER_RADIUS_UV,
56
57
  BLOOM_LEVELS,
57
58
  bloomLevelSizes,
59
+ bloomThresholdOf,
58
60
  bloomProfileWarning,
59
61
  } from '../../bloomChain.ts';
60
62
  import {
@@ -93,6 +95,7 @@ import {
93
95
  sameGrid,
94
96
  } from '../../probeGrid.ts';
95
97
  import type { ProbeGridOptions } from '../../probeGrid.ts';
98
+ import { ProbeSweeps } from '../../probeSweeps.ts';
96
99
  import { ggxMaxLevelFor, irradianceLevelFor, octahedralEdgeFor } from '../../prefilterEnvMap.ts';
97
100
  import { environmentTexels } from './environmentTexels.ts';
98
101
  import { packIesAtlas } from '../../iesProfile.ts';
@@ -105,7 +108,6 @@ import {
105
108
  DEFAULT_SOURCE_RADIUS,
106
109
  FACE_COUNT,
107
110
  createResolvedPointShadows,
108
- type ResolvedPointShadows,
109
111
  } from '../../pointShadowImage.ts';
110
112
  import { MAX_AREA_LIGHTS, type AreaLightSource } from '../../areaLights.ts';
111
113
  import {
@@ -178,6 +180,10 @@ import type { RenderBackend } from '../api.ts';
178
180
  import { reflectionTargetSize, type ReflectionSize } from '../../planarReflectionDraw.ts';
179
181
  import { drawingBufferSize } from '../../drawingBuffer.ts';
180
182
  import { MOTION_BLUR_MAX_UV, OUTPUT_TRANSFORM_CODE, RUSH_REACH_UV } from '../../vertexDefaults.ts';
183
+ import { ExposurePass } from './exposurePass.ts';
184
+ import { AO_STORE } from '../../shaders/ambientOcclusion.ts';
185
+ import { clampAutoExposure } from '../../shaders/exposure.ts';
186
+ import { clampLocalExposure } from '../../shaders/localExposure.ts';
181
187
  import type { UniformFields } from './scatterPass.ts';
182
188
  import {
183
189
  createDepthResolveLayout,
@@ -253,12 +259,15 @@ import {
253
259
  identityGradeLut,
254
260
  validateGradeLut,
255
261
  } from '../../colourGrade.ts';
262
+ import {
263
+ FILM_LOOK_WITHOUT_COMPOSITE,
264
+ clampGrain,
265
+ clampVignette,
266
+ grainSeed,
267
+ } from '../../shaders/filmLook.ts';
256
268
  import {
257
269
  createProbeMipBindGroupLayout,
258
270
  createProbePrefilterBindGroupLayout,
259
- PREFILTER_SAMPLER_BINDING,
260
- PREFILTER_TEXTURE_BINDING,
261
- PREFILTER_UNIFORM_BINDING,
262
271
  PREFILTER_UNIFORM_OFFSETS,
263
272
  probeLevels,
264
273
  probeMipPipeline,
@@ -339,6 +348,9 @@ import {
339
348
  DEPTH_FRAG_SIZE,
340
349
  DEPTH_FRAG_WGSL,
341
350
  DEPTH_PREVIOUS_BINDING,
351
+ DEPTH_CUTOUT_VERT_FIELDS,
352
+ DEPTH_CUTOUT_VERT_SIZE,
353
+ DEPTH_INSTANCED_CUTOUT_VERT_FIELDS,
342
354
  DEPTH_VERT_FIELDS,
343
355
  DEPTH_VERT_SIZE,
344
356
  SHADOW_FORMAT,
@@ -394,7 +406,6 @@ import {
394
406
  } from './plumePass.ts';
395
407
  import {
396
408
  PARTICLE_VERT_FIELDS,
397
- PARTICLE_VERT_SIZE,
398
409
  createGpuParticles,
399
410
  createParticleBindGroupLayout,
400
411
  packInstances,
@@ -461,8 +472,12 @@ import { MaterialChanges, ownsMaterial } from '../materialChanges.ts';
461
472
  import { DYNAMIC_ALIGNMENT as DYNAMIC_UNIFORM_ALIGNMENT, UniformRing } from './uniformRing.ts';
462
473
  import { DecalQueue, MAX_DRAWN_DECALS } from '../../decalQueue.ts';
463
474
  import { DistanceFieldScene } from '../../gi/fieldScene.ts';
464
- import { DEFAULT_FIELD_COMPOSE, FieldComposer } from './fieldCompose.ts';
475
+ import { DEFAULT_FIELD_COMPOSE, FieldComposer, MAX_FIELD_CASCADES } from './fieldCompose.ts';
476
+ import { cascadesForGrid } from '../../gi/fieldReach.ts';
465
477
  import { ProbeBaker } from './probeBake.ts';
478
+ import type { ProbeBakeLightField } from './probeBake.ts';
479
+ import { createBounceLights, resolveBounceLights } from '../../gi/bounceLights.ts';
480
+ import { ProbeTargets } from './probeTargets.ts';
466
481
  import { distanceFieldBounds } from '../../gi/fieldScene.ts';
467
482
  import { fitProbeGrid } from '../../gi/probeGridFit.ts';
468
483
 
@@ -535,6 +550,11 @@ import type {
535
550
  import type { Arena, FlushSchedule, ScheduledPass } from '../../frame/index.ts';
536
551
  import { GpuInstancedBatch } from './instanced.ts';
537
552
  import type { MeshInstances } from '../../instances.ts';
553
+ import { cutoutOf } from '../../cutoutCaster.ts';
554
+ import type { CutoutCaster } from '../../cutoutCaster.ts';
555
+ import { LightField } from '../../driftLight/lightField.ts';
556
+ import type { LightFieldOptions, LightFieldSource } from '../../driftLight/lightField.ts';
557
+ import { createDriftLightUniforms, resolveDriftLight } from '../../driftLight/uniforms.ts';
538
558
 
539
559
  /**
540
560
  * How many draws one frame may make before the ring is full.
@@ -588,6 +608,12 @@ const MAX_SCATTER_DEPTH_DRAWS = 256;
588
608
  * against the world.
589
609
  */
590
610
  const MAX_OVERLAYS = 64;
611
+ /**
612
+ * Sky draws a frame can hold. A frame draws one, a mirror adds one, and a probe bake six a probe —
613
+ * given back after each probe's submit, so a whole grid needs six. Sixty-four is room for insets
614
+ * and passes nobody has written yet; the cost is sixteen kilobytes.
615
+ */
616
+ const MAX_SKY_DRAWS = 64;
591
617
 
592
618
  /**
593
619
  * Distinct materials a frame may set.
@@ -1213,8 +1239,6 @@ type ShadowLayer = 'static' | 'static-peel' | 'dynamic';
1213
1239
 
1214
1240
  /** The tint a draw gets when it asks for none, matching the WebGL2 path's reset-to-white. */
1215
1241
  const WHITE = new Float32Array([1, 1, 1]);
1216
- /** No surface-texture scaling, which is what an untextured mesh wants. */
1217
- const UNIT_UV = new Float32Array([1, 1]);
1218
1242
  /** A dust field standing still, for a light volume that asked for dust and not for wind. */
1219
1243
  const NO_DRIFT = new Float32Array([0, 0, 0]);
1220
1244
  /**
@@ -1265,6 +1289,24 @@ const NO_CLIP_PLANE = new Float32Array(4);
1265
1289
  */
1266
1290
  const MOTION_CLIP_FLIP = mat4.fromValues(1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
1267
1291
 
1292
+ /* Scratch for the probe blur's and convolution's uniform writes, so a bake writes no array literal. */
1293
+ const SCRATCH_ONE = new Float32Array(1);
1294
+ const SCRATCH_VEC4 = new Float32Array(4);
1295
+ const ZERO4 = new Float32Array(4);
1296
+ /** `value` in a one-float scratch, overwritten by the next call. */
1297
+ function one(value: number): Float32Array {
1298
+ SCRATCH_ONE[0] = value;
1299
+ return SCRATCH_ONE;
1300
+ }
1301
+ /** A direction padded to a `vec4`, in a scratch overwritten by the next call. */
1302
+ function padded(direction: ArrayLike<number>): Float32Array {
1303
+ SCRATCH_VEC4[0] = direction[0] ?? 0;
1304
+ SCRATCH_VEC4[1] = direction[1] ?? 0;
1305
+ SCRATCH_VEC4[2] = direction[2] ?? 0;
1306
+ SCRATCH_VEC4[3] = 0;
1307
+ return SCRATCH_VEC4;
1308
+ }
1309
+
1268
1310
  export class WebGPURenderer implements RendererApi {
1269
1311
  /**
1270
1312
  * Real timestamp queries, where the adapter offers them.
@@ -2081,13 +2123,21 @@ export class WebGPURenderer implements RendererApi {
2081
2123
  private readonly frameEye = new Float32Array(3);
2082
2124
  /** The environment the frame was drawn with. See `bindMeshPass`. */
2083
2125
  private frameEnv: Environment | null = null;
2126
+ /**
2127
+ * The sky this frame drew, or null until it draws one. A traced probe's escaping rays read it,
2128
+ * so a frame that stops drawing a sky must not go on lighting its probes with the last one.
2129
+ */
2130
+ private frameSky: SkyColors | null = null;
2084
2131
  /** That matrix with the clip-space correction folded in, rebuilt once a frame. */
2085
2132
  private readonly correctedViewProj = new Float32Array(16);
2086
2133
 
2087
2134
  private readonly skyLayout: GPUBindGroupLayout;
2088
- private readonly skyUniforms: GPUBuffer;
2089
- private readonly skyStaging = new ArrayBuffer(SKY_UNIFORM_SIZE);
2090
- private readonly skyFloats = new Float32Array(this.skyStaging);
2135
+ /**
2136
+ * A slot a sky draw, as every per-draw resource here is: see `UniformRing` and the 2026-08-27 rule.
2137
+ * It was one buffer rewritten at each draw, which gave every sky in an encoder the last camera.
2138
+ */
2139
+ private readonly skyUniforms: UniformRing;
2140
+ private readonly skyFloats = new Float32Array(SKY_UNIFORM_SIZE / 4);
2091
2141
  private readonly skyBindGroup: GPUBindGroup;
2092
2142
  /** The camera's inverse with the clip correction undone, rebuilt once a frame. */
2093
2143
  private readonly skyInvViewProj = new Float32Array(16);
@@ -2363,35 +2413,74 @@ export class WebGPURenderer implements RendererApi {
2363
2413
  * prefix that has already been uploaded costs one write of unchanged bytes and cannot be
2364
2414
  * wrong: a ring only ever appends within a frame.
2365
2415
  */
2416
+ /**
2417
+ * Every per-draw ring the frame writes and flushes together, in one list.
2418
+ *
2419
+ * **One list, read by the flush and by the probe bake's rewind**, so a ring added to one cannot be
2420
+ * forgotten by the other: a ring flushed but never rewound would let a grid bake spend it, and one
2421
+ * rewound but never flushed would upload nothing. Built on first use, after the constructor has
2422
+ * set every field; `growTo` replaces a ring's buffer, never the ring, so the list stays true.
2423
+ */
2424
+ private ringList: readonly UniformRing[] | null = null;
2425
+ private rings(): readonly UniformRing[] {
2426
+ this.ringList ??= [
2427
+ this.perDraw,
2428
+ this.lightVolumeVertices,
2429
+ this.lightVolumeFragments,
2430
+ this.plumeVerts,
2431
+ this.plumeFrags,
2432
+ this.waterVerts,
2433
+ this.waterFrags,
2434
+ this.boltVerts,
2435
+ this.boltFrags,
2436
+ this.causticsVerts,
2437
+ this.causticsFrags,
2438
+ this.flockVerts,
2439
+ this.flockFrags,
2440
+ this.windStreakVerts,
2441
+ this.windStreakFrags,
2442
+ this.scatterVerts,
2443
+ this.scatterFrags,
2444
+ this.perFrame,
2445
+ this.filmVerts,
2446
+ this.filmFrags,
2447
+ this.insetUniforms,
2448
+ this.panelVerts,
2449
+ this.panelFrags,
2450
+ this.textVerts,
2451
+ this.textFrags,
2452
+ this.sdfTextVerts,
2453
+ this.sdfTextFrags,
2454
+ this.lineVerts,
2455
+ this.lineFrags,
2456
+ this.skyUniforms,
2457
+ ];
2458
+ return this.ringList;
2459
+ }
2460
+
2366
2461
  private flushRings(): void {
2367
- this.perDraw.flush();
2368
- this.lightVolumeVertices.flush();
2369
- this.lightVolumeFragments.flush();
2370
- this.plumeVerts.flush();
2371
- this.plumeFrags.flush();
2372
- this.waterVerts.flush();
2373
- this.waterFrags.flush();
2374
- this.boltVerts.flush();
2375
- this.boltFrags.flush();
2376
- this.causticsVerts.flush();
2377
- this.causticsFrags.flush();
2378
- this.flockVerts.flush();
2379
- this.flockFrags.flush();
2380
- this.windStreakVerts.flush();
2381
- this.windStreakFrags.flush();
2382
- this.scatterVerts.flush();
2383
- this.perFrame.flush();
2384
- this.filmVerts.flush();
2385
- this.filmFrags.flush();
2386
- this.insetUniforms.flush();
2387
- this.panelVerts.flush();
2388
- this.panelFrags.flush();
2389
- this.textVerts.flush();
2390
- this.textFrags.flush();
2391
- this.sdfTextVerts.flush();
2392
- this.sdfTextFrags.flush();
2393
- this.lineVerts.flush();
2394
- this.lineFrags.flush();
2462
+ for (const ring of this.rings()) ring.flush();
2463
+ }
2464
+
2465
+ /** Where every ring stands before work that submits an encoder of its own. */
2466
+ private readonly ringMarks: number[] = [];
2467
+ private markRings(): void {
2468
+ const rings = this.rings();
2469
+ this.ringMarks.length = rings.length;
2470
+ for (let i = 0; i < rings.length; i++) this.ringMarks[i] = (rings[i] as UniformRing).mark();
2471
+ }
2472
+
2473
+ /**
2474
+ * Give back every slot taken since `markRings`, once that work has been submitted.
2475
+ *
2476
+ * See `UniformRing.rewind` for why this is safe only after the submit. The palette ring goes back
2477
+ * with them, and the cached palette slot is dropped if it was one of those given back.
2478
+ */
2479
+ private rewindRings(skinMark: number): void {
2480
+ const rings = this.rings();
2481
+ for (let i = 0; i < rings.length; i++) (rings[i] as UniformRing).rewind(this.ringMarks[i] ?? 0);
2482
+ this.skinPalettes.rewind(skinMark);
2483
+ if (this.skinPaletteSlot >= skinMark) this.skinPaletteSlot = -1;
2395
2484
  }
2396
2485
 
2397
2486
  /* -- The film ------------------------------------------------------------------------- */
@@ -2600,6 +2689,8 @@ export class WebGPURenderer implements RendererApi {
2600
2689
  private blankAlbedoBindGroup: GPUBindGroup;
2601
2690
  /** Repeats across the mesh's own UV range. Per draw, because `uUvScale` is a vertex uniform. */
2602
2691
  private readonly uvScale = new Float32Array([1, 1]);
2692
+ /** Whether the bound material is seen from both faces. See `SurfaceMaterial.doubleSided`. */
2693
+ private materialDoubleSided = false;
2603
2694
 
2604
2695
  createSurfaceTexture(
2605
2696
  source: TexImageSource,
@@ -2664,6 +2755,8 @@ export class WebGPURenderer implements RendererApi {
2664
2755
  * a group holding the old view hands a destroyed texture to the next draw that asks.
2665
2756
  */
2666
2757
  private forgetBindingsOf(texture: GpuSurfaceTexture): void {
2758
+ this.cutoutGroups.delete(texture);
2759
+ this.cutoutPeelGroups.delete(texture);
2667
2760
  /* Same reasoning, for whichever SDF labels were bound against this atlas. */
2668
2761
  this.sdfTextBindGroups.delete(texture);
2669
2762
  this.flatBindGroups.clear();
@@ -2720,12 +2813,14 @@ export class WebGPURenderer implements RendererApi {
2720
2813
  const albedo = material?.albedo ?? null;
2721
2814
  this.uvScale[0] = material?.uScale ?? 1;
2722
2815
  this.uvScale[1] = material?.vScale ?? 1;
2816
+ this.materialDoubleSided = material?.doubleSided === true;
2723
2817
  const normal = (material?.normal ?? null) as GpuSurfaceTexture | null;
2724
2818
  const orm = (material?.orm ?? null) as GpuSurfaceTexture | null;
2725
2819
  const emissiveMap = (material?.emissive ?? null) as GpuSurfaceTexture | null;
2726
2820
  this.material((f, i) => {
2727
2821
  i[this.materialField('uAlbedoEnabled')] = albedo === null ? 0 : 1;
2728
2822
  f[this.materialField('uAlbedoCutout')] = material?.cutout ?? 0;
2823
+ i[this.materialField('uDoubleSided')] = material?.doubleSided === true ? 1 : 0;
2729
2824
  /* 0 is no map, and gates the whole block in the shader. See `uNormalStrength`. */
2730
2825
  f[this.materialField('uNormalStrength')] =
2731
2826
  normal === null ? 0 : (material?.normalStrength ?? 1);
@@ -3586,6 +3681,18 @@ export class WebGPURenderer implements RendererApi {
3586
3681
  timeSeconds: number,
3587
3682
  ): void {
3588
3683
  if (!this.canDraw() || data.count === 0) return;
3684
+ if (batch.drawnInEncoder === this.encoderEpoch) {
3685
+ if (!this.warnedParticlesTwice) {
3686
+ this.warnedParticlesTwice = true;
3687
+ console.warn(
3688
+ 'WebGPU: a particle batch was drawn twice into one encoder — a mirror and the view, or a ' +
3689
+ "probe's faces — and the second draw is skipped, because both would show the second " +
3690
+ "draw's particles. Give each view its own batch.",
3691
+ );
3692
+ }
3693
+ return;
3694
+ }
3695
+ batch.drawnInEncoder = this.encoderEpoch;
3589
3696
  const live = packInstances(batch, data);
3590
3697
  if (live === 0) return;
3591
3698
 
@@ -4364,7 +4471,6 @@ export class WebGPURenderer implements RendererApi {
4364
4471
 
4365
4472
  this.windStreakFragFloats.set(tint, (WIND_STREAK_FRAG_FIELDS.uTint.offset ?? 0) / 4);
4366
4473
 
4367
- const { queue } = this.surface.device;
4368
4474
  this.windStreakVerts.writeBlock(vertexSlot, this.windStreakVertBlock);
4369
4475
  this.windStreakFrags.writeBlock(fragmentSlot, this.windStreakFragBlock);
4370
4476
 
@@ -4466,7 +4572,6 @@ export class WebGPURenderer implements RendererApi {
4466
4572
 
4467
4573
  this.flockFragFloats.set(tint, (FLOCK_FRAG_FIELDS.uTint.offset ?? 0) / 4);
4468
4574
 
4469
- const { queue } = this.surface.device;
4470
4575
  this.flockVerts.writeBlock(vertexSlot, this.flockVertBlock);
4471
4576
  this.flockFrags.writeBlock(fragmentSlot, this.flockFragBlock);
4472
4577
 
@@ -4819,7 +4924,8 @@ export class WebGPURenderer implements RendererApi {
4819
4924
  private readonly scatterLayout: GPUBindGroupLayout;
4820
4925
  /** One slot per batch: the gust and the trample field belong to the batch, not the frame. */
4821
4926
  private readonly scatterVerts: UniformRing;
4822
- private readonly scatterFragUniforms: GPUBuffer;
4927
+ /** A slot a draw, beside the vertex block's: see `createScatterBindGroupLayout` for why. */
4928
+ private readonly scatterFrags: UniformRing;
4823
4929
  private readonly scatterFragStaging = new ArrayBuffer(SCATTER_FRAG_SIZE);
4824
4930
  private readonly scatterFragFloats = new Float32Array(this.scatterFragStaging);
4825
4931
  private readonly scatterFragInts = new Int32Array(this.scatterFragStaging);
@@ -5014,17 +5120,28 @@ export class WebGPURenderer implements RendererApi {
5014
5120
  private readonly dynamicView: GPUTextureView | null;
5015
5121
  /** The peel's own bind groups: `uPeelShadowLayer` at 1, and the static map to peel against. */
5016
5122
  private readonly shadowPeelBindGroup: GPUBindGroup | null;
5123
+ /**
5124
+ * The cutout casters' layout, and a bind group per cutout map for each layer, built the first
5125
+ * time a map casts and kept: a `createBindGroup` per draw is the allocation the house rules forbid.
5126
+ * Cleared in `forgetBindingsOf`, because a group outliving its texture is a destroyed-texture error
5127
+ * at submit, and a texture's view is new when an update changes its size.
5128
+ */
5129
+ private readonly shadowCutoutLayout: GPUBindGroupLayout;
5130
+ private readonly cutoutGroups = new Map<GpuSurfaceTexture, GPUBindGroup>();
5131
+ private readonly cutoutPeelGroups = new Map<GpuSurfaceTexture, GPUBindGroup>();
5132
+ private shadowPeelPassUniforms: GPUBuffer | null = null;
5133
+ private shadowPeelSampler: GPUSampler | null = null;
5017
5134
  private readonly scatterDepthPeelBindGroup: GPUBindGroup | null;
5018
5135
  /** Which layer the open pass is writing, so the sink picks the bindings that match it. */
5019
5136
  private shadowLayerIsPeel = false;
5020
5137
  /**
5021
5138
  * Whether a peel pass has filled the map, which is what the frame may sample it on.
5022
5139
  *
5023
- * **Not whether the map exists.** `renderer.ts` can ask that question because a GL texture is
5024
- * born cleared; a WebGPU texture no pass has written holds undefined contents, so a scene
5025
- * that builds a peel map and never opens the layer would sample garbage. Cleared when the
5026
- * static layer opens and set when a peel pass ends, which is per frame because both demo
5027
- * scenes that peel do so every frame.
5140
+ * **Not whether the map exists.** `renderer.ts` asked that question, on the belief that a GL
5141
+ * texture is born reading as the far plane, and it is born zeroed: a scene that built a peel
5142
+ * map and never opened the layer had no sun at all under ANGLE's GL backend. It holds this
5143
+ * rule now as well. Cleared when the static layer opens and set when a peel pass ends, which
5144
+ * is per frame because every scene that peels does so every frame.
5028
5145
  */
5029
5146
  private peelFilled = false;
5030
5147
  /** One slot per caster, the same trick `perDraw` uses and for the same reason. */
@@ -5066,6 +5183,44 @@ export class WebGPURenderer implements RendererApi {
5066
5183
  * otherwise. A lookup rather than a rebind, because a `createBindGroup` per draw is the
5067
5184
  * per-frame allocation the house rules forbid; both groups are built once at init.
5068
5185
  */
5186
+ /** The cutout bind group for `texture` in the open layer, built the first time it casts there. */
5187
+ private cutoutGroup(texture: GpuSurfaceTexture): GPUBindGroup | null {
5188
+ const peel = this.shadowLayerIsPeel && this.shadowPeelPassUniforms !== null;
5189
+ const groups = peel ? this.cutoutPeelGroups : this.cutoutGroups;
5190
+ const held = groups.get(texture);
5191
+ if (held !== undefined) return held;
5192
+ const passUniforms = peel ? this.shadowPeelPassUniforms : this.shadowPassUniforms;
5193
+ const previousView = peel ? this.shadowView : this.shadowBlankView;
5194
+ const previousSampler = peel ? this.shadowPeelSampler : this.shadowBlankSampler;
5195
+ if (passUniforms === null || previousView === null || previousSampler === null) return null;
5196
+ const group = createDepthBindGroup(
5197
+ this.surface.device,
5198
+ this.shadowCutoutLayout,
5199
+ this.shadowDraws.buffer,
5200
+ passUniforms,
5201
+ previousView,
5202
+ previousSampler,
5203
+ null,
5204
+ { view: texture.view, sampler: texture.sampler },
5205
+ );
5206
+ groups.set(texture, group);
5207
+ return group;
5208
+ }
5209
+
5210
+ /** The cutoff and UV scale into a cutout draw's slot, at the offsets its own variant declares. */
5211
+ private writeCutout(
5212
+ slot: number,
5213
+ cutout: CutoutCaster<unknown>,
5214
+ fields: {
5215
+ readonly uUvScale: { readonly offset: number };
5216
+ readonly uAlphaCutout: { readonly offset: number };
5217
+ },
5218
+ ): void {
5219
+ this.shadowDraws.writeFloat(slot, fields.uUvScale.offset, cutout.u);
5220
+ this.shadowDraws.writeFloat(slot, fields.uUvScale.offset + 4, cutout.v);
5221
+ this.shadowDraws.writeFloat(slot, fields.uAlphaCutout.offset, cutout.cutoff);
5222
+ }
5223
+
5069
5224
  private shadowGroup(): GPUBindGroup {
5070
5225
  return this.shadowLayerIsPeel && this.shadowPeelBindGroup !== null
5071
5226
  ? this.shadowPeelBindGroup
@@ -5114,9 +5269,13 @@ export class WebGPURenderer implements RendererApi {
5114
5269
  * every frame would be an allocation in a per-frame path.
5115
5270
  */
5116
5271
  private readonly casterSink: ShadowCasterSink = {
5117
- mesh: (mesh, model) => {
5272
+ mesh: (mesh, model, material) => {
5118
5273
  const pass = this.shadowPass;
5119
5274
  if (pass === null) return;
5275
+ /* A cutout caster's group is looked up before a slot is spent, so a miss costs nothing. */
5276
+ const cutout = cutoutOf(material);
5277
+ const cutoutGroup =
5278
+ cutout === null ? null : this.cutoutGroup(cutout.albedo as unknown as GpuSurfaceTexture);
5120
5279
  const slot = this.shadowDraws.allocate();
5121
5280
  if (slot === null) return;
5122
5281
 
@@ -5127,20 +5286,28 @@ export class WebGPURenderer implements RendererApi {
5127
5286
  );
5128
5287
  this.shadowDraws.writeFloats(slot, DEPTH_VERT_FIELDS.uModel.offset, model as Float32Array);
5129
5288
  this.writeShadowWind(slot);
5289
+ if (cutout !== null && cutoutGroup !== null)
5290
+ this.writeCutout(slot, cutout, DEPTH_CUTOUT_VERT_FIELDS);
5130
5291
 
5131
5292
  const geometry = mesh as GpuMesh & { key?: string };
5132
5293
  if (geometry.vertexBuffers === undefined) return;
5294
+ const cuts = cutoutGroup !== null;
5295
+ /* A two-sided caster casts from whichever face the light sees: nothing culled. */
5296
+ const cull = material?.doubleSided === true ? 'none' : this.depthCullMode;
5133
5297
  pass.setPipeline(
5134
5298
  depthPipeline(
5135
5299
  this.pipelines,
5136
5300
  this.surface.device,
5137
- this.shadowLayout,
5138
- `depth|${this.depthCullMode}|${geometry.key ?? ''}`,
5301
+ cuts ? this.shadowCutoutLayout : this.shadowLayout,
5302
+ `${cuts ? 'depth-cutout' : 'depth'}|${cull}|${geometry.key ?? ''}`,
5139
5303
  this.presentOf(geometry),
5140
- this.depthCullMode,
5304
+ cull,
5305
+ false,
5306
+ false,
5307
+ cuts,
5141
5308
  ),
5142
5309
  );
5143
- pass.setBindGroup(0, this.shadowGroup(), [slot]);
5310
+ pass.setBindGroup(0, cutoutGroup ?? this.shadowGroup(), [slot]);
5144
5311
  for (let index = 0; index < geometry.vertexBuffers.length; index++) {
5145
5312
  pass.setVertexBuffer(index, geometry.vertexBuffers[index] as GPUBuffer);
5146
5313
  }
@@ -5151,7 +5318,7 @@ export class WebGPURenderer implements RendererApi {
5151
5318
  * A batch of rigid meshes into the depth map, placed by the same matrices the visible draw
5152
5319
  * uses — one draw, however many instances, exactly as the colour pass does it.
5153
5320
  */
5154
- instanced: (batch, data) => {
5321
+ instanced: (batch, data, material) => {
5155
5322
  const pass = this.shadowPass;
5156
5323
  if (pass === null) return;
5157
5324
  const gpuBatch = batch as GpuInstancedBatch;
@@ -5159,6 +5326,9 @@ export class WebGPURenderer implements RendererApi {
5159
5326
  if (geometry.vertexBuffers === undefined) return;
5160
5327
  const count = Math.min(data.count, gpuBatch.capacity);
5161
5328
  if (count === 0) return;
5329
+ const cutout = cutoutOf(material);
5330
+ const cutoutGroup =
5331
+ cutout === null ? null : this.cutoutGroup(cutout.albedo as unknown as GpuSurfaceTexture);
5162
5332
 
5163
5333
  const slot = this.shadowDraws.allocate();
5164
5334
  if (slot === null) return;
@@ -5169,20 +5339,26 @@ export class WebGPURenderer implements RendererApi {
5169
5339
  DEPTH_VERT_FIELDS.uLightViewProj.offset,
5170
5340
  this.correctedLightViewProj,
5171
5341
  );
5342
+ if (cutout !== null && cutoutGroup !== null) {
5343
+ this.writeCutout(slot, cutout, DEPTH_INSTANCED_CUTOUT_VERT_FIELDS);
5344
+ }
5172
5345
 
5346
+ const cuts = cutoutGroup !== null;
5347
+ const cull = material?.doubleSided === true ? 'none' : this.depthCullMode;
5173
5348
  pass.setPipeline(
5174
5349
  depthPipeline(
5175
5350
  this.pipelines,
5176
5351
  this.surface.device,
5177
- this.shadowLayout,
5178
- `depth-instanced|${this.depthCullMode}|${geometry.key ?? ''}`,
5352
+ cuts ? this.shadowCutoutLayout : this.shadowLayout,
5353
+ `${cuts ? 'depth-instanced-cutout' : 'depth-instanced'}|${cull}|${geometry.key ?? ''}`,
5179
5354
  this.presentOf(geometry),
5180
- this.depthCullMode,
5355
+ cull,
5181
5356
  false,
5182
5357
  true,
5358
+ cuts,
5183
5359
  ),
5184
5360
  );
5185
- pass.setBindGroup(0, this.shadowGroup(), [slot]);
5361
+ pass.setBindGroup(0, cutoutGroup ?? this.shadowGroup(), [slot]);
5186
5362
  let index = 0;
5187
5363
  for (; index < geometry.vertexBuffers.length; index++) {
5188
5364
  pass.setVertexBuffer(index, geometry.vertexBuffers[index] as GPUBuffer);
@@ -5681,34 +5857,6 @@ export class WebGPURenderer implements RendererApi {
5681
5857
  const blankView = blank.createView();
5682
5858
  const blankSampler = device.createSampler({ label: 'flat.sampler' });
5683
5859
 
5684
- /*
5685
- * A one-texel cube for every point-shadow sampler the variant declares.
5686
- *
5687
- * Twelve of them exist in the `pointShadows` permutation and none has a map behind it
5688
- * until row 3, but WebGPU requires every declared binding to be filled — and a cube view
5689
- * needs six array layers, so the flat 1×1 stand-in above cannot serve.
5690
- *
5691
- * **White is the second guard, and it is a deliberate value rather than a spare one.** The
5692
- * first is `uPointShadowIndex`, written to −1 for every light in `bindMeshPass`. If that
5693
- * ever failed, a full-depth texel reads as an occluder at the far plane — `pointShadow`
5694
- * converts the stored value back to a distance and 1.0 comes out as `far` — so the worst a
5695
- * mis-selected light can do is find nothing in the way. Zeroes would have meant an
5696
- * occluder pressed against the lamp, and every surface in the scene in shadow.
5697
- */
5698
- const blankCube = device.createTexture({
5699
- label: 'flat.emptyPointShadow',
5700
- size: [1, 1, 6],
5701
- format: 'rgba8unorm',
5702
- usage: 0x2 | 0x4, // COPY_DST | TEXTURE_BINDING
5703
- });
5704
- device.queue.writeTexture(
5705
- { texture: blankCube },
5706
- new Uint8Array(4 * 6).fill(255),
5707
- { bytesPerRow: 4, rowsPerImage: 1 },
5708
- [1, 1, 6],
5709
- );
5710
- const blankCubeView = blankCube.createView({ dimension: 'cube' });
5711
-
5712
5860
  /*
5713
5861
  * A one-texel integer stand-in for the froxel table.
5714
5862
  *
@@ -5758,6 +5906,17 @@ export class WebGPURenderer implements RendererApi {
5758
5906
  [1, 1, 1],
5759
5907
  );
5760
5908
  const blankArrayView = blankArray.createView({ dimension: '2d-array' });
5909
+ /*
5910
+ * DriftLight's two volumes before a field is whole: an index of one empty cell and an atlas
5911
+ * of one zero texel. Never read while `uDriftLight.x` is zero, and bound anyway, because a
5912
+ * group missing an entry its layout declares is a validation failure at `submit`.
5913
+ */
5914
+ const blankVolume = (label: string, format: GPUTextureFormat): GPUTextureView =>
5915
+ device
5916
+ .createTexture({ label, size: [1, 1, 1], dimension: '3d', format, usage: 0x2 | 0x4 })
5917
+ .createView({ dimension: '3d' });
5918
+ const blankDriftIndexView = blankVolume('flat.emptyDriftLightIndex', 'r32uint');
5919
+ const blankDriftAtlasView = blankVolume('flat.emptyDriftLightAtlas', 'rgba16float');
5761
5920
 
5762
5921
  this.shadowMap = device.createTexture({
5763
5922
  label: 'shadow.static',
@@ -5797,14 +5956,15 @@ export class WebGPURenderer implements RendererApi {
5797
5956
  this.dynamicView = this.dynamicMap === null ? null : this.dynamicMap.createView();
5798
5957
 
5799
5958
  /*
5800
- * **Cleared once, here, because a texture no pass has written holds undefined contents.**
5959
+ * **Cleared once, here, because a texture no pass has written reads as zero**, which the
5960
+ * specification requires, and a zero in a map compared with `LEQUAL` is an occluder at the
5961
+ * light. A scene is entitled to switch directional shadows *on* and never open a pass —
5962
+ * `night-street` does exactly that, with `shadowStrength` at 0.85 and no `beginShadowPass`
5963
+ * anywhere in it — and sampling that zero shadows everything, which is a picture rather than
5964
+ * an error and reads as a lighting bug.
5801
5965
  *
5802
- * A GL depth texture is born defined and reads as the far plane, so `renderer.ts` can hand
5803
- * an unrendered shadow map to the shader and get "nothing occludes anything". WebGPU makes
5804
- * no such promise, and a scene is entitled to switch directional shadows *on* and never
5805
- * open a pass — `night-street` does exactly that, with `shadowStrength` at 0.85 and no
5806
- * `beginShadowPass` anywhere in it. Sampling garbage then shadows whatever the garbage
5807
- * happens to say, which is a picture rather than an error and reads as a lighting bug.
5966
+ * `shadowMap.ts` does the same for the other backend, for the same reason found later: WebGL
5967
+ * zeroes a new texture too, and only ANGLE on Vulkan happened to hand one back reading 1.
5808
5968
  *
5809
5969
  * Found by dumping the flat block off both live renderers and comparing it field by field;
5810
5970
  * `uPeeledShadowEnabled` came back 1 against 0 and pointed straight at the maps behind it.
@@ -5914,7 +6074,7 @@ export class WebGPURenderer implements RendererApi {
5914
6074
  * is writing, so the binding has to move whatever the shader intends to do with it.
5915
6075
  */
5916
6076
  if (name === 'uEnvironment') {
5917
- return this.probeView === null || !this.probeBaked || this.probePassActive
6077
+ return this.probeView === null || !this.probeReadable()
5918
6078
  ? { view: blankArrayView, sampler: blankSampler }
5919
6079
  : { view: this.probeView, sampler: this.probeSampler };
5920
6080
  }
@@ -5948,6 +6108,17 @@ export class WebGPURenderer implements RendererApi {
5948
6108
  if (name === 'uCookieAtlas') {
5949
6109
  return { view: this.cookieView ?? blankView, sampler: iesSampler };
5950
6110
  }
6111
+ /*
6112
+ * DriftLight's brick index, read by `texelFetch` alone, so it shares the shadow group's
6113
+ * sampler the way the froxel table does; and its atlas, which is filtered inside a brick and
6114
+ * so takes the linear, clamped one the photometric atlas uses.
6115
+ */
6116
+ if (name === 'uDriftLightIndex') {
6117
+ return { view: this.driftIndexView ?? blankDriftIndexView, sampler: shadowSampler };
6118
+ }
6119
+ if (name === 'uDriftLightAtlas') {
6120
+ return { view: this.driftAtlasView ?? blankDriftAtlasView, sampler: iesSampler };
6121
+ }
5951
6122
  /*
5952
6123
  * The colour the frame had already drawn, or the blank texel a frame that refracts nothing
5953
6124
  * falls back to.
@@ -6003,12 +6174,14 @@ export class WebGPURenderer implements RendererApi {
6003
6174
  this.blankAlbedoBindGroup = this.bindGroup;
6004
6175
 
6005
6176
  this.skyLayout = createSkyBindGroupLayout(device);
6006
- this.skyUniforms = device.createBuffer({
6007
- label: 'sky.uniforms',
6008
- size: SKY_UNIFORM_SIZE,
6009
- usage: USAGE_UNIFORM_DST,
6010
- });
6011
- this.skyBindGroup = createSkyBindGroup(device, this.skyLayout, this.skyUniforms);
6177
+ this.skyUniforms = new UniformRing(
6178
+ device,
6179
+ SKY_UNIFORM_SIZE,
6180
+ MAX_SKY_DRAWS,
6181
+ USAGE_UNIFORM_DST,
6182
+ 'sky.ring',
6183
+ );
6184
+ this.skyBindGroup = createSkyBindGroup(device, this.skyLayout, this.skyUniforms.buffer);
6012
6185
 
6013
6186
  this.plumeLayout = createPlumeBindGroupLayout(device);
6014
6187
  this.plumeVerts = new UniformRing(
@@ -6087,6 +6260,8 @@ export class WebGPURenderer implements RendererApi {
6087
6260
  usage: 0x10 | 0x4 | 0x2, // RENDER_ATTACHMENT | TEXTURE_BINDING | COPY_DST
6088
6261
  })
6089
6262
  : null;
6263
+ this.probeTargets =
6264
+ this.probe === null ? null : new ProbeTargets(device, this.probe, this.probeSize);
6090
6265
  /*
6091
6266
  * The prefiltered twin, allocated on exactly the condition the capture is and with exactly
6092
6267
  * its format and level count. Same format because a pipeline's colour target must equal its
@@ -6129,6 +6304,10 @@ export class WebGPURenderer implements RendererApi {
6129
6304
  is not a screen-sized target. Filtering is what makes a 32-lattice table smooth rather
6130
6305
  than banded, so it is declared filterable like the colour inputs and unlike the depth. */
6131
6306
  { binding: RUSH_TEXTURES.uGradeLut, dimension: '3d' },
6307
+ /* Eye adaptation's held brightness: one half-float texel, or the scene where none is held. */
6308
+ { binding: RUSH_TEXTURES.uExposureHeld },
6309
+ /* Local exposure's grid, read filtered between tiles. See `localExposure.ts`. */
6310
+ { binding: RUSH_TEXTURES.uExposureLocal },
6132
6311
  ]);
6133
6312
  this.rushUniforms = device.createBuffer({
6134
6313
  label: 'post.rushUniforms',
@@ -6358,23 +6537,27 @@ export class WebGPURenderer implements RendererApi {
6358
6537
  MAX_PLUMES_PER_FRAME,
6359
6538
  USAGE_UNIFORM_DST,
6360
6539
  );
6361
- this.scatterFragUniforms = device.createBuffer({
6362
- label: 'scatter.fragUniforms',
6363
- size: SCATTER_FRAG_SIZE,
6364
- usage: USAGE_UNIFORM_DST,
6365
- });
6540
+ this.scatterFrags = new UniformRing(
6541
+ device,
6542
+ SCATTER_FRAG_SIZE,
6543
+ MAX_PLUMES_PER_FRAME,
6544
+ USAGE_UNIFORM_DST,
6545
+ 'scatter.fragRing',
6546
+ );
6366
6547
  this.scatterBindGroup = createScatterBindGroup(
6367
6548
  device,
6368
6549
  this.scatterLayout,
6369
6550
  this.scatterVerts.buffer,
6370
- this.scatterFragUniforms,
6551
+ this.scatterFrags.buffer,
6371
6552
  );
6372
6553
 
6373
6554
  this.shadowLayout = createDepthBindGroupLayout(device);
6374
6555
  this.shadowSkinnedLayout = createDepthBindGroupLayout(device, true);
6556
+ this.shadowCutoutLayout = createDepthBindGroupLayout(device, false, true);
6375
6557
  this.shadowDraws = new UniformRing(
6376
6558
  device,
6377
- DEPTH_VERT_SIZE,
6559
+ /* The cutout block is the plain one and two fields more, and a slot must hold either. */
6560
+ Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE),
6378
6561
  MAX_DRAWS_PER_FRAME,
6379
6562
  USAGE_UNIFORM_DST,
6380
6563
  'shadow.drawRing',
@@ -6446,6 +6629,8 @@ export class WebGPURenderer implements RendererApi {
6446
6629
  if (peelPassUniforms !== null) {
6447
6630
  device.queue.writeBuffer(peelPassUniforms, 0, new Int32Array([1]));
6448
6631
  }
6632
+ this.shadowPeelPassUniforms = peelPassUniforms;
6633
+ this.shadowPeelSampler = shadowSampler;
6449
6634
  this.shadowPeelBindGroup =
6450
6635
  peelPassUniforms === null
6451
6636
  ? null
@@ -6470,15 +6655,6 @@ export class WebGPURenderer implements RendererApi {
6470
6655
  shadowSampler,
6471
6656
  );
6472
6657
 
6473
- /*
6474
- * The light volume, last because it wants the shadow map and the stand-in that everything
6475
- * above already built. The peel and dynamic maps get the white one-pixel texture, which is
6476
- * depth 1 and therefore nothing in the way — see `createLightVolumeBindGroup` for why a
6477
- * stand-in of zero would put every beam in shadow instead.
6478
- */
6479
- const uniformBuffer = (label: string, size: number): GPUBuffer =>
6480
- device.createBuffer({ label, size, usage: USAGE_UNIFORM_DST });
6481
-
6482
6658
  /*
6483
6659
  * The overlays. Rings rather than single blocks because a frame draws several of each —
6484
6660
  * the loader alone is two panels and two strings — and `queue.writeBuffer` does not
@@ -6801,6 +6977,7 @@ export class WebGPURenderer implements RendererApi {
6801
6977
  this.reflectionQueue.reset();
6802
6978
  /* Redeclared every frame, so a field a consumer stops declaring stops lighting. */
6803
6979
  this.distanceFields.reset();
6980
+ this.frameSky = null;
6804
6981
  this.oitActive =
6805
6982
  this.quality.orderIndependent && this.quality.screenEffects && this.samples === 1;
6806
6983
  /* Read off the line above rather than restating its last term: with the other two true, the
@@ -6823,6 +7000,7 @@ export class WebGPURenderer implements RendererApi {
6823
7000
  this.flushOverlay();
6824
7001
 
6825
7002
  this.framePresented = false;
7003
+ this.encoderEpoch = ++this.encoderEpochs;
6826
7004
  /* Before the budget is cleared, because what to grow to is what the last frame asked for. */
6827
7005
  this.growRings();
6828
7006
  this.budget.reset();
@@ -6862,6 +7040,7 @@ export class WebGPURenderer implements RendererApi {
6862
7040
  this.windStreakVerts.reset();
6863
7041
  this.windStreakFrags.reset();
6864
7042
  this.scatterVerts.reset();
7043
+ this.scatterFrags.reset();
6865
7044
  this.panelVerts.reset();
6866
7045
  this.panelFrags.reset();
6867
7046
  this.filmVerts.reset();
@@ -6873,6 +7052,7 @@ export class WebGPURenderer implements RendererApi {
6873
7052
  this.sdfTextFrags.reset();
6874
7053
  this.lineVerts.reset();
6875
7054
  this.lineFrags.reset();
7055
+ this.skyUniforms.reset();
6876
7056
  /* The material ring, and the open slot with it: last frame's slots are gone. */
6877
7057
  this.perFrame.reset();
6878
7058
  this.materials.dirty();
@@ -7469,9 +7649,29 @@ export class WebGPURenderer implements RendererApi {
7469
7649
  f[base + 1] = counts[1];
7470
7650
  f[base + 2] = counts[2];
7471
7651
  }
7652
+ /* Which sets the shading reads, and how far between them. See `probeSweeps.ts`. */
7653
+ const setsAt = this.fragment.fields['uProbeGridSets'];
7654
+ if (setsAt !== undefined) {
7655
+ const sets = this.probeSweeps?.uniforms(this.probeSets) ?? this.probeSets.fill(0);
7656
+ const base = setsAt.offset / 4;
7657
+ f[base] = sets[0] ?? 0;
7658
+ f[base + 1] = sets[1] ?? 0;
7659
+ f[base + 2] = sets[2] ?? 0;
7660
+ }
7472
7661
  /* Which chain is bound, because the level and the weight both depend on it. */
7473
7662
  f[at('uEnvironmentPrefiltered')] = this.quality.environmentPrefilter ? 1 : 0;
7474
7663
  }
7664
+ /* DriftLight, or zeros that switch it off: see `driftLight/uniforms.ts`. */
7665
+ this.uploadLightField();
7666
+ const drift = this.driftUniforms;
7667
+ resolveDriftLight(
7668
+ this.driftAtlas === null ? null : this.lightField,
7669
+ this.probePassActive,
7670
+ camera.position,
7671
+ drift,
7672
+ );
7673
+ f.set(drift.light, at('uDriftLight'));
7674
+ f.set(drift.origin, at('uDriftLightOrigin'));
7475
7675
  /*
7476
7676
  * The ORM scales, whose base is 1 and not 0 — the value an unwritten uniform would hold.
7477
7677
  *
@@ -7549,7 +7749,7 @@ export class WebGPURenderer implements RendererApi {
7549
7749
  * it every frame.
7550
7750
  */
7551
7751
  if (this.probeEnabled) {
7552
- const usable = this.probeBaked && !this.probePassActive;
7752
+ const usable = this.probeReadable();
7553
7753
  f[at('uEnvironmentEnabled')] = usable ? 1 : 0;
7554
7754
  f[at('uEnvironmentMaxLod')] = this.probeMaxLod;
7555
7755
  /*
@@ -7808,6 +8008,14 @@ export class WebGPURenderer implements RendererApi {
7808
8008
 
7809
8009
  /** Said once rather than every frame, for the reason `warnedFull` gives. */
7810
8010
  private warnedMaterialsFull = false;
8011
+ private warnedSkyFull = false;
8012
+ private warnedParticlesTwice = false;
8013
+ /**
8014
+ * Which encoder draws are being recorded into, by count: a new one at every `beginFrame`, and one
8015
+ * of its own for each probe bake, restored after. What a batch drawn per encoder is checked by.
8016
+ */
8017
+ private encoderEpoch = 0;
8018
+ private encoderEpochs = 0;
7811
8019
 
7812
8020
  /**
7813
8021
  * Draw one mesh.
@@ -8121,7 +8329,9 @@ export class WebGPURenderer implements RendererApi {
8121
8329
  `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
8122
8330
  /* The two order-independent buffers are two formats and two blend states, so a draw into
8123
8331
  one must never share a cached pipeline with the same mesh drawn into the other. */
8124
- `${this.oitMode === 'none' ? '' : `|oit${this.oitMode}`}`;
8332
+ `${this.oitMode === 'none' ? '' : `|oit${this.oitMode}`}` +
8333
+ /* Culling is pipeline state here: a two-sided surface culls nothing. */
8334
+ `${this.materialDoubleSided ? '|2s' : ''}`;
8125
8335
 
8126
8336
  /*
8127
8337
  * The morph uniforms, written only for a morphed draw and read from the *morphed* variant's
@@ -8166,6 +8376,7 @@ export class WebGPURenderer implements RendererApi {
8166
8376
  layer,
8167
8377
  false,
8168
8378
  this.oitMode,
8379
+ this.materialDoubleSided,
8169
8380
  );
8170
8381
  }
8171
8382
 
@@ -8399,7 +8610,8 @@ export class WebGPURenderer implements RendererApi {
8399
8610
  plain one would hand a draw the wrong layout. */
8400
8611
  const key =
8401
8612
  `${base}|inst${blend ? '|blend' : ''}` +
8402
- `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}`;
8613
+ `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
8614
+ `${this.materialDoubleSided ? '|2s' : ''}`;
8403
8615
 
8404
8616
  const pipelines = this.targetPipelines();
8405
8617
  let pipeline = pipelines.peek(key);
@@ -8421,6 +8633,8 @@ export class WebGPURenderer implements RendererApi {
8421
8633
  depthWrite,
8422
8634
  layer,
8423
8635
  true,
8636
+ 'none',
8637
+ this.materialDoubleSided,
8424
8638
  );
8425
8639
  }
8426
8640
 
@@ -8581,7 +8795,9 @@ export class WebGPURenderer implements RendererApi {
8581
8795
  f[atF('uFogNear')] = medium.fogNear;
8582
8796
  f[atF('uFogFar')] = medium.fogFar;
8583
8797
 
8584
- this.surface.device.queue.writeBuffer(this.scatterFragUniforms, 0, this.scatterFragStaging);
8798
+ const fragmentSlot = this.scatterFrags.allocate();
8799
+ if (fragmentSlot === null) return;
8800
+ this.scatterFrags.writeBlock(fragmentSlot, this.scatterFragInts);
8585
8801
 
8586
8802
  const scatterPipe = scatterPipeline(this.pipelines, this.surface.device, this.scatterLayout);
8587
8803
  /* One dynamic offset, and a group of two uniform buffers: nothing sampled, nothing read. */
@@ -8590,7 +8806,8 @@ export class WebGPURenderer implements RendererApi {
8590
8806
  scatterCommand.pipeline = scatterPipe;
8591
8807
  scatterCommand.bindGroup = this.scatterBindGroup;
8592
8808
  scatterCommand.offsetA = slot;
8593
- scatterCommand.offsetCount = 1;
8809
+ scatterCommand.offsetB = fragmentSlot;
8810
+ scatterCommand.offsetCount = 2;
8594
8811
  for (let index = 0; index < scatter.vertexBuffers.length; index++) {
8595
8812
  scatterCommand.vertexBuffers[index] = scatter.vertexBuffers[index] as GPUBuffer;
8596
8813
  }
@@ -8603,7 +8820,7 @@ export class WebGPURenderer implements RendererApi {
8603
8820
  const pass = this.openPass();
8604
8821
  if (pass === null) return;
8605
8822
  pass.setPipeline(scatterPipe);
8606
- pass.setBindGroup(0, this.scatterBindGroup, [slot]);
8823
+ pass.setBindGroup(0, this.scatterBindGroup, [slot, fragmentSlot]);
8607
8824
  for (let index = 0; index < scatter.vertexBuffers.length; index++) {
8608
8825
  pass.setVertexBuffer(index, scatter.vertexBuffers[index] as GPUBuffer);
8609
8826
  }
@@ -9264,6 +9481,7 @@ export class WebGPURenderer implements RendererApi {
9264
9481
  */
9265
9482
  private rebuildFlatGroupsForNewRings(): void {
9266
9483
  this.flatBindGroups.clear();
9484
+ this.flatEnvironment = this.flatTextures('uEnvironment').view;
9267
9485
  this.skinnedGroups.length = 0;
9268
9486
 
9269
9487
  const albedo = this.albedo;
@@ -9290,6 +9508,27 @@ export class WebGPURenderer implements RendererApi {
9290
9508
  this.materials.dirty();
9291
9509
  }
9292
9510
 
9511
+ /**
9512
+ * The environment view the cached flat groups bind: the grid, or the blank stand-in while the
9513
+ * grid may not be read. What `rebindEnvironment` compares against.
9514
+ */
9515
+ private flatEnvironment: GPUTextureView | null = null;
9516
+
9517
+ /**
9518
+ * Rebuild the flat groups if, and only if, the environment they bind has changed.
9519
+ *
9520
+ * **Every cached group holds the environment view**, so a change of it makes all of them stale;
9521
+ * but nothing else a bake does touches a group. Rebuilding on every bake's two edges regardless
9522
+ * emptied the cache twice a bake, and every material built its group again inside the bake and
9523
+ * again after it: a courtyard re-baking a few faces a frame spent 28% of its CPU there on the
9524
+ * native host, where a `createBindGroup` costs most. What would make this wrong is a second
9525
+ * binding that a bake changes, which would have to be compared here too.
9526
+ */
9527
+ private rebindEnvironment(): void {
9528
+ if (this.flatTextures('uEnvironment').view !== this.flatEnvironment)
9529
+ this.rebuildFlatBindGroup();
9530
+ }
9531
+
9293
9532
  /**
9294
9533
  * Rebuild the group for the open material, and drop every cached one.
9295
9534
  *
@@ -9301,6 +9540,7 @@ export class WebGPURenderer implements RendererApi {
9301
9540
  */
9302
9541
  private rebuildFlatBindGroup(): void {
9303
9542
  this.flatBindGroups.clear();
9543
+ this.flatEnvironment = this.flatTextures('uEnvironment').view;
9304
9544
  if (
9305
9545
  this.albedo === null &&
9306
9546
  this.normalMap === null &&
@@ -9341,6 +9581,7 @@ export class WebGPURenderer implements RendererApi {
9341
9581
  */
9342
9582
  drawSky(camera: Camera, sky: SkyColors, env: Environment): void {
9343
9583
  if (!this.canDraw()) return;
9584
+ this.frameSky = sky;
9344
9585
 
9345
9586
  const f = this.skyFloats;
9346
9587
  const at = (name: keyof typeof SKY_FIELDS): number => SKY_FIELDS[name].offset / 4;
@@ -9375,7 +9616,17 @@ export class WebGPURenderer implements RendererApi {
9375
9616
  f.set(medium.underwaterColor, at('uUnderwaterColor'));
9376
9617
  f[at('uUnderwaterFactor')] = medium.underwaterFactor;
9377
9618
 
9378
- this.surface.device.queue.writeBuffer(this.skyUniforms, 0, this.skyStaging);
9619
+ const slot = this.skyUniforms.allocate();
9620
+ if (slot === null) {
9621
+ if (!this.warnedSkyFull) {
9622
+ this.warnedSkyFull = true;
9623
+ console.warn(
9624
+ `WebGPU: more than ${MAX_SKY_DRAWS} sky draws in a frame; the rest are skipped`,
9625
+ );
9626
+ }
9627
+ return;
9628
+ }
9629
+ this.skyUniforms.writeFloats(slot, 0, f);
9379
9630
 
9380
9631
  const skyPipe = skyPipeline(this.pipelines, this.surface.device, this.skyLayout);
9381
9632
  /* Reads nothing: the group is one uniform buffer, and the sky samples no attachment. */
@@ -9383,12 +9634,14 @@ export class WebGPURenderer implements RendererApi {
9383
9634
  if (skyCommand !== null) {
9384
9635
  skyCommand.pipeline = skyPipe;
9385
9636
  skyCommand.bindGroup = this.skyBindGroup;
9637
+ skyCommand.offsetA = slot;
9638
+ skyCommand.offsetCount = 1;
9386
9639
  skyCommand.count = 3;
9387
9640
  } else {
9388
9641
  const pass = this.openPass();
9389
9642
  if (pass === null) return;
9390
9643
  pass.setPipeline(skyPipe);
9391
- pass.setBindGroup(0, this.skyBindGroup);
9644
+ pass.setBindGroup(0, this.skyBindGroup, [slot]);
9392
9645
  pass.draw(3);
9393
9646
  }
9394
9647
  }
@@ -9499,6 +9752,18 @@ export class WebGPURenderer implements RendererApi {
9499
9752
  if (aoStrength > 0 && this.frameProjection !== null) this.runOcclusion(encoder);
9500
9753
  const bloomStrength = this.quality.bloom * this.bloomScale;
9501
9754
  if (bloomStrength > 0) this.runBloom(encoder);
9755
+ /*
9756
+ * Eye adaptation, metered from the scene the composite shows, and local exposure's grid beside
9757
+ * it. The held brightness is kept for either, since local exposure moves regions relative to it.
9758
+ * See `setAutoExposure` and `setLocalExposure`.
9759
+ */
9760
+ const localising = this.localExposure > 0;
9761
+ const measuring = (this.autoExposure > 0 || localising) && this.exposurePass !== null;
9762
+ const adapting = measuring && this.autoExposure > 0;
9763
+ const shownScene = this.reconstructing ? this.reconShownView : this.sceneColorView;
9764
+ if (measuring && shownScene !== null) {
9765
+ this.exposurePass?.run(encoder, shownScene, this.autoExposureDt, localising);
9766
+ }
9502
9767
 
9503
9768
  /* The grade travels here only where the mesh pass gave it up. See `bindMeshPass`. */
9504
9769
  const graded = this.quality.hdrScene;
@@ -9538,6 +9803,12 @@ export class WebGPURenderer implements RendererApi {
9538
9803
  /* The grade, sampled after the curve and before the veil. See `uGradeLut` in `rush.ts`. */
9539
9804
  f[at('uGradeStrength')] = this.gradeStrength;
9540
9805
  f[at('uGradeSize')] = this.gradeSize;
9806
+ /* The lens and the print. See `setVignette` and `setFilmGrain`. */
9807
+ f[at('uVignette')] = this.vignette;
9808
+ f[at('uAutoExposure')] = adapting ? this.autoExposure : 0;
9809
+ f[at('uLocalExposure')] = measuring && localising ? this.localExposure : 0;
9810
+ f[at('uGrain')] = this.grain;
9811
+ f[at('uGrain') + 1] = this.grainSeed;
9541
9812
  device.queue.writeBuffer(this.rushUniforms, 0, this.rushStaging);
9542
9813
 
9543
9814
  const pass = encoder.beginRenderPass({
@@ -9620,6 +9891,10 @@ export class WebGPURenderer implements RendererApi {
9620
9891
  a frame that never grades anything should not carry a texture, and a declared binding
9621
9892
  must still have a complete one — the argument the bloom fallback above makes. */
9622
9893
  ...entry(RUSH_TEXTURES.uGradeLut, this.ensureGradeView()),
9894
+ /* The scene as the placeholder until adaptation is asked for: the strength is zero then,
9895
+ and a declared binding still needs a complete texture, as the bloom fallback says. */
9896
+ ...entry(RUSH_TEXTURES.uExposureHeld, this.exposurePass?.heldView ?? sceneView),
9897
+ ...entry(RUSH_TEXTURES.uExposureLocal, this.exposurePass?.localView ?? sceneView),
9623
9898
  ],
9624
9899
  layout: this.rushLayout,
9625
9900
  });
@@ -10771,12 +11046,15 @@ export class WebGPURenderer implements RendererApi {
10771
11046
  otherwise halve the blur and leave half the pattern standing. */
10772
11047
  const width = Math.max(1, this.aoTarget?.width ?? 1);
10773
11048
  const height = Math.max(1, this.aoTarget?.height ?? 1);
10774
- for (const [index, step] of [
10775
- [0, [1 / width, 0]],
10776
- [1, [0, 1 / height]],
11049
+ /* Across at the estimate's own scale, which the second axis needs whole; down decoded, so the
11050
+ composite reads open sky as one. The same two `ambientOcclusionPass.ts` writes. */
11051
+ for (const [index, step, scale] of [
11052
+ [0, [1 / width, 0], 1],
11053
+ [1, [0, 1 / height], 1 / AO_STORE],
10777
11054
  ] as const) {
10778
11055
  f[slot(index) + at('uStep')] = step[0];
10779
11056
  f[slot(index) + at('uStep') + 1] = step[1];
11057
+ f[slot(index) + at('uAoScale')] = scale;
10780
11058
  f[slot(index) + at('uDepthToViewZ')] = inv[10] ?? 0;
10781
11059
  f[slot(index) + at('uDepthToViewZ') + 1] = inv[14] ?? 0;
10782
11060
  f[slot(index) + at('uDepthToViewZ') + 2] = inv[11] ?? 0;
@@ -11509,8 +11787,13 @@ export class WebGPURenderer implements RendererApi {
11509
11787
  const swap = this.swapView();
11510
11788
  if (swap === null) return null;
11511
11789
 
11512
- const width = this.depth?.width ?? 1;
11513
- const height = this.depth?.height ?? 1;
11790
+ /*
11791
+ * **The drawing buffer's size, not the scene depth's.** The overlay draws on the swap image,
11792
+ * past the resolve, and a reconstruction draws the scene depth small: sized from that, the
11793
+ * device refused the pass beside a full-size swap image and every overlay went missing.
11794
+ */
11795
+ const width = Math.max(1, this.surface.canvas.width);
11796
+ const height = Math.max(1, this.surface.canvas.height);
11514
11797
  if (
11515
11798
  this.overlayDepth === null ||
11516
11799
  this.overlayDepth.width !== width ||
@@ -11868,9 +12151,31 @@ export class WebGPURenderer implements RendererApi {
11868
12151
  * had stopped declaring. What the guard is actually for is not *building* a composer for a
11869
12152
  * profile that never declares anything, so that is all it now guards.
11870
12153
  */
12154
+ if (this.fieldComposer === null && this.distanceFields.length === 0) return;
12155
+ /*
12156
+ * **As many cascades as reach the probe grid**, and rebuilt when the grid asks for a different
12157
+ * number: the far end of a courtyard longer than the default 16 m was traced against no field
12158
+ * at all and lit as open sky from every side. See `gi/fieldReach.ts`.
12159
+ */
12160
+ const grid = this.indirectGrid();
12161
+ const cascades =
12162
+ grid === null
12163
+ ? DEFAULT_FIELD_COMPOSE.cascades
12164
+ : cascadesForGrid(
12165
+ grid,
12166
+ DEFAULT_FIELD_COMPOSE.radius,
12167
+ DEFAULT_FIELD_COMPOSE.cascades,
12168
+ MAX_FIELD_CASCADES,
12169
+ );
12170
+ if (this.fieldComposer !== null && this.fieldComposer.cascadeCount !== cascades) {
12171
+ this.fieldComposer.dispose();
12172
+ this.fieldComposer = null;
12173
+ }
11871
12174
  if (this.fieldComposer === null) {
11872
- if (this.distanceFields.length === 0) return;
11873
- this.fieldComposer = new FieldComposer(this.surface.device, DEFAULT_FIELD_COMPOSE);
12175
+ this.fieldComposer = new FieldComposer(this.surface.device, {
12176
+ ...DEFAULT_FIELD_COMPOSE,
12177
+ cascades,
12178
+ });
11874
12179
  }
11875
12180
  this.fieldComposer.compose(encoder, this.distanceFields, this.frameEye);
11876
12181
  }
@@ -11938,12 +12243,34 @@ export class WebGPURenderer implements RendererApi {
11938
12243
  * never said what was lighting it.
11939
12244
  */
11940
12245
  const env = this.frameEnv;
11941
- this.probeBaker.bake(encoder, field, grid, this.probeBaked, {
11942
- direction: env?.directionalDir ?? NO_SUN,
11943
- colour: env?.directionalColor ?? NO_SUN,
11944
- /* What a ray that left the world finds. The frame's own ambient, not a capture. */
11945
- sky: env?.ambient ?? NO_SUN,
11946
- });
12246
+ /*
12247
+ * And the scene's summed fixed lights, which the trace adds to what a hit receives: the bounce
12248
+ * of every candle, not only of the ones near enough to be shaded exactly.
12249
+ */
12250
+ resolveDriftLight(
12251
+ this.bakeLightField === null ? null : this.lightField,
12252
+ true,
12253
+ this.frameEye,
12254
+ this.bakeDriftUniforms,
12255
+ );
12256
+ /* And the frame's exact lights, flickering and moving as the frame shades them. */
12257
+ this.bakeLamps.count = resolveBounceLights(env, this.bounceLights);
12258
+ this.bakeLamps.falloff = this.lights.falloff;
12259
+ this.probeBaker.bake(
12260
+ encoder,
12261
+ field,
12262
+ grid,
12263
+ this.probeBaked,
12264
+ this.bakeLightField,
12265
+ this.bakeLamps,
12266
+ {
12267
+ direction: env?.directionalDir ?? NO_SUN,
12268
+ colour: env?.directionalColor ?? NO_SUN,
12269
+ /* What a ray that left the world finds: the sky this frame drew, or its ambient. */
12270
+ ambient: env?.ambient ?? NO_SUN,
12271
+ sky: this.frameSky,
12272
+ },
12273
+ );
11947
12274
  /*
11948
12275
  * **The bake is what fills the layers now, so it is what marks them filled.** `probeBaked`
11949
12276
  * gates the whole array, and a grid nothing rasterised would otherwise never be sampled — the
@@ -12170,7 +12497,7 @@ export class WebGPURenderer implements RendererApi {
12170
12497
  * currently open. Offering it earlier hands a pass a texture of undefined contents, which is
12171
12498
  * the failure `probeBaked` was introduced for on the forward path.
12172
12499
  */
12173
- const usable = this.probeView !== null && this.probeBaked && !this.probePassActive;
12500
+ const usable = this.probeView !== null && this.probeReadable();
12174
12501
  if (usable) {
12175
12502
  const environment = this.passEnvironment;
12176
12503
  environment.view = this.probeView;
@@ -12658,6 +12985,8 @@ export class WebGPURenderer implements RendererApi {
12658
12985
  */
12659
12986
 
12660
12987
  private bloomScale = 1;
12988
+ /** In scene units, once `setBloom` has moved it; null reads the profile's. */
12989
+ private bloomThresholdSet: number | null = null;
12661
12990
  private rushStrength = 0;
12662
12991
  private motionBlurScale = 1;
12663
12992
  /**
@@ -12669,6 +12998,51 @@ export class WebGPURenderer implements RendererApi {
12669
12998
  */
12670
12999
  private readonly veilColor: Vec3 = [0, 0, 0];
12671
13000
  private veilAlpha = 0;
13001
+ private vignette = 0;
13002
+ private grain = 0;
13003
+ private grainSeed = 0;
13004
+ private warnedLookWithoutComposite = false;
13005
+ /** Eye adaptation: how far, this frame's time step, and the pass once asked for. */
13006
+ private autoExposure = 0;
13007
+ private autoExposureDt = 0;
13008
+ /** How far each region is brought toward the frame. See `setLocalExposure`. */
13009
+ private localExposure = 0;
13010
+ private exposurePass: ExposurePass | null = null;
13011
+
13012
+ /**
13013
+ * How strongly the lens darkens the frame's corners, held until changed: 0 is none, 0.5 is about
13014
+ * 1.2 stops at the corner. Applied to scene light before the tone curve, as a lens loses light;
13015
+ * see `filmLook.ts`. Needs `screenEffects`, and says so once rather than doing nothing.
13016
+ */
13017
+ setVignette(strength: number): void {
13018
+ if (!this.hasComposite) {
13019
+ this.warnLookWithoutComposite(strength);
13020
+ return;
13021
+ }
13022
+ this.vignette = clampVignette(strength);
13023
+ }
13024
+
13025
+ /**
13026
+ * Film grain, held until changed: an amplitude in display values (0 is none, 0.03 is a fine
13027
+ * grain) and this frame's seed. **The seed is the caller's**, because the engine takes time from
13028
+ * its caller: pass a new one each frame for grain that moves, and the same one for a still that
13029
+ * is identical run to run. Applied last, after the grade and the veil; see `filmLook.ts`.
13030
+ */
13031
+ setFilmGrain(strength: number, seed: number): void {
13032
+ if (!this.hasComposite) {
13033
+ this.warnLookWithoutComposite(strength);
13034
+ return;
13035
+ }
13036
+ this.grain = clampGrain(strength);
13037
+ this.grainSeed = grainSeed(seed);
13038
+ }
13039
+
13040
+ private warnLookWithoutComposite(strength: number): void {
13041
+ if (strength > 0 && !this.warnedLookWithoutComposite) {
13042
+ this.warnedLookWithoutComposite = true;
13043
+ console.warn(FILM_LOOK_WITHOUT_COMPOSITE);
13044
+ }
13045
+ }
12672
13046
 
12673
13047
  /*
12674
13048
  * **These three warned that the composite did not exist, and went on warning after it did.**
@@ -12754,8 +13128,14 @@ export class WebGPURenderer implements RendererApi {
12754
13128
  this.gradeSize = lut.size;
12755
13129
  }
12756
13130
 
12757
- setBloom(scale: number): void {
13131
+ setBloom(scale: number, threshold?: number): void {
12758
13132
  this.bloomScale = Math.min(Math.max(scale, 0), 1);
13133
+ if (threshold === undefined) return;
13134
+ const next = bloomThresholdOf(threshold);
13135
+ if (next === this.bloomThresholdSet) return;
13136
+ this.bloomThresholdSet = next;
13137
+ /* The blocks are written once per size, so a moved threshold rewrites them now. */
13138
+ if (this.bloomLevels.length > 0) this.writeBloomBlocks();
12759
13139
  }
12760
13140
 
12761
13141
  /**
@@ -12788,6 +13168,62 @@ export class WebGPURenderer implements RendererApi {
12788
13168
  setCameraMotionBlur(scale: number): void {
12789
13169
  this.motionBlurScale = Math.min(Math.max(scale, 0), 1);
12790
13170
  }
13171
+ /**
13172
+ * The camera has cut: this frame is a new shot, not the next moment of the last one.
13173
+ *
13174
+ * **Everything temporal reprojects through the previous frame's view**: the motion blur measures
13175
+ * its smear against it, and the temporal resolve reads its history through it. A cut is a jump
13176
+ * the renderer cannot tell from a fast camera, so without this a transport that seeks, a respawn
13177
+ * or an edit smears its first frame along the whole jump and blends in a picture of somewhere
13178
+ * else. The caller knows it cut; this is how it says so, before the frame's `beginFrame`.
13179
+ *
13180
+ * What it gives up is one frame of history: the frame after a cut has no blur and no temporal
13181
+ * blend, as the first frame of a session has none. Nothing else is reset.
13182
+ */
13183
+ cameraCut(): void {
13184
+ this.hasPreviousView = false;
13185
+ this.temporalHistory.invalidate();
13186
+ /* The reconstruction keeps histories of its own, and they are the last shot's too. */
13187
+ this.reconHasHistory = false;
13188
+ /* And the eye was adapted to the last shot. */
13189
+ this.exposurePass?.cut();
13190
+ }
13191
+
13192
+ /**
13193
+ * Eye adaptation. **The other backend's `setAutoExposure` carries the reasoning**, whole; this is
13194
+ * the same setter over the same state, and the pass is built the first time it is asked for, so
13195
+ * a renderer that never adapts carries none of it.
13196
+ */
13197
+ setAutoExposure(strength: number, dtSec: number): void {
13198
+ if (!this.hasComposite || !this.quality.hdrScene) {
13199
+ this.warnLookWithoutComposite(strength);
13200
+ return;
13201
+ }
13202
+ this.autoExposure = clampAutoExposure(strength);
13203
+ this.autoExposureDt = dtSec;
13204
+ if (this.autoExposure > 0) this.ensureExposurePass();
13205
+ }
13206
+
13207
+ /**
13208
+ * Local exposure. **The other backend's `setLocalExposure` carries the reasoning**, whole; this is
13209
+ * the same setter over the same state, sharing eye adaptation's pass, which is built the first
13210
+ * time either is asked for.
13211
+ */
13212
+ setLocalExposure(strength: number): void {
13213
+ if (!this.hasComposite || !this.quality.hdrScene) {
13214
+ this.warnLookWithoutComposite(strength);
13215
+ return;
13216
+ }
13217
+ this.localExposure = clampLocalExposure(strength);
13218
+ if (this.localExposure > 0) this.ensureExposurePass();
13219
+ }
13220
+
13221
+ /** The exposure pass, built once; the composite's group holds placeholders until it exists. */
13222
+ private ensureExposurePass(): void {
13223
+ if (this.exposurePass !== null) return;
13224
+ this.exposurePass = new ExposurePass(this.surface.device, this.pipelines, this.postSampler);
13225
+ this.rebuildRushBindGroup();
13226
+ }
12791
13227
 
12792
13228
  /**
12793
13229
  * Where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling
@@ -13027,6 +13463,8 @@ export class WebGPURenderer implements RendererApi {
13027
13463
  * over a lobe-convolved one.
13028
13464
  */
13029
13465
  private readonly probe: GPUTexture | null;
13466
+ /** The views, groups and attachments a bake draws with, made once. See `probeTargets.ts`. */
13467
+ private readonly probeTargets: ProbeTargets | null;
13030
13468
  /**
13031
13469
  * The prefiltered cube: what the lit pass samples, one GGX-convolved level per roughness.
13032
13470
  *
@@ -13058,6 +13496,19 @@ export class WebGPURenderer implements RendererApi {
13058
13496
  private readonly probeMipLayout: GPUBindGroupLayout;
13059
13497
  /** The photometric atlas, allocated on first use. See `setIesProfiles`. */
13060
13498
  private iesTexture: GPUTexture | null = null;
13499
+ /** The scene's DriftLight field, and its two volumes once it is whole. See `createLightField`. */
13500
+ private lightField: LightField | null = null;
13501
+ private driftIndex: GPUTexture | null = null;
13502
+ private driftAtlas: GPUTexture | null = null;
13503
+ private driftIndexView: GPUTextureView | null = null;
13504
+ private driftAtlasView: GPUTextureView | null = null;
13505
+ private readonly driftUniforms = createDriftLightUniforms();
13506
+ /** The same field resolved for a traced probe, and what the baker binds; null while not whole. */
13507
+ private readonly bakeDriftUniforms = createDriftLightUniforms();
13508
+ private bakeLightField: ProbeBakeLightField | null = null;
13509
+ /** The frame's exact lights packed for the traced bake. See `gi/bounceLights.ts`. */
13510
+ private readonly bounceLights = createBounceLights();
13511
+ private readonly bakeLamps = { data: this.bounceLights, count: 0, falloff: 0 };
13061
13512
  private iesView: GPUTextureView | null = null;
13062
13513
  private cookieTexture: GPUTexture | null = null;
13063
13514
  private cookieView: GPUTextureView | null = null;
@@ -13082,8 +13533,9 @@ export class WebGPURenderer implements RendererApi {
13082
13533
  * mirroring uninitialised memory is worse than a car mirroring a gradient — the rule one probe
13083
13534
  * already had, unchanged by there being more of them.
13084
13535
  */
13085
- private probeFilled = new Uint8Array(1);
13086
- private probeFilledCount = 0;
13536
+ private probeSweeps: ProbeSweeps | null = null;
13537
+ /** `uProbeGridSets`, rewritten in place. */
13538
+ private readonly probeSets = new Float32Array(3);
13087
13539
  /**
13088
13540
  * Whether a bake is running, which is when the cube may be neither sampled nor bound.
13089
13541
  *
@@ -13091,15 +13543,26 @@ export class WebGPURenderer implements RendererApi {
13091
13543
  * load-bearing rather than defensive: WebGPU rejects a pass that holds the texture it writes.
13092
13544
  */
13093
13545
  private probePassActive = false;
13546
+ /** Whether the bake under way lights its faces by the grid. `ProbeBakeOptions.bounce`. */
13547
+ private probeBounce = false;
13548
+
13549
+ /**
13550
+ * Whether the shading may read the grid now: once every probe is filled, and not during a bake
13551
+ * unless it is a bounce bake. A bounce bake's faces are drawn into the probe's own cube and only
13552
+ * the resolve writes the array, in a pass of its own, so no pass both samples and writes it.
13553
+ */
13554
+ private probeReadable(): boolean {
13555
+ return this.probeBaked && (!this.probePassActive || this.probeBounce);
13556
+ }
13094
13557
 
13095
13558
  /** How many mip levels the roughness blur has to work with. `flat.ts` reads this. */
13096
13559
  private get probeMaxLod(): number {
13097
13560
  return ggxMaxLevelFor(this.probeEdge);
13098
13561
  }
13099
13562
 
13100
- /** Whether every layer of the grid has been convolved. See `probeFilled`. */
13563
+ /** Whether a whole sweep of the grid has been convolved. See `probeSweeps.ts`. */
13101
13564
  private get probeBaked(): boolean {
13102
- return this.probes !== null && this.probeFilledCount === this.probes.layers;
13565
+ return this.probes !== null && this.probeSweeps?.ready === true;
13103
13566
  }
13104
13567
 
13105
13568
  /**
@@ -13119,11 +13582,9 @@ export class WebGPURenderer implements RendererApi {
13119
13582
  * mistake, and `setProbeGrid` says it about the array being replaced.
13120
13583
  */
13121
13584
  private markProbeFilled(layer: number): void {
13122
- if (layer < 0 || layer >= this.probeFilled.length) return;
13123
- if (this.probeFilled[layer] === 1) return;
13124
- this.probeFilled[layer] = 1;
13125
- this.probeFilledCount++;
13126
- if (this.probeBaked) this.rebuildFlatGroupsForNewRings();
13585
+ const was = this.probeBaked;
13586
+ this.probeSweeps?.baked(layer);
13587
+ if (!was && this.probeBaked) this.rebuildFlatGroupsForNewRings();
13127
13588
  }
13128
13589
 
13129
13590
  /**
@@ -13134,6 +13595,7 @@ export class WebGPURenderer implements RendererApi {
13134
13595
  */
13135
13596
  private allocateProbeArray(layers: number, device: GPUDevice): void {
13136
13597
  this.probeArray?.destroy();
13598
+ this.probeTargets?.forgetArray();
13137
13599
  this.probeArray = device.createTexture({
13138
13600
  label: 'probe.array',
13139
13601
  size: [this.probeEdge, this.probeEdge, layers],
@@ -13143,8 +13605,6 @@ export class WebGPURenderer implements RendererApi {
13143
13605
  usage: 0x10 | 0x4, // RENDER_ATTACHMENT | TEXTURE_BINDING
13144
13606
  });
13145
13607
  this.probeView = this.probeArray.createView({ dimension: '2d-array' });
13146
- this.probeFilled = new Uint8Array(layers);
13147
- this.probeFilledCount = 0;
13148
13608
 
13149
13609
  /*
13150
13610
  * **Cleared here, because a texture no pass has written holds undefined contents** — the same
@@ -13201,8 +13661,16 @@ export class WebGPURenderer implements RendererApi {
13201
13661
  const current = this.probes;
13202
13662
  if (current !== null && this.probeArray !== null && sameGrid(current, grid)) return true;
13203
13663
  this.probes = grid;
13204
- if (this.probeArray === null || this.probeArray.depthOrArrayLayers !== grid.layers) {
13205
- this.allocateProbeArray(grid.layers, this.surface.device);
13664
+ /* A traced grid writes its layers itself, so it keeps one set. See `ProbeGridOptions.crossfade`. */
13665
+ const crossfade = grid.crossfade && !this.quality.indirectLight;
13666
+ const sets = crossfade ? 3 : 1;
13667
+ if (
13668
+ this.probeArray === null ||
13669
+ this.probeArray.depthOrArrayLayers !== grid.layers * sets ||
13670
+ this.probeSweeps?.sets !== sets
13671
+ ) {
13672
+ this.probeSweeps = new ProbeSweeps(grid.layers, crossfade);
13673
+ this.allocateProbeArray(grid.layers * sets, this.surface.device);
13206
13674
  this.rebuildFlatGroupsForNewRings();
13207
13675
  }
13208
13676
  return true;
@@ -13278,40 +13746,20 @@ export class WebGPURenderer implements RendererApi {
13278
13746
  camera.roll = 0;
13279
13747
 
13280
13748
  const { device } = this.surface;
13281
- const depth = device.createTexture({
13282
- label: 'probe.depth',
13283
- size: [this.probeSize, this.probeSize],
13284
- format: DEPTH_FORMAT,
13285
- /*
13286
- * **The world's sample count, because a bake draws the world with the world's pipelines.**
13287
- * A pipeline whose count disagrees with its attachment is rejected at `finish` and takes
13288
- * the whole bake with it, and WebGPU requires every attachment in a pass to agree — so a
13289
- * single-sample depth beside a four-sample colour is refused in words that read as a depth
13290
- * problem and are a multisampling one.
13291
- */
13292
- sampleCount: this.samples,
13293
- usage: 0x10, // RENDER_ATTACHMENT
13294
- });
13295
- const depthView = depth.createView();
13749
+ const targets = this.probeTargets;
13750
+ if (targets === null) return false;
13296
13751
  /*
13297
- * The multisampled twin the faces are drawn into, resolved into the cube.
13298
- *
13299
- * **A cube face cannot be both multisampled and sampled**, which is the same bind the mirror
13300
- * is in and it is solved the same way: render into this, name the face as `resolveTarget`,
13301
- * and the averaging happens on the way out. One twin for all six faces, because they are
13302
- * drawn one at a time and nothing reads it between them.
13752
+ * The depth, at the world's sample count because a bake draws the world with the world's
13753
+ * pipelines: a pipeline whose count disagrees with its attachment is rejected at `finish` and
13754
+ * takes the whole bake with it, in words that read as a depth problem and are a multisampling
13755
+ * one. And the multisampled twin the faces are drawn into and resolved from, since **a cube
13756
+ * face cannot be both multisampled and sampled** — the same bind the mirror is in. Both kept
13757
+ * between bakes: see `probeTargets.ts`.
13303
13758
  */
13304
- const colorMsaa =
13305
- this.samples > 1
13306
- ? device.createTexture({
13307
- label: 'probe.colorMsaa',
13308
- size: [this.probeSize, this.probeSize],
13309
- format: this.pipelines.format,
13310
- sampleCount: this.samples,
13311
- usage: 0x10, // RENDER_ATTACHMENT
13312
- })
13313
- : null;
13314
- const colorMsaaView = colorMsaa?.createView() ?? null;
13759
+ const { depth: depthView, colorMsaa: colorMsaaView } = targets.attachments(
13760
+ this.samples,
13761
+ this.pipelines.format,
13762
+ );
13315
13763
 
13316
13764
  /*
13317
13765
  * The frame's own pass is set aside and restored, because `drawFace` calls straight back into
@@ -13330,16 +13778,26 @@ export class WebGPURenderer implements RendererApi {
13330
13778
  */
13331
13779
  if (this.quality.frameGraph) this.flushGraph();
13332
13780
  const savedEncoder = this.encoder;
13781
+ /*
13782
+ * **The rings are marked here and given back after the submit below**, because this bake is its
13783
+ * own encoder: once it is submitted nothing in the frame can read its slots, and a grid baked in
13784
+ * one call would otherwise spend the frame's whole ring on probes and skip the draws past it.
13785
+ */
13786
+ this.markRings();
13787
+ const skinMark = this.skinPalettes.mark();
13788
+ const frameEpoch = this.encoderEpoch;
13789
+ this.encoderEpoch = ++this.encoderEpochs;
13333
13790
  const encoder = device.createCommandEncoder({ label: 'probe' });
13334
13791
  this.encoder = encoder;
13335
13792
  /*
13336
- * Fenced before the first face and released after the last, with the group rebuilt on both
13337
- * edges so the cube is not bound while it is being written. Two `createBindGroup` calls per
13338
- * bake, which is not a frame path: a bake is six passes and a mip chain, asked for by a
13339
- * scene at a moment of its choosing.
13793
+ * Fenced before the first face and released after the last, so the grid is not bound while a
13794
+ * plain bake is drawn. **Rebuilt only where that changes what the group binds**: a bounce bake
13795
+ * of a whole grid binds it exactly as the frame does, and it is the bake a scene repeats every
13796
+ * frame, a few faces at a time. See `rebindEnvironment`.
13340
13797
  */
13798
+ this.probeBounce = options?.bounce ?? false;
13341
13799
  this.probePassActive = true;
13342
- this.rebuildFlatBindGroup();
13800
+ this.rebindEnvironment();
13343
13801
  /*
13344
13802
  * **A probe stores radiance, not display pixels.** See `renderer.ts` for the whole argument:
13345
13803
  * baking through the output transform crushes every highlight with the tone curve before it
@@ -13352,7 +13810,9 @@ export class WebGPURenderer implements RendererApi {
13352
13810
  this.perFrameFloats[this.materialField('uOutputExposure')] = 1;
13353
13811
  this.materials.dirty();
13354
13812
 
13355
- for (let face = 0; face < PROBE_FACES.length; face++) {
13813
+ /* Only the faces asked for; the chain waits for the sixth. See `ProbeBakeOptions.faces`. */
13814
+ const [firstFace, endFace] = probeFaceRange(options);
13815
+ for (let face = firstFace; face < endFace; face++) {
13356
13816
  const aim = PROBE_FACES[face];
13357
13817
  if (aim === undefined) continue;
13358
13818
  camera.yaw = aim.yaw;
@@ -13363,13 +13823,7 @@ export class WebGPURenderer implements RendererApi {
13363
13823
  */
13364
13824
  camera.updateMatrices(1);
13365
13825
 
13366
- const faceView = probe.createView({
13367
- dimension: '2d',
13368
- baseArrayLayer: face,
13369
- arrayLayerCount: 1,
13370
- baseMipLevel: 0,
13371
- mipLevelCount: 1,
13372
- });
13826
+ const faceView = targets.faceViews[face] as GPUTextureView;
13373
13827
  this.pass = encoder.beginRenderPass({
13374
13828
  label: `probe.face${face}`,
13375
13829
  colorAttachments: [
@@ -13418,20 +13872,22 @@ export class WebGPURenderer implements RendererApi {
13418
13872
  this.pass.end();
13419
13873
  }
13420
13874
  this.pass = null;
13421
- this.buildProbeChain(encoder, probe, layer);
13875
+ const whole = endFace === PROBE_FACES.length;
13876
+ if (whole) this.buildProbeChain(encoder, probe, this.probeSweeps?.writeLayer(layer) ?? layer);
13422
13877
  this.flushRings();
13423
13878
  device.queue.submit([encoder.finish()]);
13424
- depth.destroy();
13425
- colorMsaa?.destroy();
13879
+ this.rewindRings(skinMark);
13880
+ this.encoderEpoch = frameEpoch;
13426
13881
 
13427
13882
  this.encoder = savedEncoder;
13428
13883
  this.pass = savedPass;
13429
- this.markProbeFilled(layer);
13884
+ if (whole) this.markProbeFilled(layer);
13430
13885
  this.perFrameInts[this.materialField('uOutputTransform')] = heldGrade;
13431
13886
  this.perFrameFloats[this.materialField('uOutputExposure')] = heldExposure;
13432
13887
  this.materials.dirty();
13433
13888
  this.probePassActive = false;
13434
- this.rebuildFlatBindGroup();
13889
+ this.probeBounce = false;
13890
+ this.rebindEnvironment();
13435
13891
  return true;
13436
13892
  }
13437
13893
 
@@ -13582,6 +14038,92 @@ export class WebGPURenderer implements RendererApi {
13582
14038
  this.rebuildFlatBindGroup();
13583
14039
  }
13584
14040
 
14041
+ /**
14042
+ * Sum a scene's many fixed lights into a DriftLight field. The same contract as
14043
+ * `Renderer.createLightField`; see `driftLight/lightField.ts`.
14044
+ */
14045
+ createLightField(
14046
+ lights: readonly LightFieldSource[],
14047
+ options: Omit<LightFieldOptions, 'falloff'> = {},
14048
+ ): LightField {
14049
+ this.forgetLightFieldVolumes();
14050
+ const field = new LightField(lights, { ...options, falloff: this.quality.pointLightFalloff });
14051
+ this.lightField = field;
14052
+ return field;
14053
+ }
14054
+
14055
+ /** Let go of the field and its volumes; the scene's lights are all exact again. */
14056
+ disposeLightField(): void {
14057
+ this.forgetLightFieldVolumes();
14058
+ this.lightField = null;
14059
+ }
14060
+
14061
+ private forgetLightFieldVolumes(): void {
14062
+ const bound = this.driftAtlas !== null;
14063
+ this.driftIndex?.destroy();
14064
+ this.driftAtlas?.destroy();
14065
+ this.driftIndex = null;
14066
+ this.driftAtlas = null;
14067
+ this.driftIndexView = null;
14068
+ this.driftAtlasView = null;
14069
+ this.bakeLightField = null;
14070
+ /* The groups hold the destroyed volumes, and a group holding a destroyed texture fails. */
14071
+ if (bound && !this.surface.lost) this.rebuildFlatBindGroup();
14072
+ }
14073
+
14074
+ /**
14075
+ * Upload the field's two volumes, once, when its last brick has baked, and rebuild the groups
14076
+ * that bind them: nothing samples them before that, so uploading as bricks land would be work
14077
+ * for no picture, and a rebuild per brick would be a bind group per brick.
14078
+ */
14079
+ private uploadLightField(): void {
14080
+ const field = this.lightField;
14081
+ if (field === null || this.driftAtlas !== null || !field.ready || field.layout.count === 0) {
14082
+ return;
14083
+ }
14084
+ if (this.surface.lost) return;
14085
+ const { device } = this.surface;
14086
+ const [ix, iy, iz] = field.layout.dims;
14087
+ const index = device.createTexture({
14088
+ label: 'driftLight.index',
14089
+ size: [ix, iy, iz],
14090
+ dimension: '3d',
14091
+ format: 'r32uint',
14092
+ usage: 0x2 | 0x4, // COPY_DST | TEXTURE_BINDING
14093
+ });
14094
+ device.queue.writeTexture(
14095
+ { texture: index },
14096
+ field.layout.index,
14097
+ { bytesPerRow: ix * 4, rowsPerImage: iy },
14098
+ [ix, iy, iz],
14099
+ );
14100
+ const [aw, ah, ad] = field.atlasSize;
14101
+ const atlas = device.createTexture({
14102
+ label: 'driftLight.atlas',
14103
+ size: [aw, ah, ad],
14104
+ dimension: '3d',
14105
+ format: 'rgba16float',
14106
+ usage: 0x2 | 0x4, // COPY_DST | TEXTURE_BINDING
14107
+ });
14108
+ device.queue.writeTexture(
14109
+ { texture: atlas },
14110
+ field.atlas,
14111
+ { bytesPerRow: aw * 8, rowsPerImage: ah },
14112
+ [aw, ah, ad],
14113
+ );
14114
+ this.driftIndex = index;
14115
+ this.driftAtlas = atlas;
14116
+ this.driftIndexView = index.createView({ dimension: '3d' });
14117
+ this.driftAtlasView = atlas.createView({ dimension: '3d' });
14118
+ this.bakeLightField = {
14119
+ index: this.driftIndexView,
14120
+ atlas: this.driftAtlasView,
14121
+ light: this.bakeDriftUniforms.light,
14122
+ origin: this.bakeDriftUniforms.origin,
14123
+ };
14124
+ this.rebuildFlatBindGroup();
14125
+ }
14126
+
13585
14127
  setIesProfiles(profiles: readonly PhotometricProfile[]): void {
13586
14128
  if (this.surface.lost) return;
13587
14129
  const { device } = this.surface;
@@ -13629,6 +14171,8 @@ export class WebGPURenderer implements RendererApi {
13629
14171
  /** Each level from the one above it, six faces at a time. See `probePass.ts`. */
13630
14172
  private buildProbeChain(encoder: GPUCommandEncoder, probe: GPUTexture, layer: number): void {
13631
14173
  const { device } = this.surface;
14174
+ const targets = this.probeTargets;
14175
+ if (targets === null) return;
13632
14176
  /*
13633
14177
  * **Reset and flushed here, because a bake is not a frame.**
13634
14178
  *
@@ -13657,12 +14201,13 @@ export class WebGPURenderer implements RendererApi {
13657
14201
  );
13658
14202
  const levels = probeLevels(this.probeSize);
13659
14203
  for (let level = 1; level < levels; level++) {
13660
- /* A view of the whole cube at the level above, so the blur can cross a face edge. */
13661
- const source = probe.createView({
13662
- dimension: 'cube',
13663
- baseMipLevel: level - 1,
13664
- mipLevelCount: 1,
13665
- });
14204
+ /* The whole cube at the level above, so the blur can cross a face edge, in a group kept. */
14205
+ const group = targets.chainGroup(
14206
+ this.probeMipLayout,
14207
+ this.probeMipUniforms.buffer,
14208
+ this.probeSampler,
14209
+ level,
14210
+ );
13666
14211
  for (let face = 0; face < PROBE_FACE_BASIS.length; face++) {
13667
14212
  const basis = PROBE_FACE_BASIS[face];
13668
14213
  if (basis === undefined) continue;
@@ -13679,22 +14224,16 @@ export class WebGPURenderer implements RendererApi {
13679
14224
  this.probeMipUniforms.flush();
13680
14225
  return;
13681
14226
  }
13682
- this.probeMipUniforms.writeFloats(slot, 0, [...basis.basisX, 0]);
13683
- this.probeMipUniforms.writeFloats(slot, 16, [...basis.basisY, 0]);
13684
- this.probeMipUniforms.writeFloats(slot, 32, [...basis.forward, 0]);
13685
- this.probeMipUniforms.writeFloats(slot, 48, [0, 0, 0, 0]);
14227
+ this.probeMipUniforms.writeFloats(slot, 0, padded(basis.basisX));
14228
+ this.probeMipUniforms.writeFloats(slot, 16, padded(basis.basisY));
14229
+ this.probeMipUniforms.writeFloats(slot, 32, padded(basis.forward));
14230
+ this.probeMipUniforms.writeFloats(slot, 48, ZERO4);
13686
14231
 
13687
14232
  const pass = encoder.beginRenderPass({
13688
14233
  label: `probe.mip${level}.face${face}`,
13689
14234
  colorAttachments: [
13690
14235
  {
13691
- view: probe.createView({
13692
- dimension: '2d',
13693
- baseArrayLayer: face,
13694
- arrayLayerCount: 1,
13695
- baseMipLevel: level,
13696
- mipLevelCount: 1,
13697
- }),
14236
+ view: targets.chainTarget(level, face),
13698
14237
  loadOp: 'clear',
13699
14238
  storeOp: 'store',
13700
14239
  clearValue: { r: 0, g: 0, b: 0, a: 1 },
@@ -13702,21 +14241,7 @@ export class WebGPURenderer implements RendererApi {
13702
14241
  ],
13703
14242
  });
13704
14243
  pass.setPipeline(pipeline);
13705
- pass.setBindGroup(
13706
- 0,
13707
- device.createBindGroup({
13708
- layout: this.probeMipLayout,
13709
- entries: [
13710
- {
13711
- binding: 0,
13712
- resource: { buffer: this.probeMipUniforms.buffer, size: PROBE_MIP_UNIFORM_SIZE },
13713
- },
13714
- { binding: 1, resource: source },
13715
- { binding: 2, resource: this.probeSampler },
13716
- ],
13717
- }),
13718
- [slot],
13719
- );
14244
+ pass.setBindGroup(0, group, [slot]);
13720
14245
  pass.draw(3);
13721
14246
  pass.end();
13722
14247
  }
@@ -13742,8 +14267,12 @@ export class WebGPURenderer implements RendererApi {
13742
14267
  this.probePrefilterLayout,
13743
14268
  this.pipelines.format,
13744
14269
  );
13745
- /* The whole chain, so a wide sample can select a level from it. */
13746
- const source = probe.createView({ dimension: 'cube' });
14270
+ /* The whole chain, so a wide sample can select a level from it, in a group kept. */
14271
+ const group = targets.prefilterGroupFor(
14272
+ this.probePrefilterLayout,
14273
+ this.probeMipUniforms.buffer,
14274
+ this.probeSampler,
14275
+ );
13747
14276
  const offsets = PREFILTER_UNIFORM_OFFSETS;
13748
14277
  const topLevel = levels - 1;
13749
14278
  const samples = this.quality.environmentPrefilterSamples;
@@ -13751,13 +14280,20 @@ export class WebGPURenderer implements RendererApi {
13751
14280
  const irradianceLevel = irradianceLevelFor(edge);
13752
14281
  const ggxTop = ggxMaxLevelFor(edge);
13753
14282
  const boxChain = !this.quality.environmentPrefilter;
14283
+ /*
14284
+ * **The diffuse level of a layer the trace has written is the trace's**, so a rasterised bake
14285
+ * stops one level short of it. See `ProbeBaker.traced`: two writers of one level was light
14286
+ * sweeping down a courtyard a probe at a time with the clock paused.
14287
+ */
14288
+ const lastLevel =
14289
+ this.probeBaker?.traced(layer) === true ? irradianceLevel - 1 : irradianceLevel;
13754
14290
 
13755
14291
  /*
13756
14292
  * **One draw a level, where the cube needed six.** An octahedral map has no faces, so the
13757
14293
  * six-way loop is gone rather than moved, and a probe's convolution is six times fewer
13758
14294
  * passes than the cube it replaces.
13759
14295
  */
13760
- for (let level = 0; level <= irradianceLevel; level++) {
14296
+ for (let level = 0; level <= lastLevel; level++) {
13761
14297
  const irradiance = level === irradianceLevel;
13762
14298
  const roughness = irradiance ? 1 : roughnessForLevel(level, ggxTop);
13763
14299
  const slot = this.probeMipUniforms.allocate();
@@ -13776,32 +14312,40 @@ export class WebGPURenderer implements RendererApi {
13776
14312
  /* Written at the generator's own offsets rather than at literals, because a roughness at
13777
14313
  the wrong offset is a level convolved for some other roughness — which looks like a
13778
14314
  plausible reflection and points at nothing. */
13779
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterEdge.offset, [
13780
- Math.max(1, edge >> level),
13781
- ]);
13782
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterIrradiance.offset, [
13783
- irradiance ? 1 : 0,
13784
- ]);
13785
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterBox.offset, [boxChain ? 1 : 0]);
13786
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterLevel.offset, [level]);
13787
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterRoughness.offset, [roughness]);
13788
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterSamples.offset, [samples]);
13789
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterSourceTexels.offset, [
13790
- this.probeSize,
13791
- ]);
13792
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterSourceMaxLod.offset, [topLevel]);
14315
+ this.probeMipUniforms.writeFloats(
14316
+ slot,
14317
+ offsets.uPrefilterEdge.offset,
14318
+ one(Math.max(1, edge >> level)),
14319
+ );
14320
+ this.probeMipUniforms.writeFloats(
14321
+ slot,
14322
+ offsets.uPrefilterIrradiance.offset,
14323
+ one(irradiance ? 1 : 0),
14324
+ );
14325
+ this.probeMipUniforms.writeFloats(
14326
+ slot,
14327
+ offsets.uPrefilterBox.offset,
14328
+ one(boxChain ? 1 : 0),
14329
+ );
14330
+ this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterLevel.offset, one(level));
14331
+ this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterRoughness.offset, one(roughness));
14332
+ this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterSamples.offset, one(samples));
14333
+ this.probeMipUniforms.writeFloats(
14334
+ slot,
14335
+ offsets.uPrefilterSourceTexels.offset,
14336
+ one(this.probeSize),
14337
+ );
14338
+ this.probeMipUniforms.writeFloats(
14339
+ slot,
14340
+ offsets.uPrefilterSourceMaxLod.offset,
14341
+ one(topLevel),
14342
+ );
13793
14343
 
13794
14344
  const pass = encoder.beginRenderPass({
13795
14345
  label: `probe.prefilter${level}.layer${layer}`,
13796
14346
  colorAttachments: [
13797
14347
  {
13798
- view: prefilterTarget.createView({
13799
- dimension: '2d',
13800
- baseArrayLayer: layer,
13801
- arrayLayerCount: 1,
13802
- baseMipLevel: level,
13803
- mipLevelCount: 1,
13804
- }),
14348
+ view: targets.arrayView(prefilterTarget, layer, level),
13805
14349
  loadOp: 'clear',
13806
14350
  storeOp: 'store',
13807
14351
  clearValue: { r: 0, g: 0, b: 0, a: 1 },
@@ -13809,21 +14353,7 @@ export class WebGPURenderer implements RendererApi {
13809
14353
  ],
13810
14354
  });
13811
14355
  pass.setPipeline(prefilter);
13812
- pass.setBindGroup(
13813
- 0,
13814
- device.createBindGroup({
13815
- layout: this.probePrefilterLayout,
13816
- entries: [
13817
- {
13818
- binding: PREFILTER_UNIFORM_BINDING,
13819
- resource: { buffer: this.probeMipUniforms.buffer, size: PROBE_MIP_UNIFORM_SIZE },
13820
- },
13821
- { binding: PREFILTER_TEXTURE_BINDING, resource: source },
13822
- { binding: PREFILTER_SAMPLER_BINDING, resource: this.probeSampler },
13823
- ],
13824
- }),
13825
- [slot],
13826
- );
14356
+ pass.setBindGroup(0, group, [slot]);
13827
14357
  pass.draw(3);
13828
14358
  pass.end();
13829
14359
  }
@@ -14302,7 +14832,7 @@ export class WebGPURenderer implements RendererApi {
14302
14832
  f[slot(0) + prefilter('uTexel') + 1] = 1 / Math.max(1, this.frameHeight);
14303
14833
  /* In scene units, which is why an HDR target is what makes a threshold above 1 mean
14304
14834
  anything: against a clamped buffer nothing is ever brighter than white. */
14305
- f[slot(0) + prefilter('uThreshold')] = this.quality.bloomThreshold;
14835
+ f[slot(0) + prefilter('uThreshold')] = this.bloomThresholdSet ?? this.quality.bloomThreshold;
14306
14836
 
14307
14837
  /* Each downsample's texel is its *source's*, which is the level above it. */
14308
14838
  const down = (name: string): number => this.postField(BLOOM_DOWNSAMPLE_FIELDS, name);
@@ -14420,6 +14950,8 @@ export class WebGPURenderer implements RendererApi {
14420
14950
 
14421
14951
  this.fieldComposer?.dispose();
14422
14952
  this.fieldComposer = null;
14953
+ this.driftIndex?.destroy();
14954
+ this.driftAtlas?.destroy();
14423
14955
 
14424
14956
  this.pass = null;
14425
14957
  this.encoder = null;
@@ -14427,6 +14959,9 @@ export class WebGPURenderer implements RendererApi {
14427
14959
  this.gpuTimer.dispose();
14428
14960
  this.reflection?.dispose();
14429
14961
  this.reflection = null;
14962
+ this.probeTargets?.dispose();
14963
+ this.exposurePass?.dispose();
14964
+ this.exposurePass = null;
14430
14965
  this.surface.dispose();
14431
14966
  }
14432
14967
  }