@driftengine/core 4.3.0 → 4.4.1

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 (218) hide show
  1. package/CHANGELOG.json +282 -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/ambientOcclusionPass.js +5 -1
  12. package/dist/render/backend/createRenderer.d.ts +13 -1
  13. package/dist/render/backend/createRenderer.js +6 -5
  14. package/dist/render/backend/webgl2/renderer.d.ts +147 -1
  15. package/dist/render/backend/webgl2/renderer.js +402 -23
  16. package/dist/render/backend/webgpu/buffers.js +46 -8
  17. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  18. package/dist/render/backend/webgpu/depthPass.js +75 -14
  19. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  20. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  21. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  22. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  23. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  24. package/dist/render/backend/webgpu/flatPass.js +12 -5
  25. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  26. package/dist/render/backend/webgpu/particlePass.js +1 -0
  27. package/dist/render/backend/webgpu/postPass.d.ts +13 -3
  28. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  29. package/dist/render/backend/webgpu/probeBake.js +97 -7
  30. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  31. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  32. package/dist/render/backend/webgpu/renderer.d.ts +174 -11
  33. package/dist/render/backend/webgpu/renderer.js +682 -252
  34. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  35. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  36. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  37. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  38. package/dist/render/backend/webgpu/skyPass.js +5 -1
  39. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  40. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  41. package/dist/render/bloomChain.d.ts +6 -0
  42. package/dist/render/bloomChain.js +8 -0
  43. package/dist/render/clusteredLights.d.ts +19 -11
  44. package/dist/render/clusteredLights.js +96 -45
  45. package/dist/render/cutoutCaster.d.ts +23 -0
  46. package/dist/render/cutoutCaster.js +20 -0
  47. package/dist/render/driftLight/bake.d.ts +34 -0
  48. package/dist/render/driftLight/bake.js +95 -0
  49. package/dist/render/driftLight/falloff.d.ts +16 -0
  50. package/dist/render/driftLight/falloff.js +23 -0
  51. package/dist/render/driftLight/layout.d.ts +59 -0
  52. package/dist/render/driftLight/layout.js +138 -0
  53. package/dist/render/driftLight/lightField.d.ts +108 -0
  54. package/dist/render/driftLight/lightField.js +179 -0
  55. package/dist/render/driftLight/uniforms.d.ts +28 -0
  56. package/dist/render/driftLight/uniforms.js +41 -0
  57. package/dist/render/driftLight/visibility.d.ts +18 -0
  58. package/dist/render/driftLight/visibility.js +47 -0
  59. package/dist/render/emptyTexture.d.ts +6 -0
  60. package/dist/render/emptyTexture.js +19 -0
  61. package/dist/render/exposurePass.d.ts +48 -0
  62. package/dist/render/exposurePass.js +172 -0
  63. package/dist/render/flameLight.d.ts +71 -0
  64. package/dist/render/flameLight.js +63 -0
  65. package/dist/render/gi/bounceLights.d.ts +34 -0
  66. package/dist/render/gi/bounceLights.js +66 -0
  67. package/dist/render/gi/fieldReach.d.ts +12 -0
  68. package/dist/render/gi/fieldReach.js +62 -0
  69. package/dist/render/gi/globalField.d.ts +8 -0
  70. package/dist/render/gi/globalField.js +31 -0
  71. package/dist/render/gi/probeTrace.d.ts +3 -1
  72. package/dist/render/gi/probeTrace.js +3 -1
  73. package/dist/render/gpuCapability.js +19 -4
  74. package/dist/render/lightBudget.d.ts +11 -0
  75. package/dist/render/lightBudget.js +11 -0
  76. package/dist/render/pointLightSelection.d.ts +16 -0
  77. package/dist/render/pointLightSelection.js +178 -83
  78. package/dist/render/pointShadowBudget.d.ts +12 -2
  79. package/dist/render/pointShadowBudget.js +16 -10
  80. package/dist/render/probeGrid.d.ts +11 -1
  81. package/dist/render/probeGrid.js +5 -2
  82. package/dist/render/probeSweeps.d.ts +45 -0
  83. package/dist/render/probeSweeps.js +77 -0
  84. package/dist/render/reflectionProbe.d.ts +43 -1
  85. package/dist/render/reflectionProbe.js +17 -3
  86. package/dist/render/renderQuality.d.ts +3 -1
  87. package/dist/render/rendererHarness.d.ts +9 -0
  88. package/dist/render/rendererHarness.js +18 -3
  89. package/dist/render/sceneTarget.d.ts +20 -0
  90. package/dist/render/sceneTarget.js +62 -1
  91. package/dist/render/shaders/ambientOcclusion.d.ts +63 -6
  92. package/dist/render/shaders/ambientOcclusion.js +188 -71
  93. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  94. package/dist/render/shaders/cutoutCoverage.js +40 -0
  95. package/dist/render/shaders/depth.d.ts +25 -0
  96. package/dist/render/shaders/depth.js +69 -3
  97. package/dist/render/shaders/exposure.d.ts +52 -0
  98. package/dist/render/shaders/exposure.js +124 -0
  99. package/dist/render/shaders/filmLook.d.ts +47 -0
  100. package/dist/render/shaders/filmLook.js +98 -0
  101. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  102. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  103. package/dist/render/shaders/flat/driftLight.js +75 -0
  104. package/dist/render/shaders/flat/index.js +5 -0
  105. package/dist/render/shaders/flat/lobes.js +13 -0
  106. package/dist/render/shaders/flat/main.d.ts +1 -1
  107. package/dist/render/shaders/flat/main.js +68 -9
  108. package/dist/render/shaders/flat/preamble.js +23 -5
  109. package/dist/render/shaders/flat/probeGrid.js +46 -3
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +8 -3
  111. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +8 -3
  112. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  113. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  115. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  116. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  117. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  118. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  119. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  120. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  121. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  123. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  124. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  125. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  126. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  127. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  128. package/dist/render/shaders/globalMedium.js +4 -0
  129. package/dist/render/shaders/localExposure.d.ts +35 -0
  130. package/dist/render/shaders/localExposure.js +138 -0
  131. package/dist/render/shaders/prefilter.d.ts +1 -1
  132. package/dist/render/shaders/prefilter.js +6 -0
  133. package/dist/render/shaders/rush.js +33 -5
  134. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  135. package/dist/render/shaders/tangentFrame.js +15 -3
  136. package/dist/render/shaders/temporalResolve.js +39 -1
  137. package/dist/render/shadowCasters.d.ts +5 -3
  138. package/dist/render/shadowMap.js +13 -0
  139. package/dist/render/surfaceTexture.d.ts +5 -0
  140. package/package.json +6 -6
  141. package/scripts/gpuCompute.mjs +24 -3
  142. package/scripts/png.mjs +23 -12
  143. package/src/cinematic/cameraPath.ts +178 -0
  144. package/src/environment/daylight.ts +152 -0
  145. package/src/geometry/meshConcat.ts +100 -0
  146. package/src/index.ts +32 -0
  147. package/src/render/ambientOcclusionPass.ts +5 -1
  148. package/src/render/backend/createRenderer.ts +22 -6
  149. package/src/render/backend/webgl2/renderer.ts +439 -20
  150. package/src/render/backend/webgpu/buffers.ts +51 -8
  151. package/src/render/backend/webgpu/depthPass.ts +78 -10
  152. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  153. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  154. package/src/render/backend/webgpu/flatPass.ts +10 -2
  155. package/src/render/backend/webgpu/particlePass.ts +9 -0
  156. package/src/render/backend/webgpu/probeBake.ts +143 -9
  157. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  158. package/src/render/backend/webgpu/renderer.ts +808 -273
  159. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  160. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  161. package/src/render/backend/webgpu/skyPass.ts +5 -1
  162. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  163. package/src/render/bloomChain.ts +9 -0
  164. package/src/render/clusteredLights.ts +108 -63
  165. package/src/render/cutoutCaster.ts +44 -0
  166. package/src/render/driftLight/bake.ts +125 -0
  167. package/src/render/driftLight/falloff.ts +26 -0
  168. package/src/render/driftLight/layout.ts +177 -0
  169. package/src/render/driftLight/lightField.ts +227 -0
  170. package/src/render/driftLight/uniforms.ts +61 -0
  171. package/src/render/driftLight/visibility.ts +58 -0
  172. package/src/render/emptyTexture.ts +41 -0
  173. package/src/render/exposurePass.ts +186 -0
  174. package/src/render/flameLight.ts +135 -0
  175. package/src/render/gi/bounceLights.ts +68 -0
  176. package/src/render/gi/fieldReach.ts +74 -0
  177. package/src/render/gi/globalField.ts +39 -0
  178. package/src/render/gi/probeTrace.ts +3 -1
  179. package/src/render/gpuCapability.ts +18 -5
  180. package/src/render/lightBudget.ts +12 -0
  181. package/src/render/pointLightSelection.ts +191 -79
  182. package/src/render/pointShadowBudget.ts +16 -8
  183. package/src/render/probeGrid.ts +13 -2
  184. package/src/render/probeSweeps.ts +80 -0
  185. package/src/render/reflectionProbe.ts +54 -2
  186. package/src/render/renderQuality.ts +3 -1
  187. package/src/render/rendererHarness.ts +25 -3
  188. package/src/render/sceneTarget.ts +75 -1
  189. package/src/render/shaders/ambientOcclusion.ts +205 -71
  190. package/src/render/shaders/cutoutCoverage.ts +43 -0
  191. package/src/render/shaders/depth.ts +80 -3
  192. package/src/render/shaders/exposure.ts +128 -0
  193. package/src/render/shaders/filmLook.ts +111 -0
  194. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  195. package/src/render/shaders/flat/driftLight.ts +75 -0
  196. package/src/render/shaders/flat/index.ts +5 -0
  197. package/src/render/shaders/flat/lobes.ts +13 -0
  198. package/src/render/shaders/flat/main.ts +68 -9
  199. package/src/render/shaders/flat/preamble.ts +23 -5
  200. package/src/render/shaders/flat/probeGrid.ts +46 -3
  201. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +8 -3
  202. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  203. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  204. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  205. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  206. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  207. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  208. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  209. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  210. package/src/render/shaders/globalMedium.ts +4 -0
  211. package/src/render/shaders/localExposure.ts +154 -0
  212. package/src/render/shaders/prefilter.ts +6 -0
  213. package/src/render/shaders/rush.ts +33 -5
  214. package/src/render/shaders/tangentFrame.ts +15 -3
  215. package/src/render/shaders/temporalResolve.ts +39 -1
  216. package/src/render/shadowCasters.ts +5 -3
  217. package/src/render/shadowMap.ts +14 -0
  218. package/src/render/surfaceTexture.ts +5 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Whether the traced field reaches the probes it lights: how many cascades a grid needs, and which
3
+ * scheduled probes the field reaches this frame.
4
+ *
5
+ * **A probe traced where the field is not learns that the world is sky.** Every ray it casts finds
6
+ * nothing and falls to the sky colour, the floor under it and the wall beside it included, so it
7
+ * reports the open sky from every side: a covered arcade lit like a field at noon. The field is
8
+ * composed around the camera, and three cascades from a 4 m inner one reach 16 m, so a courtyard 36
9
+ * m long had its far end lit exactly that way from wherever the viewer stood, a milky wash over
10
+ * everything past the outermost cascade that the rasterised grid did not have.
11
+ *
12
+ * Two answers, one for each way it happens. A grid is given the cascades that reach all of it from
13
+ * anywhere inside it; and a probe the field still does not reach, because the camera has left the
14
+ * grid, is left out of the schedule and keeps the light it has, rather than being overwritten with
15
+ * a sky it never saw. What it gives up is that probe following a change while the camera is away.
16
+ */
17
+ import { GLOBAL_FIELD_BLEND } from './globalField.js';
18
+ /** The share of the outermost cascade's half-extent a probe can trust, inside its fading faces. */
19
+ const TRUSTED = 1 - 2 * GLOBAL_FIELD_BLEND;
20
+ /**
21
+ * Cascades enough for the outermost to hold the whole grid, and one probe step past it, within the
22
+ * part of it that is trusted, wherever inside the grid the camera stands. At least `least` and at
23
+ * most `most`: each cascade is the same number of samples, so the bound is on memory, not reach.
24
+ */
25
+ export function cascadesForGrid(grid, radius, least, most) {
26
+ let span = 0;
27
+ let step = 0;
28
+ for (let axis = 0; axis < 3; axis++) {
29
+ const spacing = grid.spacing[axis];
30
+ span = Math.max(span, spacing * (grid.counts[axis] - 1));
31
+ step = Math.max(step, spacing);
32
+ }
33
+ const needed = (span + step) / TRUSTED;
34
+ let cascades = 1;
35
+ while (radius * 2 ** (cascades - 1) < needed && cascades < most)
36
+ cascades++;
37
+ return Math.min(most, Math.max(least, cascades));
38
+ }
39
+ const at = [0, 0, 0];
40
+ /**
41
+ * Keeps, in place and in order, the scheduled probes inside the trusted part of the outermost
42
+ * cascade, `bounds` as `[minX, minY, minZ, maxX, maxY, maxZ]`. Returns how many are kept.
43
+ */
44
+ export function probesTheFieldReaches(grid, scheduled, count, bounds) {
45
+ let kept = 0;
46
+ for (let i = 0; i < count; i++) {
47
+ const layer = scheduled[i];
48
+ grid.positionOf(layer, at);
49
+ let inside = true;
50
+ for (let axis = 0; axis < 3; axis++) {
51
+ const low = bounds[axis];
52
+ const high = bounds[axis + 3];
53
+ const band = GLOBAL_FIELD_BLEND * (high - low);
54
+ const p = at[axis];
55
+ if (p < low + band || p > high - band)
56
+ inside = false;
57
+ }
58
+ if (inside)
59
+ scheduled[kept++] = layer;
60
+ }
61
+ return kept;
62
+ }
@@ -131,6 +131,14 @@ export declare function composeGlobalField(instances: readonly GlobalFieldInstan
131
131
  * is the honest one.
132
132
  */
133
133
  export declare function sampleGlobalField(field: GlobalField, x: number, y: number, z: number): number;
134
+ /**
135
+ * The world distance to the nearest of `instances` at any point, rather than composed onto a grid
136
+ * around the camera: for a caller that traces through the scene once instead of every frame, such
137
+ * as DriftLight's occlusion bake. The same sampling `composeGlobalField` does, under the same
138
+ * refusals of a non-uniform scale and non-cubic voxels, so the two cannot disagree about a surface.
139
+ * Nothing placed answers a very large distance: open space everywhere.
140
+ */
141
+ export declare function distanceToInstances(instances: readonly GlobalFieldInstance[]): (x: number, y: number, z: number) => number;
134
142
  /**
135
143
  * Refuse a source whose voxels are not cubic, which `sourceAt` and the shader both assume.
136
144
  *
@@ -187,6 +187,37 @@ function insideBy(cascade, x, y, z) {
187
187
  }
188
188
  return least;
189
189
  }
190
+ /**
191
+ * The world distance to the nearest of `instances` at any point, rather than composed onto a grid
192
+ * around the camera: for a caller that traces through the scene once instead of every frame, such
193
+ * as DriftLight's occlusion bake. The same sampling `composeGlobalField` does, under the same
194
+ * refusals of a non-uniform scale and non-cubic voxels, so the two cannot disagree about a surface.
195
+ * Nothing placed answers a very large distance: open space everywhere.
196
+ */
197
+ export function distanceToInstances(instances) {
198
+ const inverses = [];
199
+ const scales = [];
200
+ const kept = [];
201
+ for (const instance of instances) {
202
+ assertUniformScale(instance.transform);
203
+ assertCubicVoxels(instance.source);
204
+ const inverse = new Float32Array(16);
205
+ if (mat4.invert(inverse, instance.transform) === null)
206
+ continue;
207
+ inverses.push(inverse);
208
+ scales.push(uniformScaleOf(instance.transform));
209
+ kept.push(instance);
210
+ }
211
+ return (x, y, z) => {
212
+ let nearest = 1e9;
213
+ for (let at = 0; at < kept.length; at++) {
214
+ const distance = sampleInstance(kept[at], inverses[at], scales[at], x, y, z);
215
+ if (distance < nearest)
216
+ nearest = distance;
217
+ }
218
+ return nearest;
219
+ };
220
+ }
190
221
  /**
191
222
  * The distance one instance reports at a world point.
192
223
  *
@@ -53,8 +53,10 @@ export declare const GOLDEN_ANGLE: number;
53
53
  /**
54
54
  * How far the set turns between one refresh and the next, in turns.
55
55
  *
56
- * The golden ratio's fractional part: successive frames land their offsets as far from each other
56
+ * The golden ratio's fractional part: successive turns land their offsets as far from each other
57
57
  * as the sequence allows, for the same reason the spiral's own points do, and it never repeats.
58
+ * The WebGPU bake holds the turn at 0, because a set that turns makes every refresh a new estimate
59
+ * and a still scene flicker; see `ProbeBaker.bake`.
58
60
  */
59
61
  export declare const FRAME_TURN = 0.618033988749895;
60
62
  /**
@@ -56,8 +56,10 @@ export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
56
56
  /**
57
57
  * How far the set turns between one refresh and the next, in turns.
58
58
  *
59
- * The golden ratio's fractional part: successive frames land their offsets as far from each other
59
+ * The golden ratio's fractional part: successive turns land their offsets as far from each other
60
60
  * as the sequence allows, for the same reason the spiral's own points do, and it never repeats.
61
+ * The WebGPU bake holds the turn at 0, because a set that turns makes every refresh a new estimate
62
+ * and a still scene flicker; see `ProbeBaker.bake`.
61
63
  */
62
64
  export const FRAME_TURN = 0.618033988749895;
63
65
  /**
@@ -143,10 +143,25 @@ export async function describeGpu(options = {}) {
143
143
  weak: isWeakGpuFamily(rendererName),
144
144
  source,
145
145
  });
146
- /* WebGL2 first: it is the API that gives a model number. See the header for the measurement. */
147
- const canvas = options.createCanvas === undefined
148
- ? (globalThis.document?.createElement('canvas') ?? null)
149
- : options.createCanvas();
146
+ /*
147
+ * WebGL2 first: it is the API that gives a model number. See the header for the measurement.
148
+ *
149
+ * **The canvas is made inside the guard as well as the context.** A host can have a `document`
150
+ * and still refuse to make one: the native host installs a page so a game finds what it expects,
151
+ * and its `createElement` throws, because there is no DOM behind it. Made outside the guard, that
152
+ * took a scene's mount down on the second host; inside it, such a host is simply one without
153
+ * WebGL2, and the adapter below names the part.
154
+ */
155
+ let canvas = null;
156
+ try {
157
+ canvas =
158
+ options.createCanvas === undefined
159
+ ? (globalThis.document?.createElement('canvas') ?? null)
160
+ : options.createCanvas();
161
+ }
162
+ catch {
163
+ canvas = null;
164
+ }
150
165
  if (canvas !== null) {
151
166
  let gl = null;
152
167
  try {
@@ -201,6 +201,17 @@ export declare const COOKIE_ATLAS_TEXTURE_UNIT: number;
201
201
  * `textureUnitBudget.test.ts` carries the phone that budget was written for.
202
202
  */
203
203
  export declare const REFRACT_SCENE_TEXTURE_UNIT: number;
204
+ /**
205
+ * DriftLight's brick index and atlas, the lit program's fourteenth and fifteenth fragment samplers.
206
+ *
207
+ * Units past fifteen are legal: `MAX_TEXTURE_IMAGE_UNITS` caps how many samplers a stage *uses*,
208
+ * sixteen at the least, while a unit's number only has to be under the combined limit, which WebGL2
209
+ * guarantees is at least thirty-two. The skin palette and morph deltas below them are vertex
210
+ * textures and count against the vertex stage instead. What would make this wrong is a sixteenth
211
+ * fragment sampler, which the lit program would then be one short of on the guaranteed minimum.
212
+ */
213
+ export declare const DRIFT_LIGHT_INDEX_TEXTURE_UNIT: number;
214
+ export declare const DRIFT_LIGHT_ATLAS_TEXTURE_UNIT: number;
204
215
  /**
205
216
  * A cookie's tile, in texels a side.
206
217
  *
@@ -199,6 +199,17 @@ export const COOKIE_ATLAS_TEXTURE_UNIT = MORPH_DELTA_TEXTURE_UNIT + 1;
199
199
  * `textureUnitBudget.test.ts` carries the phone that budget was written for.
200
200
  */
201
201
  export const REFRACT_SCENE_TEXTURE_UNIT = COOKIE_ATLAS_TEXTURE_UNIT + 1;
202
+ /**
203
+ * DriftLight's brick index and atlas, the lit program's fourteenth and fifteenth fragment samplers.
204
+ *
205
+ * Units past fifteen are legal: `MAX_TEXTURE_IMAGE_UNITS` caps how many samplers a stage *uses*,
206
+ * sixteen at the least, while a unit's number only has to be under the combined limit, which WebGL2
207
+ * guarantees is at least thirty-two. The skin palette and morph deltas below them are vertex
208
+ * textures and count against the vertex stage instead. What would make this wrong is a sixteenth
209
+ * fragment sampler, which the lit program would then be one short of on the guaranteed minimum.
210
+ */
211
+ export const DRIFT_LIGHT_INDEX_TEXTURE_UNIT = REFRACT_SCENE_TEXTURE_UNIT + 1;
212
+ export const DRIFT_LIGHT_ATLAS_TEXTURE_UNIT = DRIFT_LIGHT_INDEX_TEXTURE_UNIT + 1;
202
213
  /**
203
214
  * A cookie's tile, in texels a side.
204
215
  *
@@ -59,6 +59,15 @@ export interface PointLightSource {
59
59
  * They are floor lighting 35 cm off the deck and were never meant to cast at all.
60
60
  */
61
61
  castsShadow?: boolean;
62
+ /**
63
+ * Whether a DriftLight field also sums this light (`createLightField` sets it).
64
+ *
65
+ * Then the lit shader shades it exactly only where the choice is complete and lets the field's
66
+ * summed light take over past it, so the light is never counted twice. Carried to the shader as
67
+ * the sign of the light's weight, which both light paths already have: no new storage on a part
68
+ * whose uniform budget has none to spare.
69
+ */
70
+ inLightField?: boolean;
62
71
  }
63
72
  export interface PointLightBuffer {
64
73
  readonly positions: Float32Array;
@@ -132,6 +141,13 @@ export interface PointLightBuffer {
132
141
  */
133
142
  readonly shadowIndex: Int32Array;
134
143
  shadowCount: number;
144
+ /**
145
+ * How far from the selection's centre the choice is complete for DriftLight field lights: a
146
+ * point nearer than this is reached only by field lights chosen at full strength. A field light
147
+ * left out at distance d reaching r first reaches d - r; one dimmed at the cut counts as left
148
+ * out. At most the view range, past which nothing is chosen.
149
+ */
150
+ complete: number;
135
151
  }
136
152
  /**
137
153
  * A buffer for the selection to fill, sized to what the caller can actually bind.
@@ -40,6 +40,82 @@ const FADE_BAND_M = 40;
40
40
  const CONTENTION_BAND_M = 3;
41
41
  /** Sort keys for the shadow list, which is ordered independently of the shaded one. */
42
42
  const shadowRank = new Float32Array(POINT_SHADOW_POOL);
43
+ /**
44
+ * Every light in range this frame, by source index and squared distance, before the nearest are
45
+ * chosen. Grown when a world offers more than it has held, and never shrunk, so a frame allocates
46
+ * only when the world has grown. Float64, so two lights a hair apart are ordered by their real
47
+ * distances rather than by what survives a float's rounding.
48
+ */
49
+ let candidateIndex = new Int32Array(64);
50
+ let candidateDistance = new Float64Array(64);
51
+ function growCandidates(needed) {
52
+ if (needed <= candidateIndex.length)
53
+ return;
54
+ const size = Math.max(needed, candidateIndex.length * 2);
55
+ const index = new Int32Array(size);
56
+ const distance = new Float64Array(size);
57
+ index.set(candidateIndex);
58
+ distance.set(candidateDistance);
59
+ candidateIndex = index;
60
+ candidateDistance = distance;
61
+ }
62
+ /** Whether candidate `a` goes before candidate `b`: nearer, or as near and offered first. */
63
+ function before(a, b) {
64
+ const da = candidateDistance[a] ?? 0;
65
+ const db = candidateDistance[b] ?? 0;
66
+ return da < db || (da === db && (candidateIndex[a] ?? 0) < (candidateIndex[b] ?? 0));
67
+ }
68
+ function swap(a, b) {
69
+ const index = candidateIndex[a] ?? 0;
70
+ const distance = candidateDistance[a] ?? 0;
71
+ candidateIndex[a] = candidateIndex[b] ?? 0;
72
+ candidateDistance[a] = candidateDistance[b] ?? 0;
73
+ candidateIndex[b] = index;
74
+ candidateDistance[b] = distance;
75
+ }
76
+ /**
77
+ * Move the `keep` nearest of the first `count` candidates into `[0, keep)`, in no order: a
78
+ * quickselect, linear on average. The pivot is the median of three, which keeps a list that
79
+ * arrives already sorted — a world laid out along the camera's path — away from the quadratic case.
80
+ */
81
+ function chooseNearest(count, keep) {
82
+ let lo = 0;
83
+ let hi = count - 1;
84
+ while (lo < hi) {
85
+ const mid = (lo + hi) >> 1;
86
+ if (before(mid, lo))
87
+ swap(mid, lo);
88
+ if (before(hi, lo))
89
+ swap(hi, lo);
90
+ if (before(hi, mid))
91
+ swap(hi, mid);
92
+ swap(mid, hi);
93
+ let store = lo;
94
+ for (let at = lo; at < hi; at++) {
95
+ if (before(at, hi)) {
96
+ swap(at, store);
97
+ store++;
98
+ }
99
+ }
100
+ swap(store, hi);
101
+ if (store === keep || store === keep - 1)
102
+ return;
103
+ if (store < keep)
104
+ lo = store + 1;
105
+ else
106
+ hi = store - 1;
107
+ }
108
+ }
109
+ /** Sort the first `count` candidates nearest first. An insertion sort: `count` is a slot budget. */
110
+ function sortCandidates(count) {
111
+ for (let at = 1; at < count; at++) {
112
+ let back = at;
113
+ while (back > 0 && before(back, back - 1)) {
114
+ swap(back, back - 1);
115
+ back--;
116
+ }
117
+ }
118
+ }
43
119
  /**
44
120
  * A buffer for the selection to fill, sized to what the caller can actually bind.
45
121
  *
@@ -72,6 +148,7 @@ export function createPointLightBuffer(capacity = MAX_POINT_LIGHTS) {
72
148
  scratchDistance: new Float32Array(Math.max(shaded, POINT_SHADOW_POOL)),
73
149
  shadowIndex: new Int32Array(POINT_SHADOW_POOL),
74
150
  shadowCount: 0,
151
+ complete: 0,
75
152
  };
76
153
  }
77
154
  /**
@@ -115,6 +192,7 @@ export function selectPointLights(sources, x, y, z, out, timeSeconds = 0, viewRa
115
192
  * shader binds, which is the case where nothing is contested and nothing dims.
116
193
  */
117
194
  let cutSq = Infinity;
195
+ let candidateCount = 0;
118
196
  for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) {
119
197
  const light = sources[sourceIndex];
120
198
  if (light === undefined)
@@ -153,88 +231,14 @@ export function selectPointLights(sources, x, y, z, out, timeSeconds = 0, viewRa
153
231
  *
154
232
  * Sharing one sorted list is what made them drift apart, so they do not share one.
155
233
  */
156
- let slot = out.count;
157
- while (slot > 0 && (out.scratchDistance[slot - 1] ?? 0) > distanceSq)
158
- slot--;
159
- if (slot >= shadedCapacity) {
160
- // Rejected outright, so it is a candidate for the boundary the set fades against.
161
- if (distanceSq < cutSq)
162
- cutSq = distanceSq;
163
- }
164
- else {
165
- // Full, so making room drops whoever was last off the end — and that is the
166
- // nearest light not in the set unless something nearer is rejected later.
167
- if (out.count >= shadedCapacity) {
168
- const displaced = out.scratchDistance[shadedCapacity - 1] ?? Infinity;
169
- if (displaced < cutSq)
170
- cutSq = displaced;
171
- }
172
- const last = Math.min(out.count, shadedCapacity - 1);
173
- for (let index = last; index > slot; index--) {
174
- out.scratchDistance[index] = out.scratchDistance[index - 1] ?? 0;
175
- out.radii[index] = out.radii[index - 1] ?? 0;
176
- out.sourceRadii[index] = out.sourceRadii[index - 1] ?? 0;
177
- out.sourceIndex[index] = out.sourceIndex[index - 1] ?? -1;
178
- for (let component = 0; component < 3; component++) {
179
- out.positions[index * 3 + component] = out.positions[(index - 1) * 3 + component] ?? 0;
180
- out.colors[index * 3 + component] = out.colors[(index - 1) * 3 + component] ?? 0;
181
- out.directions[index * 3 + component] = out.directions[(index - 1) * 3 + component] ?? 0;
182
- }
183
- out.coneCos[index * 2] = out.coneCos[(index - 1) * 2] ?? 0;
184
- out.coneCos[index * 2 + 1] = out.coneCos[(index - 1) * 2 + 1] ?? 0;
185
- out.iesProfiles[index] = out.iesProfiles[index - 1] ?? -1;
186
- }
187
- out.scratchDistance[slot] = distanceSq;
188
- out.sourceIndex[slot] = sourceIndex;
189
- out.radii[slot] = light.radius;
190
- out.sourceRadii[slot] = Math.max(light.sourceRadius, 0);
191
- out.positions[slot * 3] = light.x;
192
- out.positions[slot * 3 + 1] = light.y;
193
- out.positions[slot * 3 + 2] = light.z;
194
- const scale = flickerScale(light, timeSeconds);
195
- out.colors[slot * 3] = light.r * scale;
196
- out.colors[slot * 3 + 1] = light.g * scale;
197
- out.colors[slot * 3 + 2] = light.b * scale;
198
- /*
199
- * **Normalised here rather than trusted**, because the cone is a comparison against a cosine
200
- * and an un-normalised direction scales that cosine — so a vector of length 2 gives a cone of
201
- * the wrong width, which reads as the angle having been set wrong rather than the vector.
202
- * A zero-length direction is a light that declared no cone, and it takes the open pair.
203
- */
204
- const dx = light.dirX ?? 0;
205
- const dy = light.dirY ?? 0;
206
- const dz = light.dirZ ?? 0;
207
- const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
208
- const aimed = length > 1e-6 && light.coneOuterDeg !== undefined;
209
- const inverse = aimed ? 1 / length : 0;
210
- out.directions[slot * 3] = dx * inverse;
211
- out.directions[slot * 3 + 1] = dy * inverse;
212
- out.directions[slot * 3 + 2] = dz * inverse;
213
- if (aimed) {
214
- /*
215
- * Clamped so the inner edge never passes the outer. A caller that swaps them would
216
- * otherwise get `smoothstep` with its edges inverted, which is a cone that is dark in the
217
- * middle and bright at the rim — a picture nobody would attribute to two numbers being the
218
- * wrong way round.
219
- */
220
- const outerDeg = Math.min(89.9, Math.max(0, light.coneOuterDeg ?? 45));
221
- const innerDeg = Math.min(outerDeg, Math.max(0, light.coneInnerDeg ?? outerDeg * 0.75));
222
- out.coneCos[slot * 2] = Math.cos((innerDeg * Math.PI) / 180);
223
- out.coneCos[slot * 2 + 1] = Math.cos((outerDeg * Math.PI) / 180);
224
- }
225
- else {
226
- out.coneCos[slot * 2] = POINT_LIGHT_COS_INNER;
227
- out.coneCos[slot * 2 + 1] = POINT_LIGHT_COS_OUTER;
228
- }
229
- /*
230
- * The profile applies to a point light as readily as to a spot: a bare bulb has a measured
231
- * distribution too, and the arithmetic does not care whether a cone is also being applied.
232
- * Restricting it to spots would be a smaller feature for no saving.
233
- */
234
- out.iesProfiles[slot] = light.iesProfile ?? -1;
235
- if (out.count < shadedCapacity)
236
- out.count++;
237
- }
234
+ /*
235
+ * A candidate for the shaded list. Which of them win is decided once every source has been
236
+ * seen, below, rather than by keeping a sorted list as they arrive: see `chooseNearest`.
237
+ */
238
+ growCandidates(candidateCount + 1);
239
+ candidateIndex[candidateCount] = sourceIndex;
240
+ candidateDistance[candidateCount] = distanceSq;
241
+ candidateCount++;
238
242
  /*
239
243
  * Inside its own radius, so it is lighting something and therefore has something
240
244
  * to cast. Nearest first, capped at the pool, and independent of whether the
@@ -275,6 +279,86 @@ export function selectPointLights(sources, x, y, z, out, timeSeconds = 0, viewRa
275
279
  if (out.shadowCount < POINT_SHADOW_POOL)
276
280
  out.shadowCount++;
277
281
  }
282
+ /*
283
+ * The nearest `shadedCapacity` candidates, nearest first, and the nearest one left out.
284
+ *
285
+ * **Partitioned, then only the winners sorted.** This kept a sorted list as the sources arrived,
286
+ * shifting fourteen fields a slot to make room, which is O(sources × slots): 3.2 ms a frame to
287
+ * choose 320 of a courtyard's 10,000 candles, most of it moving lights that were then displaced.
288
+ * A quickselect finds the winners in time linear in the sources and the sort touches only them,
289
+ * and each winner is packed once. Ties go to the earlier source, as they did.
290
+ */
291
+ const chosen = Math.min(candidateCount, shadedCapacity);
292
+ let complete = viewRange;
293
+ if (candidateCount > shadedCapacity) {
294
+ chooseNearest(candidateCount, shadedCapacity);
295
+ for (let at = shadedCapacity; at < candidateCount; at++) {
296
+ const distanceSq = candidateDistance[at] ?? Infinity;
297
+ if (distanceSq < cutSq)
298
+ cutSq = distanceSq;
299
+ const left = sources[candidateIndex[at] ?? 0];
300
+ if (left?.inLightField === true) {
301
+ complete = Math.min(complete, Math.sqrt(distanceSq) - left.radius);
302
+ }
303
+ }
304
+ }
305
+ sortCandidates(chosen);
306
+ for (let slot = 0; slot < chosen; slot++) {
307
+ const sourceIndex = candidateIndex[slot] ?? 0;
308
+ const light = sources[sourceIndex];
309
+ if (light === undefined)
310
+ continue;
311
+ const distanceSq = candidateDistance[slot] ?? 0;
312
+ out.scratchDistance[slot] = distanceSq;
313
+ out.sourceIndex[slot] = sourceIndex;
314
+ out.radii[slot] = light.radius;
315
+ out.sourceRadii[slot] = Math.max(light.sourceRadius, 0);
316
+ out.positions[slot * 3] = light.x;
317
+ out.positions[slot * 3 + 1] = light.y;
318
+ out.positions[slot * 3 + 2] = light.z;
319
+ const scale = flickerScale(light, timeSeconds);
320
+ out.colors[slot * 3] = light.r * scale;
321
+ out.colors[slot * 3 + 1] = light.g * scale;
322
+ out.colors[slot * 3 + 2] = light.b * scale;
323
+ /*
324
+ * **Normalised here rather than trusted**, because the cone is a comparison against a cosine
325
+ * and an un-normalised direction scales that cosine — so a vector of length 2 gives a cone of
326
+ * the wrong width, which reads as the angle having been set wrong rather than the vector.
327
+ * A zero-length direction is a light that declared no cone, and it takes the open pair.
328
+ */
329
+ const dx = light.dirX ?? 0;
330
+ const dy = light.dirY ?? 0;
331
+ const dz = light.dirZ ?? 0;
332
+ const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
333
+ const aimed = length > 1e-6 && light.coneOuterDeg !== undefined;
334
+ const inverse = aimed ? 1 / length : 0;
335
+ out.directions[slot * 3] = dx * inverse;
336
+ out.directions[slot * 3 + 1] = dy * inverse;
337
+ out.directions[slot * 3 + 2] = dz * inverse;
338
+ if (aimed) {
339
+ /*
340
+ * Clamped so the inner edge never passes the outer. A caller that swaps them would
341
+ * otherwise get `smoothstep` with its edges inverted, which is a cone that is dark in the
342
+ * middle and bright at the rim — a picture nobody would attribute to two numbers being the
343
+ * wrong way round.
344
+ */
345
+ const outerDeg = Math.min(89.9, Math.max(0, light.coneOuterDeg ?? 45));
346
+ const innerDeg = Math.min(outerDeg, Math.max(0, light.coneInnerDeg ?? outerDeg * 0.75));
347
+ out.coneCos[slot * 2] = Math.cos((innerDeg * Math.PI) / 180);
348
+ out.coneCos[slot * 2 + 1] = Math.cos((outerDeg * Math.PI) / 180);
349
+ }
350
+ else {
351
+ out.coneCos[slot * 2] = POINT_LIGHT_COS_INNER;
352
+ out.coneCos[slot * 2 + 1] = POINT_LIGHT_COS_OUTER;
353
+ }
354
+ /*
355
+ * The profile applies to a point light as readily as to a spot: a bare bulb has a measured
356
+ * distribution too, and the arithmetic does not care whether a cone is also being applied.
357
+ * Restricting it to spots would be a smaller feature for no saving.
358
+ */
359
+ out.iesProfiles[slot] = light.iesProfile ?? -1;
360
+ }
361
+ out.count = chosen;
278
362
  /*
279
363
  * How present each of them is, now that the set — and with it the boundary — is
280
364
  * known. A second pass over at most eight entries, which is why the cut can be a
@@ -289,8 +373,19 @@ export function selectPointLights(sources, x, y, z, out, timeSeconds = 0, viewRa
289
373
  const cullDistance = viewRange + (out.radii[index] ?? 0);
290
374
  const arriving = (cullDistance - distance) / FADE_BAND_M;
291
375
  const contested = (cut - distance) / CONTENTION_BAND_M;
292
- out.weights[index] = Math.max(0, Math.min(1, arriving, contested));
376
+ const field = sources[out.sourceIndex[index] ?? 0]?.inLightField === true;
377
+ /*
378
+ * **A field light does not fade in at the edge of the view range**, because the field is what
379
+ * stands in for it past the choice: the fade exists so a light does not switch on in front of
380
+ * the player, and a light the field sums was never off. Kept, it dimmed every light across a
381
+ * short range, and a dimmed light is an incomplete one, so the field took over at the camera.
382
+ */
383
+ const weight = Math.max(0, Math.min(1, field ? contested : Math.min(arriving, contested)));
384
+ out.weights[index] = field ? -weight : weight;
385
+ if (field && weight < 1)
386
+ complete = Math.min(complete, distance - (out.radii[index] ?? 0));
293
387
  }
388
+ out.complete = Math.max(0, complete);
294
389
  }
295
390
  function flickerScale(light, timeSeconds) {
296
391
  if (light.flicker <= 0)
@@ -45,7 +45,8 @@ ready: Uint8Array, count: number, out: Int32Array,
45
45
  */
46
46
  chronic?: Uint8Array): number;
47
47
  /**
48
- * The shaded lights that also cast, in the order they were shaded.
48
+ * The shaded lights that also cast, each at its own place in the shaded list, and −1 at the place
49
+ * of a light that does not.
49
50
  *
50
51
  * `castsShadow: false` says a light is not worth a cubemap. `selectPointLights` honours
51
52
  * that when it builds the *eligible* list, and that was taken for the whole promise — but
@@ -57,7 +58,16 @@ chronic?: Uint8Array): number;
57
58
  * It showed up as a shadow with nothing to throw it — a lamp that rides the camera
58
59
  * declines to cast for exactly that reason, and cast anyway.
59
60
  *
60
- * Writes into `out` and returns how many it wrote; allocation-free, because this runs
61
+ * **The place is kept rather than the gap closed, because the place is the shader's index.** The
62
+ * pool publishes each map under its light's position in this list, and the shader reads a light's
63
+ * shadow at the light's position in the shaded one. Closing the gap made the two numberings
64
+ * disagree after the first light that declined: every lamp shaded after it was published one
65
+ * place down, the first on the declining light and each of the rest on its neighbour. A consumer
66
+ * whose headlights decline, and are nearly always shaded first, could only keep its lamps honest by
67
+ * dropping the shadows of every lamp ranked after them, and which lamps those were changed as the
68
+ * car moved.
69
+ *
70
+ * Writes into `out` and returns how many places it wrote; allocation-free, because this runs
61
71
  * every frame.
62
72
  */
63
73
  export declare function selectCastingLights(lights: readonly {
@@ -84,7 +84,8 @@ chronic) {
84
84
  return written;
85
85
  }
86
86
  /**
87
- * The shaded lights that also cast, in the order they were shaded.
87
+ * The shaded lights that also cast, each at its own place in the shaded list, and −1 at the place
88
+ * of a light that does not.
88
89
  *
89
90
  * `castsShadow: false` says a light is not worth a cubemap. `selectPointLights` honours
90
91
  * that when it builds the *eligible* list, and that was taken for the whole promise — but
@@ -96,20 +97,25 @@ chronic) {
96
97
  * It showed up as a shadow with nothing to throw it — a lamp that rides the camera
97
98
  * declines to cast for exactly that reason, and cast anyway.
98
99
  *
99
- * Writes into `out` and returns how many it wrote; allocation-free, because this runs
100
+ * **The place is kept rather than the gap closed, because the place is the shader's index.** The
101
+ * pool publishes each map under its light's position in this list, and the shader reads a light's
102
+ * shadow at the light's position in the shaded one. Closing the gap made the two numberings
103
+ * disagree after the first light that declined: every lamp shaded after it was published one
104
+ * place down, the first on the declining light and each of the rest on its neighbour. A consumer
105
+ * whose headlights decline, and are nearly always shaded first, could only keep its lamps honest by
106
+ * dropping the shadows of every lamp ranked after them, and which lamps those were changed as the
107
+ * car moved.
108
+ *
109
+ * Writes into `out` and returns how many places it wrote; allocation-free, because this runs
100
110
  * every frame.
101
111
  */
102
112
  export function selectCastingLights(lights, activeWorldIndices, activeCount, out) {
103
- let written = 0;
104
- for (let i = 0; i < activeCount && written < out.length; i++) {
113
+ const count = Math.min(activeCount, out.length);
114
+ for (let i = 0; i < count; i++) {
105
115
  const worldIndex = activeWorldIndices[i] ?? -1;
106
- if (worldIndex < 0)
107
- continue;
108
- if (lights[worldIndex]?.castsShadow === false)
109
- continue;
110
- out[written++] = worldIndex;
116
+ out[i] = worldIndex >= 0 && lights[worldIndex]?.castsShadow !== false ? worldIndex : -1;
111
117
  }
112
- return written;
118
+ return count;
113
119
  }
114
120
  /**
115
121
  * How many consecutive frames a slot must be stale before it is treated as chronically so.
@@ -32,6 +32,14 @@ export interface ProbeGridOptions {
32
32
  * convolution and the shader instead of a grid path and a legacy path that drift.
33
33
  */
34
34
  readonly counts: Vec3;
35
+ /**
36
+ * Blend each sweep of bakes into the next rather than showing each probe's bake the moment it
37
+ * lands. For a grid re-baked a probe a frame under a moving light, where each probe otherwise
38
+ * jumps once a sweep and the jumps travel across the scene as a pass of light. Costs three times
39
+ * the layers and a sweep of lag; ignored where indirect light is traced, which writes the grid's
40
+ * layers itself. See `probeSweeps.ts`. Off by default.
41
+ */
42
+ readonly crossfade?: boolean;
35
43
  }
36
44
  /**
37
45
  * The eight probes surrounding a point, and how much of each it takes.
@@ -87,8 +95,10 @@ export declare class ProbeGrid {
87
95
  readonly origin: Vec3;
88
96
  readonly spacing: Vec3;
89
97
  readonly counts: Vec3;
90
- /** Probes in total, which is also the array's layer count. */
98
+ /** Probes in total: the array's layer count, or a third of it for a crossfading grid. */
91
99
  readonly layers: number;
100
+ /** Whether sweeps blend into one another. See `ProbeGridOptions.crossfade`. */
101
+ readonly crossfade: boolean;
92
102
  /** The reciprocal of the spacing, because the shader multiplies and so does everything here. */
93
103
  readonly invSpacing: Vec3;
94
104
  constructor(options: ProbeGridOptions);