@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
@@ -2,7 +2,6 @@ import { FrameBudget } from '../budget.ts';
2
2
  import { MaterialChanges, ownsMaterial } from '../materialChanges.ts';
3
3
  import { mat4 } from 'gl-matrix';
4
4
  import {
5
- DEPTH_OFFSET_SIGN,
6
5
  GL_DEPTH_REMAP,
7
6
  MAX_DEPTH_LAYER,
8
7
  depthClearFor,
@@ -81,12 +80,17 @@ import {
81
80
  import { FILM_FRAG, FILM_VERT } from '../../shaders/film.ts';
82
81
  import { SceneTarget } from '../../sceneTarget.ts';
83
82
  import type { ColourGradeLut } from '../../colourGrade.ts';
84
- import { bloomProfileWarning } from '../../bloomChain.ts';
83
+ import { FILM_LOOK_WITHOUT_COMPOSITE } from '../../shaders/filmLook.ts';
84
+ import { bloomProfileWarning, bloomThresholdOf } from '../../bloomChain.ts';
85
85
  import {
86
86
  createEmptyTexture2D,
87
87
  createEmptyTexture2DArray,
88
+ createEmptyTexture3D,
88
89
  createEmptyTextureCube,
89
90
  } from '../../emptyTexture.ts';
91
+ import { LightField } from '../../driftLight/lightField.ts';
92
+ import type { LightFieldOptions, LightFieldSource } from '../../driftLight/lightField.ts';
93
+ import { createDriftLightUniforms, resolveDriftLight } from '../../driftLight/uniforms.ts';
90
94
 
91
95
  /**
92
96
  * How far the rush's outermost tap reaches at full strength, in UV.
@@ -113,11 +117,12 @@ import { SkinPaletteTexture } from './skinPaletteTexture.ts';
113
117
  /* The codes the shader reads, stated once in `vertexDefaults.ts` for both backends. */
114
118
  import { LIGHT_VOLUME_VERT, lightVolumeFrag } from '../../shaders/lightVolume.ts';
115
119
  import { PANEL_FRAG, PANEL_VERT } from '../../shaders/panel.ts';
116
- import { ReflectionProbe, cubeFaceProjection } from '../../reflectionProbe.ts';
120
+ import { ReflectionProbe, cubeFaceProjection, probeFaceRange } from '../../reflectionProbe.ts';
117
121
  import type { ProbeBakeOptions } from '../../reflectionProbe.ts';
118
122
  import { EnvProbeArray } from '../../envProbeArray.ts';
119
123
  import { ProbeGrid, SINGLE_PROBE, UNIT_STEP, WORLD_ORIGIN, sameGrid } from '../../probeGrid.ts';
120
124
  import type { ProbeGridOptions } from '../../probeGrid.ts';
125
+ import { ProbeSweeps } from '../../probeSweeps.ts';
121
126
  import { octahedralEdgeFor } from '../../prefilterEnvMap.ts';
122
127
  import { EnvironmentPrefilterPass } from '../../prefilterPass.ts';
123
128
  import { packIesAtlas } from '../../iesProfile.ts';
@@ -135,7 +140,10 @@ import { equirectToCubeFaces } from '../../equirectToCube.ts';
135
140
 
136
141
  import { SKY_FRAG, skyVertFor } from '../../shaders/sky.ts';
137
142
  import {
143
+ DEPTH_CUTOUT_FRAG,
144
+ DEPTH_CUTOUT_VERT,
138
145
  DEPTH_FRAG,
146
+ DEPTH_INSTANCED_CUTOUT_VERT,
139
147
  DEPTH_INSTANCED_VERT,
140
148
  DEPTH_SKINNED_VERT,
141
149
  DEPTH_VERT,
@@ -167,6 +175,8 @@ import {
167
175
  bindPointLights,
168
176
  COOKIE_ATLAS_TEXTURE_UNIT,
169
177
  REFRACT_SCENE_TEXTURE_UNIT,
178
+ DRIFT_LIGHT_ATLAS_TEXTURE_UNIT,
179
+ DRIFT_LIGHT_INDEX_TEXTURE_UNIT,
170
180
  COOKIE_TILE,
171
181
  IES_ATLAS_TEXTURE_UNIT,
172
182
  bindAreaLights,
@@ -218,6 +228,7 @@ import type { SdfTextStyle } from '../../sdfTextLayout.ts';
218
228
  import { MOTION_BLUR_MAX_UV, RUSH_REACH_UV } from '../../vertexDefaults.ts';
219
229
  import { OUTPUT_TRANSFORM_CODE } from '../../vertexDefaults.ts';
220
230
  import type { RenderBackend } from '../api.ts';
231
+ import { deviceSnappedCellSize } from '../../textLayout.ts';
221
232
  import type { TextStyle } from '../../textLayout.ts';
222
233
  import { WindStreakRenderer } from '../../windStreakRenderer.ts';
223
234
  import type { WindStreakOptions } from '../../windStreakRenderer.ts';
@@ -225,6 +236,9 @@ import type { WindField } from '../../windField.ts';
225
236
  import type { FlockParams } from '../../flockRenderer.ts';
226
237
  import { InstancedBatch } from './instanced.ts';
227
238
  import type { MeshInstances } from '../../instances.ts';
239
+ import { cutoutOf } from '../../cutoutCaster.ts';
240
+ import type { CutoutCaster } from '../../cutoutCaster.ts';
241
+ import type { SurfaceTextureHandle } from '../api.ts';
228
242
 
229
243
  /**
230
244
  * How many frames `firstFrameSettled` will ask before it stops asking.
@@ -788,6 +802,14 @@ export interface TranslucentMeshOptions {
788
802
  * that two surfaces overlapping in view blend in submission order. And a surface that writes no
789
803
  * depth cannot occlude the sky. A caller wanting both wants the pieces sorted and this left
790
804
  * alone.
805
+ *
806
+ * **And depth a blended surface writes is where the frame's depth-reading passes stop.** The
807
+ * global medium marches to it on both backends, and on WebGPU ambient occlusion, the temporal
808
+ * resolve and motion blur take it for the surface too. Right for a pane that is most of a pixel's
809
+ * colour; wrong for a faint sheet. A bought courtyard's dirt decals stand a centimetre off their
810
+ * stone and overhang every corner they wrap, and the haze stopped at each overhang, a clear band
811
+ * down a pier's whole height. A decal lying on an opaque surface needs no depth to hold the sky
812
+ * off, since the surface under it already does, so `false` costs it only an order among decals.
791
813
  */
792
814
  readonly depthWrite?: boolean;
793
815
  /**
@@ -1059,6 +1081,8 @@ export class WebGL2Renderer implements RendererApi {
1059
1081
  private readonly panelProgram: WebGLProgram;
1060
1082
  private readonly panelUniforms: Record<string, WebGLUniformLocation>;
1061
1083
  private readonly panelVao: WebGLVertexArrayObject;
1084
+ /** The unit quad behind `panelVao`, held so `dispose` can release it. */
1085
+ private readonly panelBuffer: WebGLBuffer | null;
1062
1086
  private readonly filmProgram: WebGLProgram;
1063
1087
  /**
1064
1088
  * The off-screen colour target, when screen effects are on. Null is not a failure —
@@ -1090,6 +1114,14 @@ export class WebGL2Renderer implements RendererApi {
1090
1114
  private skinPaletteSet = false;
1091
1115
 
1092
1116
  private readonly emptyTexture2D: WebGLTexture;
1117
+ /** DriftLight's stand-ins while no field is whole: no brick anywhere, and no light. */
1118
+ private readonly emptyDriftIndex: WebGLTexture;
1119
+ private readonly emptyDriftAtlas: WebGLTexture;
1120
+ /** The scene's DriftLight field, and its two volumes once it is whole. See `createLightField`. */
1121
+ private lightField: LightField | null = null;
1122
+ private driftIndex: WebGLTexture | null = null;
1123
+ private driftAtlas: WebGLTexture | null = null;
1124
+ private readonly driftUniforms = createDriftLightUniforms();
1093
1125
  private readonly emptyTexture2DArray: WebGLTexture;
1094
1126
  private readonly emptyTextureCube: WebGLTexture;
1095
1127
  /** How hard the rush blurs this frame, and how far its outermost tap reaches. */
@@ -1169,6 +1201,8 @@ export class WebGL2Renderer implements RendererApi {
1169
1201
  private motionBlurScale = 1;
1170
1202
  /** How much of the bloom ceiling this frame takes. See `setBloom`. */
1171
1203
  private bloomScale = 1;
1204
+ /** In scene units, once `setBloom` has moved it; null reads the profile's. */
1205
+ private bloomThresholdSet: number | null = null;
1172
1206
  /**
1173
1207
  * This frame's exposure into the tone curve. See `setOutputExposure`.
1174
1208
  *
@@ -1276,6 +1310,15 @@ export class WebGL2Renderer implements RendererApi {
1276
1310
  private readonly depthUniforms: Record<string, WebGLUniformLocation>;
1277
1311
  private readonly depthInstancedProgram: WebGLProgram;
1278
1312
  private readonly depthInstancedUniforms: Record<string, WebGLUniformLocation>;
1313
+ /**
1314
+ * The cutout casters' depth programs, rigid and instanced: a leaf or a chain link casts the shape
1315
+ * in its texture's alpha, not its whole card. See `DEPTH_CUTOUT_FRAG`. Chosen per draw by
1316
+ * `cutoutOf`, so an opaque caster never touches them.
1317
+ */
1318
+ private readonly depthCutoutProgram: WebGLProgram;
1319
+ private readonly depthCutoutUniforms: Record<string, WebGLUniformLocation>;
1320
+ private readonly depthInstancedCutoutProgram: WebGLProgram;
1321
+ private readonly depthInstancedCutoutUniforms: Record<string, WebGLUniformLocation>;
1279
1322
  private readonly scatterDepthProgram: WebGLProgram;
1280
1323
  private readonly scatterDepthUniforms: Record<string, WebGLUniformLocation>;
1281
1324
  private readonly shadowMap: ShadowMap | null;
@@ -1334,6 +1377,10 @@ export class WebGL2Renderer implements RendererApi {
1334
1377
  private readonly probeCapture: ReflectionProbe | null;
1335
1378
  /** Every probe of the grid, as one array texture. Allocated when a grid is declared. */
1336
1379
  private probeArray: EnvProbeArray | null = null;
1380
+ /** Which layers a bake writes and the shading reads. See `probeSweeps.ts`. */
1381
+ private probeSweeps: ProbeSweeps | null = null;
1382
+ /** `uProbeGridSets`, rewritten in place. */
1383
+ private readonly probeSets = new Float32Array(3);
1337
1384
  /**
1338
1385
  * Where those probes stand. A single baked probe is a grid of one at that probe's own origin.
1339
1386
  *
@@ -1359,6 +1406,8 @@ export class WebGL2Renderer implements RendererApi {
1359
1406
  private warnedEnvironmentAmbient = false;
1360
1407
  /** Whether a probe is being baked, so the passes that would recurse into one can tell. */
1361
1408
  private probePassActive = false;
1409
+ /** Whether the bake under way lights its faces by the grid. `ProbeBakeOptions.bounce`. */
1410
+ private probeBounce = false;
1362
1411
  /** The clip-control extension, or null where this context does not offer it. */
1363
1412
  private clipControl: ClipControlExtension | null = null;
1364
1413
  /**
@@ -1517,6 +1566,17 @@ export class WebGL2Renderer implements RendererApi {
1517
1566
  */
1518
1567
  private activeDepthViewProj: ReadonlyMat4 = IDENTITY_MAT4;
1519
1568
  private activeDepthPeel = false;
1569
+ /**
1570
+ * Whether a peel pass has filled the second layer since the first one last opened, which is
1571
+ * what the frame samples it on: the rule the other backend's `peelFilled` states, held here too.
1572
+ *
1573
+ * **Not whether the map exists.** That was the test here, on the belief that a GL texture is
1574
+ * born reading as the far plane; it is born zeroed, and a scene that built the layer and never
1575
+ * peeled it sampled an occluder at the light. `ShadowMap` now clears itself at birth, so an
1576
+ * unfilled layer would read as lit either way — this is what keeps the two backends one
1577
+ * decision, and a frame that does not peel from paying a fetch per tap for nothing.
1578
+ */
1579
+ private peelFilled = false;
1520
1580
  /**
1521
1581
  * Whether the depth pass this scatter draw interrupts wants back-face culling.
1522
1582
  *
@@ -1527,6 +1587,11 @@ export class WebGL2Renderer implements RendererApi {
1527
1587
  * cubemap — so the pass states what it wants and this restores that.
1528
1588
  */
1529
1589
  private depthPassCullsFaces = true;
1590
+ /**
1591
+ * Whether the bound material is seen from both faces, so its draws cull nothing. Toggled around
1592
+ * each such draw and handed back, where the other backend bakes it into a pipeline.
1593
+ */
1594
+ private materialDoubleSided = false;
1530
1595
 
1531
1596
  /**
1532
1597
  * The one sink handed to every caster enumeration.
@@ -1535,16 +1600,34 @@ export class WebGL2Renderer implements RendererApi {
1535
1600
  * per pass would allocate six objects per light per frame.
1536
1601
  */
1537
1602
  private readonly casterSink: ShadowCasterSink = {
1538
- mesh: (mesh, model) => {
1603
+ mesh: (mesh, model, material) => {
1539
1604
  this.shadowDrawBudget.ask();
1540
1605
  const { gl } = this;
1606
+ /* A two-sided caster casts from whichever face the light sees: nothing culled. */
1607
+ const twoSided = material?.doubleSided === true;
1608
+ const cutout = cutoutOf(material);
1609
+ if (cutout !== null) {
1610
+ const u = this.depthCutoutUniforms;
1611
+ gl.useProgram(this.depthCutoutProgram);
1612
+ this.bindCutoutDepth(u, cutout);
1613
+ gl.uniformMatrix4fv(u['uModel'] ?? null, false, model);
1614
+ if (twoSided) gl.disable(gl.CULL_FACE);
1615
+ (mesh as Mesh).draw(gl);
1616
+ if (twoSided && this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
1617
+ /* Hand the pass back as it was found: the next caster may be rigid and opaque. */
1618
+ gl.useProgram(this.depthProgram);
1619
+ this.restoreSurfaceTextureUnit();
1620
+ return;
1621
+ }
1541
1622
  gl.uniformMatrix4fv(this.depthUniforms['uModel'] ?? null, false, model);
1542
1623
  /*
1543
1624
  * The sink takes the opaque handle both backends share, and this renderer only ever
1544
1625
  * handed out its own `Mesh`. Narrowing is safe here for that reason and nowhere else:
1545
1626
  * a handle from the other backend would have to have crossed renderers to arrive.
1546
1627
  */
1628
+ if (twoSided) gl.disable(gl.CULL_FACE);
1547
1629
  (mesh as Mesh).draw(gl);
1630
+ if (twoSided && this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
1548
1631
  },
1549
1632
  /**
1550
1633
  * A batch into the depth map, placed by the same matrices the visible draw uses.
@@ -1552,18 +1635,31 @@ export class WebGL2Renderer implements RendererApi {
1552
1635
  * No `uModel`: the instanced depth program has none, its placement being the four attribute
1553
1636
  * columns the batch's own buffer supplies.
1554
1637
  */
1555
- instanced: (batch, data) => {
1638
+ instanced: (batch, data, material) => {
1556
1639
  const { gl } = this;
1557
1640
  const gpuBatch = batch as InstancedBatch;
1558
1641
  const count = Math.min(data.count, gpuBatch.capacity);
1559
1642
  if (count === 0) return;
1560
1643
  this.shadowDrawBudget.ask();
1644
+ const twoSided = material?.doubleSided === true;
1645
+ if (twoSided) gl.disable(gl.CULL_FACE);
1646
+ const cutout = cutoutOf(material);
1647
+ if (cutout !== null) {
1648
+ gl.useProgram(this.depthInstancedCutoutProgram);
1649
+ this.bindCutoutDepth(this.depthInstancedCutoutUniforms, cutout);
1650
+ gpuBatch.mesh.drawInstances(gl, count);
1651
+ if (twoSided && this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
1652
+ gl.useProgram(this.depthProgram);
1653
+ this.restoreSurfaceTextureUnit();
1654
+ return;
1655
+ }
1561
1656
  const u = this.depthInstancedUniforms;
1562
1657
  gl.useProgram(this.depthInstancedProgram);
1563
1658
  gl.uniformMatrix4fv(u['uLightViewProj'] ?? null, false, this.activeDepthViewProj);
1564
1659
  gl.uniform1i(u['uPeelShadowLayer'] ?? null, this.activeDepthPeel ? 1 : 0);
1565
1660
  if (this.activeDepthPeel) gl.uniform1i(u['uPreviousShadowMap'] ?? null, 0);
1566
1661
  gpuBatch.mesh.drawInstances(gl, count);
1662
+ if (twoSided && this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
1567
1663
  gl.useProgram(this.depthProgram);
1568
1664
  },
1569
1665
  skinnedMesh: (mesh, model, palette) => {
@@ -1611,6 +1707,24 @@ export class WebGL2Renderer implements RendererApi {
1611
1707
  },
1612
1708
  };
1613
1709
 
1710
+ /**
1711
+ * What a cutout depth draw needs beyond the plain one: the pass's matrix and peel, the material's
1712
+ * cutoff and UV scale, and its albedo on the surface unit, which the caller gives back after.
1713
+ */
1714
+ private bindCutoutDepth(
1715
+ u: Record<string, WebGLUniformLocation>,
1716
+ cutout: CutoutCaster<SurfaceTextureHandle>,
1717
+ ): void {
1718
+ const { gl } = this;
1719
+ gl.uniformMatrix4fv(u['uLightViewProj'] ?? null, false, this.activeDepthViewProj);
1720
+ gl.uniform1i(u['uPeelShadowLayer'] ?? null, this.activeDepthPeel ? 1 : 0);
1721
+ if (this.activeDepthPeel) gl.uniform1i(u['uPreviousShadowMap'] ?? null, 0);
1722
+ gl.uniform2f(u['uUvScale'] ?? null, cutout.u, cutout.v);
1723
+ gl.uniform1f(u['uAlphaCutout'] ?? null, cutout.cutoff);
1724
+ /* Narrowed for the reason the sink's own `mesh` gives: this renderer only hands out its own. */
1725
+ (cutout.albedo as SurfaceTexture).bind(gl, SURFACE_TEXTURE_UNIT);
1726
+ }
1727
+
1614
1728
  private readonly drawPointShadowFace = (viewProj: ReadonlyMat4): void => {
1615
1729
  const casters = this.pointShadowCasters;
1616
1730
  if (casters === null) return;
@@ -2261,6 +2375,8 @@ export class WebGL2Renderer implements RendererApi {
2261
2375
  * "unbound". Two 1x1 uploads for the life of the renderer.
2262
2376
  */
2263
2377
  this.emptyTexture2D = createEmptyTexture2D(gl);
2378
+ this.emptyDriftIndex = createEmptyTexture3D(gl, true);
2379
+ this.emptyDriftAtlas = createEmptyTexture3D(gl, false);
2264
2380
  this.emptyTexture2DArray = createEmptyTexture2DArray(gl);
2265
2381
  this.emptyTextureCube = createEmptyTextureCube(gl);
2266
2382
  this.gpuTimer = new GpuTimer(gl);
@@ -2482,6 +2598,7 @@ export class WebGL2Renderer implements RendererApi {
2482
2598
  this.panelVao = panelVao;
2483
2599
  gl.bindVertexArray(panelVao);
2484
2600
  const panelBuffer = gl.createBuffer();
2601
+ this.panelBuffer = panelBuffer;
2485
2602
  gl.bindBuffer(gl.ARRAY_BUFFER, panelBuffer);
2486
2603
  // A unit quad. The rectangle it becomes is a uniform, so this is uploaded once
2487
2604
  // for the life of the renderer however many panels are drawn.
@@ -2569,6 +2686,33 @@ export class WebGL2Renderer implements RendererApi {
2569
2686
  */
2570
2687
  this.scatterDepthProgram = compileProgram(gl, SCATTER_DEPTH_VERT, DEPTH_FRAG, 'scatterDepth');
2571
2688
  this.scatterDepthUniforms = uniformLocations(gl, this.scatterDepthProgram, 'scatterDepth');
2689
+ this.depthCutoutProgram = compileProgram(
2690
+ gl,
2691
+ DEPTH_CUTOUT_VERT,
2692
+ DEPTH_CUTOUT_FRAG,
2693
+ 'depth-cutout',
2694
+ );
2695
+ this.depthCutoutUniforms = uniformLocations(gl, this.depthCutoutProgram, 'depth-cutout');
2696
+ this.depthInstancedCutoutProgram = compileProgram(
2697
+ gl,
2698
+ DEPTH_INSTANCED_CUTOUT_VERT,
2699
+ DEPTH_CUTOUT_FRAG,
2700
+ 'depth-instanced-cutout',
2701
+ );
2702
+ this.depthInstancedCutoutUniforms = uniformLocations(
2703
+ gl,
2704
+ this.depthInstancedCutoutProgram,
2705
+ 'depth-instanced-cutout',
2706
+ );
2707
+ /* The cutout map is always read from the surface unit, which the draw borrows and gives back. */
2708
+ for (const [program, uniforms] of [
2709
+ [this.depthCutoutProgram, this.depthCutoutUniforms],
2710
+ [this.depthInstancedCutoutProgram, this.depthInstancedCutoutUniforms],
2711
+ ] as const) {
2712
+ gl.useProgram(program);
2713
+ gl.uniform1i(uniforms['uCutoutMap'] ?? null, SURFACE_TEXTURE_UNIT);
2714
+ }
2715
+ gl.useProgram(null);
2572
2716
  this.shadowMap = this.quality.directionalShadows
2573
2717
  ? new ShadowMap(gl, this.quality.directionalShadowMapSize)
2574
2718
  : null;
@@ -2735,6 +2879,7 @@ export class WebGL2Renderer implements RendererApi {
2735
2879
  this.flatSkinnedProgram,
2736
2880
  this.flatMorphedProgram,
2737
2881
  this.flatBothProgram,
2882
+ this.flatInstancedProgram,
2738
2883
  ]) {
2739
2884
  if (extra !== null) gl.deleteProgram(extra);
2740
2885
  }
@@ -2745,12 +2890,20 @@ export class WebGL2Renderer implements RendererApi {
2745
2890
  this.skyProgram,
2746
2891
  this.scatterProgram,
2747
2892
  this.depthProgram,
2893
+ this.depthSkinnedProgram,
2894
+ this.depthInstancedProgram,
2748
2895
  this.scatterDepthProgram,
2896
+ this.lightVolumeProgram,
2897
+ this.depthCutoutProgram,
2898
+ this.depthInstancedCutoutProgram,
2749
2899
  ]) {
2750
2900
  gl.deleteProgram(program);
2751
2901
  }
2752
2902
  this.skinPalette.dispose(gl);
2753
2903
  gl.deleteTexture(this.emptyTexture2D);
2904
+ gl.deleteTexture(this.emptyDriftIndex);
2905
+ gl.deleteTexture(this.emptyDriftAtlas);
2906
+ this.forgetLightFieldVolumes();
2754
2907
  gl.deleteTexture(this.emptyTexture2DArray);
2755
2908
  gl.deleteTexture(this.emptyTextureCube);
2756
2909
  /* A cubemap and its whole mip chain, which is the largest single thing this
@@ -2758,6 +2911,24 @@ export class WebGL2Renderer implements RendererApi {
2758
2911
  this.probeCapture?.dispose(gl);
2759
2912
  this.probeArray?.dispose(gl);
2760
2913
  this.environmentPrefilter?.dispose(gl);
2914
+ /*
2915
+ * **Everything else the constructor made, which this method never released.** The screen
2916
+ * chain and its bloom and occlusion, three sun maps, the mirror, the point-shadow array, two
2917
+ * vertex arrays, a quad and three textures made on first use. A page swapping renderers kept
2918
+ * every one of them, per renderer, for as long as it lived; `renderer.test.ts` counts them.
2919
+ */
2920
+ this.sceneTarget?.dispose();
2921
+ this.shadowMap?.dispose(gl);
2922
+ this.peeledShadowMap?.dispose(gl);
2923
+ this.dynamicShadowMap?.dispose(gl);
2924
+ this.planarReflection?.dispose(gl);
2925
+ this.pointShadows?.dispose();
2926
+ gl.deleteVertexArray(this.panelVao);
2927
+ gl.deleteVertexArray(this.skyVao);
2928
+ if (this.panelBuffer !== null) gl.deleteBuffer(this.panelBuffer);
2929
+ if (this.cookieTexture !== null) gl.deleteTexture(this.cookieTexture);
2930
+ if (this.iesTexture !== null) gl.deleteTexture(this.iesTexture);
2931
+ if (this.clusterTexture !== null) gl.deleteTexture(this.clusterTexture);
2761
2932
  }
2762
2933
 
2763
2934
  if (options.releaseContext === true) {
@@ -2986,6 +3157,7 @@ export class WebGL2Renderer implements RendererApi {
2986
3157
  if (this.contextLost) return;
2987
3158
  this.materials.dirty();
2988
3159
  this.currentSurfaceTexture = material?.albedo ?? null;
3160
+ this.materialDoubleSided = material?.doubleSided === true;
2989
3161
  /*
2990
3162
  * Written to every flat program, because material state persists across draws and a skinned
2991
3163
  * draw is entitled to the material the caller set before it. A second program holding none of
@@ -3065,10 +3237,11 @@ export class WebGL2Renderer implements RendererApi {
3065
3237
  * The emissive map, on exactly the terms the two maps above it are bound: before the albedo
3066
3238
  * early return, and the stand-in rather than `null`.
3067
3239
  *
3068
- * **White is the right stand-in here and black would be a bug.** The shader multiplies by this
3069
- * map, so the placeholder has to be the multiplicative identity; `emptyTexture2D` is white,
3070
- * which is what makes an unbound material collapse to the arithmetic it had before. A black
3071
- * placeholder would switch every glow in the scene off the moment the gate was ever wrong.
3240
+ * **The stand-in is black, so the gate is what keeps a glow on.** The shader multiplies by
3241
+ * this map, and `emptyTexture2D` is opaque black rather than the multiplicative identity, so an
3242
+ * unbound material keeps the arithmetic it had before only because `uEmissiveMapEnabled` is 0
3243
+ * and the read never happens. What would make that wrong is a read taken outside that branch:
3244
+ * every glow on a material without a map would go out.
3072
3245
  */
3073
3246
  const emissiveMap = material?.emissive ?? null;
3074
3247
  gl.activeTexture(gl.TEXTURE0 + EMISSIVE_TEXTURE_UNIT);
@@ -3092,6 +3265,7 @@ export class WebGL2Renderer implements RendererApi {
3092
3265
  * how big a dome was — WebGPU writes it before its own early return and WebGL2 did not.
3093
3266
  */
3094
3267
  gl.uniform2f(u['uUvScale'] ?? null, material?.uScale ?? 1, material?.vScale ?? 1);
3268
+ gl.uniform1i(u['uDoubleSided'] ?? null, material?.doubleSided === true ? 1 : 0);
3095
3269
 
3096
3270
  if (albedo === null) {
3097
3271
  gl.uniform1i(u['uAlbedoEnabled'] ?? null, 0);
@@ -3657,6 +3831,28 @@ export class WebGL2Renderer implements RendererApi {
3657
3831
  }
3658
3832
  }
3659
3833
 
3834
+ /**
3835
+ * The cell size to draw a bitmap glyph at so every cell covers whole device pixels.
3836
+ *
3837
+ * **The caller snaps, and then measures and draws with the one number.** 4.1.4 applied this
3838
+ * inside `drawText` instead, where `textWidthPx` could not see it — it takes no viewport and so
3839
+ * cannot know the ratio — and every consumer went on laying out against the cell it asked for
3840
+ * while the engine drew a smaller one. A centred line, a right-aligned column and a line fitted
3841
+ * to a box broke together. Snapped here, the arithmetic and the picture cannot disagree.
3842
+ *
3843
+ * **It is on the renderer because only the renderer knows the second number.** `viewportWidth`
3844
+ * is the caller's, and it already passes it to `drawText`; the drawing buffer is not on the
3845
+ * shared surface and a game's text layout is a pure function over CSS pixels, so reaching it
3846
+ * meant threading a canvas through an interface layer to a value the renderer was holding.
3847
+ *
3848
+ * The decision itself stays in `textLayout.ts` and only this binding is per backend, which is
3849
+ * the 2026-08-13 rule: a cell already whole comes back untouched, and the snap is downward so a
3850
+ * line fitted to a box can only leave a gap rather than overflow it.
3851
+ */
3852
+ snapTextCellSize(cellSize: number, viewportWidth: number): number {
3853
+ return deviceSnappedCellSize(cellSize, viewportWidth, this.gl.drawingBufferWidth);
3854
+ }
3855
+
3660
3856
  /** Width of the string this handle currently holds, in pixels at a given cell size. */
3661
3857
  textWidth(text: TextRenderer, cellSize: number): number {
3662
3858
  return text.widthPx(cellSize);
@@ -3866,7 +4062,9 @@ export class WebGL2Renderer implements RendererApi {
3866
4062
  this.reflectionPassActive ? this.reflectionAtmosphereY : (camera.position[1] ?? 0),
3867
4063
  this.quality.underwaterAtmosphere,
3868
4064
  );
4065
+ if (this.materialDoubleSided) gl.disable(gl.CULL_FACE);
3869
4066
  batch.drawTo(gl, data.count);
4067
+ if (this.materialDoubleSided) gl.enable(gl.CULL_FACE);
3870
4068
  }
3871
4069
 
3872
4070
  /** Build a batch for electrical arcs. `capacity` is in segments, not arcs. */
@@ -4540,6 +4738,8 @@ export class WebGL2Renderer implements RendererApi {
4540
4738
  this.gpuTimer.begin('shadows');
4541
4739
  this.shadowPassActive = true;
4542
4740
  this.activeShadowMap = shadowMap;
4741
+ /* The static layer opens first, so a frame's peel counts from here. See `peelFilled`. */
4742
+ if (layer === 'static') this.peelFilled = false;
4543
4743
  /*
4544
4744
  * **Range-corrected but not reversed.** `EXT_clip_control` is context state, so a light matrix
4545
4745
  * that still emits OpenGL's `[-1, 1]` loses everything below zero the moment it is on. See
@@ -4597,6 +4797,8 @@ export class WebGL2Renderer implements RendererApi {
4597
4797
  this.uploadWind(this.depthUniforms, this.frameWind);
4598
4798
  gl.useProgram(this.depthSkinnedProgram);
4599
4799
  this.uploadWind(this.depthSkinnedUniforms, this.frameWind);
4800
+ gl.useProgram(this.depthCutoutProgram);
4801
+ this.uploadWind(this.depthCutoutUniforms, this.frameWind);
4600
4802
  gl.useProgram(this.depthProgram);
4601
4803
  }
4602
4804
 
@@ -4688,6 +4890,7 @@ export class WebGL2Renderer implements RendererApi {
4688
4890
  const shadowMap = this.activeShadowMap;
4689
4891
  if (!this.shadowPassActive || shadowMap === null) return;
4690
4892
  shadowMap.end(this.gl);
4893
+ if (shadowMap === this.peeledShadowMap) this.peelFilled = true;
4691
4894
  /* Back to the frame's sense; see `beginShadowPass` for why it left it. */
4692
4895
  this.gl.depthFunc(glDepthFuncEqual(this.gl));
4693
4896
  this.gl.clearDepth(this.depthClear);
@@ -5418,6 +5621,73 @@ export class WebGL2Renderer implements RendererApi {
5418
5621
  this.motionBlurScale = Math.min(Math.max(scale, 0), 1);
5419
5622
  }
5420
5623
 
5624
+ /**
5625
+ * The camera has cut: this frame is a new shot, not the next moment of the last one.
5626
+ *
5627
+ * **Everything temporal reprojects through the previous frame's view**: the motion blur measures
5628
+ * its smear against it, and the temporal resolve reads its history through it. A cut is a jump
5629
+ * the renderer cannot tell from a fast camera, so without this a transport that seeks, a respawn
5630
+ * or an edit smears its first frame along the whole jump and blends in a picture of somewhere
5631
+ * else. The caller knows it cut; this is how it says so, before the frame's `beginFrame`.
5632
+ *
5633
+ * What it gives up is one frame of history: the frame after a cut has no blur and no temporal
5634
+ * blend, as the first frame of a session has none. Nothing else is reset.
5635
+ */
5636
+ cameraCut(): void {
5637
+ this.hasPreviousView = false;
5638
+ this.temporalHistory.invalidate();
5639
+ /* The eye was adapted to the last shot. */
5640
+ this.sceneTarget?.cutExposure();
5641
+ }
5642
+
5643
+ /**
5644
+ * **Eye adaptation**: how far the frame's exposure follows its own brightness, 0 to 1, and the
5645
+ * seconds since the last frame. 0 is off and the default, and at 0 nothing is metered or allocated.
5646
+ *
5647
+ * The finished scene is metered on the GPU each frame — the log-mean of its luminance — and a
5648
+ * held brightness moves toward it at a fixed rate, about 78% of the way in a second; the composite
5649
+ * then scales scene light toward middle grey by \`strength\` of the stops between them, at most six
5650
+ * either way. So a shaded courtyard floor opens up the way a camera or an eye would, and
5651
+ * \`setOutputExposure\` becomes a bias on top: a night kept darker than a day is a bias under one.
5652
+ * **The time is the caller's**, because the engine takes time from its caller; a held capture
5653
+ * passes 0 and holds. \`cameraCut\` snaps it, so a new shot is metered rather than eased into.
5654
+ *
5655
+ * What it gives up is regional metering: every pixel counts the same. Needs \`screenEffects\` and
5656
+ * \`hdrScene\`, and says so once rather than doing nothing. See \`shaders/exposure.ts\`.
5657
+ */
5658
+ setAutoExposure(strength: number, dtSec: number): void {
5659
+ if (this.sceneTarget === null || !this.quality.hdrScene) {
5660
+ this.warnLookWithoutComposite(strength);
5661
+ return;
5662
+ }
5663
+ this.sceneTarget.setAutoExposure(strength, dtSec);
5664
+ }
5665
+
5666
+ /**
5667
+ * **Local exposure**: how far each region of the frame is brought toward the frame's own
5668
+ * brightness, 0 to 1. 0 is off and the default, and at 0 nothing more is measured or allocated.
5669
+ *
5670
+ * A tone curve maps one range, and a sunlit courtyard is two: its shaded arcades hold a fiftieth
5671
+ * of the light on its sunlit paving, so exposed for either the other is black or white. This moves
5672
+ * each region `strength` of the stops between it and the frame's held brightness, at most three,
5673
+ * read from a bilateral grid over the exposure meter's tiles, so shade beside sun is lifted as
5674
+ * shade rather than as the average of the two. The eye's held brightness is what regions move
5675
+ * toward, so it is measured whether or not `setAutoExposure` is on; with adaptation off the frame
5676
+ * as a whole keeps the exposure it was given. About 0.5 is a photograph's latitude, and 1 is a
5677
+ * frame with no light and shade left in it.
5678
+ *
5679
+ * What it gives up is contrast between regions, which is the point, and a halo a tile wide where
5680
+ * two regions of one brightness band differ. Needs `screenEffects` and `hdrScene`, and says so
5681
+ * once rather than doing nothing. See `shaders/localExposure.ts`.
5682
+ */
5683
+ setLocalExposure(strength: number): void {
5684
+ if (this.sceneTarget === null || !this.quality.hdrScene) {
5685
+ this.warnLookWithoutComposite(strength);
5686
+ return;
5687
+ }
5688
+ this.sceneTarget.setLocalExposure(strength);
5689
+ }
5690
+
5421
5691
  /**
5422
5692
  * Where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling
5423
5693
  * to take.
@@ -5472,9 +5742,17 @@ export class WebGL2Renderer implements RendererApi {
5472
5742
  *
5473
5743
  * Set before `beginFrame` and held until changed. Ignored entirely when `bloom` is 0, since
5474
5744
  * the chain is never built.
5745
+ *
5746
+ * **`threshold` moves `bloomThreshold` for this frame and every one after**, in the same scene
5747
+ * units. A threshold is compared before exposure is applied, so one fixed at construction means a
5748
+ * different brightness on screen at every exposure. That is harmless while exposure stands still
5749
+ * and wrong for a day whose exposure spans 2.5 to 14: a courtyard blooming at noon, or candles
5750
+ * never blooming at night. A caller whose exposure moves passes what it wants on screen over the
5751
+ * exposure. Omitted leaves it where it was, so a caller that never passes one keeps the profile's.
5475
5752
  */
5476
- setBloom(scale: number): void {
5753
+ setBloom(scale: number, threshold?: number): void {
5477
5754
  this.bloomScale = Math.min(Math.max(scale, 0), 1);
5755
+ if (threshold !== undefined) this.bloomThresholdSet = bloomThresholdOf(threshold);
5478
5756
  }
5479
5757
 
5480
5758
  /**
@@ -5621,6 +5899,41 @@ export class WebGL2Renderer implements RendererApi {
5621
5899
  this.sceneTarget.setColourGrade(lut, strength);
5622
5900
  }
5623
5901
 
5902
+ /**
5903
+ * How strongly the lens darkens the frame's corners, held until changed: 0 is none, 0.5 is about
5904
+ * 1.2 stops at the corner. Applied to scene light before the tone curve, as a lens loses light;
5905
+ * see `filmLook.ts`. Needs `screenEffects`, and says so once rather than doing nothing.
5906
+ */
5907
+ setVignette(strength: number): void {
5908
+ if (this.sceneTarget === null) {
5909
+ this.warnLookWithoutComposite(strength);
5910
+ return;
5911
+ }
5912
+ this.sceneTarget.setVignette(strength);
5913
+ }
5914
+
5915
+ /**
5916
+ * Film grain, held until changed: an amplitude in display values (0 is none, 0.03 is a fine
5917
+ * grain) and this frame's seed. **The seed is the caller's**, because the engine takes time from
5918
+ * its caller: pass a new one each frame for grain that moves, and the same one for a still that
5919
+ * is identical run to run. Applied last, after the grade and the veil; see `filmLook.ts`.
5920
+ */
5921
+ setFilmGrain(strength: number, seed: number): void {
5922
+ if (this.sceneTarget === null) {
5923
+ this.warnLookWithoutComposite(strength);
5924
+ return;
5925
+ }
5926
+ this.sceneTarget.setFilmGrain(strength, seed);
5927
+ }
5928
+
5929
+ private warnedLookWithoutComposite = false;
5930
+ private warnLookWithoutComposite(strength: number): void {
5931
+ if (strength > 0 && !this.warnedLookWithoutComposite) {
5932
+ this.warnedLookWithoutComposite = true;
5933
+ console.warn(FILM_LOOK_WITHOUT_COMPOSITE);
5934
+ }
5935
+ }
5936
+
5624
5937
  /**
5625
5938
  * What this frame asked for. **Every ceiling here is `null`, and that is the finding.**
5626
5939
  *
@@ -5959,7 +6272,10 @@ export class WebGL2Renderer implements RendererApi {
5959
6272
  const bloomStrength = this.quality.bloom * this.bloomScale;
5960
6273
  const bloom =
5961
6274
  bloomStrength > 0
5962
- ? { strength: bloomStrength, threshold: this.quality.bloomThreshold }
6275
+ ? {
6276
+ strength: bloomStrength,
6277
+ threshold: this.bloomThresholdSet ?? this.quality.bloomThreshold,
6278
+ }
5963
6279
  : undefined;
5964
6280
 
5965
6281
  /* The grade travels to the resolve only where the mesh pass gave it up. See `bindMeshPass`. */
@@ -6166,11 +6482,12 @@ export class WebGL2Renderer implements RendererApi {
6166
6482
  sunShadow,
6167
6483
  lightViewProj: env?.lightViewProj ?? IDENTITY_MAT4,
6168
6484
  /* The placeholder rather than null where a map is merely absent, for the reason
6169
- `emptyTexture.ts` gives: white is depth 1, the far plane, nothing in the way. */
6485
+ `emptyTexture.ts` gives. It is black, which as a depth is an occluder at the light,
6486
+ so it is safe only because `sunShadow` is 0 whenever these maps do not exist. */
6170
6487
  staticShadowMap: this.shadowMap?.texture ?? this.emptyTexture2D,
6171
6488
  peeledShadowMap: this.peeledShadowMap?.texture ?? this.emptyTexture2D,
6172
6489
  dynamicShadowMap: this.dynamicShadowMap?.texture ?? this.emptyTexture2D,
6173
- peeledEnabled: this.peeledShadowMap !== null,
6490
+ peeledEnabled: this.peelFilled,
6174
6491
  },
6175
6492
  );
6176
6493
  if (marched) {
@@ -6392,6 +6709,100 @@ export class WebGL2Renderer implements RendererApi {
6392
6709
  this.useFlatProgram();
6393
6710
  }
6394
6711
 
6712
+ /**
6713
+ * Sum a scene's many fixed lights into a DriftLight field, which stands in for each of them
6714
+ * wherever the frame's choice of exact lights does not reach. See `driftLight/lightField.ts`.
6715
+ *
6716
+ * Replaces any field made before. The scene then paces `field.bake(bricks)` and calls
6717
+ * `field.follow(buffer.complete, ...)` each frame with the selection that chose its exact lights;
6718
+ * the volume is summed into the frame once every brick has landed.
6719
+ */
6720
+ createLightField(
6721
+ lights: readonly LightFieldSource[],
6722
+ options: Omit<LightFieldOptions, 'falloff'> = {},
6723
+ ): LightField {
6724
+ this.forgetLightFieldVolumes();
6725
+ const field = new LightField(lights, { ...options, falloff: this.quality.pointLightFalloff });
6726
+ this.lightField = field;
6727
+ return field;
6728
+ }
6729
+
6730
+ /** Let go of the field and its volumes; the scene's lights are all exact again. */
6731
+ disposeLightField(): void {
6732
+ this.forgetLightFieldVolumes();
6733
+ this.lightField = null;
6734
+ }
6735
+
6736
+ private forgetLightFieldVolumes(): void {
6737
+ if (this.driftIndex !== null) this.gl.deleteTexture(this.driftIndex);
6738
+ if (this.driftAtlas !== null) this.gl.deleteTexture(this.driftAtlas);
6739
+ this.driftIndex = null;
6740
+ this.driftAtlas = null;
6741
+ }
6742
+
6743
+ /**
6744
+ * Upload the field's two volumes, once, when its last brick has baked: nothing samples them
6745
+ * before that, so uploading as bricks land would be work for no picture.
6746
+ */
6747
+ private uploadLightField(): void {
6748
+ const field = this.lightField;
6749
+ if (field === null || this.driftAtlas !== null || !field.ready || field.layout.count === 0) {
6750
+ return;
6751
+ }
6752
+ const { gl } = this;
6753
+ const [ix, iy, iz] = field.layout.dims;
6754
+ const index = gl.createTexture();
6755
+ gl.bindTexture(gl.TEXTURE_3D, index);
6756
+ gl.texImage3D(
6757
+ gl.TEXTURE_3D,
6758
+ 0,
6759
+ gl.R32UI,
6760
+ ix,
6761
+ iy,
6762
+ iz,
6763
+ 0,
6764
+ gl.RED_INTEGER,
6765
+ gl.UNSIGNED_INT,
6766
+ field.layout.index,
6767
+ );
6768
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
6769
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
6770
+ const [aw, ah, ad] = field.atlasSize;
6771
+ const atlas = gl.createTexture();
6772
+ gl.bindTexture(gl.TEXTURE_3D, atlas);
6773
+ gl.texImage3D(gl.TEXTURE_3D, 0, gl.RGBA16F, aw, ah, ad, 0, gl.RGBA, gl.HALF_FLOAT, field.atlas);
6774
+ /* Filtered, because a brick's samples share their faces: the lookup is seamless inside one. */
6775
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6776
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6777
+ for (const wrap of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R]) {
6778
+ gl.texParameteri(gl.TEXTURE_3D, wrap, gl.CLAMP_TO_EDGE);
6779
+ }
6780
+ gl.bindTexture(gl.TEXTURE_3D, null);
6781
+ this.driftIndex = index;
6782
+ this.driftAtlas = atlas;
6783
+ }
6784
+
6785
+ /** The field's uniforms and volumes for this pass, or the stand-ins that switch it off. */
6786
+ private bindDriftLight(u: Record<string, WebGLUniformLocation>, eye: ArrayLike<number>): void {
6787
+ const { gl } = this;
6788
+ this.uploadLightField();
6789
+ const d = this.driftUniforms;
6790
+ resolveDriftLight(
6791
+ this.driftAtlas === null ? null : this.lightField,
6792
+ this.probePassActive,
6793
+ eye,
6794
+ d,
6795
+ );
6796
+ gl.uniform4fv(u['uDriftLight'] ?? null, d.light);
6797
+ gl.uniform4fv(u['uDriftLightOrigin'] ?? null, d.origin);
6798
+ gl.activeTexture(gl.TEXTURE0 + DRIFT_LIGHT_INDEX_TEXTURE_UNIT);
6799
+ gl.bindTexture(gl.TEXTURE_3D, this.driftIndex ?? this.emptyDriftIndex);
6800
+ gl.uniform1i(u['uDriftLightIndex'] ?? null, DRIFT_LIGHT_INDEX_TEXTURE_UNIT);
6801
+ gl.activeTexture(gl.TEXTURE0 + DRIFT_LIGHT_ATLAS_TEXTURE_UNIT);
6802
+ gl.bindTexture(gl.TEXTURE_3D, this.driftAtlas ?? this.emptyDriftAtlas);
6803
+ gl.uniform1i(u['uDriftLightAtlas'] ?? null, DRIFT_LIGHT_ATLAS_TEXTURE_UNIT);
6804
+ }
6805
+
6395
6806
  /**
6396
6807
  * Everything one flat program needs to know about this frame.
6397
6808
  *
@@ -6530,7 +6941,7 @@ export class WebGL2Renderer implements RendererApi {
6530
6941
  gl.uniform1f(u['uShadowMapSize'] ?? null, this.quality.directionalShadowMapSize);
6531
6942
  gl.uniform1f(u['uShadowDepthSpan'] ?? null, env.shadowDepthSpan);
6532
6943
  gl.uniform1f(u['uShadowMaxDistance'] ?? null, this.quality.directionalShadowMaxDistance);
6533
- gl.uniform1i(u['uPeeledShadowEnabled'] ?? null, this.peeledShadowMap === null ? 0 : 1);
6944
+ gl.uniform1i(u['uPeeledShadowEnabled'] ?? null, this.peelFilled ? 1 : 0);
6534
6945
  gl.uniform1f(u['uShadowMaxSlope'] ?? null, this.quality.directionalShadowMaxSlope);
6535
6946
  gl.uniform1i(u['uShadowFilterTaps'] ?? null, this.quality.shadowFilterTaps);
6536
6947
  gl.uniform3fv(u['uHighlightMin'] ?? null, env.highlightMin);
@@ -6610,11 +7021,20 @@ export class WebGL2Renderer implements RendererApi {
6610
7021
  * zero would not be enough on its own — a driver may fetch a sampler's descriptor before it
6611
7022
  * evaluates the arithmetic that discards the result, which is the same reason
6612
7023
  * `emptyTexture.ts` exists.
7024
+ *
7025
+ * **Except a bounce bake, which reads it on purpose.** Its faces are drawn into the capture
7026
+ * cube, and the array is attached only by the convolution after them, so no pass both samples
7027
+ * and attaches it.
6613
7028
  */
6614
7029
  if (this.probeCapture !== null) {
6615
7030
  const array = this.probeArray;
6616
7031
  const grid = this.probes;
6617
- const usable = array !== null && grid !== null && array.ready && !this.probePassActive;
7032
+ const usable =
7033
+ array !== null &&
7034
+ grid !== null &&
7035
+ array.usable &&
7036
+ this.probeSweeps?.ready === true &&
7037
+ (!this.probePassActive || this.probeBounce);
6618
7038
  gl.activeTexture(gl.TEXTURE0 + ENVIRONMENT_TEXTURE_UNIT);
6619
7039
  gl.bindTexture(gl.TEXTURE_2D_ARRAY, usable ? array.texture : this.emptyTexture2DArray);
6620
7040
  gl.uniform1i(u['uEnvironment'] ?? null, ENVIRONMENT_TEXTURE_UNIT);
@@ -6655,14 +7075,21 @@ export class WebGL2Renderer implements RendererApi {
6655
7075
  * times over two days as four different bugs before it could be asked for.
6656
7076
  */
6657
7077
  gl.uniform1f(u['uProbeGridAmbient'] ?? null, usable && this.probeAmbient ? 1 : 0);
7078
+ /* Which sets the shading reads, and how far between them. See `probeSweeps.ts`. */
7079
+ const sets = this.probeSweeps?.uniforms(this.probeSets) ?? this.probeSets.fill(0);
7080
+ gl.uniform3f(u['uProbeGridSets'] ?? null, sets[0] ?? 0, sets[1] ?? 0, sets[2] ?? 0);
6658
7081
  }
6659
7082
 
7083
+ this.bindDriftLight(u, camera.position);
7084
+
6660
7085
  gl.activeTexture(gl.TEXTURE0 + SURFACE_TEXTURE_UNIT);
6661
7086
  gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
6662
7087
  gl.uniform1i(u['uAlbedo'] ?? null, SURFACE_TEXTURE_UNIT);
6663
7088
  gl.uniform1i(u['uAlbedoEnabled'] ?? null, 0);
6664
7089
  gl.uniform2f(u['uUvScale'] ?? null, 1, 1);
6665
7090
  gl.uniform1f(u['uAlbedoCutout'] ?? null, 0);
7091
+ gl.uniform1i(u['uDoubleSided'] ?? null, 0);
7092
+ this.materialDoubleSided = false;
6666
7093
  this.currentSurfaceTexture = null;
6667
7094
 
6668
7095
  /*
@@ -6895,7 +7322,9 @@ export class WebGL2Renderer implements RendererApi {
6895
7322
  */
6896
7323
  gl.depthFunc(this.reversedDepth ? gl.GREATER : gl.LESS);
6897
7324
  if (layer === 0) {
7325
+ if (this.materialDoubleSided) gl.disable(gl.CULL_FACE);
6898
7326
  mesh.draw(gl);
7327
+ if (this.materialDoubleSided) gl.enable(gl.CULL_FACE);
6899
7328
  gl.depthFunc(this.reversedDepth ? gl.GEQUAL : gl.LEQUAL);
6900
7329
  if (tint !== null) gl.uniform3fv(u['uTint'] ?? null, WHITE_TINT);
6901
7330
  if (program !== null) this.useFlatProgram();
@@ -6914,7 +7343,9 @@ export class WebGL2Renderer implements RendererApi {
6914
7343
  */
6915
7344
  const offset = depthOffsetForLayer(layer, this.reversedDepth);
6916
7345
  gl.polygonOffset(offset.slope, offset.units);
7346
+ if (this.materialDoubleSided) gl.disable(gl.CULL_FACE);
6917
7347
  mesh.draw(gl);
7348
+ if (this.materialDoubleSided) gl.enable(gl.CULL_FACE);
6918
7349
  gl.polygonOffset(0, 0);
6919
7350
  gl.disable(gl.POLYGON_OFFSET_FILL);
6920
7351
  if (program !== null) this.useFlatProgram();
@@ -7067,7 +7498,9 @@ export class WebGL2Renderer implements RendererApi {
7067
7498
  gl.enable(gl.POLYGON_OFFSET_FILL);
7068
7499
  gl.polygonOffset(offset.slope, offset.units);
7069
7500
  }
7501
+ if (this.materialDoubleSided) gl.disable(gl.CULL_FACE);
7070
7502
  mesh.draw(gl);
7503
+ if (this.materialDoubleSided) gl.enable(gl.CULL_FACE);
7071
7504
  if (offset.units !== 0) {
7072
7505
  gl.polygonOffset(0, 0);
7073
7506
  gl.disable(gl.POLYGON_OFFSET_FILL);
@@ -7140,10 +7573,11 @@ export class WebGL2Renderer implements RendererApi {
7140
7573
 
7141
7574
  this.probeArray?.dispose(this.gl);
7142
7575
  this.probes = grid;
7576
+ this.probeSweeps = new ProbeSweeps(grid.layers, grid.crossfade);
7143
7577
  this.probeArray = new EnvProbeArray(
7144
7578
  this.gl,
7145
7579
  octahedralEdgeFor(this.quality.reflectionProbeSize),
7146
- grid.layers,
7580
+ grid.layers * this.probeSweeps.sets,
7147
7581
  this.quality.hdrScene,
7148
7582
  );
7149
7583
  return this.probeArray.usable;
@@ -7170,12 +7604,14 @@ export class WebGL2Renderer implements RendererApi {
7170
7604
  const probe = this.probeCapture;
7171
7605
  const array = this.probeArray;
7172
7606
  const grid = this.probes;
7173
- if (probe === null || array === null || grid === null) return false;
7607
+ const sweeps = this.probeSweeps;
7608
+ if (probe === null || array === null || grid === null || sweeps === null) return false;
7174
7609
  if (layer < 0 || layer >= grid.layers) return false;
7175
7610
 
7176
7611
  this.probeAmbient = options?.irradiance ?? true;
7177
7612
  grid.positionOf(layer, this.probeOrigin);
7178
7613
 
7614
+ this.probeBounce = options?.bounce ?? false;
7179
7615
  this.probePassActive = true;
7180
7616
  /*
7181
7617
  * **A probe stores radiance, not display pixels.**
@@ -7213,7 +7649,10 @@ export class WebGL2Renderer implements RendererApi {
7213
7649
  this.gl.frontFace(this.gl.CW);
7214
7650
  let baked = false;
7215
7651
  try {
7216
- baked = probe.bake(this.gl, this.probeOrigin, clearColor, drawFace);
7652
+ const [first, end] = probeFaceRange(options);
7653
+ baked = probe.bake(this.gl, this.probeOrigin, clearColor, drawFace, first, end);
7654
+ /* A bake that has not reached its sixth face has nothing whole to convolve yet. */
7655
+ if (end < 6) return baked;
7217
7656
  /*
7218
7657
  * The convolution, immediately after the faces and inside the same guard, because it reads
7219
7658
  * the chain `bake` has just generated. A bake that failed leaves the layer untouched rather
@@ -7224,11 +7663,12 @@ export class WebGL2Renderer implements RendererApi {
7224
7663
  this.gl,
7225
7664
  probe,
7226
7665
  array,
7227
- layer,
7666
+ sweeps.writeLayer(layer),
7228
7667
  ENVIRONMENT_TEXTURE_UNIT,
7229
7668
  this.quality.environmentPrefilterSamples,
7230
7669
  this.quality.environmentPrefilter,
7231
7670
  );
7671
+ sweeps.baked(layer);
7232
7672
  }
7233
7673
  } finally {
7234
7674
  this.gl.frontFace(this.gl.CCW);
@@ -7241,6 +7681,7 @@ export class WebGL2Renderer implements RendererApi {
7241
7681
  * later frame reading the placeholder and quietly reflecting nothing.
7242
7682
  */
7243
7683
  this.probePassActive = false;
7684
+ this.probeBounce = false;
7244
7685
  /* Back to the canvas, or to the frame's own target if one is bound. See `SceneTarget`. */
7245
7686
  if (this.sceneTarget !== null && !this.framePresented) this.sceneTarget.bind();
7246
7687
  else {
@@ -7314,6 +7755,7 @@ export class WebGL2Renderer implements RendererApi {
7314
7755
  this.quality.environmentPrefilterSamples,
7315
7756
  this.quality.environmentPrefilter,
7316
7757
  );
7758
+ this.probeSweeps?.baked(0);
7317
7759
  return true;
7318
7760
  }
7319
7761
 
@@ -7451,7 +7893,7 @@ export class WebGL2Renderer implements RendererApi {
7451
7893
  lookup does its own `* 0.5 + 0.5` and correcting the matrix as well doubles it. */
7452
7894
  gl.uniformMatrix4fv(u['uLightViewProj'] ?? null, false, env.lightViewProj);
7453
7895
  gl.uniform1f(u['uShadowMapSize'] ?? null, this.quality.directionalShadowMapSize);
7454
- gl.uniform1i(u['uPeeledShadowEnabled'] ?? null, this.peeledShadowMap === null ? 0 : 1);
7896
+ gl.uniform1i(u['uPeeledShadowEnabled'] ?? null, this.peelFilled ? 1 : 0);
7455
7897
 
7456
7898
  gl.activeTexture(gl.TEXTURE0);
7457
7899
  gl.bindTexture(gl.TEXTURE_2D, this.shadowMap?.texture ?? this.emptyTexture2D);