@driftengine/core 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.json +261 -0
  2. package/README.md +1 -1
  3. package/dist/cinematic/cameraPath.d.ts +53 -0
  4. package/dist/cinematic/cameraPath.js +118 -0
  5. package/dist/environment/daylight.d.ts +77 -0
  6. package/dist/environment/daylight.js +77 -0
  7. package/dist/geometry/meshConcat.d.ts +9 -0
  8. package/dist/geometry/meshConcat.js +101 -0
  9. package/dist/index.d.ts +12 -2
  10. package/dist/index.js +14 -1
  11. package/dist/render/backend/createRenderer.d.ts +13 -1
  12. package/dist/render/backend/createRenderer.js +6 -5
  13. package/dist/render/backend/webgl2/renderer.d.ts +147 -1
  14. package/dist/render/backend/webgl2/renderer.js +402 -23
  15. package/dist/render/backend/webgpu/buffers.js +46 -8
  16. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  17. package/dist/render/backend/webgpu/depthPass.js +75 -14
  18. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  19. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  20. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  21. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  22. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  23. package/dist/render/backend/webgpu/flatPass.js +12 -5
  24. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  25. package/dist/render/backend/webgpu/particlePass.js +1 -0
  26. package/dist/render/backend/webgpu/postPass.d.ts +12 -2
  27. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  28. package/dist/render/backend/webgpu/probeBake.js +97 -7
  29. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  30. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  31. package/dist/render/backend/webgpu/renderer.d.ts +174 -11
  32. package/dist/render/backend/webgpu/renderer.js +675 -249
  33. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  34. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  35. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  36. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  37. package/dist/render/backend/webgpu/skyPass.js +5 -1
  38. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  39. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  40. package/dist/render/bloomChain.d.ts +6 -0
  41. package/dist/render/bloomChain.js +8 -0
  42. package/dist/render/clusteredLights.d.ts +19 -11
  43. package/dist/render/clusteredLights.js +96 -45
  44. package/dist/render/cutoutCaster.d.ts +23 -0
  45. package/dist/render/cutoutCaster.js +20 -0
  46. package/dist/render/driftLight/bake.d.ts +34 -0
  47. package/dist/render/driftLight/bake.js +95 -0
  48. package/dist/render/driftLight/falloff.d.ts +16 -0
  49. package/dist/render/driftLight/falloff.js +23 -0
  50. package/dist/render/driftLight/layout.d.ts +59 -0
  51. package/dist/render/driftLight/layout.js +138 -0
  52. package/dist/render/driftLight/lightField.d.ts +108 -0
  53. package/dist/render/driftLight/lightField.js +179 -0
  54. package/dist/render/driftLight/uniforms.d.ts +28 -0
  55. package/dist/render/driftLight/uniforms.js +41 -0
  56. package/dist/render/driftLight/visibility.d.ts +18 -0
  57. package/dist/render/driftLight/visibility.js +47 -0
  58. package/dist/render/emptyTexture.d.ts +6 -0
  59. package/dist/render/emptyTexture.js +19 -0
  60. package/dist/render/exposurePass.d.ts +48 -0
  61. package/dist/render/exposurePass.js +172 -0
  62. package/dist/render/flameLight.d.ts +71 -0
  63. package/dist/render/flameLight.js +63 -0
  64. package/dist/render/gi/bounceLights.d.ts +34 -0
  65. package/dist/render/gi/bounceLights.js +66 -0
  66. package/dist/render/gi/fieldReach.d.ts +12 -0
  67. package/dist/render/gi/fieldReach.js +62 -0
  68. package/dist/render/gi/globalField.d.ts +8 -0
  69. package/dist/render/gi/globalField.js +31 -0
  70. package/dist/render/gi/probeTrace.d.ts +3 -1
  71. package/dist/render/gi/probeTrace.js +3 -1
  72. package/dist/render/gpuCapability.js +19 -4
  73. package/dist/render/lightBudget.d.ts +11 -0
  74. package/dist/render/lightBudget.js +11 -0
  75. package/dist/render/pointLightSelection.d.ts +16 -0
  76. package/dist/render/pointLightSelection.js +178 -83
  77. package/dist/render/probeGrid.d.ts +11 -1
  78. package/dist/render/probeGrid.js +5 -2
  79. package/dist/render/probeSweeps.d.ts +45 -0
  80. package/dist/render/probeSweeps.js +77 -0
  81. package/dist/render/reflectionProbe.d.ts +43 -1
  82. package/dist/render/reflectionProbe.js +17 -3
  83. package/dist/render/renderQuality.d.ts +3 -1
  84. package/dist/render/rendererHarness.d.ts +9 -0
  85. package/dist/render/rendererHarness.js +18 -3
  86. package/dist/render/sceneTarget.d.ts +20 -0
  87. package/dist/render/sceneTarget.js +62 -1
  88. package/dist/render/shaders/agx.d.ts +31 -0
  89. package/dist/render/shaders/agx.js +81 -0
  90. package/dist/render/shaders/ambientOcclusion.d.ts +24 -5
  91. package/dist/render/shaders/ambientOcclusion.js +120 -63
  92. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  93. package/dist/render/shaders/cutoutCoverage.js +40 -0
  94. package/dist/render/shaders/depth.d.ts +25 -0
  95. package/dist/render/shaders/depth.js +69 -3
  96. package/dist/render/shaders/exposure.d.ts +52 -0
  97. package/dist/render/shaders/exposure.js +124 -0
  98. package/dist/render/shaders/filmLook.d.ts +47 -0
  99. package/dist/render/shaders/filmLook.js +98 -0
  100. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  101. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  102. package/dist/render/shaders/flat/driftLight.js +75 -0
  103. package/dist/render/shaders/flat/index.js +5 -0
  104. package/dist/render/shaders/flat/lobes.js +13 -0
  105. package/dist/render/shaders/flat/main.d.ts +1 -1
  106. package/dist/render/shaders/flat/main.js +68 -9
  107. package/dist/render/shaders/flat/preamble.js +23 -5
  108. package/dist/render/shaders/flat/probeGrid.js +46 -3
  109. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +1 -1
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +1 -1
  111. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  112. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  113. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  115. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  116. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  117. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  118. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  119. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  120. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  121. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  123. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  124. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  125. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  126. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  127. package/dist/render/shaders/globalMedium.js +4 -0
  128. package/dist/render/shaders/localExposure.d.ts +35 -0
  129. package/dist/render/shaders/localExposure.js +138 -0
  130. package/dist/render/shaders/prefilter.d.ts +1 -1
  131. package/dist/render/shaders/prefilter.js +6 -0
  132. package/dist/render/shaders/rush.js +33 -5
  133. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  134. package/dist/render/shaders/tangentFrame.js +15 -3
  135. package/dist/render/shaders/temporalResolve.js +39 -1
  136. package/dist/render/shadowCasters.d.ts +5 -3
  137. package/dist/render/shadowMap.js +13 -0
  138. package/dist/render/surfaceTexture.d.ts +5 -0
  139. package/package.json +6 -6
  140. package/scripts/gpuCompute.mjs +24 -3
  141. package/scripts/png.mjs +23 -12
  142. package/src/cinematic/cameraPath.ts +178 -0
  143. package/src/environment/daylight.ts +152 -0
  144. package/src/geometry/meshConcat.ts +100 -0
  145. package/src/index.ts +32 -0
  146. package/src/render/backend/createRenderer.ts +22 -6
  147. package/src/render/backend/webgl2/renderer.ts +439 -20
  148. package/src/render/backend/webgpu/buffers.ts +51 -8
  149. package/src/render/backend/webgpu/depthPass.ts +78 -10
  150. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  151. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  152. package/src/render/backend/webgpu/flatPass.ts +10 -2
  153. package/src/render/backend/webgpu/particlePass.ts +9 -0
  154. package/src/render/backend/webgpu/probeBake.ts +143 -9
  155. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  156. package/src/render/backend/webgpu/renderer.ts +801 -270
  157. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  158. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  159. package/src/render/backend/webgpu/skyPass.ts +5 -1
  160. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  161. package/src/render/bloomChain.ts +9 -0
  162. package/src/render/clusteredLights.ts +108 -63
  163. package/src/render/cutoutCaster.ts +44 -0
  164. package/src/render/driftLight/bake.ts +125 -0
  165. package/src/render/driftLight/falloff.ts +26 -0
  166. package/src/render/driftLight/layout.ts +177 -0
  167. package/src/render/driftLight/lightField.ts +227 -0
  168. package/src/render/driftLight/uniforms.ts +61 -0
  169. package/src/render/driftLight/visibility.ts +58 -0
  170. package/src/render/emptyTexture.ts +41 -0
  171. package/src/render/exposurePass.ts +186 -0
  172. package/src/render/flameLight.ts +135 -0
  173. package/src/render/gi/bounceLights.ts +68 -0
  174. package/src/render/gi/fieldReach.ts +74 -0
  175. package/src/render/gi/globalField.ts +39 -0
  176. package/src/render/gi/probeTrace.ts +3 -1
  177. package/src/render/gpuCapability.ts +18 -5
  178. package/src/render/lightBudget.ts +12 -0
  179. package/src/render/pointLightSelection.ts +191 -79
  180. package/src/render/probeGrid.ts +13 -2
  181. package/src/render/probeSweeps.ts +80 -0
  182. package/src/render/reflectionProbe.ts +54 -2
  183. package/src/render/renderQuality.ts +3 -1
  184. package/src/render/rendererHarness.ts +25 -3
  185. package/src/render/sceneTarget.ts +75 -1
  186. package/src/render/shaders/ambientOcclusion.ts +125 -63
  187. package/src/render/shaders/cutoutCoverage.ts +43 -0
  188. package/src/render/shaders/depth.ts +80 -3
  189. package/src/render/shaders/exposure.ts +128 -0
  190. package/src/render/shaders/filmLook.ts +111 -0
  191. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  192. package/src/render/shaders/flat/driftLight.ts +75 -0
  193. package/src/render/shaders/flat/index.ts +5 -0
  194. package/src/render/shaders/flat/lobes.ts +13 -0
  195. package/src/render/shaders/flat/main.ts +68 -9
  196. package/src/render/shaders/flat/preamble.ts +23 -5
  197. package/src/render/shaders/flat/probeGrid.ts +46 -3
  198. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +1 -1
  199. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  200. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  201. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  202. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  203. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  204. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  205. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  206. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  207. package/src/render/shaders/globalMedium.ts +4 -0
  208. package/src/render/shaders/localExposure.ts +154 -0
  209. package/src/render/shaders/prefilter.ts +6 -0
  210. package/src/render/shaders/rush.ts +33 -5
  211. package/src/render/shaders/tangentFrame.ts +15 -3
  212. package/src/render/shaders/temporalResolve.ts +39 -1
  213. package/src/render/shadowCasters.ts +5 -3
  214. package/src/render/shadowMap.ts +14 -0
  215. package/src/render/surfaceTexture.ts +5 -0
@@ -46,6 +46,81 @@ const CONTENTION_BAND_M = 3;
46
46
  /** Sort keys for the shadow list, which is ordered independently of the shaded one. */
47
47
  const shadowRank = new Float32Array(POINT_SHADOW_POOL);
48
48
 
49
+ /**
50
+ * Every light in range this frame, by source index and squared distance, before the nearest are
51
+ * chosen. Grown when a world offers more than it has held, and never shrunk, so a frame allocates
52
+ * only when the world has grown. Float64, so two lights a hair apart are ordered by their real
53
+ * distances rather than by what survives a float's rounding.
54
+ */
55
+ let candidateIndex = new Int32Array(64);
56
+ let candidateDistance = new Float64Array(64);
57
+
58
+ function growCandidates(needed: number): void {
59
+ if (needed <= candidateIndex.length) return;
60
+ const size = Math.max(needed, candidateIndex.length * 2);
61
+ const index = new Int32Array(size);
62
+ const distance = new Float64Array(size);
63
+ index.set(candidateIndex);
64
+ distance.set(candidateDistance);
65
+ candidateIndex = index;
66
+ candidateDistance = distance;
67
+ }
68
+
69
+ /** Whether candidate `a` goes before candidate `b`: nearer, or as near and offered first. */
70
+ function before(a: number, b: number): boolean {
71
+ const da = candidateDistance[a] ?? 0;
72
+ const db = candidateDistance[b] ?? 0;
73
+ return da < db || (da === db && (candidateIndex[a] ?? 0) < (candidateIndex[b] ?? 0));
74
+ }
75
+
76
+ function swap(a: number, b: number): void {
77
+ const index = candidateIndex[a] ?? 0;
78
+ const distance = candidateDistance[a] ?? 0;
79
+ candidateIndex[a] = candidateIndex[b] ?? 0;
80
+ candidateDistance[a] = candidateDistance[b] ?? 0;
81
+ candidateIndex[b] = index;
82
+ candidateDistance[b] = distance;
83
+ }
84
+
85
+ /**
86
+ * Move the `keep` nearest of the first `count` candidates into `[0, keep)`, in no order: a
87
+ * quickselect, linear on average. The pivot is the median of three, which keeps a list that
88
+ * arrives already sorted — a world laid out along the camera's path — away from the quadratic case.
89
+ */
90
+ function chooseNearest(count: number, keep: number): void {
91
+ let lo = 0;
92
+ let hi = count - 1;
93
+ while (lo < hi) {
94
+ const mid = (lo + hi) >> 1;
95
+ if (before(mid, lo)) swap(mid, lo);
96
+ if (before(hi, lo)) swap(hi, lo);
97
+ if (before(hi, mid)) swap(hi, mid);
98
+ swap(mid, hi);
99
+ let store = lo;
100
+ for (let at = lo; at < hi; at++) {
101
+ if (before(at, hi)) {
102
+ swap(at, store);
103
+ store++;
104
+ }
105
+ }
106
+ swap(store, hi);
107
+ if (store === keep || store === keep - 1) return;
108
+ if (store < keep) lo = store + 1;
109
+ else hi = store - 1;
110
+ }
111
+ }
112
+
113
+ /** Sort the first `count` candidates nearest first. An insertion sort: `count` is a slot budget. */
114
+ function sortCandidates(count: number): void {
115
+ for (let at = 1; at < count; at++) {
116
+ let back = at;
117
+ while (back > 0 && before(back, back - 1)) {
118
+ swap(back, back - 1);
119
+ back--;
120
+ }
121
+ }
122
+ }
123
+
49
124
  export interface PointLightSource {
50
125
  x: number;
51
126
  y: number;
@@ -105,6 +180,15 @@ export interface PointLightSource {
105
180
  * They are floor lighting 35 cm off the deck and were never meant to cast at all.
106
181
  */
107
182
  castsShadow?: boolean;
183
+ /**
184
+ * Whether a DriftLight field also sums this light (`createLightField` sets it).
185
+ *
186
+ * Then the lit shader shades it exactly only where the choice is complete and lets the field's
187
+ * summed light take over past it, so the light is never counted twice. Carried to the shader as
188
+ * the sign of the light's weight, which both light paths already have: no new storage on a part
189
+ * whose uniform budget has none to spare.
190
+ */
191
+ inLightField?: boolean;
108
192
  }
109
193
 
110
194
  export interface PointLightBuffer {
@@ -179,6 +263,13 @@ export interface PointLightBuffer {
179
263
  */
180
264
  readonly shadowIndex: Int32Array;
181
265
  shadowCount: number;
266
+ /**
267
+ * How far from the selection's centre the choice is complete for DriftLight field lights: a
268
+ * point nearer than this is reached only by field lights chosen at full strength. A field light
269
+ * left out at distance d reaching r first reaches d - r; one dimmed at the cut counts as left
270
+ * out. At most the view range, past which nothing is chosen.
271
+ */
272
+ complete: number;
182
273
  }
183
274
 
184
275
  /**
@@ -213,6 +304,7 @@ export function createPointLightBuffer(capacity: number = MAX_POINT_LIGHTS): Poi
213
304
  scratchDistance: new Float32Array(Math.max(shaded, POINT_SHADOW_POOL)),
214
305
  shadowIndex: new Int32Array(POINT_SHADOW_POOL),
215
306
  shadowCount: 0,
307
+ complete: 0,
216
308
  };
217
309
  }
218
310
 
@@ -268,6 +360,7 @@ export function selectPointLights(
268
360
  * shader binds, which is the case where nothing is contested and nothing dims.
269
361
  */
270
362
  let cutSq = Infinity;
363
+ let candidateCount = 0;
271
364
 
272
365
  for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) {
273
366
  const light = sources[sourceIndex];
@@ -305,84 +398,14 @@ export function selectPointLights(
305
398
  *
306
399
  * Sharing one sorted list is what made them drift apart, so they do not share one.
307
400
  */
308
- let slot = out.count;
309
- while (slot > 0 && (out.scratchDistance[slot - 1] ?? 0) > distanceSq) slot--;
310
- if (slot >= shadedCapacity) {
311
- // Rejected outright, so it is a candidate for the boundary the set fades against.
312
- if (distanceSq < cutSq) cutSq = distanceSq;
313
- } else {
314
- // Full, so making room drops whoever was last off the end — and that is the
315
- // nearest light not in the set unless something nearer is rejected later.
316
- if (out.count >= shadedCapacity) {
317
- const displaced = out.scratchDistance[shadedCapacity - 1] ?? Infinity;
318
- if (displaced < cutSq) cutSq = displaced;
319
- }
320
- const last = Math.min(out.count, shadedCapacity - 1);
321
- for (let index = last; index > slot; index--) {
322
- out.scratchDistance[index] = out.scratchDistance[index - 1] ?? 0;
323
- out.radii[index] = out.radii[index - 1] ?? 0;
324
- out.sourceRadii[index] = out.sourceRadii[index - 1] ?? 0;
325
- out.sourceIndex[index] = out.sourceIndex[index - 1] ?? -1;
326
- for (let component = 0; component < 3; component++) {
327
- out.positions[index * 3 + component] = out.positions[(index - 1) * 3 + component] ?? 0;
328
- out.colors[index * 3 + component] = out.colors[(index - 1) * 3 + component] ?? 0;
329
- out.directions[index * 3 + component] = out.directions[(index - 1) * 3 + component] ?? 0;
330
- }
331
- out.coneCos[index * 2] = out.coneCos[(index - 1) * 2] ?? 0;
332
- out.coneCos[index * 2 + 1] = out.coneCos[(index - 1) * 2 + 1] ?? 0;
333
- out.iesProfiles[index] = out.iesProfiles[index - 1] ?? -1;
334
- }
335
- out.scratchDistance[slot] = distanceSq;
336
- out.sourceIndex[slot] = sourceIndex;
337
- out.radii[slot] = light.radius;
338
- out.sourceRadii[slot] = Math.max(light.sourceRadius, 0);
339
- out.positions[slot * 3] = light.x;
340
- out.positions[slot * 3 + 1] = light.y;
341
- out.positions[slot * 3 + 2] = light.z;
342
- const scale = flickerScale(light, timeSeconds);
343
- out.colors[slot * 3] = light.r * scale;
344
- out.colors[slot * 3 + 1] = light.g * scale;
345
- out.colors[slot * 3 + 2] = light.b * scale;
346
-
347
- /*
348
- * **Normalised here rather than trusted**, because the cone is a comparison against a cosine
349
- * and an un-normalised direction scales that cosine — so a vector of length 2 gives a cone of
350
- * the wrong width, which reads as the angle having been set wrong rather than the vector.
351
- * A zero-length direction is a light that declared no cone, and it takes the open pair.
352
- */
353
- const dx = light.dirX ?? 0;
354
- const dy = light.dirY ?? 0;
355
- const dz = light.dirZ ?? 0;
356
- const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
357
- const aimed = length > 1e-6 && light.coneOuterDeg !== undefined;
358
- const inverse = aimed ? 1 / length : 0;
359
- out.directions[slot * 3] = dx * inverse;
360
- out.directions[slot * 3 + 1] = dy * inverse;
361
- out.directions[slot * 3 + 2] = dz * inverse;
362
- if (aimed) {
363
- /*
364
- * Clamped so the inner edge never passes the outer. A caller that swaps them would
365
- * otherwise get `smoothstep` with its edges inverted, which is a cone that is dark in the
366
- * middle and bright at the rim — a picture nobody would attribute to two numbers being the
367
- * wrong way round.
368
- */
369
- const outerDeg = Math.min(89.9, Math.max(0, light.coneOuterDeg ?? 45));
370
- const innerDeg = Math.min(outerDeg, Math.max(0, light.coneInnerDeg ?? outerDeg * 0.75));
371
- out.coneCos[slot * 2] = Math.cos((innerDeg * Math.PI) / 180);
372
- out.coneCos[slot * 2 + 1] = Math.cos((outerDeg * Math.PI) / 180);
373
- } else {
374
- out.coneCos[slot * 2] = POINT_LIGHT_COS_INNER;
375
- out.coneCos[slot * 2 + 1] = POINT_LIGHT_COS_OUTER;
376
- }
377
- /*
378
- * The profile applies to a point light as readily as to a spot: a bare bulb has a measured
379
- * distribution too, and the arithmetic does not care whether a cone is also being applied.
380
- * Restricting it to spots would be a smaller feature for no saving.
381
- */
382
- out.iesProfiles[slot] = light.iesProfile ?? -1;
383
-
384
- if (out.count < shadedCapacity) out.count++;
385
- }
401
+ /*
402
+ * A candidate for the shaded list. Which of them win is decided once every source has been
403
+ * seen, below, rather than by keeping a sorted list as they arrive: see `chooseNearest`.
404
+ */
405
+ growCandidates(candidateCount + 1);
406
+ candidateIndex[candidateCount] = sourceIndex;
407
+ candidateDistance[candidateCount] = distanceSq;
408
+ candidateCount++;
386
409
 
387
410
  /*
388
411
  * Inside its own radius, so it is lighting something and therefore has something
@@ -420,6 +443,85 @@ export function selectPointLights(
420
443
  if (out.shadowCount < POINT_SHADOW_POOL) out.shadowCount++;
421
444
  }
422
445
 
446
+ /*
447
+ * The nearest `shadedCapacity` candidates, nearest first, and the nearest one left out.
448
+ *
449
+ * **Partitioned, then only the winners sorted.** This kept a sorted list as the sources arrived,
450
+ * shifting fourteen fields a slot to make room, which is O(sources × slots): 3.2 ms a frame to
451
+ * choose 320 of a courtyard's 10,000 candles, most of it moving lights that were then displaced.
452
+ * A quickselect finds the winners in time linear in the sources and the sort touches only them,
453
+ * and each winner is packed once. Ties go to the earlier source, as they did.
454
+ */
455
+ const chosen = Math.min(candidateCount, shadedCapacity);
456
+ let complete = viewRange;
457
+ if (candidateCount > shadedCapacity) {
458
+ chooseNearest(candidateCount, shadedCapacity);
459
+ for (let at = shadedCapacity; at < candidateCount; at++) {
460
+ const distanceSq = candidateDistance[at] ?? Infinity;
461
+ if (distanceSq < cutSq) cutSq = distanceSq;
462
+ const left = sources[candidateIndex[at] ?? 0];
463
+ if (left?.inLightField === true) {
464
+ complete = Math.min(complete, Math.sqrt(distanceSq) - left.radius);
465
+ }
466
+ }
467
+ }
468
+ sortCandidates(chosen);
469
+ for (let slot = 0; slot < chosen; slot++) {
470
+ const sourceIndex = candidateIndex[slot] ?? 0;
471
+ const light = sources[sourceIndex];
472
+ if (light === undefined) continue;
473
+ const distanceSq = candidateDistance[slot] ?? 0;
474
+ out.scratchDistance[slot] = distanceSq;
475
+ out.sourceIndex[slot] = sourceIndex;
476
+ out.radii[slot] = light.radius;
477
+ out.sourceRadii[slot] = Math.max(light.sourceRadius, 0);
478
+ out.positions[slot * 3] = light.x;
479
+ out.positions[slot * 3 + 1] = light.y;
480
+ out.positions[slot * 3 + 2] = light.z;
481
+ const scale = flickerScale(light, timeSeconds);
482
+ out.colors[slot * 3] = light.r * scale;
483
+ out.colors[slot * 3 + 1] = light.g * scale;
484
+ out.colors[slot * 3 + 2] = light.b * scale;
485
+
486
+ /*
487
+ * **Normalised here rather than trusted**, because the cone is a comparison against a cosine
488
+ * and an un-normalised direction scales that cosine — so a vector of length 2 gives a cone of
489
+ * the wrong width, which reads as the angle having been set wrong rather than the vector.
490
+ * A zero-length direction is a light that declared no cone, and it takes the open pair.
491
+ */
492
+ const dx = light.dirX ?? 0;
493
+ const dy = light.dirY ?? 0;
494
+ const dz = light.dirZ ?? 0;
495
+ const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
496
+ const aimed = length > 1e-6 && light.coneOuterDeg !== undefined;
497
+ const inverse = aimed ? 1 / length : 0;
498
+ out.directions[slot * 3] = dx * inverse;
499
+ out.directions[slot * 3 + 1] = dy * inverse;
500
+ out.directions[slot * 3 + 2] = dz * inverse;
501
+ if (aimed) {
502
+ /*
503
+ * Clamped so the inner edge never passes the outer. A caller that swaps them would
504
+ * otherwise get `smoothstep` with its edges inverted, which is a cone that is dark in the
505
+ * middle and bright at the rim — a picture nobody would attribute to two numbers being the
506
+ * wrong way round.
507
+ */
508
+ const outerDeg = Math.min(89.9, Math.max(0, light.coneOuterDeg ?? 45));
509
+ const innerDeg = Math.min(outerDeg, Math.max(0, light.coneInnerDeg ?? outerDeg * 0.75));
510
+ out.coneCos[slot * 2] = Math.cos((innerDeg * Math.PI) / 180);
511
+ out.coneCos[slot * 2 + 1] = Math.cos((outerDeg * Math.PI) / 180);
512
+ } else {
513
+ out.coneCos[slot * 2] = POINT_LIGHT_COS_INNER;
514
+ out.coneCos[slot * 2 + 1] = POINT_LIGHT_COS_OUTER;
515
+ }
516
+ /*
517
+ * The profile applies to a point light as readily as to a spot: a bare bulb has a measured
518
+ * distribution too, and the arithmetic does not care whether a cone is also being applied.
519
+ * Restricting it to spots would be a smaller feature for no saving.
520
+ */
521
+ out.iesProfiles[slot] = light.iesProfile ?? -1;
522
+ }
523
+ out.count = chosen;
524
+
423
525
  /*
424
526
  * How present each of them is, now that the set — and with it the boundary — is
425
527
  * known. A second pass over at most eight entries, which is why the cut can be a
@@ -434,8 +536,18 @@ export function selectPointLights(
434
536
  const cullDistance = viewRange + (out.radii[index] ?? 0);
435
537
  const arriving = (cullDistance - distance) / FADE_BAND_M;
436
538
  const contested = (cut - distance) / CONTENTION_BAND_M;
437
- out.weights[index] = Math.max(0, Math.min(1, arriving, contested));
539
+ const field = sources[out.sourceIndex[index] ?? 0]?.inLightField === true;
540
+ /*
541
+ * **A field light does not fade in at the edge of the view range**, because the field is what
542
+ * stands in for it past the choice: the fade exists so a light does not switch on in front of
543
+ * the player, and a light the field sums was never off. Kept, it dimmed every light across a
544
+ * short range, and a dimmed light is an incomplete one, so the field took over at the camera.
545
+ */
546
+ const weight = Math.max(0, Math.min(1, field ? contested : Math.min(arriving, contested)));
547
+ out.weights[index] = field ? -weight : weight;
548
+ if (field && weight < 1) complete = Math.min(complete, distance - (out.radii[index] ?? 0));
438
549
  }
550
+ out.complete = Math.max(0, complete);
439
551
  }
440
552
 
441
553
  function flickerScale(light: PointLightSource, timeSeconds: number): number {
@@ -34,6 +34,14 @@ export interface ProbeGridOptions {
34
34
  * convolution and the shader instead of a grid path and a legacy path that drift.
35
35
  */
36
36
  readonly counts: Vec3;
37
+ /**
38
+ * Blend each sweep of bakes into the next rather than showing each probe's bake the moment it
39
+ * lands. For a grid re-baked a probe a frame under a moving light, where each probe otherwise
40
+ * jumps once a sweep and the jumps travel across the scene as a pass of light. Costs three times
41
+ * the layers and a sweep of lag; ignored where indirect light is traced, which writes the grid's
42
+ * layers itself. See `probeSweeps.ts`. Off by default.
43
+ */
44
+ readonly crossfade?: boolean;
37
45
  }
38
46
 
39
47
  /**
@@ -97,15 +105,17 @@ export function sameGrid(a: ProbeGrid, b: ProbeGrid): boolean {
97
105
  if (a.origin[axis] !== b.origin[axis]) return false;
98
106
  if (a.spacing[axis] !== b.spacing[axis]) return false;
99
107
  }
100
- return true;
108
+ return a.crossfade === b.crossfade;
101
109
  }
102
110
 
103
111
  export class ProbeGrid {
104
112
  readonly origin: Vec3;
105
113
  readonly spacing: Vec3;
106
114
  readonly counts: Vec3;
107
- /** Probes in total, which is also the array's layer count. */
115
+ /** Probes in total: the array's layer count, or a third of it for a crossfading grid. */
108
116
  readonly layers: number;
117
+ /** Whether sweeps blend into one another. See `ProbeGridOptions.crossfade`. */
118
+ readonly crossfade: boolean;
109
119
  /** The reciprocal of the spacing, because the shader multiplies and so does everything here. */
110
120
  readonly invSpacing: Vec3;
111
121
 
@@ -149,6 +159,7 @@ export class ProbeGrid {
149
159
  this.invSpacing = [1 / this.spacing[0], 1 / this.spacing[1], 1 / this.spacing[2]];
150
160
  this.counts = counts;
151
161
  this.layers = layers;
162
+ this.crossfade = options.crossfade === true;
152
163
  }
153
164
 
154
165
  /**
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A probe grid's sweeps: which layer a bake writes, when a sweep is whole, and how far the shading
3
+ * has moved from the last whole sweep toward the newest.
4
+ *
5
+ * **A grid re-baked a probe a frame changes in steps.** Each probe holds the light of the moment it
6
+ * was baked until its turn comes round again, a sweep later, and then jumps to the light of that
7
+ * moment. Under a moving sun the jump is a pass of light across the scene once a sweep: reported as
8
+ * "a pass every second or two", which at sixty-four probes a frame at 60 Hz is exactly one sweep.
9
+ *
10
+ * **So a crossfading grid keeps three sets of layers**: the last whole sweep, the newest whole
11
+ * sweep, and the one being baked. The shading blends the first toward the second by how much of the
12
+ * third has been baked, so it reaches the newest sweep at the moment the next one is whole, and the
13
+ * light moves a sixty-fourth of a sweep a frame rather than a probe's worth at once. The set being
14
+ * written is never one the shading reads.
15
+ *
16
+ * What it gives up is three times the layers, and a sweep of lag: the light shown is a blend of what
17
+ * the grid saw one and two sweeps ago. What would make it wrong is a light that must follow a change
18
+ * faster than a sweep, which a burst of bakes serves, since the blend follows the bakes rather than
19
+ * the clock. A grid that does not crossfade is one set, ready once every probe is baked, as before.
20
+ *
21
+ * Backend-neutral, per the rule that a decision lives in one place and only the binding is per
22
+ * backend: both renderers ask this where to write and what to upload.
23
+ */
24
+ export class ProbeSweeps {
25
+ /** Sets of layers the array holds: three for a crossfading grid, one otherwise. */
26
+ readonly sets: 1 | 3;
27
+ private readonly layers: number;
28
+ /** Which probes the sweep being baked has reached, and how many. */
29
+ private readonly reached: Uint8Array;
30
+ private reachedCount = 0;
31
+ private from = 0;
32
+ private to = 0;
33
+ private write = 0;
34
+ /** Whether a whole sweep has been rotated in. */
35
+ private whole = false;
36
+
37
+ constructor(layers: number, crossfade: boolean) {
38
+ this.layers = Math.max(1, Math.trunc(layers));
39
+ this.sets = crossfade ? 3 : 1;
40
+ this.reached = new Uint8Array(this.layers);
41
+ }
42
+
43
+ /** The array layer a bake of probe `layer` writes. */
44
+ writeLayer(layer: number): number {
45
+ return this.write * this.layers + layer;
46
+ }
47
+
48
+ /** Record that probe `layer` was baked into the set being written; a whole sweep rotates. */
49
+ baked(layer: number): void {
50
+ if (layer < 0 || layer >= this.layers || this.reached[layer] === 1) return;
51
+ this.reached[layer] = 1;
52
+ this.reachedCount++;
53
+ if (this.reachedCount < this.layers) return;
54
+ this.reached.fill(0);
55
+ this.reachedCount = 0;
56
+ if (this.sets === 1) {
57
+ this.whole = true;
58
+ return;
59
+ }
60
+ /* The newest is shown from now on; the first whole sweep is shown alone, blending into itself. */
61
+ this.from = this.whole ? this.to : this.write;
62
+ this.to = this.write;
63
+ this.whole = true;
64
+ /* The set neither end of the blend holds. */
65
+ this.write = this.from === this.to ? (this.to + 1) % 3 : 3 - this.from - this.to;
66
+ }
67
+
68
+ /** Whether the shading may read the grid: once a sweep is whole. */
69
+ get ready(): boolean {
70
+ return this.whole;
71
+ }
72
+
73
+ /** The layer offsets of the two sets shown and the blend between them, into `out`. */
74
+ uniforms(out: Float32Array): Float32Array {
75
+ out[0] = this.from * this.layers;
76
+ out[1] = this.to * this.layers;
77
+ out[2] = this.sets === 1 ? 0 : this.reachedCount / this.layers;
78
+ return out;
79
+ }
80
+ }
@@ -70,6 +70,52 @@ export interface ProbeBakeOptions {
70
70
  * nothing gets the frame it already had.
71
71
  */
72
72
  readonly irradiance?: boolean;
73
+ /**
74
+ * Whether what the probe sees is lit by the grid's last bake, so each bake adds a bounce.
75
+ *
76
+ * Defaults to **false**, which is what every bake did before this existed. Then everything a
77
+ * probe sees is lit by the sun and by the hemispheric ambient, so the grid holds one bounce. An
78
+ * enclosed space is mostly lit at the second bounce and later. A courtyard's arcades are the
79
+ * case this was built for: one bounce came out about two stops under a reference render.
80
+ *
81
+ * With it on, the surfaces in each face take their ambient from the grid instead. Round-robin
82
+ * baking then converges on the space's full interreflection, about a bounce each time the grid
83
+ * is swept, and a scene holding one moment bakes the grid two or three times. It costs no pass
84
+ * and no memory. The faces are drawn into their own cube and only the resolve writes the array,
85
+ * so the array is never read and written in one pass.
86
+ *
87
+ * **Nothing changes until every probe has been baked once**, because until then the grid is not
88
+ * readable; the first sweep lights by the ambient as before. What it gives up: light that leaks
89
+ * through a wall into a probe is bounced again, so a leak grows with each sweep. What would make
90
+ * it wrong is a space with near-white walls, where a sweep adds almost as much as the last and
91
+ * convergence takes many sweeps.
92
+ */
93
+ readonly bounce?: boolean;
94
+ /**
95
+ * Which of the six faces this call draws, as `[first, count]`: all six when absent.
96
+ *
97
+ * **A bake is six draws of the scene, and a scene of millions of triangles makes it the most
98
+ * expensive thing in a frame.** Measured on a bought courtyard of eleven million: 6.4 ms of a
99
+ * 14 ms frame, on the frames it ran, which is a frame rate no display can hold steady. Spread over
100
+ * calls, each draws only its faces into the capture, and the convolution into the layer, which
101
+ * reads all six, runs when the sixth is drawn. Faces in the order a cube numbers them,
102
+ * `+X, -X, +Y, -Y, +Z, -Z`.
103
+ *
104
+ * **The caller finishes one probe before starting the next**, because the capture the faces are
105
+ * drawn into is one for the whole grid: faces of two probes interleaved make a cube of neither.
106
+ * What it gives up is a probe whose faces were drawn a few frames apart, under a light that moved
107
+ * a little between them.
108
+ */
109
+ readonly faces?: readonly [first: number, count: number];
110
+ }
111
+
112
+ /** The faces a bake draws, `[first, end)`, from `ProbeBakeOptions.faces`. All six when absent. */
113
+ export function probeFaceRange(options: ProbeBakeOptions | undefined): [number, number] {
114
+ const asked = options?.faces;
115
+ if (asked === undefined) return [0, 6];
116
+ const first = Math.min(Math.max(Math.trunc(asked[0]), 0), 5);
117
+ const count = Math.max(Math.trunc(asked[1]), 1);
118
+ return [first, Math.min(first + count, 6)];
73
119
  }
74
120
 
75
121
  /**
@@ -407,12 +453,17 @@ export class ReflectionProbe {
407
453
  * Returns whether the bake happened. A false means the allocation was refused and the caller
408
454
  * should carry on without a probe, which is a picture with a gradient in its paint rather
409
455
  * than a room.
456
+ *
457
+ * `first` and `end` draw only those faces, and the chain the roughness reads is built when the
458
+ * sixth is drawn. See `ProbeBakeOptions.faces`.
410
459
  */
411
460
  bake(
412
461
  gl: WebGL2RenderingContext,
413
462
  origin: Vec3,
414
463
  clearColor: Vec3,
415
464
  drawFace: (camera: Camera) => void,
465
+ first = 0,
466
+ end = FACES.length,
416
467
  ): boolean {
417
468
  if (this.unusable) return false;
418
469
 
@@ -429,7 +480,7 @@ export class ReflectionProbe {
429
480
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, this.depth);
430
481
  gl.viewport(0, 0, this.size, this.size);
431
482
 
432
- for (let face = 0; face < FACES.length; face++) {
483
+ for (let face = first; face < end; face++) {
433
484
  const aim = FACES[face];
434
485
  if (aim === undefined) continue;
435
486
  gl.framebufferTexture2D(
@@ -439,7 +490,7 @@ export class ReflectionProbe {
439
490
  this.texture,
440
491
  0,
441
492
  );
442
- if (face === 0 && gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
493
+ if (face === first && gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
443
494
  this.unusable = true;
444
495
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
445
496
  console.warn(
@@ -464,6 +515,7 @@ export class ReflectionProbe {
464
515
  }
465
516
 
466
517
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
518
+ if (end < FACES.length) return true;
467
519
  /* The blur the roughness reads. Once per bake, not once per frame. */
468
520
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, this.texture);
469
521
  gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
@@ -560,7 +560,9 @@ export interface RenderQuality {
560
560
  */
561
561
  readonly reconstruction: number;
562
562
  /**
563
- * Light a scene from what bounces, rather than from what was baked. **Off by default.**
563
+ * Light a scene from what bounces, rather than from what was baked: **DriftRay**, this engine's
564
+ * ray tracing, rays marched through the declared distance fields in GPU compute rather than on
565
+ * the dedicated ray-tracing units WebGPU does not expose. **Off by default.**
564
566
  *
565
567
  * On, the probe grid the shading already samples is filled by tracing `render/gi/`'s three-level
566
568
  * chain — the world distance field, and behind it the probes themselves, which is what makes the
@@ -49,9 +49,21 @@ export function recordingGl(
49
49
  * `Infinity` by default, so a link only ever fails where a test asked for it.
50
50
  */
51
51
  readonly refuseLinkAbove?: number;
52
+ /**
53
+ * Uniforms every program reports as active, each located by a handle carrying its name.
54
+ *
55
+ * **None by default**, which ends every reflection loop at once and leaves every upload aimed
56
+ * at `null`. A test about what a frame *uploads* names the uniform here and then finds the
57
+ * calls whose location says that name, rather than guessing at an upload's position in the
58
+ * recording.
59
+ */
60
+ readonly uniforms?: readonly string[];
52
61
  } = {},
53
62
  ) {
54
63
  const extensions = new Set(options.extensions ?? []);
64
+ const uniforms = options.uniforms ?? [];
65
+ /* What `clearDepth` last set, so a module that borrows the value can be seen to put it back. */
66
+ let depthClear = 1;
55
67
  const reportedVectors = options.fragmentUniformVectors ?? 1024;
56
68
  const enforcedVectors = options.refuseLinkAbove ?? Number.POSITIVE_INFINITY;
57
69
  /** Fragment sources by shader handle, so a refusal can be decided from what was compiled. */
@@ -71,6 +83,7 @@ export function recordingGl(
71
83
  MAX_TEXTURE_IMAGE_UNITS: 0x8872,
72
84
  MAX_VERTEX_UNIFORM_VECTORS: 0x8dfb,
73
85
  MAX_FRAGMENT_UNIFORM_VECTORS: 0x8dfd,
86
+ DEPTH_CLEAR_VALUE: 0x0b73,
74
87
  /*
75
88
  * Enums a test needs to compare against, rather than merely to pass through.
76
89
  *
@@ -126,6 +139,7 @@ export function recordingGl(
126
139
  if (pname === constants.MAX_SAMPLES) return 4;
127
140
  if (pname === constants.MAX_FRAGMENT_UNIFORM_VECTORS) return reportedVectors;
128
141
  if (pname === constants.MAX_VERTEX_UNIFORM_VECTORS) return 1024;
142
+ if (pname === constants.DEPTH_CLEAR_VALUE) return depthClear;
129
143
  /* Generous by default: every remaining limit is a ceiling something is checked against,
130
144
  and a small answer is read as a device that cannot do the thing. */
131
145
  return 4096;
@@ -156,16 +170,24 @@ export function recordingGl(
156
170
  /* Linked yes; zero active uniforms and attributes, which ends every reflection loop at once
157
171
  and is why this harness does not have to know a single uniform name. */
158
172
  getProgramParameter: (program: unknown, pname: unknown) => {
159
- if (pname === constants.ACTIVE_UNIFORMS || pname === constants.ACTIVE_ATTRIBUTES) return 0;
173
+ if (pname === constants.ACTIVE_UNIFORMS) return uniforms.length;
174
+ if (pname === constants.ACTIVE_ATTRIBUTES) return 0;
160
175
  if (pname !== constants.LINK_STATUS) return true;
161
176
  if (!Number.isFinite(enforcedVectors)) return true;
162
177
  if (typeof program !== 'object' || program === null) return true;
163
178
  const source = programFragments.get(program);
164
179
  return source === undefined || countUniformVectors(source) <= enforcedVectors;
165
180
  },
166
- getActiveUniform: () => ({ name: '', size: 1, type: 0 }),
181
+ getActiveUniform: (_program: unknown, index: unknown) => ({
182
+ name: uniforms[Number(index)] ?? '',
183
+ size: 1,
184
+ type: 0,
185
+ }),
167
186
  getActiveAttrib: () => ({ name: '', size: 1, type: 0 }),
168
- getUniformLocation: () => ({}),
187
+ getUniformLocation: (_program: unknown, name: unknown) => ({ name }),
188
+ clearDepth: (value: unknown) => {
189
+ depthClear = Number(value);
190
+ },
169
191
  getAttribLocation: () => -1,
170
192
  checkFramebufferStatus: () => constants.FRAMEBUFFER_COMPLETE,
171
193
  getError: () => constants.NO_ERROR,