@driftengine/core 4.2.1 → 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 +272 -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 +166 -1
  14. package/dist/render/backend/webgl2/renderer.js +424 -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 +176 -11
  32. package/dist/render/backend/webgpu/renderer.js +680 -250
  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 +462 -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 +807 -271
  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 {
@@ -150,7 +152,7 @@ import {
150
152
  type GpuTextCube,
151
153
  } from './textPass.ts';
152
154
  import type { TextStyle } from '../../textLayout.ts';
153
- import { deviceSnappedOrigin } from '../../textLayout.ts';
155
+ import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.ts';
154
156
  import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
155
157
  import {
156
158
  createSdfTextBindGroup,
@@ -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);
@@ -3000,6 +3094,11 @@ export class WebGPURenderer implements RendererApi {
3000
3094
  text.setPlate(widthCells, heightCells, bottomCell);
3001
3095
  }
3002
3096
 
3097
+ /** See `Renderer.snapTextCellSize`; only the drawing buffer differs. */
3098
+ snapTextCellSize(cellSize: number, viewportWidth: number): number {
3099
+ return deviceSnappedCellSize(cellSize, viewportWidth, this.surface.canvas.width);
3100
+ }
3101
+
3003
3102
  textWidth(text: GpuText, cellSize: number): number {
3004
3103
  return text.layout.widthPx(cellSize);
3005
3104
  }
@@ -3581,6 +3680,18 @@ export class WebGPURenderer implements RendererApi {
3581
3680
  timeSeconds: number,
3582
3681
  ): void {
3583
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;
3584
3695
  const live = packInstances(batch, data);
3585
3696
  if (live === 0) return;
3586
3697
 
@@ -4359,7 +4470,6 @@ export class WebGPURenderer implements RendererApi {
4359
4470
 
4360
4471
  this.windStreakFragFloats.set(tint, (WIND_STREAK_FRAG_FIELDS.uTint.offset ?? 0) / 4);
4361
4472
 
4362
- const { queue } = this.surface.device;
4363
4473
  this.windStreakVerts.writeBlock(vertexSlot, this.windStreakVertBlock);
4364
4474
  this.windStreakFrags.writeBlock(fragmentSlot, this.windStreakFragBlock);
4365
4475
 
@@ -4461,7 +4571,6 @@ export class WebGPURenderer implements RendererApi {
4461
4571
 
4462
4572
  this.flockFragFloats.set(tint, (FLOCK_FRAG_FIELDS.uTint.offset ?? 0) / 4);
4463
4573
 
4464
- const { queue } = this.surface.device;
4465
4574
  this.flockVerts.writeBlock(vertexSlot, this.flockVertBlock);
4466
4575
  this.flockFrags.writeBlock(fragmentSlot, this.flockFragBlock);
4467
4576
 
@@ -4814,7 +4923,8 @@ export class WebGPURenderer implements RendererApi {
4814
4923
  private readonly scatterLayout: GPUBindGroupLayout;
4815
4924
  /** One slot per batch: the gust and the trample field belong to the batch, not the frame. */
4816
4925
  private readonly scatterVerts: UniformRing;
4817
- private readonly scatterFragUniforms: GPUBuffer;
4926
+ /** A slot a draw, beside the vertex block's: see `createScatterBindGroupLayout` for why. */
4927
+ private readonly scatterFrags: UniformRing;
4818
4928
  private readonly scatterFragStaging = new ArrayBuffer(SCATTER_FRAG_SIZE);
4819
4929
  private readonly scatterFragFloats = new Float32Array(this.scatterFragStaging);
4820
4930
  private readonly scatterFragInts = new Int32Array(this.scatterFragStaging);
@@ -5009,17 +5119,28 @@ export class WebGPURenderer implements RendererApi {
5009
5119
  private readonly dynamicView: GPUTextureView | null;
5010
5120
  /** The peel's own bind groups: `uPeelShadowLayer` at 1, and the static map to peel against. */
5011
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;
5012
5133
  private readonly scatterDepthPeelBindGroup: GPUBindGroup | null;
5013
5134
  /** Which layer the open pass is writing, so the sink picks the bindings that match it. */
5014
5135
  private shadowLayerIsPeel = false;
5015
5136
  /**
5016
5137
  * Whether a peel pass has filled the map, which is what the frame may sample it on.
5017
5138
  *
5018
- * **Not whether the map exists.** `renderer.ts` can ask that question because a GL texture is
5019
- * born cleared; a WebGPU texture no pass has written holds undefined contents, so a scene
5020
- * that builds a peel map and never opens the layer would sample garbage. Cleared when the
5021
- * static layer opens and set when a peel pass ends, which is per frame because both demo
5022
- * 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.
5023
5144
  */
5024
5145
  private peelFilled = false;
5025
5146
  /** One slot per caster, the same trick `perDraw` uses and for the same reason. */
@@ -5061,6 +5182,44 @@ export class WebGPURenderer implements RendererApi {
5061
5182
  * otherwise. A lookup rather than a rebind, because a `createBindGroup` per draw is the
5062
5183
  * per-frame allocation the house rules forbid; both groups are built once at init.
5063
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
+
5064
5223
  private shadowGroup(): GPUBindGroup {
5065
5224
  return this.shadowLayerIsPeel && this.shadowPeelBindGroup !== null
5066
5225
  ? this.shadowPeelBindGroup
@@ -5109,9 +5268,13 @@ export class WebGPURenderer implements RendererApi {
5109
5268
  * every frame would be an allocation in a per-frame path.
5110
5269
  */
5111
5270
  private readonly casterSink: ShadowCasterSink = {
5112
- mesh: (mesh, model) => {
5271
+ mesh: (mesh, model, material) => {
5113
5272
  const pass = this.shadowPass;
5114
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);
5115
5278
  const slot = this.shadowDraws.allocate();
5116
5279
  if (slot === null) return;
5117
5280
 
@@ -5122,20 +5285,28 @@ export class WebGPURenderer implements RendererApi {
5122
5285
  );
5123
5286
  this.shadowDraws.writeFloats(slot, DEPTH_VERT_FIELDS.uModel.offset, model as Float32Array);
5124
5287
  this.writeShadowWind(slot);
5288
+ if (cutout !== null && cutoutGroup !== null)
5289
+ this.writeCutout(slot, cutout, DEPTH_CUTOUT_VERT_FIELDS);
5125
5290
 
5126
5291
  const geometry = mesh as GpuMesh & { key?: string };
5127
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;
5128
5296
  pass.setPipeline(
5129
5297
  depthPipeline(
5130
5298
  this.pipelines,
5131
5299
  this.surface.device,
5132
- this.shadowLayout,
5133
- `depth|${this.depthCullMode}|${geometry.key ?? ''}`,
5300
+ cuts ? this.shadowCutoutLayout : this.shadowLayout,
5301
+ `${cuts ? 'depth-cutout' : 'depth'}|${cull}|${geometry.key ?? ''}`,
5134
5302
  this.presentOf(geometry),
5135
- this.depthCullMode,
5303
+ cull,
5304
+ false,
5305
+ false,
5306
+ cuts,
5136
5307
  ),
5137
5308
  );
5138
- pass.setBindGroup(0, this.shadowGroup(), [slot]);
5309
+ pass.setBindGroup(0, cutoutGroup ?? this.shadowGroup(), [slot]);
5139
5310
  for (let index = 0; index < geometry.vertexBuffers.length; index++) {
5140
5311
  pass.setVertexBuffer(index, geometry.vertexBuffers[index] as GPUBuffer);
5141
5312
  }
@@ -5146,7 +5317,7 @@ export class WebGPURenderer implements RendererApi {
5146
5317
  * A batch of rigid meshes into the depth map, placed by the same matrices the visible draw
5147
5318
  * uses — one draw, however many instances, exactly as the colour pass does it.
5148
5319
  */
5149
- instanced: (batch, data) => {
5320
+ instanced: (batch, data, material) => {
5150
5321
  const pass = this.shadowPass;
5151
5322
  if (pass === null) return;
5152
5323
  const gpuBatch = batch as GpuInstancedBatch;
@@ -5154,6 +5325,9 @@ export class WebGPURenderer implements RendererApi {
5154
5325
  if (geometry.vertexBuffers === undefined) return;
5155
5326
  const count = Math.min(data.count, gpuBatch.capacity);
5156
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);
5157
5331
 
5158
5332
  const slot = this.shadowDraws.allocate();
5159
5333
  if (slot === null) return;
@@ -5164,20 +5338,26 @@ export class WebGPURenderer implements RendererApi {
5164
5338
  DEPTH_VERT_FIELDS.uLightViewProj.offset,
5165
5339
  this.correctedLightViewProj,
5166
5340
  );
5341
+ if (cutout !== null && cutoutGroup !== null) {
5342
+ this.writeCutout(slot, cutout, DEPTH_INSTANCED_CUTOUT_VERT_FIELDS);
5343
+ }
5167
5344
 
5345
+ const cuts = cutoutGroup !== null;
5346
+ const cull = material?.doubleSided === true ? 'none' : this.depthCullMode;
5168
5347
  pass.setPipeline(
5169
5348
  depthPipeline(
5170
5349
  this.pipelines,
5171
5350
  this.surface.device,
5172
- this.shadowLayout,
5173
- `depth-instanced|${this.depthCullMode}|${geometry.key ?? ''}`,
5351
+ cuts ? this.shadowCutoutLayout : this.shadowLayout,
5352
+ `${cuts ? 'depth-instanced-cutout' : 'depth-instanced'}|${cull}|${geometry.key ?? ''}`,
5174
5353
  this.presentOf(geometry),
5175
- this.depthCullMode,
5354
+ cull,
5176
5355
  false,
5177
5356
  true,
5357
+ cuts,
5178
5358
  ),
5179
5359
  );
5180
- pass.setBindGroup(0, this.shadowGroup(), [slot]);
5360
+ pass.setBindGroup(0, cutoutGroup ?? this.shadowGroup(), [slot]);
5181
5361
  let index = 0;
5182
5362
  for (; index < geometry.vertexBuffers.length; index++) {
5183
5363
  pass.setVertexBuffer(index, geometry.vertexBuffers[index] as GPUBuffer);
@@ -5676,34 +5856,6 @@ export class WebGPURenderer implements RendererApi {
5676
5856
  const blankView = blank.createView();
5677
5857
  const blankSampler = device.createSampler({ label: 'flat.sampler' });
5678
5858
 
5679
- /*
5680
- * A one-texel cube for every point-shadow sampler the variant declares.
5681
- *
5682
- * Twelve of them exist in the `pointShadows` permutation and none has a map behind it
5683
- * until row 3, but WebGPU requires every declared binding to be filled — and a cube view
5684
- * needs six array layers, so the flat 1×1 stand-in above cannot serve.
5685
- *
5686
- * **White is the second guard, and it is a deliberate value rather than a spare one.** The
5687
- * first is `uPointShadowIndex`, written to −1 for every light in `bindMeshPass`. If that
5688
- * ever failed, a full-depth texel reads as an occluder at the far plane — `pointShadow`
5689
- * converts the stored value back to a distance and 1.0 comes out as `far` — so the worst a
5690
- * mis-selected light can do is find nothing in the way. Zeroes would have meant an
5691
- * occluder pressed against the lamp, and every surface in the scene in shadow.
5692
- */
5693
- const blankCube = device.createTexture({
5694
- label: 'flat.emptyPointShadow',
5695
- size: [1, 1, 6],
5696
- format: 'rgba8unorm',
5697
- usage: 0x2 | 0x4, // COPY_DST | TEXTURE_BINDING
5698
- });
5699
- device.queue.writeTexture(
5700
- { texture: blankCube },
5701
- new Uint8Array(4 * 6).fill(255),
5702
- { bytesPerRow: 4, rowsPerImage: 1 },
5703
- [1, 1, 6],
5704
- );
5705
- const blankCubeView = blankCube.createView({ dimension: 'cube' });
5706
-
5707
5859
  /*
5708
5860
  * A one-texel integer stand-in for the froxel table.
5709
5861
  *
@@ -5753,6 +5905,17 @@ export class WebGPURenderer implements RendererApi {
5753
5905
  [1, 1, 1],
5754
5906
  );
5755
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');
5756
5919
 
5757
5920
  this.shadowMap = device.createTexture({
5758
5921
  label: 'shadow.static',
@@ -5792,14 +5955,15 @@ export class WebGPURenderer implements RendererApi {
5792
5955
  this.dynamicView = this.dynamicMap === null ? null : this.dynamicMap.createView();
5793
5956
 
5794
5957
  /*
5795
- * **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.
5796
5964
  *
5797
- * A GL depth texture is born defined and reads as the far plane, so `renderer.ts` can hand
5798
- * an unrendered shadow map to the shader and get "nothing occludes anything". WebGPU makes
5799
- * no such promise, and a scene is entitled to switch directional shadows *on* and never
5800
- * open a pass — `night-street` does exactly that, with `shadowStrength` at 0.85 and no
5801
- * `beginShadowPass` anywhere in it. Sampling garbage then shadows whatever the garbage
5802
- * 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.
5803
5967
  *
5804
5968
  * Found by dumping the flat block off both live renderers and comparing it field by field;
5805
5969
  * `uPeeledShadowEnabled` came back 1 against 0 and pointed straight at the maps behind it.
@@ -5909,7 +6073,7 @@ export class WebGPURenderer implements RendererApi {
5909
6073
  * is writing, so the binding has to move whatever the shader intends to do with it.
5910
6074
  */
5911
6075
  if (name === 'uEnvironment') {
5912
- return this.probeView === null || !this.probeBaked || this.probePassActive
6076
+ return this.probeView === null || !this.probeReadable()
5913
6077
  ? { view: blankArrayView, sampler: blankSampler }
5914
6078
  : { view: this.probeView, sampler: this.probeSampler };
5915
6079
  }
@@ -5943,6 +6107,17 @@ export class WebGPURenderer implements RendererApi {
5943
6107
  if (name === 'uCookieAtlas') {
5944
6108
  return { view: this.cookieView ?? blankView, sampler: iesSampler };
5945
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
+ }
5946
6121
  /*
5947
6122
  * The colour the frame had already drawn, or the blank texel a frame that refracts nothing
5948
6123
  * falls back to.
@@ -5998,12 +6173,14 @@ export class WebGPURenderer implements RendererApi {
5998
6173
  this.blankAlbedoBindGroup = this.bindGroup;
5999
6174
 
6000
6175
  this.skyLayout = createSkyBindGroupLayout(device);
6001
- this.skyUniforms = device.createBuffer({
6002
- label: 'sky.uniforms',
6003
- size: SKY_UNIFORM_SIZE,
6004
- usage: USAGE_UNIFORM_DST,
6005
- });
6006
- 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);
6007
6184
 
6008
6185
  this.plumeLayout = createPlumeBindGroupLayout(device);
6009
6186
  this.plumeVerts = new UniformRing(
@@ -6082,6 +6259,8 @@ export class WebGPURenderer implements RendererApi {
6082
6259
  usage: 0x10 | 0x4 | 0x2, // RENDER_ATTACHMENT | TEXTURE_BINDING | COPY_DST
6083
6260
  })
6084
6261
  : null;
6262
+ this.probeTargets =
6263
+ this.probe === null ? null : new ProbeTargets(device, this.probe, this.probeSize);
6085
6264
  /*
6086
6265
  * The prefiltered twin, allocated on exactly the condition the capture is and with exactly
6087
6266
  * its format and level count. Same format because a pipeline's colour target must equal its
@@ -6124,6 +6303,10 @@ export class WebGPURenderer implements RendererApi {
6124
6303
  is not a screen-sized target. Filtering is what makes a 32-lattice table smooth rather
6125
6304
  than banded, so it is declared filterable like the colour inputs and unlike the depth. */
6126
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 },
6127
6310
  ]);
6128
6311
  this.rushUniforms = device.createBuffer({
6129
6312
  label: 'post.rushUniforms',
@@ -6353,23 +6536,27 @@ export class WebGPURenderer implements RendererApi {
6353
6536
  MAX_PLUMES_PER_FRAME,
6354
6537
  USAGE_UNIFORM_DST,
6355
6538
  );
6356
- this.scatterFragUniforms = device.createBuffer({
6357
- label: 'scatter.fragUniforms',
6358
- size: SCATTER_FRAG_SIZE,
6359
- usage: USAGE_UNIFORM_DST,
6360
- });
6539
+ this.scatterFrags = new UniformRing(
6540
+ device,
6541
+ SCATTER_FRAG_SIZE,
6542
+ MAX_PLUMES_PER_FRAME,
6543
+ USAGE_UNIFORM_DST,
6544
+ 'scatter.fragRing',
6545
+ );
6361
6546
  this.scatterBindGroup = createScatterBindGroup(
6362
6547
  device,
6363
6548
  this.scatterLayout,
6364
6549
  this.scatterVerts.buffer,
6365
- this.scatterFragUniforms,
6550
+ this.scatterFrags.buffer,
6366
6551
  );
6367
6552
 
6368
6553
  this.shadowLayout = createDepthBindGroupLayout(device);
6369
6554
  this.shadowSkinnedLayout = createDepthBindGroupLayout(device, true);
6555
+ this.shadowCutoutLayout = createDepthBindGroupLayout(device, false, true);
6370
6556
  this.shadowDraws = new UniformRing(
6371
6557
  device,
6372
- 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),
6373
6560
  MAX_DRAWS_PER_FRAME,
6374
6561
  USAGE_UNIFORM_DST,
6375
6562
  'shadow.drawRing',
@@ -6441,6 +6628,8 @@ export class WebGPURenderer implements RendererApi {
6441
6628
  if (peelPassUniforms !== null) {
6442
6629
  device.queue.writeBuffer(peelPassUniforms, 0, new Int32Array([1]));
6443
6630
  }
6631
+ this.shadowPeelPassUniforms = peelPassUniforms;
6632
+ this.shadowPeelSampler = shadowSampler;
6444
6633
  this.shadowPeelBindGroup =
6445
6634
  peelPassUniforms === null
6446
6635
  ? null
@@ -6465,15 +6654,6 @@ export class WebGPURenderer implements RendererApi {
6465
6654
  shadowSampler,
6466
6655
  );
6467
6656
 
6468
- /*
6469
- * The light volume, last because it wants the shadow map and the stand-in that everything
6470
- * above already built. The peel and dynamic maps get the white one-pixel texture, which is
6471
- * depth 1 and therefore nothing in the way — see `createLightVolumeBindGroup` for why a
6472
- * stand-in of zero would put every beam in shadow instead.
6473
- */
6474
- const uniformBuffer = (label: string, size: number): GPUBuffer =>
6475
- device.createBuffer({ label, size, usage: USAGE_UNIFORM_DST });
6476
-
6477
6657
  /*
6478
6658
  * The overlays. Rings rather than single blocks because a frame draws several of each —
6479
6659
  * the loader alone is two panels and two strings — and `queue.writeBuffer` does not
@@ -6796,6 +6976,7 @@ export class WebGPURenderer implements RendererApi {
6796
6976
  this.reflectionQueue.reset();
6797
6977
  /* Redeclared every frame, so a field a consumer stops declaring stops lighting. */
6798
6978
  this.distanceFields.reset();
6979
+ this.frameSky = null;
6799
6980
  this.oitActive =
6800
6981
  this.quality.orderIndependent && this.quality.screenEffects && this.samples === 1;
6801
6982
  /* Read off the line above rather than restating its last term: with the other two true, the
@@ -6818,6 +6999,7 @@ export class WebGPURenderer implements RendererApi {
6818
6999
  this.flushOverlay();
6819
7000
 
6820
7001
  this.framePresented = false;
7002
+ this.encoderEpoch = ++this.encoderEpochs;
6821
7003
  /* Before the budget is cleared, because what to grow to is what the last frame asked for. */
6822
7004
  this.growRings();
6823
7005
  this.budget.reset();
@@ -6857,6 +7039,7 @@ export class WebGPURenderer implements RendererApi {
6857
7039
  this.windStreakVerts.reset();
6858
7040
  this.windStreakFrags.reset();
6859
7041
  this.scatterVerts.reset();
7042
+ this.scatterFrags.reset();
6860
7043
  this.panelVerts.reset();
6861
7044
  this.panelFrags.reset();
6862
7045
  this.filmVerts.reset();
@@ -6868,6 +7051,7 @@ export class WebGPURenderer implements RendererApi {
6868
7051
  this.sdfTextFrags.reset();
6869
7052
  this.lineVerts.reset();
6870
7053
  this.lineFrags.reset();
7054
+ this.skyUniforms.reset();
6871
7055
  /* The material ring, and the open slot with it: last frame's slots are gone. */
6872
7056
  this.perFrame.reset();
6873
7057
  this.materials.dirty();
@@ -7464,9 +7648,29 @@ export class WebGPURenderer implements RendererApi {
7464
7648
  f[base + 1] = counts[1];
7465
7649
  f[base + 2] = counts[2];
7466
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
+ }
7467
7660
  /* Which chain is bound, because the level and the weight both depend on it. */
7468
7661
  f[at('uEnvironmentPrefiltered')] = this.quality.environmentPrefilter ? 1 : 0;
7469
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'));
7470
7674
  /*
7471
7675
  * The ORM scales, whose base is 1 and not 0 — the value an unwritten uniform would hold.
7472
7676
  *
@@ -7544,7 +7748,7 @@ export class WebGPURenderer implements RendererApi {
7544
7748
  * it every frame.
7545
7749
  */
7546
7750
  if (this.probeEnabled) {
7547
- const usable = this.probeBaked && !this.probePassActive;
7751
+ const usable = this.probeReadable();
7548
7752
  f[at('uEnvironmentEnabled')] = usable ? 1 : 0;
7549
7753
  f[at('uEnvironmentMaxLod')] = this.probeMaxLod;
7550
7754
  /*
@@ -7803,6 +8007,14 @@ export class WebGPURenderer implements RendererApi {
7803
8007
 
7804
8008
  /** Said once rather than every frame, for the reason `warnedFull` gives. */
7805
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;
7806
8018
 
7807
8019
  /**
7808
8020
  * Draw one mesh.
@@ -8116,7 +8328,9 @@ export class WebGPURenderer implements RendererApi {
8116
8328
  `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
8117
8329
  /* The two order-independent buffers are two formats and two blend states, so a draw into
8118
8330
  one must never share a cached pipeline with the same mesh drawn into the other. */
8119
- `${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' : ''}`;
8120
8334
 
8121
8335
  /*
8122
8336
  * The morph uniforms, written only for a morphed draw and read from the *morphed* variant's
@@ -8161,6 +8375,7 @@ export class WebGPURenderer implements RendererApi {
8161
8375
  layer,
8162
8376
  false,
8163
8377
  this.oitMode,
8378
+ this.materialDoubleSided,
8164
8379
  );
8165
8380
  }
8166
8381
 
@@ -8394,7 +8609,8 @@ export class WebGPURenderer implements RendererApi {
8394
8609
  plain one would hand a draw the wrong layout. */
8395
8610
  const key =
8396
8611
  `${base}|inst${blend ? '|blend' : ''}` +
8397
- `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}`;
8612
+ `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
8613
+ `${this.materialDoubleSided ? '|2s' : ''}`;
8398
8614
 
8399
8615
  const pipelines = this.targetPipelines();
8400
8616
  let pipeline = pipelines.peek(key);
@@ -8416,6 +8632,8 @@ export class WebGPURenderer implements RendererApi {
8416
8632
  depthWrite,
8417
8633
  layer,
8418
8634
  true,
8635
+ 'none',
8636
+ this.materialDoubleSided,
8419
8637
  );
8420
8638
  }
8421
8639
 
@@ -8576,7 +8794,9 @@ export class WebGPURenderer implements RendererApi {
8576
8794
  f[atF('uFogNear')] = medium.fogNear;
8577
8795
  f[atF('uFogFar')] = medium.fogFar;
8578
8796
 
8579
- 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);
8580
8800
 
8581
8801
  const scatterPipe = scatterPipeline(this.pipelines, this.surface.device, this.scatterLayout);
8582
8802
  /* One dynamic offset, and a group of two uniform buffers: nothing sampled, nothing read. */
@@ -8585,7 +8805,8 @@ export class WebGPURenderer implements RendererApi {
8585
8805
  scatterCommand.pipeline = scatterPipe;
8586
8806
  scatterCommand.bindGroup = this.scatterBindGroup;
8587
8807
  scatterCommand.offsetA = slot;
8588
- scatterCommand.offsetCount = 1;
8808
+ scatterCommand.offsetB = fragmentSlot;
8809
+ scatterCommand.offsetCount = 2;
8589
8810
  for (let index = 0; index < scatter.vertexBuffers.length; index++) {
8590
8811
  scatterCommand.vertexBuffers[index] = scatter.vertexBuffers[index] as GPUBuffer;
8591
8812
  }
@@ -8598,7 +8819,7 @@ export class WebGPURenderer implements RendererApi {
8598
8819
  const pass = this.openPass();
8599
8820
  if (pass === null) return;
8600
8821
  pass.setPipeline(scatterPipe);
8601
- pass.setBindGroup(0, this.scatterBindGroup, [slot]);
8822
+ pass.setBindGroup(0, this.scatterBindGroup, [slot, fragmentSlot]);
8602
8823
  for (let index = 0; index < scatter.vertexBuffers.length; index++) {
8603
8824
  pass.setVertexBuffer(index, scatter.vertexBuffers[index] as GPUBuffer);
8604
8825
  }
@@ -9259,6 +9480,7 @@ export class WebGPURenderer implements RendererApi {
9259
9480
  */
9260
9481
  private rebuildFlatGroupsForNewRings(): void {
9261
9482
  this.flatBindGroups.clear();
9483
+ this.flatEnvironment = this.flatTextures('uEnvironment').view;
9262
9484
  this.skinnedGroups.length = 0;
9263
9485
 
9264
9486
  const albedo = this.albedo;
@@ -9285,6 +9507,27 @@ export class WebGPURenderer implements RendererApi {
9285
9507
  this.materials.dirty();
9286
9508
  }
9287
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
+
9288
9531
  /**
9289
9532
  * Rebuild the group for the open material, and drop every cached one.
9290
9533
  *
@@ -9296,6 +9539,7 @@ export class WebGPURenderer implements RendererApi {
9296
9539
  */
9297
9540
  private rebuildFlatBindGroup(): void {
9298
9541
  this.flatBindGroups.clear();
9542
+ this.flatEnvironment = this.flatTextures('uEnvironment').view;
9299
9543
  if (
9300
9544
  this.albedo === null &&
9301
9545
  this.normalMap === null &&
@@ -9336,6 +9580,7 @@ export class WebGPURenderer implements RendererApi {
9336
9580
  */
9337
9581
  drawSky(camera: Camera, sky: SkyColors, env: Environment): void {
9338
9582
  if (!this.canDraw()) return;
9583
+ this.frameSky = sky;
9339
9584
 
9340
9585
  const f = this.skyFloats;
9341
9586
  const at = (name: keyof typeof SKY_FIELDS): number => SKY_FIELDS[name].offset / 4;
@@ -9370,7 +9615,17 @@ export class WebGPURenderer implements RendererApi {
9370
9615
  f.set(medium.underwaterColor, at('uUnderwaterColor'));
9371
9616
  f[at('uUnderwaterFactor')] = medium.underwaterFactor;
9372
9617
 
9373
- 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);
9374
9629
 
9375
9630
  const skyPipe = skyPipeline(this.pipelines, this.surface.device, this.skyLayout);
9376
9631
  /* Reads nothing: the group is one uniform buffer, and the sky samples no attachment. */
@@ -9378,12 +9633,14 @@ export class WebGPURenderer implements RendererApi {
9378
9633
  if (skyCommand !== null) {
9379
9634
  skyCommand.pipeline = skyPipe;
9380
9635
  skyCommand.bindGroup = this.skyBindGroup;
9636
+ skyCommand.offsetA = slot;
9637
+ skyCommand.offsetCount = 1;
9381
9638
  skyCommand.count = 3;
9382
9639
  } else {
9383
9640
  const pass = this.openPass();
9384
9641
  if (pass === null) return;
9385
9642
  pass.setPipeline(skyPipe);
9386
- pass.setBindGroup(0, this.skyBindGroup);
9643
+ pass.setBindGroup(0, this.skyBindGroup, [slot]);
9387
9644
  pass.draw(3);
9388
9645
  }
9389
9646
  }
@@ -9494,6 +9751,18 @@ export class WebGPURenderer implements RendererApi {
9494
9751
  if (aoStrength > 0 && this.frameProjection !== null) this.runOcclusion(encoder);
9495
9752
  const bloomStrength = this.quality.bloom * this.bloomScale;
9496
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
+ }
9497
9766
 
9498
9767
  /* The grade travels here only where the mesh pass gave it up. See `bindMeshPass`. */
9499
9768
  const graded = this.quality.hdrScene;
@@ -9533,6 +9802,12 @@ export class WebGPURenderer implements RendererApi {
9533
9802
  /* The grade, sampled after the curve and before the veil. See `uGradeLut` in `rush.ts`. */
9534
9803
  f[at('uGradeStrength')] = this.gradeStrength;
9535
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;
9536
9811
  device.queue.writeBuffer(this.rushUniforms, 0, this.rushStaging);
9537
9812
 
9538
9813
  const pass = encoder.beginRenderPass({
@@ -9615,6 +9890,10 @@ export class WebGPURenderer implements RendererApi {
9615
9890
  a frame that never grades anything should not carry a texture, and a declared binding
9616
9891
  must still have a complete one — the argument the bloom fallback above makes. */
9617
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),
9618
9897
  ],
9619
9898
  layout: this.rushLayout,
9620
9899
  });
@@ -11504,8 +11783,13 @@ export class WebGPURenderer implements RendererApi {
11504
11783
  const swap = this.swapView();
11505
11784
  if (swap === null) return null;
11506
11785
 
11507
- const width = this.depth?.width ?? 1;
11508
- 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);
11509
11793
  if (
11510
11794
  this.overlayDepth === null ||
11511
11795
  this.overlayDepth.width !== width ||
@@ -11863,9 +12147,31 @@ export class WebGPURenderer implements RendererApi {
11863
12147
  * had stopped declaring. What the guard is actually for is not *building* a composer for a
11864
12148
  * profile that never declares anything, so that is all it now guards.
11865
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
+ }
11866
12170
  if (this.fieldComposer === null) {
11867
- if (this.distanceFields.length === 0) return;
11868
- 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
+ });
11869
12175
  }
11870
12176
  this.fieldComposer.compose(encoder, this.distanceFields, this.frameEye);
11871
12177
  }
@@ -11933,12 +12239,34 @@ export class WebGPURenderer implements RendererApi {
11933
12239
  * never said what was lighting it.
11934
12240
  */
11935
12241
  const env = this.frameEnv;
11936
- this.probeBaker.bake(encoder, field, grid, this.probeBaked, {
11937
- direction: env?.directionalDir ?? NO_SUN,
11938
- colour: env?.directionalColor ?? NO_SUN,
11939
- /* What a ray that left the world finds. The frame's own ambient, not a capture. */
11940
- sky: env?.ambient ?? NO_SUN,
11941
- });
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
+ );
11942
12270
  /*
11943
12271
  * **The bake is what fills the layers now, so it is what marks them filled.** `probeBaked`
11944
12272
  * gates the whole array, and a grid nothing rasterised would otherwise never be sampled — the
@@ -12165,7 +12493,7 @@ export class WebGPURenderer implements RendererApi {
12165
12493
  * currently open. Offering it earlier hands a pass a texture of undefined contents, which is
12166
12494
  * the failure `probeBaked` was introduced for on the forward path.
12167
12495
  */
12168
- const usable = this.probeView !== null && this.probeBaked && !this.probePassActive;
12496
+ const usable = this.probeView !== null && this.probeReadable();
12169
12497
  if (usable) {
12170
12498
  const environment = this.passEnvironment;
12171
12499
  environment.view = this.probeView;
@@ -12653,6 +12981,8 @@ export class WebGPURenderer implements RendererApi {
12653
12981
  */
12654
12982
 
12655
12983
  private bloomScale = 1;
12984
+ /** In scene units, once `setBloom` has moved it; null reads the profile's. */
12985
+ private bloomThresholdSet: number | null = null;
12656
12986
  private rushStrength = 0;
12657
12987
  private motionBlurScale = 1;
12658
12988
  /**
@@ -12664,6 +12994,51 @@ export class WebGPURenderer implements RendererApi {
12664
12994
  */
12665
12995
  private readonly veilColor: Vec3 = [0, 0, 0];
12666
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
+ }
12667
13042
 
12668
13043
  /*
12669
13044
  * **These three warned that the composite did not exist, and went on warning after it did.**
@@ -12749,8 +13124,14 @@ export class WebGPURenderer implements RendererApi {
12749
13124
  this.gradeSize = lut.size;
12750
13125
  }
12751
13126
 
12752
- setBloom(scale: number): void {
13127
+ setBloom(scale: number, threshold?: number): void {
12753
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();
12754
13135
  }
12755
13136
 
12756
13137
  /**
@@ -12783,6 +13164,62 @@ export class WebGPURenderer implements RendererApi {
12783
13164
  setCameraMotionBlur(scale: number): void {
12784
13165
  this.motionBlurScale = Math.min(Math.max(scale, 0), 1);
12785
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
+ }
12786
13223
 
12787
13224
  /**
12788
13225
  * Where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling
@@ -13022,6 +13459,8 @@ export class WebGPURenderer implements RendererApi {
13022
13459
  * over a lobe-convolved one.
13023
13460
  */
13024
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;
13025
13464
  /**
13026
13465
  * The prefiltered cube: what the lit pass samples, one GGX-convolved level per roughness.
13027
13466
  *
@@ -13053,6 +13492,19 @@ export class WebGPURenderer implements RendererApi {
13053
13492
  private readonly probeMipLayout: GPUBindGroupLayout;
13054
13493
  /** The photometric atlas, allocated on first use. See `setIesProfiles`. */
13055
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 };
13056
13508
  private iesView: GPUTextureView | null = null;
13057
13509
  private cookieTexture: GPUTexture | null = null;
13058
13510
  private cookieView: GPUTextureView | null = null;
@@ -13077,8 +13529,9 @@ export class WebGPURenderer implements RendererApi {
13077
13529
  * mirroring uninitialised memory is worse than a car mirroring a gradient — the rule one probe
13078
13530
  * already had, unchanged by there being more of them.
13079
13531
  */
13080
- private probeFilled = new Uint8Array(1);
13081
- private probeFilledCount = 0;
13532
+ private probeSweeps: ProbeSweeps | null = null;
13533
+ /** `uProbeGridSets`, rewritten in place. */
13534
+ private readonly probeSets = new Float32Array(3);
13082
13535
  /**
13083
13536
  * Whether a bake is running, which is when the cube may be neither sampled nor bound.
13084
13537
  *
@@ -13086,15 +13539,26 @@ export class WebGPURenderer implements RendererApi {
13086
13539
  * load-bearing rather than defensive: WebGPU rejects a pass that holds the texture it writes.
13087
13540
  */
13088
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
+ }
13089
13553
 
13090
13554
  /** How many mip levels the roughness blur has to work with. `flat.ts` reads this. */
13091
13555
  private get probeMaxLod(): number {
13092
13556
  return ggxMaxLevelFor(this.probeEdge);
13093
13557
  }
13094
13558
 
13095
- /** 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`. */
13096
13560
  private get probeBaked(): boolean {
13097
- return this.probes !== null && this.probeFilledCount === this.probes.layers;
13561
+ return this.probes !== null && this.probeSweeps?.ready === true;
13098
13562
  }
13099
13563
 
13100
13564
  /**
@@ -13114,11 +13578,9 @@ export class WebGPURenderer implements RendererApi {
13114
13578
  * mistake, and `setProbeGrid` says it about the array being replaced.
13115
13579
  */
13116
13580
  private markProbeFilled(layer: number): void {
13117
- if (layer < 0 || layer >= this.probeFilled.length) return;
13118
- if (this.probeFilled[layer] === 1) return;
13119
- this.probeFilled[layer] = 1;
13120
- this.probeFilledCount++;
13121
- if (this.probeBaked) this.rebuildFlatGroupsForNewRings();
13581
+ const was = this.probeBaked;
13582
+ this.probeSweeps?.baked(layer);
13583
+ if (!was && this.probeBaked) this.rebuildFlatGroupsForNewRings();
13122
13584
  }
13123
13585
 
13124
13586
  /**
@@ -13129,6 +13591,7 @@ export class WebGPURenderer implements RendererApi {
13129
13591
  */
13130
13592
  private allocateProbeArray(layers: number, device: GPUDevice): void {
13131
13593
  this.probeArray?.destroy();
13594
+ this.probeTargets?.forgetArray();
13132
13595
  this.probeArray = device.createTexture({
13133
13596
  label: 'probe.array',
13134
13597
  size: [this.probeEdge, this.probeEdge, layers],
@@ -13138,8 +13601,6 @@ export class WebGPURenderer implements RendererApi {
13138
13601
  usage: 0x10 | 0x4, // RENDER_ATTACHMENT | TEXTURE_BINDING
13139
13602
  });
13140
13603
  this.probeView = this.probeArray.createView({ dimension: '2d-array' });
13141
- this.probeFilled = new Uint8Array(layers);
13142
- this.probeFilledCount = 0;
13143
13604
 
13144
13605
  /*
13145
13606
  * **Cleared here, because a texture no pass has written holds undefined contents** — the same
@@ -13196,8 +13657,16 @@ export class WebGPURenderer implements RendererApi {
13196
13657
  const current = this.probes;
13197
13658
  if (current !== null && this.probeArray !== null && sameGrid(current, grid)) return true;
13198
13659
  this.probes = grid;
13199
- if (this.probeArray === null || this.probeArray.depthOrArrayLayers !== grid.layers) {
13200
- 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);
13201
13670
  this.rebuildFlatGroupsForNewRings();
13202
13671
  }
13203
13672
  return true;
@@ -13273,40 +13742,20 @@ export class WebGPURenderer implements RendererApi {
13273
13742
  camera.roll = 0;
13274
13743
 
13275
13744
  const { device } = this.surface;
13276
- const depth = device.createTexture({
13277
- label: 'probe.depth',
13278
- size: [this.probeSize, this.probeSize],
13279
- format: DEPTH_FORMAT,
13280
- /*
13281
- * **The world's sample count, because a bake draws the world with the world's pipelines.**
13282
- * A pipeline whose count disagrees with its attachment is rejected at `finish` and takes
13283
- * the whole bake with it, and WebGPU requires every attachment in a pass to agree — so a
13284
- * single-sample depth beside a four-sample colour is refused in words that read as a depth
13285
- * problem and are a multisampling one.
13286
- */
13287
- sampleCount: this.samples,
13288
- usage: 0x10, // RENDER_ATTACHMENT
13289
- });
13290
- const depthView = depth.createView();
13745
+ const targets = this.probeTargets;
13746
+ if (targets === null) return false;
13291
13747
  /*
13292
- * The multisampled twin the faces are drawn into, resolved into the cube.
13293
- *
13294
- * **A cube face cannot be both multisampled and sampled**, which is the same bind the mirror
13295
- * is in and it is solved the same way: render into this, name the face as `resolveTarget`,
13296
- * and the averaging happens on the way out. One twin for all six faces, because they are
13297
- * 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`.
13298
13754
  */
13299
- const colorMsaa =
13300
- this.samples > 1
13301
- ? device.createTexture({
13302
- label: 'probe.colorMsaa',
13303
- size: [this.probeSize, this.probeSize],
13304
- format: this.pipelines.format,
13305
- sampleCount: this.samples,
13306
- usage: 0x10, // RENDER_ATTACHMENT
13307
- })
13308
- : null;
13309
- const colorMsaaView = colorMsaa?.createView() ?? null;
13755
+ const { depth: depthView, colorMsaa: colorMsaaView } = targets.attachments(
13756
+ this.samples,
13757
+ this.pipelines.format,
13758
+ );
13310
13759
 
13311
13760
  /*
13312
13761
  * The frame's own pass is set aside and restored, because `drawFace` calls straight back into
@@ -13325,16 +13774,26 @@ export class WebGPURenderer implements RendererApi {
13325
13774
  */
13326
13775
  if (this.quality.frameGraph) this.flushGraph();
13327
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;
13328
13786
  const encoder = device.createCommandEncoder({ label: 'probe' });
13329
13787
  this.encoder = encoder;
13330
13788
  /*
13331
- * Fenced before the first face and released after the last, with the group rebuilt on both
13332
- * edges so the cube is not bound while it is being written. Two `createBindGroup` calls per
13333
- * bake, which is not a frame path: a bake is six passes and a mip chain, asked for by a
13334
- * 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`.
13335
13793
  */
13794
+ this.probeBounce = options?.bounce ?? false;
13336
13795
  this.probePassActive = true;
13337
- this.rebuildFlatBindGroup();
13796
+ this.rebindEnvironment();
13338
13797
  /*
13339
13798
  * **A probe stores radiance, not display pixels.** See `renderer.ts` for the whole argument:
13340
13799
  * baking through the output transform crushes every highlight with the tone curve before it
@@ -13347,7 +13806,9 @@ export class WebGPURenderer implements RendererApi {
13347
13806
  this.perFrameFloats[this.materialField('uOutputExposure')] = 1;
13348
13807
  this.materials.dirty();
13349
13808
 
13350
- 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++) {
13351
13812
  const aim = PROBE_FACES[face];
13352
13813
  if (aim === undefined) continue;
13353
13814
  camera.yaw = aim.yaw;
@@ -13358,13 +13819,7 @@ export class WebGPURenderer implements RendererApi {
13358
13819
  */
13359
13820
  camera.updateMatrices(1);
13360
13821
 
13361
- const faceView = probe.createView({
13362
- dimension: '2d',
13363
- baseArrayLayer: face,
13364
- arrayLayerCount: 1,
13365
- baseMipLevel: 0,
13366
- mipLevelCount: 1,
13367
- });
13822
+ const faceView = targets.faceViews[face] as GPUTextureView;
13368
13823
  this.pass = encoder.beginRenderPass({
13369
13824
  label: `probe.face${face}`,
13370
13825
  colorAttachments: [
@@ -13413,20 +13868,22 @@ export class WebGPURenderer implements RendererApi {
13413
13868
  this.pass.end();
13414
13869
  }
13415
13870
  this.pass = null;
13416
- this.buildProbeChain(encoder, probe, layer);
13871
+ const whole = endFace === PROBE_FACES.length;
13872
+ if (whole) this.buildProbeChain(encoder, probe, this.probeSweeps?.writeLayer(layer) ?? layer);
13417
13873
  this.flushRings();
13418
13874
  device.queue.submit([encoder.finish()]);
13419
- depth.destroy();
13420
- colorMsaa?.destroy();
13875
+ this.rewindRings(skinMark);
13876
+ this.encoderEpoch = frameEpoch;
13421
13877
 
13422
13878
  this.encoder = savedEncoder;
13423
13879
  this.pass = savedPass;
13424
- this.markProbeFilled(layer);
13880
+ if (whole) this.markProbeFilled(layer);
13425
13881
  this.perFrameInts[this.materialField('uOutputTransform')] = heldGrade;
13426
13882
  this.perFrameFloats[this.materialField('uOutputExposure')] = heldExposure;
13427
13883
  this.materials.dirty();
13428
13884
  this.probePassActive = false;
13429
- this.rebuildFlatBindGroup();
13885
+ this.probeBounce = false;
13886
+ this.rebindEnvironment();
13430
13887
  return true;
13431
13888
  }
13432
13889
 
@@ -13577,6 +14034,92 @@ export class WebGPURenderer implements RendererApi {
13577
14034
  this.rebuildFlatBindGroup();
13578
14035
  }
13579
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
+
13580
14123
  setIesProfiles(profiles: readonly PhotometricProfile[]): void {
13581
14124
  if (this.surface.lost) return;
13582
14125
  const { device } = this.surface;
@@ -13624,6 +14167,8 @@ export class WebGPURenderer implements RendererApi {
13624
14167
  /** Each level from the one above it, six faces at a time. See `probePass.ts`. */
13625
14168
  private buildProbeChain(encoder: GPUCommandEncoder, probe: GPUTexture, layer: number): void {
13626
14169
  const { device } = this.surface;
14170
+ const targets = this.probeTargets;
14171
+ if (targets === null) return;
13627
14172
  /*
13628
14173
  * **Reset and flushed here, because a bake is not a frame.**
13629
14174
  *
@@ -13652,12 +14197,13 @@ export class WebGPURenderer implements RendererApi {
13652
14197
  );
13653
14198
  const levels = probeLevels(this.probeSize);
13654
14199
  for (let level = 1; level < levels; level++) {
13655
- /* A view of the whole cube at the level above, so the blur can cross a face edge. */
13656
- const source = probe.createView({
13657
- dimension: 'cube',
13658
- baseMipLevel: level - 1,
13659
- mipLevelCount: 1,
13660
- });
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
+ );
13661
14207
  for (let face = 0; face < PROBE_FACE_BASIS.length; face++) {
13662
14208
  const basis = PROBE_FACE_BASIS[face];
13663
14209
  if (basis === undefined) continue;
@@ -13674,22 +14220,16 @@ export class WebGPURenderer implements RendererApi {
13674
14220
  this.probeMipUniforms.flush();
13675
14221
  return;
13676
14222
  }
13677
- this.probeMipUniforms.writeFloats(slot, 0, [...basis.basisX, 0]);
13678
- this.probeMipUniforms.writeFloats(slot, 16, [...basis.basisY, 0]);
13679
- this.probeMipUniforms.writeFloats(slot, 32, [...basis.forward, 0]);
13680
- 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);
13681
14227
 
13682
14228
  const pass = encoder.beginRenderPass({
13683
14229
  label: `probe.mip${level}.face${face}`,
13684
14230
  colorAttachments: [
13685
14231
  {
13686
- view: probe.createView({
13687
- dimension: '2d',
13688
- baseArrayLayer: face,
13689
- arrayLayerCount: 1,
13690
- baseMipLevel: level,
13691
- mipLevelCount: 1,
13692
- }),
14232
+ view: targets.chainTarget(level, face),
13693
14233
  loadOp: 'clear',
13694
14234
  storeOp: 'store',
13695
14235
  clearValue: { r: 0, g: 0, b: 0, a: 1 },
@@ -13697,21 +14237,7 @@ export class WebGPURenderer implements RendererApi {
13697
14237
  ],
13698
14238
  });
13699
14239
  pass.setPipeline(pipeline);
13700
- pass.setBindGroup(
13701
- 0,
13702
- device.createBindGroup({
13703
- layout: this.probeMipLayout,
13704
- entries: [
13705
- {
13706
- binding: 0,
13707
- resource: { buffer: this.probeMipUniforms.buffer, size: PROBE_MIP_UNIFORM_SIZE },
13708
- },
13709
- { binding: 1, resource: source },
13710
- { binding: 2, resource: this.probeSampler },
13711
- ],
13712
- }),
13713
- [slot],
13714
- );
14240
+ pass.setBindGroup(0, group, [slot]);
13715
14241
  pass.draw(3);
13716
14242
  pass.end();
13717
14243
  }
@@ -13737,8 +14263,12 @@ export class WebGPURenderer implements RendererApi {
13737
14263
  this.probePrefilterLayout,
13738
14264
  this.pipelines.format,
13739
14265
  );
13740
- /* The whole chain, so a wide sample can select a level from it. */
13741
- 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
+ );
13742
14272
  const offsets = PREFILTER_UNIFORM_OFFSETS;
13743
14273
  const topLevel = levels - 1;
13744
14274
  const samples = this.quality.environmentPrefilterSamples;
@@ -13746,13 +14276,20 @@ export class WebGPURenderer implements RendererApi {
13746
14276
  const irradianceLevel = irradianceLevelFor(edge);
13747
14277
  const ggxTop = ggxMaxLevelFor(edge);
13748
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;
13749
14286
 
13750
14287
  /*
13751
14288
  * **One draw a level, where the cube needed six.** An octahedral map has no faces, so the
13752
14289
  * six-way loop is gone rather than moved, and a probe's convolution is six times fewer
13753
14290
  * passes than the cube it replaces.
13754
14291
  */
13755
- for (let level = 0; level <= irradianceLevel; level++) {
14292
+ for (let level = 0; level <= lastLevel; level++) {
13756
14293
  const irradiance = level === irradianceLevel;
13757
14294
  const roughness = irradiance ? 1 : roughnessForLevel(level, ggxTop);
13758
14295
  const slot = this.probeMipUniforms.allocate();
@@ -13771,32 +14308,40 @@ export class WebGPURenderer implements RendererApi {
13771
14308
  /* Written at the generator's own offsets rather than at literals, because a roughness at
13772
14309
  the wrong offset is a level convolved for some other roughness — which looks like a
13773
14310
  plausible reflection and points at nothing. */
13774
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterEdge.offset, [
13775
- Math.max(1, edge >> level),
13776
- ]);
13777
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterIrradiance.offset, [
13778
- irradiance ? 1 : 0,
13779
- ]);
13780
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterBox.offset, [boxChain ? 1 : 0]);
13781
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterLevel.offset, [level]);
13782
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterRoughness.offset, [roughness]);
13783
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterSamples.offset, [samples]);
13784
- this.probeMipUniforms.writeFloats(slot, offsets.uPrefilterSourceTexels.offset, [
13785
- this.probeSize,
13786
- ]);
13787
- 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
+ );
13788
14339
 
13789
14340
  const pass = encoder.beginRenderPass({
13790
14341
  label: `probe.prefilter${level}.layer${layer}`,
13791
14342
  colorAttachments: [
13792
14343
  {
13793
- view: prefilterTarget.createView({
13794
- dimension: '2d',
13795
- baseArrayLayer: layer,
13796
- arrayLayerCount: 1,
13797
- baseMipLevel: level,
13798
- mipLevelCount: 1,
13799
- }),
14344
+ view: targets.arrayView(prefilterTarget, layer, level),
13800
14345
  loadOp: 'clear',
13801
14346
  storeOp: 'store',
13802
14347
  clearValue: { r: 0, g: 0, b: 0, a: 1 },
@@ -13804,21 +14349,7 @@ export class WebGPURenderer implements RendererApi {
13804
14349
  ],
13805
14350
  });
13806
14351
  pass.setPipeline(prefilter);
13807
- pass.setBindGroup(
13808
- 0,
13809
- device.createBindGroup({
13810
- layout: this.probePrefilterLayout,
13811
- entries: [
13812
- {
13813
- binding: PREFILTER_UNIFORM_BINDING,
13814
- resource: { buffer: this.probeMipUniforms.buffer, size: PROBE_MIP_UNIFORM_SIZE },
13815
- },
13816
- { binding: PREFILTER_TEXTURE_BINDING, resource: source },
13817
- { binding: PREFILTER_SAMPLER_BINDING, resource: this.probeSampler },
13818
- ],
13819
- }),
13820
- [slot],
13821
- );
14352
+ pass.setBindGroup(0, group, [slot]);
13822
14353
  pass.draw(3);
13823
14354
  pass.end();
13824
14355
  }
@@ -14297,7 +14828,7 @@ export class WebGPURenderer implements RendererApi {
14297
14828
  f[slot(0) + prefilter('uTexel') + 1] = 1 / Math.max(1, this.frameHeight);
14298
14829
  /* In scene units, which is why an HDR target is what makes a threshold above 1 mean
14299
14830
  anything: against a clamped buffer nothing is ever brighter than white. */
14300
- f[slot(0) + prefilter('uThreshold')] = this.quality.bloomThreshold;
14831
+ f[slot(0) + prefilter('uThreshold')] = this.bloomThresholdSet ?? this.quality.bloomThreshold;
14301
14832
 
14302
14833
  /* Each downsample's texel is its *source's*, which is the level above it. */
14303
14834
  const down = (name: string): number => this.postField(BLOOM_DOWNSAMPLE_FIELDS, name);
@@ -14415,6 +14946,8 @@ export class WebGPURenderer implements RendererApi {
14415
14946
 
14416
14947
  this.fieldComposer?.dispose();
14417
14948
  this.fieldComposer = null;
14949
+ this.driftIndex?.destroy();
14950
+ this.driftAtlas?.destroy();
14418
14951
 
14419
14952
  this.pass = null;
14420
14953
  this.encoder = null;
@@ -14422,6 +14955,9 @@ export class WebGPURenderer implements RendererApi {
14422
14955
  this.gpuTimer.dispose();
14423
14956
  this.reflection?.dispose();
14424
14957
  this.reflection = null;
14958
+ this.probeTargets?.dispose();
14959
+ this.exposurePass?.dispose();
14960
+ this.exposurePass = null;
14425
14961
  this.surface.dispose();
14426
14962
  }
14427
14963
  }