@driftengine/core 4.3.0 → 4.4.0

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