@driftengine/core 4.5.0 → 4.6.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 (207) hide show
  1. package/CHANGELOG.json +51 -0
  2. package/README.md +1 -1
  3. package/dist/geometry/boxCsg.d.ts +28 -0
  4. package/dist/geometry/boxCsg.js +139 -0
  5. package/dist/geometry/csg.d.ts +36 -0
  6. package/dist/geometry/csg.js +329 -0
  7. package/dist/geometry/meshBuilder.js +13 -0
  8. package/dist/geometry/meshConcat.js +1 -0
  9. package/dist/geometry/pixelFont.js +2 -0
  10. package/dist/geometry/solid.d.ts +61 -0
  11. package/dist/geometry/solid.js +152 -0
  12. package/dist/geometry/solidFlat.d.ts +35 -0
  13. package/dist/geometry/solidFlat.js +428 -0
  14. package/dist/geometry/solidHarness.d.ts +19 -0
  15. package/dist/geometry/solidHarness.js +66 -0
  16. package/dist/geometry/solidNormals.d.ts +17 -0
  17. package/dist/geometry/solidNormals.js +86 -0
  18. package/dist/geometry/solidRound.d.ts +41 -0
  19. package/dist/geometry/solidRound.js +323 -0
  20. package/dist/geometry/solidWriter.d.ts +16 -0
  21. package/dist/geometry/solidWriter.js +42 -0
  22. package/dist/index.d.ts +23 -2
  23. package/dist/index.js +13 -1
  24. package/dist/render/backend/api.d.ts +17 -3
  25. package/dist/render/backend/drawFog.d.ts +26 -0
  26. package/dist/render/backend/drawFog.js +30 -0
  27. package/dist/render/backend/materialChanges.d.ts +2 -1
  28. package/dist/render/backend/materialChanges.js +6 -1
  29. package/dist/render/backend/webgl2/instanced.d.ts +24 -5
  30. package/dist/render/backend/webgl2/instanced.js +72 -15
  31. package/dist/render/backend/webgl2/renderer.d.ts +122 -8
  32. package/dist/render/backend/webgl2/renderer.js +238 -121
  33. package/dist/render/backend/webgpu/buffers.d.ts +10 -0
  34. package/dist/render/backend/webgpu/buffers.js +34 -3
  35. package/dist/render/backend/webgpu/depthPass.js +5 -1
  36. package/dist/render/backend/webgpu/drawCommand.d.ts +5 -0
  37. package/dist/render/backend/webgpu/drawCommand.js +1 -0
  38. package/dist/render/backend/webgpu/flatPass.d.ts +4 -1
  39. package/dist/render/backend/webgpu/flatPass.js +38 -9
  40. package/dist/render/backend/webgpu/glassTintPass.js +2 -1
  41. package/dist/render/backend/webgpu/instanceCullPass.d.ts +34 -0
  42. package/dist/render/backend/webgpu/instanceCullPass.js +144 -0
  43. package/dist/render/backend/webgpu/instanced.d.ts +11 -1
  44. package/dist/render/backend/webgpu/instanced.js +21 -3
  45. package/dist/render/backend/webgpu/latePass.d.ts +6 -2
  46. package/dist/render/backend/webgpu/latePass.js +11 -4
  47. package/dist/render/backend/webgpu/postPass.d.ts +1 -1
  48. package/dist/render/backend/webgpu/renderer.d.ts +100 -10
  49. package/dist/render/backend/webgpu/renderer.js +462 -201
  50. package/dist/render/backend/webgpu/sdfTextPass.js +6 -1
  51. package/dist/render/backend/webgpu/shaders/instanceCull.wgsl.d.ts +18 -0
  52. package/dist/render/backend/webgpu/shaders/instanceCull.wgsl.js +59 -0
  53. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +17 -3
  54. package/dist/render/backend/webgpu/surfaceTexturePass.js +77 -38
  55. package/dist/render/driftLight/bake.d.ts +7 -0
  56. package/dist/render/driftLight/bake.js +61 -56
  57. package/dist/render/driftLight/denseField.d.ts +46 -0
  58. package/dist/render/driftLight/denseField.js +109 -0
  59. package/dist/render/driftLight/lightField.d.ts +10 -5
  60. package/dist/render/driftLight/lightField.js +25 -19
  61. package/dist/render/driftLight/presence.d.ts +50 -0
  62. package/dist/render/driftLight/presence.js +43 -0
  63. package/dist/render/driftLight/uniforms.d.ts +3 -3
  64. package/dist/render/driftLight/uniforms.js +5 -6
  65. package/dist/render/driftLight/worldLightField.d.ts +41 -0
  66. package/dist/render/driftLight/worldLightField.js +66 -0
  67. package/dist/render/fixtureAtlas.d.ts +20 -0
  68. package/dist/render/fixtureAtlas.js +49 -0
  69. package/dist/render/instanceCull.d.ts +63 -0
  70. package/dist/render/instanceCull.js +160 -0
  71. package/dist/render/instances.d.ts +15 -0
  72. package/dist/render/lightBudget.d.ts +22 -33
  73. package/dist/render/lightBudget.js +24 -35
  74. package/dist/render/lightGrid.d.ts +42 -0
  75. package/dist/render/lightGrid.js +87 -0
  76. package/dist/render/mesh.d.ts +18 -24
  77. package/dist/render/mesh.js +51 -68
  78. package/dist/render/occlusion.d.ts +10 -0
  79. package/dist/render/occlusion.js +36 -7
  80. package/dist/render/pointLightSelection.d.ts +7 -0
  81. package/dist/render/pointLightSelection.js +29 -4
  82. package/dist/render/rainField.d.ts +13 -0
  83. package/dist/render/rainField.js +27 -5
  84. package/dist/render/recon/lateDepth.d.ts +11 -0
  85. package/dist/render/recon/lateDepth.js +70 -0
  86. package/dist/render/recon/resolve.d.ts +34 -4
  87. package/dist/render/recon/resolve.js +161 -20
  88. package/dist/render/rendererHarness.js +4 -0
  89. package/dist/render/sceneTarget.d.ts +2 -0
  90. package/dist/render/sceneTarget.js +2 -0
  91. package/dist/render/shaders/depth.js +12 -9
  92. package/dist/render/shaders/flat/driftLight.d.ts +1 -1
  93. package/dist/render/shaders/flat/driftLight.js +32 -7
  94. package/dist/render/shaders/flat/index.js +5 -3
  95. package/dist/render/shaders/flat/main.d.ts +1 -1
  96. package/dist/render/shaders/flat/main.js +75 -23
  97. package/dist/render/shaders/flat/preamble.js +75 -20
  98. package/dist/render/shaders/flat/surfaceEffects.d.ts +29 -0
  99. package/dist/render/shaders/flat/surfaceEffects.js +152 -0
  100. package/dist/render/shaders/generated/depth.wgsl.d.ts +8 -8
  101. package/dist/render/shaders/generated/depth.wgsl.js +8 -8
  102. package/dist/render/shaders/generated/flat.wgsl.d.ts +1320 -1320
  103. package/dist/render/shaders/generated/flat.wgsl.js +1359 -1345
  104. package/dist/render/shaders/generated/lightVolume.wgsl.js +18 -4
  105. package/dist/render/shaders/generated/rush.wgsl.d.ts +21 -16
  106. package/dist/render/shaders/generated/rush.wgsl.js +21 -16
  107. package/dist/render/shaders/generated/sdfText.wgsl.d.ts +2 -2
  108. package/dist/render/shaders/generated/sdfText.wgsl.js +2 -2
  109. package/dist/render/shaders/generated/text.wgsl.d.ts +1 -1
  110. package/dist/render/shaders/generated/text.wgsl.js +1 -1
  111. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +1 -1
  112. package/dist/render/shaders/gi/driftLight.wgsl.js +27 -4
  113. package/dist/render/shaders/recon/lateDepth.wgsl.js +38 -13
  114. package/dist/render/shaders/recon/resolve.wgsl.d.ts +8 -7
  115. package/dist/render/shaders/recon/resolve.wgsl.js +161 -27
  116. package/dist/render/shaders/rush.js +11 -1
  117. package/dist/render/shaders/sdfText.d.ts +1 -1
  118. package/dist/render/shaders/sdfText.js +3 -2
  119. package/dist/render/shaders/text.d.ts +1 -1
  120. package/dist/render/shaders/text.js +18 -9
  121. package/dist/render/surfaceEffects.d.ts +83 -0
  122. package/dist/render/surfaceEffects.js +75 -0
  123. package/dist/render/surfaceScene.d.ts +8 -0
  124. package/dist/render/surfaceScene.js +8 -0
  125. package/dist/render/surfaceTexture.d.ts +18 -2
  126. package/dist/render/surfaceTexture.js +68 -15
  127. package/dist/render/temporalAa.d.ts +25 -6
  128. package/dist/render/temporalAa.js +45 -15
  129. package/dist/render/textureSource.d.ts +30 -0
  130. package/dist/render/textureSource.js +53 -0
  131. package/dist/render/vertexDefaults.js +2 -0
  132. package/dist/world/hlod.d.ts +104 -0
  133. package/dist/world/hlod.js +214 -0
  134. package/package.json +6 -6
  135. package/src/geometry/boxCsg.ts +186 -0
  136. package/src/geometry/csg.ts +384 -0
  137. package/src/geometry/meshBuilder.ts +15 -0
  138. package/src/geometry/meshConcat.ts +1 -0
  139. package/src/geometry/pixelFont.ts +2 -0
  140. package/src/geometry/solid.ts +189 -0
  141. package/src/geometry/solidFlat.ts +522 -0
  142. package/src/geometry/solidHarness.ts +83 -0
  143. package/src/geometry/solidNormals.ts +103 -0
  144. package/src/geometry/solidRound.ts +443 -0
  145. package/src/geometry/solidWriter.ts +58 -0
  146. package/src/index.ts +51 -1
  147. package/src/render/backend/api.ts +21 -2
  148. package/src/render/backend/drawFog.ts +31 -0
  149. package/src/render/backend/materialChanges.ts +9 -2
  150. package/src/render/backend/webgl2/instanced.ts +80 -14
  151. package/src/render/backend/webgl2/renderer.ts +300 -143
  152. package/src/render/backend/webgpu/buffers.ts +42 -3
  153. package/src/render/backend/webgpu/depthPass.ts +5 -1
  154. package/src/render/backend/webgpu/drawCommand.ts +6 -0
  155. package/src/render/backend/webgpu/flatPass.ts +41 -11
  156. package/src/render/backend/webgpu/glassTintPass.ts +2 -1
  157. package/src/render/backend/webgpu/instanceCullPass.ts +165 -0
  158. package/src/render/backend/webgpu/instanced.ts +21 -3
  159. package/src/render/backend/webgpu/latePass.ts +12 -3
  160. package/src/render/backend/webgpu/renderer.ts +540 -226
  161. package/src/render/backend/webgpu/sdfTextPass.ts +6 -1
  162. package/src/render/backend/webgpu/shaders/instanceCull.wgsl.ts +61 -0
  163. package/src/render/backend/webgpu/surfaceTexturePass.ts +90 -50
  164. package/src/render/driftLight/bake.ts +85 -62
  165. package/src/render/driftLight/denseField.ts +149 -0
  166. package/src/render/driftLight/lightField.ts +33 -18
  167. package/src/render/driftLight/presence.ts +69 -0
  168. package/src/render/driftLight/uniforms.ts +8 -9
  169. package/src/render/driftLight/worldLightField.ts +83 -0
  170. package/src/render/fixtureAtlas.ts +63 -0
  171. package/src/render/instanceCull.ts +205 -0
  172. package/src/render/instances.ts +16 -0
  173. package/src/render/lightBudget.ts +25 -36
  174. package/src/render/lightGrid.ts +130 -0
  175. package/src/render/mesh.ts +59 -72
  176. package/src/render/occlusion.ts +51 -7
  177. package/src/render/pointLightSelection.ts +72 -4
  178. package/src/render/rainField.ts +39 -5
  179. package/src/render/recon/lateDepth.ts +83 -0
  180. package/src/render/recon/resolve.ts +206 -20
  181. package/src/render/rendererHarness.ts +4 -0
  182. package/src/render/sceneTarget.ts +4 -0
  183. package/src/render/shaders/depth.ts +12 -9
  184. package/src/render/shaders/flat/driftLight.ts +32 -7
  185. package/src/render/shaders/flat/index.ts +5 -3
  186. package/src/render/shaders/flat/main.ts +75 -23
  187. package/src/render/shaders/flat/preamble.ts +75 -20
  188. package/src/render/shaders/flat/surfaceEffects.ts +152 -0
  189. package/src/render/shaders/generated/depth.wgsl.ts +8 -8
  190. package/src/render/shaders/generated/flat.wgsl.ts +1361 -1345
  191. package/src/render/shaders/generated/lightVolume.wgsl.ts +20 -4
  192. package/src/render/shaders/generated/rush.wgsl.ts +21 -16
  193. package/src/render/shaders/generated/sdfText.wgsl.ts +2 -2
  194. package/src/render/shaders/generated/text.wgsl.ts +1 -1
  195. package/src/render/shaders/gi/driftLight.wgsl.ts +27 -4
  196. package/src/render/shaders/recon/lateDepth.wgsl.ts +38 -13
  197. package/src/render/shaders/recon/resolve.wgsl.ts +161 -27
  198. package/src/render/shaders/rush.ts +11 -1
  199. package/src/render/shaders/sdfText.ts +3 -2
  200. package/src/render/shaders/text.ts +18 -9
  201. package/src/render/surfaceEffects.ts +132 -0
  202. package/src/render/surfaceScene.ts +16 -0
  203. package/src/render/surfaceTexture.ts +110 -19
  204. package/src/render/temporalAa.ts +78 -14
  205. package/src/render/textureSource.ts +72 -0
  206. package/src/render/vertexDefaults.ts +2 -0
  207. package/src/world/hlod.ts +290 -0
@@ -1,5 +1,6 @@
1
1
  import { FrameBudget } from '../budget.ts';
2
2
  import { MaterialChanges, ownsMaterial } from '../materialChanges.ts';
3
+ import { FOG_RECEDE, fogModeOf } from '../drawFog.ts';
3
4
  import { mat4 } from 'gl-matrix';
4
5
  import {
5
6
  GL_DEPTH_REMAP,
@@ -49,6 +50,7 @@ import { TranslucentQueue } from '../../translucentQueue.ts';
49
50
  import { createFrustum, frustumFromViewProjection } from '../../../math/frustum.ts';
50
51
  import type { Frustum } from '../../../math/frustum.ts';
51
52
  import type { Bounds } from '../../../math/bounds.ts';
53
+ import { batchBoxVisible, cullsInstances } from '../../instanceCull.ts';
52
54
  import { OcclusionBuffer } from '../../occlusion.ts';
53
55
  import { boundsVisible } from '../../visibility.ts';
54
56
  import { createPassRegistry, drainRegistry, passAt, registerIn, unregisterIn } from '../../pass.ts';
@@ -93,6 +95,10 @@ import {
93
95
  } from '../../emptyTexture.ts';
94
96
  import { LightField } from '../../driftLight/lightField.ts';
95
97
  import type { LightFieldOptions, LightFieldSource } from '../../driftLight/lightField.ts';
98
+ import type { DriftLightVolumes } from '../../driftLight/presence.ts';
99
+ import type { DenseLightVolume } from '../../driftLight/denseField.ts';
100
+ import { WorldLightField } from '../../driftLight/worldLightField.ts';
101
+ import type { WorldLightFieldOptions } from '../../driftLight/worldLightField.ts';
96
102
  import { createDriftLightUniforms, resolveDriftLight } from '../../driftLight/uniforms.ts';
97
103
 
98
104
  /**
@@ -129,6 +135,8 @@ import { ProbeSweeps } from '../../probeSweeps.ts';
129
135
  import { octahedralEdgeFor } from '../../prefilterEnvMap.ts';
130
136
  import { EnvironmentPrefilterPass } from '../../prefilterPass.ts';
131
137
  import { packIesAtlas } from '../../iesProfile.ts';
138
+ import { fixtureAtlasLayout, fixtureAtlasTexels } from '../../fixtureAtlas.ts';
139
+ import { resolveSurfaceScene } from '../../surfaceScene.ts';
132
140
  import type { AreaLightBuffer, AreaLightSource } from '../../areaLights.ts';
133
141
  import { MAX_AREA_LIGHTS } from '../../areaLights.ts';
134
142
  import {
@@ -182,12 +190,12 @@ import {
182
190
  ENVIRONMENT_TEXTURE_UNIT,
183
191
  SURFACE_TEXTURE_UNIT,
184
192
  bindPointLights,
185
- COOKIE_ATLAS_TEXTURE_UNIT,
186
193
  REFRACT_SCENE_TEXTURE_UNIT,
187
194
  DRIFT_LIGHT_ATLAS_TEXTURE_UNIT,
188
195
  DRIFT_LIGHT_INDEX_TEXTURE_UNIT,
189
196
  COOKIE_TILE,
190
- IES_ATLAS_TEXTURE_UNIT,
197
+ FIXTURE_ATLAS_TEXTURE_UNIT,
198
+ SURFACE_EFFECTS_TEXTURE_UNIT,
191
199
  bindAreaLights,
192
200
  bindAreaShadows,
193
201
  openCones,
@@ -244,7 +252,7 @@ import type { WindStreakOptions } from '../../windStreakRenderer.ts';
244
252
  import type { WindField } from '../../windField.ts';
245
253
  import type { FlockParams } from '../../flockRenderer.ts';
246
254
  import { InstancedBatch } from './instanced.ts';
247
- import type { MeshInstances } from '../../instances.ts';
255
+ import type { InstancedOptions, MeshInstances } from '../../instances.ts';
248
256
  import { cutoutOf } from '../../cutoutCaster.ts';
249
257
  import type { CutoutCaster } from '../../cutoutCaster.ts';
250
258
  import type { SurfaceTextureHandle } from '../api.ts';
@@ -296,6 +304,29 @@ export interface Environment extends Atmosphere {
296
304
  * how strongly.
297
305
  */
298
306
  nightFactor: number;
307
+ /**
308
+ * Seconds on the caller's clock, for the surfaces that animate — a sign's pulse, a ticker's
309
+ * scroll, a flipbook. The caller's rather than a clock read here, so a held clock holds every
310
+ * surface still and a replay replays them. 0 unless stated.
311
+ */
312
+ surfaceTime?: number;
313
+ /**
314
+ * How wet the world is, 0 to 1: upward surfaces darken and polish, except materials that say
315
+ * they stay dry. The scene-wide counterpart of `drawFilm`, which stays for a puddle. 0 unless
316
+ * stated.
317
+ */
318
+ wetness?: number;
319
+ /**
320
+ * The share of facade windows lit, 0 to 1, which a scene sets from its time of day. Windows switch
321
+ * on in steps by a per-window hash, so a rising share lights more windows rather than brightening
322
+ * the ones already lit. 0 unless stated.
323
+ */
324
+ litWindows?: number;
325
+ /**
326
+ * The share of windows that stay lit however late it gets, 0 to 1, so the small hours are not a
327
+ * dark city. 0 unless stated.
328
+ */
329
+ lateWindows?: number;
299
330
  /**
300
331
  * Emission that only shows where the dominant light does not reach, 0 and off by default.
301
332
  *
@@ -556,6 +587,8 @@ export { MAX_DEPTH_LAYER } from '../../depthConvention.ts';
556
587
 
557
588
  /** The identity tint, handed back after any draw that asked for one. See `drawMesh`. */
558
589
  const WHITE_TINT = new Float32Array([1, 1, 1]);
590
+ /** The lit stage's clip plane when there is no mirror: zero, which clips nothing. */
591
+ const NO_CLIP_PLANE = new Float32Array(4);
559
592
 
560
593
  /**
561
594
  * What a film should do beyond shimmering, which until now was nothing.
@@ -707,6 +740,31 @@ export interface LightVolumeDrawOptions {
707
740
  * boolean is refused everywhere else in this file.
708
741
  */
709
742
  export interface TranslucentMeshOptions {
743
+ /**
744
+ * Add this surface's light to what is behind it instead of covering it: a glow, a beam, a pool
745
+ * of light under a lamp. Its colour times its opacity is added and nothing is darkened, so black
746
+ * is invisible and overlapping glows brighten — which is why such surfaces are authored nearly
747
+ * black where they fade. **Order does not matter** to a sum, so an additive draw is never held
748
+ * for the order-independent pass; it is drawn where it is asked for. Write depth off with it
749
+ * (`depthWrite: false`) or a glow hides the glows behind it.
750
+ */
751
+ /**
752
+ * Drawn into the picture a reconstruction resolves, rather than after it. For a surface that
753
+ * moves with what it lies on — a glow on a wall, a pane in a window — whose one motion is that
754
+ * surface's, so the resolve can carry it with the surface.
755
+ *
756
+ * **Late is the default, and it is right for anything else.** A translucent surface writes no
757
+ * depth the motion pass could test and may move apart from what shows through it — a caption
758
+ * over a moving car — so a history can only smear it; after the upscale it is sharp and has none.
759
+ * **What late gives up** is the edge: drawn unjittered against a depth the jittered render
760
+ * covered, it meets a silhouette the render drew differently every frame, and a glow along an
761
+ * edge is let through on one frame and not the next. Reconstructed, it is resolved with the edge.
762
+ *
763
+ * Only a reconstructing frame draws anything late, so this changes nothing on WebGL2 or with
764
+ * `reconstruction` off. An order-independent blended set replays where it replays.
765
+ */
766
+ readonly reconstructed?: boolean;
767
+ readonly additive?: boolean;
710
768
  /**
711
769
  * `false` draws the mesh exactly its own vertex colour times its own texture, with no
712
770
  * ambient, sun, point lights, specular, reflectivity, grain or emissive touching it —
@@ -1136,7 +1194,7 @@ export class WebGL2Renderer implements RendererApi {
1136
1194
  private readonly emptyDriftIndex: WebGLTexture;
1137
1195
  private readonly emptyDriftAtlas: WebGLTexture;
1138
1196
  /** The scene's DriftLight field, and its two volumes once it is whole. See `createLightField`. */
1139
- private lightField: LightField | null = null;
1197
+ private lightField: DriftLightVolumes | null = null;
1140
1198
  private driftIndex: WebGLTexture | null = null;
1141
1199
  private driftAtlas: WebGLTexture | null = null;
1142
1200
  private readonly driftUniforms = createDriftLightUniforms();
@@ -1183,6 +1241,11 @@ export class WebGL2Renderer implements RendererApi {
1183
1241
  private oitActive = false;
1184
1242
  /** True while the queue is being replayed, so a replayed draw does not record itself again. */
1185
1243
  private oitReplaying = false;
1244
+ /** `uSurfaceScene`, resolved once a pass. See `surfaceScene.ts`. */
1245
+ private readonly surfaceScene = new Float32Array(4);
1246
+ /** The two halves of `uWriteMode`, kept here because a `vec2` is written whole. */
1247
+ private oitWeight = 0;
1248
+ private ditherFade = 0;
1186
1249
  /** Said once, not per frame, where the context cannot give the float target the sum needs. */
1187
1250
  private oitRefused = false;
1188
1251
  /** Said once for the whole renderer. See `refuseOitMultisampled`. */
@@ -1288,6 +1351,8 @@ export class WebGL2Renderer implements RendererApi {
1288
1351
  private readonly taaDepthToViewZ = Float32Array.of(0, 0, 0, 1);
1289
1352
  private readonly aoInvProjection = mat4.create();
1290
1353
  private readonly aoProjScale = new Float32Array(2);
1354
+ /** Where this frame's jitter put the occlusion, in the composite's uv. See `uAoOffset`. */
1355
+ private readonly aoOffset = new Float32Array(2);
1291
1356
  /**
1292
1357
  * Whether this frame has already been resolved to the canvas.
1293
1358
  *
@@ -1379,12 +1444,15 @@ export class WebGL2Renderer implements RendererApi {
1379
1444
  * world. See `prepareStaticPointShadows`.
1380
1445
  */
1381
1446
  private pointShadowArray: PointShadowArray | null = null;
1382
- /** The photometric atlas, allocated on first use. See `setIesProfiles`. */
1383
- private iesTexture: WebGLTexture | null = null;
1384
- /** Rows in that atlas. One until a consumer loads a profile, which is the row of ones. */
1447
+ /** The fixture atlas — cookies over profiles — allocated on first use. See `fixtureAtlas.ts`. */
1448
+ private fixtureTexture: WebGLTexture | null = null;
1449
+ /** The profiles as packed, kept so a new set of cookies can rebuild the atlas around them. */
1450
+ private iesAtlas = packIesAtlas([]);
1451
+ /** The cookie images, kept so a new set of profiles can rebuild the atlas around them. */
1452
+ private cookieImages: readonly TexImageSource[] = [];
1453
+ /** Rows of profile in the atlas. One until a consumer loads a profile, which is the row of ones. */
1385
1454
  private iesRows = 1;
1386
- private cookieTexture: WebGLTexture | null = null;
1387
- /** Tiles in the cookie atlas. 0 is the white placeholder and the whole off path. */
1455
+ /** Cookie tiles in the atlas. 0 is none, and the whole off path. */
1388
1456
  private cookieTiles = 0;
1389
1457
  /** Horizontal planes each profile occupies. 1 unless something asymmetric was loaded. */
1390
1458
  private iesPlanes = 1;
@@ -1701,7 +1769,7 @@ export class WebGL2Renderer implements RendererApi {
1701
1769
  if (cutout !== null) {
1702
1770
  gl.useProgram(this.depthInstancedCutoutProgram);
1703
1771
  this.bindCutoutDepth(this.depthInstancedCutoutUniforms, cutout);
1704
- gpuBatch.mesh.drawInstances(gl, count);
1772
+ gpuBatch.draw(gl, count);
1705
1773
  if (twoSided && this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
1706
1774
  gl.useProgram(this.depthProgram);
1707
1775
  this.restoreSurfaceTextureUnit();
@@ -1712,7 +1780,7 @@ export class WebGL2Renderer implements RendererApi {
1712
1780
  gl.uniformMatrix4fv(u['uLightViewProj'] ?? null, false, this.activeDepthViewProj);
1713
1781
  gl.uniform1i(u['uPeelShadowLayer'] ?? null, this.activeDepthPeel ? 1 : 0);
1714
1782
  if (this.activeDepthPeel) gl.uniform1i(u['uPreviousShadowMap'] ?? null, 0);
1715
- gpuBatch.mesh.drawInstances(gl, count);
1783
+ gpuBatch.draw(gl, count);
1716
1784
  if (twoSided && this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
1717
1785
  gl.useProgram(this.depthProgram);
1718
1786
  },
@@ -1991,6 +2059,14 @@ export class WebGL2Renderer implements RendererApi {
1991
2059
  return this.occlusion?.occluded(bounds, model) ?? false;
1992
2060
  }
1993
2061
 
2062
+ /**
2063
+ * Whether a world-space box is wholly behind the declared occluders — the tighter question for
2064
+ * something that is a box, such as a region. False with no occlusion buffer. See `occluded`.
2065
+ */
2066
+ occludedBox(min: ArrayLike<number>, max: ArrayLike<number>): boolean {
2067
+ return this.occlusion?.occludedBox(min, max) ?? false;
2068
+ }
2069
+
1994
2070
  /* -- Registered passes ---------------------------------------------------------------- */
1995
2071
 
1996
2072
  private readonly passes: PassRegistry = createPassRegistry();
@@ -2181,106 +2257,67 @@ export class WebGL2Renderer implements RendererApi {
2181
2257
  * `createSurfaceTexture` and the material maps take.
2182
2258
  */
2183
2259
  setSpotCookies(images: readonly TexImageSource[]): void {
2184
- const { gl } = this;
2185
- this.cookieTiles = images.length;
2186
- if (this.cookieTexture === null) this.cookieTexture = gl.createTexture();
2187
- gl.activeTexture(gl.TEXTURE0 + COOKIE_ATLAS_TEXTURE_UNIT);
2188
- gl.bindTexture(gl.TEXTURE_2D, this.cookieTexture);
2189
- if (images.length === 0) {
2190
- /* White, not black: this multiplies a light's colour. */
2191
- gl.texImage2D(
2192
- gl.TEXTURE_2D,
2193
- 0,
2194
- gl.RGBA8,
2195
- 1,
2196
- 1,
2197
- 0,
2198
- gl.RGBA,
2199
- gl.UNSIGNED_BYTE,
2200
- new Uint8Array([255, 255, 255, 255]),
2201
- );
2202
- } else {
2203
- gl.texImage2D(
2204
- gl.TEXTURE_2D,
2205
- 0,
2206
- gl.RGBA8,
2207
- COOKIE_TILE * images.length,
2208
- COOKIE_TILE,
2209
- 0,
2210
- gl.RGBA,
2211
- gl.UNSIGNED_BYTE,
2212
- null,
2213
- );
2214
- for (let tile = 0; tile < images.length; tile++) {
2215
- const image = images[tile];
2216
- if (image === undefined) continue;
2217
- gl.texSubImage2D(gl.TEXTURE_2D, 0, tile * COOKIE_TILE, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
2218
- }
2219
- }
2220
- /* Linear and clamped: a cookie is an image, and the shader's own inset is what keeps a tile
2221
- from reaching its neighbour — clamping alone cannot, the seam being interior to the row. */
2222
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
2223
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2224
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2225
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2226
- gl.activeTexture(gl.TEXTURE0);
2260
+ this.cookieImages = images.slice();
2261
+ this.buildFixtureAtlas();
2227
2262
  }
2228
2263
 
2229
2264
  setIesProfiles(profiles: readonly PhotometricProfile[]): void {
2265
+ this.iesAtlas = packIesAtlas(profiles);
2266
+ this.buildFixtureAtlas();
2267
+ }
2268
+
2269
+ /**
2270
+ * The fixture atlas from the profiles and cookies in hand: the profiles' rows at the bottom, the
2271
+ * cookies' tiles on top, one `RGBA16F` texture. See `fixtureAtlas.ts` for the layout.
2272
+ *
2273
+ * **Half float, matching what the other backend must use**: a 32-bit float texture is not
2274
+ * filterable in core WebGPU, and both tables are curves and images that want linear filtering.
2275
+ * Linear and clamped: a profile is sampled between two angles, and a cookie's own inset is what
2276
+ * keeps a tile from its neighbour — clamping alone cannot, the seam being interior to the row.
2277
+ */
2278
+ private buildFixtureAtlas(): void {
2230
2279
  const { gl } = this;
2231
- const atlas = packIesAtlas(profiles);
2232
- if (this.iesTexture === null) this.iesTexture = gl.createTexture();
2233
- gl.activeTexture(gl.TEXTURE0 + IES_ATLAS_TEXTURE_UNIT);
2234
- gl.bindTexture(gl.TEXTURE_2D, this.iesTexture);
2235
- /*
2236
- * `LINEAR` across a row and clamped, unlike the froxel table's `NEAREST`. This one *is* a
2237
- * curve rather than data addressed by index: interpolating between two columns is sampling
2238
- * the distribution between two angles, which is exactly what is wanted, and clamping stops a
2239
- * row bleeding into its neighbour at the edges.
2240
- */
2241
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
2242
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2243
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2244
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2245
- /*
2246
- * One channel, because a row holds one number per angle and the other three would be three
2247
- * quarters of the upload spent on nothing — and **half float rather than full**, matching what
2248
- * the other backend must use: a 32-bit float texture is not filterable in core WebGPU, and this
2249
- * row is a curve that wants linear filtering. Keeping the two formats the same is the
2250
- * 2026-08-13 rule rather than tidiness; the driver converts the float data on the way in.
2251
- */
2280
+ const layout = fixtureAtlasLayout(this.iesAtlas.height, this.cookieImages.length);
2281
+ if (this.fixtureTexture === null) this.fixtureTexture = gl.createTexture();
2282
+ gl.activeTexture(gl.TEXTURE0 + FIXTURE_ATLAS_TEXTURE_UNIT);
2283
+ gl.bindTexture(gl.TEXTURE_2D, this.fixtureTexture);
2252
2284
  gl.texImage2D(
2253
2285
  gl.TEXTURE_2D,
2254
2286
  0,
2255
- gl.R16F,
2256
- atlas.width,
2257
- atlas.height,
2287
+ gl.RGBA16F,
2288
+ layout.width,
2289
+ layout.height,
2258
2290
  0,
2259
- gl.RED,
2291
+ gl.RGBA,
2260
2292
  gl.FLOAT,
2261
- atlas.data,
2293
+ fixtureAtlasTexels(layout, this.iesAtlas),
2262
2294
  );
2263
- this.iesRows = atlas.height;
2264
- this.iesPlanes = atlas.planes;
2295
+ for (let tile = 0; tile < this.cookieImages.length; tile++) {
2296
+ const image = this.cookieImages[tile];
2297
+ if (image === undefined) continue;
2298
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, tile * COOKIE_TILE, 0, gl.RGBA, gl.HALF_FLOAT, image);
2299
+ }
2300
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
2301
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2302
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2303
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2304
+ this.iesRows = this.iesAtlas.height;
2305
+ this.iesPlanes = this.iesAtlas.planes;
2306
+ this.cookieTiles = this.cookieImages.length;
2265
2307
  gl.bindTexture(gl.TEXTURE_2D, null);
2308
+ gl.activeTexture(gl.TEXTURE0);
2266
2309
  }
2267
2310
 
2268
- /** Bind the atlas and say how many rows it has. Always, for the reason `bindClusters` gives. */
2311
+ /** Bind the fixture atlas and say what is in it. Always, for the reason `bindClusters` gives. */
2269
2312
  private bindIesAtlas(u: Record<string, WebGLUniformLocation>): void {
2270
2313
  const { gl } = this;
2271
- if (this.iesTexture === null) this.setIesProfiles([]);
2272
- gl.activeTexture(gl.TEXTURE0 + IES_ATLAS_TEXTURE_UNIT);
2273
- gl.bindTexture(gl.TEXTURE_2D, this.iesTexture);
2274
- gl.uniform1i(u['uIesAtlas'] ?? null, IES_ATLAS_TEXTURE_UNIT);
2314
+ /* Built on demand rather than at construction, for the reason the grade's placeholder is. */
2315
+ if (this.fixtureTexture === null) this.buildFixtureAtlas();
2316
+ gl.activeTexture(gl.TEXTURE0 + FIXTURE_ATLAS_TEXTURE_UNIT);
2317
+ gl.bindTexture(gl.TEXTURE_2D, this.fixtureTexture);
2318
+ gl.uniform1i(u['uFixtureAtlas'] ?? null, FIXTURE_ATLAS_TEXTURE_UNIT);
2275
2319
  gl.uniform1f(u['uIesAtlasRows'] ?? null, this.iesRows);
2276
2320
  gl.uniform1f(u['uIesPlaneCount'] ?? null, this.iesPlanes);
2277
- /* Built on demand rather than at construction, for the reason the grade's placeholder is: a
2278
- scene that never projects a cookie should not carry a texture, and a declared sampler still
2279
- needs a complete one. */
2280
- if (this.cookieTexture === null) this.setSpotCookies([]);
2281
- gl.activeTexture(gl.TEXTURE0 + COOKIE_ATLAS_TEXTURE_UNIT);
2282
- gl.bindTexture(gl.TEXTURE_2D, this.cookieTexture);
2283
- gl.uniform1i(u['uCookieAtlas'] ?? null, COOKIE_ATLAS_TEXTURE_UNIT);
2284
2321
  gl.uniform1f(u['uCookieTiles'] ?? null, this.cookieTiles);
2285
2322
  gl.activeTexture(gl.TEXTURE0);
2286
2323
  }
@@ -3035,8 +3072,7 @@ export class WebGL2Renderer implements RendererApi {
3035
3072
  gl.deleteVertexArray(this.panelVao);
3036
3073
  gl.deleteVertexArray(this.skyVao);
3037
3074
  if (this.panelBuffer !== null) gl.deleteBuffer(this.panelBuffer);
3038
- if (this.cookieTexture !== null) gl.deleteTexture(this.cookieTexture);
3039
- if (this.iesTexture !== null) gl.deleteTexture(this.iesTexture);
3075
+ if (this.fixtureTexture !== null) gl.deleteTexture(this.fixtureTexture);
3040
3076
  if (this.clusterTexture !== null) gl.deleteTexture(this.clusterTexture);
3041
3077
  }
3042
3078
 
@@ -3193,6 +3229,19 @@ export class WebGL2Renderer implements RendererApi {
3193
3229
  return new SurfaceTexture(this.gl, source, options);
3194
3230
  }
3195
3231
 
3232
+ /**
3233
+ * Upload several images of one size as the layers of one array, for a merged mesh that names a
3234
+ * layer per vertex (`MeshData.layers`). One material binding then covers every image, which is
3235
+ * what makes a block wearing forty facades one draw. Every layer must be the same size, and the
3236
+ * refusal names the first that is not.
3237
+ */
3238
+ createSurfaceTextureArray(
3239
+ sources: readonly TexImageSource[],
3240
+ options: SurfaceTextureOptions = {},
3241
+ ): SurfaceTexture {
3242
+ return new SurfaceTexture(this.gl, sources, options);
3243
+ }
3244
+
3196
3245
  /**
3197
3246
  * Replace a surface texture's pixels, keeping the GPU object and its sampler state.
3198
3247
  *
@@ -3310,7 +3359,7 @@ export class WebGL2Renderer implements RendererApi {
3310
3359
  * page-faulted an RDNA4 card and wedged the device. See `emptyTexture.ts`.
3311
3360
  */
3312
3361
  gl.activeTexture(gl.TEXTURE0 + NORMAL_TEXTURE_UNIT);
3313
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
3362
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
3314
3363
  if (normal !== null) normal.bind(gl, NORMAL_TEXTURE_UNIT);
3315
3364
  gl.uniform1i(u['uNormalMap'] ?? null, NORMAL_TEXTURE_UNIT);
3316
3365
  gl.uniform1f(
@@ -3331,7 +3380,7 @@ export class WebGL2Renderer implements RendererApi {
3331
3380
  */
3332
3381
  const orm = material?.orm ?? null;
3333
3382
  gl.activeTexture(gl.TEXTURE0 + ORM_TEXTURE_UNIT);
3334
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
3383
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
3335
3384
  if (orm !== null) orm.bind(gl, ORM_TEXTURE_UNIT);
3336
3385
  gl.uniform1i(u['uOrmMap'] ?? null, ORM_TEXTURE_UNIT);
3337
3386
  gl.uniform1i(u['uOrmEnabled'] ?? null, orm === null ? 0 : 1);
@@ -3354,7 +3403,7 @@ export class WebGL2Renderer implements RendererApi {
3354
3403
  */
3355
3404
  const emissiveMap = material?.emissive ?? null;
3356
3405
  gl.activeTexture(gl.TEXTURE0 + EMISSIVE_TEXTURE_UNIT);
3357
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
3406
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
3358
3407
  if (emissiveMap !== null) emissiveMap.bind(gl, EMISSIVE_TEXTURE_UNIT);
3359
3408
  gl.uniform1i(u['uEmissiveMap'] ?? null, EMISSIVE_TEXTURE_UNIT);
3360
3409
  gl.uniform1i(u['uEmissiveMapEnabled'] ?? null, emissiveMap === null ? 0 : 1);
@@ -3376,7 +3425,20 @@ export class WebGL2Renderer implements RendererApi {
3376
3425
  gl.uniform2f(u['uUvScale'] ?? null, material?.uScale ?? 1, material?.vScale ?? 1);
3377
3426
  gl.uniform1i(u['uDoubleSided'] ?? null, material?.doubleSided === true ? 1 : 0);
3378
3427
 
3428
+ /* The albedo's effects table, or the stand-in the shader reads as none. Before the early
3429
+ return, so a material with no albedo still leaves a complete texture on the unit. */
3430
+ if (albedo === null) {
3431
+ gl.activeTexture(gl.TEXTURE0 + SURFACE_EFFECTS_TEXTURE_UNIT);
3432
+ gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
3433
+ } else {
3434
+ albedo.bindEffects(gl, SURFACE_EFFECTS_TEXTURE_UNIT, this.emptyTexture2D);
3435
+ }
3436
+ gl.uniform1i(u['uSurfaceEffects'] ?? null, SURFACE_EFFECTS_TEXTURE_UNIT);
3379
3437
  if (albedo === null) {
3438
+ /* The array stand-in, for the reason the maps above take one: a sampler reads its unit's
3439
+ array target, and with nothing there it is incomplete. */
3440
+ gl.activeTexture(gl.TEXTURE0 + SURFACE_TEXTURE_UNIT);
3441
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
3380
3442
  gl.uniform1i(u['uAlbedoEnabled'] ?? null, 0);
3381
3443
  return;
3382
3444
  }
@@ -3413,7 +3475,7 @@ export class WebGL2Renderer implements RendererApi {
3413
3475
  return;
3414
3476
  }
3415
3477
  gl.activeTexture(gl.TEXTURE0 + SURFACE_TEXTURE_UNIT);
3416
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
3478
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
3417
3479
  }
3418
3480
 
3419
3481
  /**
@@ -3441,8 +3503,7 @@ export class WebGL2Renderer implements RendererApi {
3441
3503
  setSurfaceReflectivity(amount: number): void {
3442
3504
  if (this.contextLost) return;
3443
3505
  this.materials.dirty();
3444
- this.useFlatProgram();
3445
- this.gl.uniform1f(this.flatUniforms['uReflectivity'] ?? null, Math.min(1, Math.max(0, amount)));
3506
+ this.writeFlatFloat('uReflectivity', Math.min(1, Math.max(0, amount)));
3446
3507
  }
3447
3508
 
3448
3509
  /**
@@ -3460,8 +3521,7 @@ export class WebGL2Renderer implements RendererApi {
3460
3521
  setEnvironmentGain(gain: number): void {
3461
3522
  if (this.contextLost) return;
3462
3523
  this.materials.dirty();
3463
- this.useFlatProgram();
3464
- this.gl.uniform1f(this.flatUniforms['uEnvironmentGain'] ?? null, Math.max(0, gain));
3524
+ this.writeFlatFloat('uEnvironmentGain', Math.max(0, gain));
3465
3525
  }
3466
3526
 
3467
3527
  /**
@@ -3488,10 +3548,55 @@ export class WebGL2Renderer implements RendererApi {
3488
3548
  }
3489
3549
 
3490
3550
  setSurfaceGrain(amount: number): void {
3551
+ if (this.contextLost) return;
3552
+ this.materials.dirty();
3553
+ this.writeFlatFloat('uGrain', Math.min(1, Math.max(0, amount)));
3554
+ }
3555
+
3556
+ /**
3557
+ * One float of material state, into every flat program.
3558
+ *
3559
+ * **Every one, for `setMaterial`'s reason**: an instanced or skinned draw runs a program of its
3560
+ * own and is entitled to the material its caller set before it. These setters wrote the plain
3561
+ * program alone until 2026-09-30, so on this backend a batch drew with the pass defaults — grain
3562
+ * full, no reflection, no relief — while the other backend, whose material block every variant
3563
+ * reads, drew what was asked.
3564
+ */
3565
+ private writeFlatFloat(name: string, value: number): void {
3566
+ for (const target of this.flatTargets) {
3567
+ this.gl.useProgram(target.program);
3568
+ this.gl.uniform1f(target.uniforms[name] ?? null, value);
3569
+ }
3570
+ this.useFlatProgram();
3571
+ }
3572
+
3573
+ /**
3574
+ * How much of the surfaces drawn next survives a screen-door dither, for crossfading two levels
3575
+ * of detail of one thing.
3576
+ *
3577
+ * 0 is off and is what `bindMeshPass` restores. A positive amount keeps that share of the
3578
+ * pattern's cells, a negative one keeps the complement, so the level fading in drawn at `t` and
3579
+ * the one fading out at `-t` cover every pixel exactly once — no hole, no pixel drawn twice.
3580
+ * Material state like `setSurfaceGrain`; instanced draws read it too, and shadow casters, which
3581
+ * are a pass of their own, do not. See `uDither` in `flat/preamble.ts` for what it costs.
3582
+ */
3583
+ setDitherFade(amount: number): void {
3491
3584
  if (this.contextLost) return;
3492
3585
  this.materials.dirty();
3493
3586
  this.useFlatProgram();
3494
- this.gl.uniform1f(this.flatUniforms['uGrain'] ?? null, Math.min(1, Math.max(0, amount)));
3587
+ this.ditherFade = Math.min(1, Math.max(-1, amount));
3588
+ /* Every flat program, as `setMaterial` writes them: an instanced or skinned draw is entitled
3589
+ to the fade the caller set before it, and a second program holding none draws whole. */
3590
+ for (const target of this.flatTargets) {
3591
+ this.gl.useProgram(target.program);
3592
+ this.writeMode(target.uniforms);
3593
+ }
3594
+ this.useFlatProgram();
3595
+ }
3596
+
3597
+ /** `uWriteMode`: the order-independent weight and the dither, which share one uniform row. */
3598
+ private writeMode(u: Record<string, WebGLUniformLocation>): void {
3599
+ this.gl.uniform2f(u['uWriteMode'] ?? null, this.oitWeight, this.ditherFade);
3495
3600
  }
3496
3601
 
3497
3602
  /**
@@ -3520,10 +3625,8 @@ export class WebGL2Renderer implements RendererApi {
3520
3625
  setSurfaceRelief(amount: number, cyclesPerMetre = 60): void {
3521
3626
  if (this.contextLost) return;
3522
3627
  this.materials.dirty();
3523
- this.useFlatProgram();
3524
- const u = this.flatUniforms;
3525
- this.gl.uniform1f(u['uRelief'] ?? null, Math.min(1, Math.max(0, amount)));
3526
- this.gl.uniform1f(u['uReliefCycles'] ?? null, Math.max(0.01, cyclesPerMetre));
3628
+ this.writeFlatFloat('uRelief', Math.min(1, Math.max(0, amount)));
3629
+ this.writeFlatFloat('uReliefCycles', Math.max(0.01, cyclesPerMetre));
3527
3630
  }
3528
3631
 
3529
3632
  /**
@@ -3554,11 +3657,7 @@ export class WebGL2Renderer implements RendererApi {
3554
3657
  setSurfaceTextureRelief(scale: number): void {
3555
3658
  if (this.contextLost) return;
3556
3659
  this.materials.dirty();
3557
- this.useFlatProgram();
3558
- this.gl.uniform1f(
3559
- this.flatUniforms['uTextureRelief'] ?? null,
3560
- Number.isFinite(scale) ? scale : 0,
3561
- );
3660
+ this.writeFlatFloat('uTextureRelief', Number.isFinite(scale) ? scale : 0);
3562
3661
  }
3563
3662
 
3564
3663
  /**
@@ -3585,8 +3684,7 @@ export class WebGL2Renderer implements RendererApi {
3585
3684
  setEmissiveGain(gain: number): void {
3586
3685
  if (this.contextLost) return;
3587
3686
  this.materials.dirty();
3588
- this.useFlatProgram();
3589
- this.gl.uniform1f(this.flatUniforms['uEmissiveGain'] ?? null, gain);
3687
+ this.writeFlatFloat('uEmissiveGain', gain);
3590
3688
  }
3591
3689
 
3592
3690
  /* -- Instanced meshes --------------------------------------------------------------- */
@@ -3634,7 +3732,7 @@ export class WebGL2Renderer implements RendererApi {
3634
3732
  * One batch per mesh: the attributes bind to the mesh's own vertex array, of which a mesh has
3635
3733
  * one. `Mesh.attachInstances` is what refuses a second, and says why.
3636
3734
  */
3637
- createInstanced(mesh: Mesh, capacity: number): InstancedBatch {
3735
+ createInstanced(mesh: Mesh, capacity: number, options: InstancedOptions = {}): InstancedBatch {
3638
3736
  /* The same refusal as the other backend, for the same reason: the instanced attributes take
3639
3737
  the two locations a skinned mesh's joints and weights already occupy. */
3640
3738
  if (mesh.isSkinned) {
@@ -3644,7 +3742,7 @@ export class WebGL2Renderer implements RendererApi {
3644
3742
  );
3645
3743
  }
3646
3744
  this.ensureInstancedProgram();
3647
- return new InstancedBatch(this.gl, mesh, capacity);
3745
+ return new InstancedBatch(this.gl, mesh, capacity, options.cull ?? false);
3648
3746
  }
3649
3747
 
3650
3748
  /** Push placement and colour. Only the live prefix. */
@@ -3699,6 +3797,11 @@ export class WebGL2Renderer implements RendererApi {
3699
3797
  if (!mesh.complete) return;
3700
3798
  const count = Math.min(data.count, batch.capacity);
3701
3799
  if (count === 0) return;
3800
+ /* A culling batch's camera draw: the whole batch against the view and the occluders first,
3801
+ then instance by instance where it survives. Blended draws keep every instance and their
3802
+ order. See `InstancedOptions.cull`. */
3803
+ const culling = !blend && batch.cull;
3804
+ if (culling && !batchBoxVisible(batch.box, this.frustum, this.occlusion)) return;
3702
3805
  this.drawBudget.ask();
3703
3806
  this.ensureInstancedProgram();
3704
3807
  const program = this.flatInstancedProgram;
@@ -3710,7 +3813,8 @@ export class WebGL2Renderer implements RendererApi {
3710
3813
  gl.uniform1i(u['uHasTangents'] ?? null, mesh.hasTangents ? 1 : 0);
3711
3814
 
3712
3815
  const lit = options.lit ?? true;
3713
- const fog = options.fog ?? true;
3816
+ /* Added light fades in the medium rather than receding into it. See `drawFog.ts`. */
3817
+ const fog = fogModeOf(options.fog ?? true, blend && options.additive === true);
3714
3818
  const toneMapped = options.toneMapped ?? true;
3715
3819
  /* A blended batch shows what is behind it — refraction or glass — as a single draw does, and
3716
3820
  counts a material of its own for it by the rule both backends share (`materialChanges.ts`). */
@@ -3739,12 +3843,14 @@ export class WebGL2Renderer implements RendererApi {
3739
3843
  * part of what the count asks.
3740
3844
  */
3741
3845
  if (!lit) gl.uniform1i(u['uLightingEnabled'] ?? null, 0);
3742
- if (!fog) gl.uniform1i(u['uFogEnabled'] ?? null, 0);
3846
+ if (fog !== FOG_RECEDE) gl.uniform1i(u['uFogEnabled'] ?? null, fog);
3743
3847
  if (!toneMapped) gl.uniform1i(u['uOutputTransform'] ?? null, Math.min(this.gradeCode(), 1));
3744
3848
 
3745
3849
  if (blend) {
3746
3850
  gl.enable(gl.BLEND);
3747
- gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
3851
+ if (options.additive === true) gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
3852
+ else
3853
+ gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
3748
3854
  if (!depthWrite) gl.depthMask(false);
3749
3855
  }
3750
3856
  /* The sign follows the compare, which `depthOffsetForLayer` owns — see `drawMesh`, where the
@@ -3758,7 +3864,8 @@ export class WebGL2Renderer implements RendererApi {
3758
3864
  /* Both sides of a double-sided material, as every mesh path does and WebGPU's pipeline does
3759
3865
  for both: without it a batch's far faces were culled here alone. */
3760
3866
  if (this.materialDoubleSided) gl.disable(gl.CULL_FACE);
3761
- mesh.drawInstances(gl, count);
3867
+ if (culling && cullsInstances(count, mesh.indexCount)) batch.drawCulled(gl, data, this.frustum);
3868
+ else batch.draw(gl, count);
3762
3869
  if (this.materialDoubleSided) gl.enable(gl.CULL_FACE);
3763
3870
 
3764
3871
  if (layer > 0) {
@@ -3776,7 +3883,7 @@ export class WebGL2Renderer implements RendererApi {
3776
3883
  outlives its frame, so a snapshot left on unit 14 is held against the next frame's copy. */
3777
3884
  if (refracting) this.unbindSeeThrough(u);
3778
3885
  if (!lit) gl.uniform1i(u['uLightingEnabled'] ?? null, 1);
3779
- if (!fog) gl.uniform1i(u['uFogEnabled'] ?? null, 1);
3886
+ if (fog !== FOG_RECEDE) gl.uniform1i(u['uFogEnabled'] ?? null, FOG_RECEDE);
3780
3887
  if (!toneMapped) gl.uniform1i(u['uOutputTransform'] ?? null, this.gradeCode());
3781
3888
  if (own) this.materials.dirty();
3782
3889
  this.useFlatProgram();
@@ -5159,7 +5266,7 @@ export class WebGL2Renderer implements RendererApi {
5159
5266
  cutout,
5160
5267
  );
5161
5268
  gl.disable(gl.CULL_FACE);
5162
- gpuBatch.mesh.drawInstances(gl, count);
5269
+ gpuBatch.draw(gl, count);
5163
5270
  if (this.depthPassCullsFaces) gl.enable(gl.CULL_FACE);
5164
5271
  if (cutout !== null) this.restoreSurfaceTextureUnit();
5165
5272
  },
@@ -6574,6 +6681,7 @@ export class WebGL2Renderer implements RendererApi {
6574
6681
  radius: number;
6575
6682
  projScale: Float32Array;
6576
6683
  invProjection: Float32Array;
6684
+ offset: Float32Array;
6577
6685
  }
6578
6686
  | undefined;
6579
6687
  if (aoStrength > 0 && projection !== null) {
@@ -6583,11 +6691,20 @@ export class WebGL2Renderer implements RendererApi {
6583
6691
  metres into a radius in UV. */
6584
6692
  this.aoProjScale[0] = projection[0] ?? 1;
6585
6693
  this.aoProjScale[1] = projection[5] ?? 1;
6694
+ /* The occlusion read where this frame's jitter put it (`uAoOffset`): a texel stands at its
6695
+ index plus a half less the jitter, in the drawing buffer the jitter was spread over. */
6696
+ this.aoOffset[0] = this.temporalJittering
6697
+ ? this.temporalJitterX / Math.max(1, this.gl.drawingBufferWidth)
6698
+ : 0;
6699
+ this.aoOffset[1] = this.temporalJittering
6700
+ ? this.temporalJitterY / Math.max(1, this.gl.drawingBufferHeight)
6701
+ : 0;
6586
6702
  ao = {
6587
6703
  strength: aoStrength,
6588
6704
  radius: this.quality.ambientOcclusionRadius,
6589
6705
  projScale: this.aoProjScale,
6590
6706
  invProjection: this.aoInvProjection as Float32Array,
6707
+ offset: this.aoOffset,
6591
6708
  };
6592
6709
  }
6593
6710
 
@@ -6739,7 +6856,8 @@ export class WebGL2Renderer implements RendererApi {
6739
6856
  this.oit.accumulateOit(this.canvas.width, this.canvas.height, oitDepth, (weighted) => {
6740
6857
  this.oitReplaying = true;
6741
6858
  this.useFlatProgram();
6742
- this.gl.uniform1f(flat['uOitWeighted'] ?? null, weighted ? 1 : 0);
6859
+ this.oitWeight = weighted ? 1 : 0;
6860
+ this.writeMode(flat);
6743
6861
  this.translucentQueue.replay((draw) => {
6744
6862
  options.lit = draw.lit;
6745
6863
  options.fog = draw.fog;
@@ -6756,7 +6874,8 @@ export class WebGL2Renderer implements RendererApi {
6756
6874
  this.drawTranslucentMesh(draw.mesh as Mesh, draw.model, draw.opacity, options);
6757
6875
  });
6758
6876
  /* Back to zero for every other draw in the frame, exactly as `uOpacity` and `uTint` are. */
6759
- this.gl.uniform1f(flat['uOitWeighted'] ?? null, 0);
6877
+ this.oitWeight = 0;
6878
+ this.writeMode(flat);
6760
6879
  this.oitReplaying = false;
6761
6880
  });
6762
6881
  this.sceneTarget?.bind();
@@ -6942,7 +7061,14 @@ export class WebGL2Renderer implements RendererApi {
6942
7061
  * A reflection pass binds its own camera and is skipped: blurring toward a mirror's
6943
7062
  * previous view is not what a viewer moved through.
6944
7063
  */
6945
- if (!this.reflectionPassActive && !this.probePassActive) {
7064
+ /*
7065
+ * **Not after the present**: a mesh pass bound then is the interface's, over a picture already
7066
+ * resolved. Deciding the frame again drew the interface jittered with nothing to resolve it,
7067
+ * and opened a second temporal frame, so the sequence stepped twice a frame, the world saw every
7068
+ * other position, and the anti-flicker's period never started.
7069
+ */
7070
+ if (this.framePresented) this.temporalJittering = false;
7071
+ if (!this.reflectionPassActive && !this.probePassActive && !this.framePresented) {
6946
7072
  this.frameViewProj = camera.viewProjection;
6947
7073
  /* Copied rather than referenced: a caller moves its camera in place, and the decal pass
6948
7074
  reads this at the end of the frame. */
@@ -7056,6 +7182,23 @@ export class WebGL2Renderer implements RendererApi {
7056
7182
  return field;
7057
7183
  }
7058
7184
 
7185
+ /**
7186
+ * Hand a world's DriftLight volume to the renderer: every fixed light of a city, summed offline by
7187
+ * `bakeDenseField`, standing in past the frame's exact choice. Replaces any field made before, of
7188
+ * either kind; the scene calls `field.follow(buffer.complete, ...)` each frame as it would for
7189
+ * `createLightField`, and marks the lights the volume summed `inLightField` so none is counted
7190
+ * twice. See `driftLight/worldLightField.ts`.
7191
+ */
7192
+ createWorldLightField(
7193
+ volume: DenseLightVolume,
7194
+ options: WorldLightFieldOptions = {},
7195
+ ): WorldLightField {
7196
+ this.forgetLightFieldVolumes();
7197
+ const field = new WorldLightField(volume, options);
7198
+ this.lightField = field;
7199
+ return field;
7200
+ }
7201
+
7059
7202
  /** Let go of the field and its volumes; the scene's lights are all exact again. */
7060
7203
  disposeLightField(): void {
7061
7204
  this.forgetLightFieldVolumes();
@@ -7075,11 +7218,11 @@ export class WebGL2Renderer implements RendererApi {
7075
7218
  */
7076
7219
  private uploadLightField(): void {
7077
7220
  const field = this.lightField;
7078
- if (field === null || this.driftAtlas !== null || !field.ready || field.layout.count === 0) {
7221
+ if (field === null || this.driftAtlas !== null || !field.ready || field.empty) {
7079
7222
  return;
7080
7223
  }
7081
7224
  const { gl } = this;
7082
- const [ix, iy, iz] = field.layout.dims;
7225
+ const [ix, iy, iz] = field.indexDims;
7083
7226
  const index = gl.createTexture();
7084
7227
  gl.bindTexture(gl.TEXTURE_3D, index);
7085
7228
  gl.texImage3D(
@@ -7092,7 +7235,7 @@ export class WebGL2Renderer implements RendererApi {
7092
7235
  0,
7093
7236
  gl.RED_INTEGER,
7094
7237
  gl.UNSIGNED_INT,
7095
- field.layout.index,
7238
+ field.index,
7096
7239
  );
7097
7240
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
7098
7241
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
@@ -7232,6 +7375,10 @@ export class WebGL2Renderer implements RendererApi {
7232
7375
  gl.uniform1i(u['uFogEnabled'] ?? null, 1);
7233
7376
  /* Grain on by default, so every scene written before it was a choice looks unchanged. */
7234
7377
  gl.uniform1f(u['uGrain'] ?? null, 1);
7378
+ /* No dither and no weighting: every draw whole, until a crossfade asks. See `setDitherFade`. */
7379
+ this.oitWeight = 0;
7380
+ this.ditherFade = 0;
7381
+ gl.uniform2f(u['uWriteMode'] ?? null, 0, 0);
7235
7382
  /* No environment reflection unless a caller asks, so existing scenes are unchanged. */
7236
7383
  gl.uniform1f(u['uReflectivity'] ?? null, 0);
7237
7384
  /* 1, not 0: this one is a multiplier and its identity is one. See `setEnvironmentGain`. */
@@ -7241,11 +7388,14 @@ export class WebGL2Renderer implements RendererApi {
7241
7388
  gl.uniform1f(u['uReliefCycles'] ?? null, 60);
7242
7389
  /* And no relief read off a surface texture either, for the same reason. */
7243
7390
  gl.uniform1f(u['uTextureRelief'] ?? null, 0);
7244
- gl.uniform1i(u['uClipEnabled'] ?? null, this.reflectionPassActive ? 1 : 0);
7391
+ /* The mirror's plane in a reflection pass, and zero — which clips nothing — in every other. */
7245
7392
  const reflection = this.planarReflection;
7246
- if (this.reflectionPassActive && reflection !== null) {
7247
- gl.uniform4fv(u['uClipPlane'] ?? null, reflection.clipPlane);
7248
- }
7393
+ gl.uniform4fv(
7394
+ u['uClipPlane'] ?? null,
7395
+ this.reflectionPassActive && reflection !== null ? reflection.clipPlane : NO_CLIP_PLANE,
7396
+ );
7397
+ resolveSurfaceScene(env, this.surfaceScene);
7398
+ gl.uniform4fv(u['uSurfaceScene'] ?? null, this.surfaceScene);
7249
7399
  bindAtmosphere(
7250
7400
  gl,
7251
7401
  u,
@@ -7414,9 +7564,13 @@ export class WebGL2Renderer implements RendererApi {
7414
7564
  this.bindDriftLight(u, camera.position);
7415
7565
 
7416
7566
  gl.activeTexture(gl.TEXTURE0 + SURFACE_TEXTURE_UNIT);
7417
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
7567
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
7418
7568
  gl.uniform1i(u['uAlbedo'] ?? null, SURFACE_TEXTURE_UNIT);
7419
7569
  gl.uniform1i(u['uAlbedoEnabled'] ?? null, 0);
7570
+ /* No albedo, so no effects table: the stand-in, which the shader reads as none. */
7571
+ gl.activeTexture(gl.TEXTURE0 + SURFACE_EFFECTS_TEXTURE_UNIT);
7572
+ gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
7573
+ gl.uniform1i(u['uSurfaceEffects'] ?? null, SURFACE_EFFECTS_TEXTURE_UNIT);
7420
7574
  gl.uniform2f(u['uUvScale'] ?? null, 1, 1);
7421
7575
  gl.uniform1f(u['uAlbedoCutout'] ?? null, 0);
7422
7576
  gl.uniform1i(u['uDoubleSided'] ?? null, 0);
@@ -7441,20 +7595,20 @@ export class WebGL2Renderer implements RendererApi {
7441
7595
  * fetch a sampler's descriptor before it evaluates the branch that would have skipped the read.
7442
7596
  */
7443
7597
  gl.activeTexture(gl.TEXTURE0 + NORMAL_TEXTURE_UNIT);
7444
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
7598
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
7445
7599
  gl.uniform1i(u['uNormalMap'] ?? null, NORMAL_TEXTURE_UNIT);
7446
7600
  gl.uniform1f(u['uNormalStrength'] ?? null, 0);
7447
7601
 
7448
7602
  /* And the ORM map, for the reason directly above and the one above that. */
7449
7603
  gl.activeTexture(gl.TEXTURE0 + ORM_TEXTURE_UNIT);
7450
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
7604
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
7451
7605
  gl.uniform1i(u['uOrmMap'] ?? null, ORM_TEXTURE_UNIT);
7452
7606
  gl.uniform1i(u['uOrmEnabled'] ?? null, 0);
7453
7607
  gl.uniform3f(u['uOrmScale'] ?? null, 1, 1, 1);
7454
7608
 
7455
7609
  /* And the emissive map. The scale goes back to one, which is the identity it multiplies by. */
7456
7610
  gl.activeTexture(gl.TEXTURE0 + EMISSIVE_TEXTURE_UNIT);
7457
- gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
7611
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
7458
7612
  gl.uniform1i(u['uEmissiveMap'] ?? null, EMISSIVE_TEXTURE_UNIT);
7459
7613
  gl.uniform1i(u['uEmissiveMapEnabled'] ?? null, 0);
7460
7614
  gl.uniform3f(u['uEmissiveScale'] ?? null, 1, 1, 1);
@@ -7741,7 +7895,7 @@ export class WebGL2Renderer implements RendererApi {
7741
7895
  * The two buffers are two blend states over the same geometry, so the set has to survive until
7742
7896
  * the end of the pass. Everything is copied — see `TranslucentQueue`.
7743
7897
  */
7744
- if (this.oitActive && !this.oitReplaying) {
7898
+ if (this.oitActive && !this.oitReplaying && options.additive !== true) {
7745
7899
  this.translucentQueue.record(mesh, model, opacity, options);
7746
7900
  return;
7747
7901
  }
@@ -7760,10 +7914,11 @@ export class WebGL2Renderer implements RendererApi {
7760
7914
  * drew with, and only a caller that asks pays for the branch that skips them.
7761
7915
  */
7762
7916
  const lit = options.lit ?? true;
7763
- const fog = options.fog ?? true;
7917
+ /* Added light fades in the medium rather than receding into it. See `drawFog.ts`. */
7918
+ const fog = fogModeOf(options.fog ?? true, options.additive === true);
7764
7919
  const toneMapped = options.toneMapped ?? true;
7765
7920
  if (!lit) gl.uniform1i(u['uLightingEnabled'] ?? null, 0);
7766
- if (!fog) gl.uniform1i(u['uFogEnabled'] ?? null, 0);
7921
+ if (fog !== FOG_RECEDE) gl.uniform1i(u['uFogEnabled'] ?? null, fog);
7767
7922
  /* `1` is sRGB alone: the conversion without the curve, which is what dropping the tone map
7768
7923
  means rather than dropping the whole transform. `Math.min` rather than a literal, so a
7769
7924
  renderer asked for `none` stays at none and one asked for `srgb` is already there. */
@@ -7801,7 +7956,9 @@ export class WebGL2Renderer implements RendererApi {
7801
7956
  const ownsState = !this.oitReplaying;
7802
7957
  if (ownsState) {
7803
7958
  gl.enable(gl.BLEND);
7804
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
7959
+ /* Added, where asked: colour times alpha onto what is there, and the target's alpha kept. */
7960
+ if (options.additive === true) gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
7961
+ else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
7805
7962
  }
7806
7963
  /*
7807
7964
  * The two depth options, both scoped to this draw and handed straight back — the same
@@ -7833,7 +7990,7 @@ export class WebGL2Renderer implements RendererApi {
7833
7990
  /* Put back for whatever is drawn next, exactly as `uOpacity` and `uTint` are — a pass
7834
7991
  cannot inherit a material from the draw before it. */
7835
7992
  if (!lit) gl.uniform1i(u['uLightingEnabled'] ?? null, 1);
7836
- if (!fog) gl.uniform1i(u['uFogEnabled'] ?? null, 1);
7993
+ if (fog !== FOG_RECEDE) gl.uniform1i(u['uFogEnabled'] ?? null, FOG_RECEDE);
7837
7994
  if (!toneMapped) gl.uniform1i(u['uOutputTransform'] ?? null, this.gradeCode());
7838
7995
  if (own) this.materials.dirty();
7839
7996
  }