@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
@@ -165,18 +165,18 @@ export function createScatterBindGroupLayout(device: GPUDevice): GPUBindGroupLay
165
165
  label: 'scatter.layout',
166
166
  entries: [
167
167
  /*
168
- * **The vertex block is per draw and the fragment block is not.**
168
+ * **Both blocks are per draw.**
169
169
  *
170
170
  * The vertex block carries the gust and the trample field, and both arrive as arguments
171
171
  * to `drawScatter` — so a scene bending its grass by one press field and its canopy by
172
172
  * another gets whichever was written last for both, from a single buffer. That is not
173
173
  * hypothetical elsewhere: the plume pass had exactly this and drew a flame bent by the
174
- * smoke's wind response. The demo that exercises this today happens to pass both
175
- * batches the same wind and no trample, which is luck rather than a guarantee.
174
+ * smoke's wind response.
176
175
  *
177
- * The fragment block holds the camera, the sun, the point lights and the medium, which
178
- * are settled per pass rather than per batch. It stops being safe the day something
179
- * per-batch is added to it, and that is what would make this a ring too.
176
+ * The fragment block holds the camera, the sun, the point lights and the medium. It was one
177
+ * buffer on the argument that those are settled per pass, and a pass is not an encoder: a
178
+ * probe records six passes in one, a mirror shares the frame's, and queue writes land before
179
+ * either runs. So every pass drew its blades from the last camera, and it is a ring too.
180
180
  */
181
181
  {
182
182
  binding: VERT_BINDING,
@@ -186,7 +186,7 @@ export function createScatterBindGroupLayout(device: GPUDevice): GPUBindGroupLay
186
186
  {
187
187
  binding: FRAG_BINDING,
188
188
  visibility: VISIBILITY_FRAGMENT,
189
- buffer: { type: 'uniform', minBindingSize: SCATTER_FRAG_SIZE },
189
+ buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: SCATTER_FRAG_SIZE },
190
190
  },
191
191
  ],
192
192
  });
@@ -63,6 +63,16 @@ export class SkinPaletteRing {
63
63
  this.used = 0;
64
64
  }
65
65
 
66
+ /** Where the ring stands, to hand back to `rewind`. See `UniformRing.rewind` for when it is safe. */
67
+ mark(): number {
68
+ return this.used;
69
+ }
70
+
71
+ /** Give back every slot taken since `mark`, once the commands reading them are submitted. */
72
+ rewind(mark: number): void {
73
+ if (mark < this.used) this.used = mark;
74
+ }
75
+
66
76
  /**
67
77
  * Upload a palette into a slot of its own and return that slot, or null when the ring is full.
68
78
  *
@@ -26,7 +26,11 @@ export function createSkyBindGroupLayout(device: GPUDevice): GPUBindGroupLayout
26
26
  {
27
27
  binding: SKY_BINDING,
28
28
  visibility: VISIBILITY_FRAGMENT,
29
- buffer: { type: 'uniform', minBindingSize: SKY_UNIFORM_SIZE },
29
+ /*
30
+ * A dynamic offset, because a frame draws more than one sky — a probe's six faces, a mirror
31
+ * and the view — and one buffer rewritten between them hands every draw the last write.
32
+ */
33
+ buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: SKY_UNIFORM_SIZE },
30
34
  },
31
35
  ],
32
36
  });
@@ -58,6 +58,11 @@ export class UniformRing {
58
58
  private ints: Int32Array;
59
59
  private capacity: number;
60
60
  private used = 0;
61
+ /**
62
+ * The byte below which the device already holds what the staging does: the end of the last
63
+ * flush, lowered by any write under it since. See `flush`.
64
+ */
65
+ private clean = 0;
61
66
 
62
67
  /**
63
68
  * `label` names the buffer on the device, and it is not decoration.
@@ -124,6 +129,7 @@ export class UniformRing {
124
129
 
125
130
  this.buffer.destroy();
126
131
  this.capacity = slots;
132
+ this.clean = 0;
127
133
  this.staging = new ArrayBuffer(bytes);
128
134
  this.floats = new Float32Array(this.staging);
129
135
  this.ints = new Int32Array(this.staging);
@@ -143,6 +149,28 @@ export class UniformRing {
143
149
  /** Start a frame. Slots are reused; nothing is freed. */
144
150
  reset(): void {
145
151
  this.used = 0;
152
+ this.clean = 0;
153
+ }
154
+
155
+ /** Where the ring stands, to hand back to `rewind`. */
156
+ mark(): number {
157
+ return this.used;
158
+ }
159
+
160
+ /**
161
+ * Give back every slot taken since `mark`, for a caller that has already submitted the commands
162
+ * reading them.
163
+ *
164
+ * **Safe because the queue is ordered, and only then.** A `writeBuffer` issued after a submit
165
+ * cannot reach the commands in it, so slots a submitted encoder read may be rewritten by the next
166
+ * one. A slot taken by an encoder not yet submitted may not be: every draw pointing at it would
167
+ * read whatever overwrote it, which is the defect this class exists to prevent. What uses it is
168
+ * work that submits encoders of its own mid-frame — a probe bake — so the frame's rings are not
169
+ * spent by something the frame never draws.
170
+ */
171
+ rewind(mark: number): void {
172
+ if (mark < this.used) this.used = mark;
173
+ if (this.used * this.slotSize < this.clean) this.clean = this.used * this.slotSize;
146
174
  }
147
175
 
148
176
  /**
@@ -180,6 +208,7 @@ export class UniformRing {
180
208
 
181
209
  /** Write floats into a slot, at a byte offset within it. */
182
210
  writeFloats(slot: number, byteOffset: number, values: ArrayLike<number>): void {
211
+ if (slot < this.clean) this.clean = slot;
183
212
  this.floats.set(values, (slot + byteOffset) / 4);
184
213
  }
185
214
 
@@ -192,11 +221,13 @@ export class UniformRing {
192
221
  * buffer being reused.
193
222
  */
194
223
  writeFloat(slot: number, byteOffset: number, value: number): void {
224
+ if (slot < this.clean) this.clean = slot;
195
225
  this.floats[(slot + byteOffset) / 4] = value;
196
226
  }
197
227
 
198
228
  /** Write one integer into a slot. Separate because a `Float32Array` cannot hold an `i32`. */
199
229
  writeInt(slot: number, byteOffset: number, value: number): void {
230
+ if (slot < this.clean) this.clean = slot;
200
231
  this.ints[(slot + byteOffset) / 4] = value;
201
232
  }
202
233
 
@@ -213,13 +244,28 @@ export class UniformRing {
213
244
  * `Int32Array.set` from an `Int32Array` is an exact copy of every bit, whatever the bits mean.
214
245
  */
215
246
  writeBlock(slot: number, block: Int32Array): void {
247
+ if (slot < this.clean) this.clean = slot;
216
248
  this.ints.set(block, slot / 4);
217
249
  }
218
250
 
219
- /** Upload every slot taken this frame, in one write. */
251
+ /**
252
+ * Upload every slot taken this frame that the device does not already hold, in one write.
253
+ *
254
+ * **From the watermark, not from zero, because a frame flushes more than once.** Every submit in
255
+ * the middle of a frame flushes first, and a probe bake submits per face: sending the whole used
256
+ * range each time sent a courtyard's first slots two hundred times over, 2.3 GB in a two-second
257
+ * grid bake, measured. Queue writes are ordered, so bytes already sent and not written since are
258
+ * on the device for every command after them, and resending them changed nothing.
259
+ *
260
+ * What it gives up is one compare per write. What would make it wrong is a write that reached the
261
+ * staging without passing a method that lowers `clean` — which is why the staging stays private.
262
+ */
220
263
  flush(): void {
221
- if (this.used === 0) return;
222
- this.device.queue.writeBuffer(this.buffer, 0, this.staging, 0, this.used * this.slotSize);
264
+ const end = this.used * this.slotSize;
265
+ if (end <= this.clean) return;
266
+ const from = this.clean;
267
+ this.device.queue.writeBuffer(this.buffer, from, this.staging, from, end - from);
268
+ this.clean = end;
223
269
  }
224
270
 
225
271
  dispose(): void {
@@ -49,6 +49,15 @@ export const BLOOM_FILTER_RADIUS_UV = 0.005;
49
49
  * an exposure with no curve to be exposed into — where everything is switched on, nothing is
50
50
  * wrong, and nothing happens.
51
51
  */
52
+ /**
53
+ * A threshold a frame asked for through `setBloom`, made safe: at least a thousandth of a scene
54
+ * unit, because zero blooms the whole frame including what is meant to be dark, and a
55
+ * non-number would reach the shader as one. Decided here so both backends answer the same.
56
+ */
57
+ export function bloomThresholdOf(threshold: number): number {
58
+ return Number.isFinite(threshold) ? Math.max(threshold, 1e-3) : 1e-3;
59
+ }
60
+
52
61
  export function bloomProfileWarning(quality: {
53
62
  readonly bloom: number;
54
63
  readonly screenEffects: boolean;
@@ -24,27 +24,35 @@ export const CLUSTER_Z = 24;
24
24
  export const CLUSTER_COUNT = CLUSTER_X * CLUSTER_Y * CLUSTER_Z;
25
25
 
26
26
  /**
27
- * Texels a cluster occupies: one for its count, seven holding four light indices each.
27
+ * Texels a cluster occupies: one for its count, nineteen holding four light indices each.
28
28
  *
29
29
  * **Fixed rather than packed, and that is what removes the atomics.** One invocation owns one
30
30
  * cluster and writes only its own run, so nothing is appended to a shared list, there is no
31
31
  * counter to increment atomically and no compaction pass. That is also what lets the GPU binner
32
32
  * write through a *write-only* storage texture, which is all core WGSL gives.
33
33
  */
34
- export const CLUSTER_TEXELS = 5;
34
+ export const CLUSTER_TEXELS = 20;
35
35
 
36
36
  /**
37
- * How many lights one cluster can hold. Four index texels at four indices each.
37
+ * How many lights one cluster can hold: nineteen index texels at four indices each, 76.
38
38
  *
39
- * **Sixteen because that is `MAX_POINT_LIGHTS`, and the equality is the point.** The light loop's
40
- * bound has to be a constant and there is only one loop, so a larger cap here would raise it for
41
- * the fixed path too — a scene that never asks for froxels would carry a loop bound of 28 where it
42
- * carries 16 today, which on the driver that faulted under the full shader is precisely the sort
43
- * of change nobody would attribute to clustering. Equal bounds mean the fixed path is untouched.
39
+ * **Sixteen until a candlelit interior drew its froxels as rectangles.** The cap was pinned to
40
+ * `MAX_POINT_LIGHTS` so the one light loop kept one bound. Then a courtyard of candles, lanterns
41
+ * and braziers asked 461 of its 3,456 froxels for more than sixteen, and up to fifty. A full froxel
42
+ * keeps its nearest and drops the rest, and its neighbour keeps a different set, so the step
43
+ * between them drew as hard-edged, screen-aligned rectangles on every vault, on both backends.
44
+ *
45
+ * Seventy-six because it is the next cap the table's shape allows above what was measured: a run
46
+ * must divide the 320-texel row, and the two regions must end on one. What it costs is the table,
47
+ * 292 KB to 1.1 MB, and the loop's bound. The bound is the clustered arm's alone in effect: the
48
+ * fixed arm still leaves at its own count and never passes `MAX_LIGHTS`, so a consumer that does
49
+ * not cluster iterates what it did. What would make 76 wrong is content that asks more of one
50
+ * froxel, and the answer then is the next cap rather than a softer overflow: any overflow rule
51
+ * draws the cluster's edge.
44
52
  *
45
53
  * **It is not the old limit wearing a new hat.** Sixteen was a budget for the *whole scene*, chosen
46
- * by distance with a contention band because somebody always had to be last. Sixteen per froxel is
47
- * a different sixteen in every froxel, out of `MAX_CLUSTERED_LIGHTS` in the scene.
54
+ * by distance with a contention band because somebody always had to be last. This is a different
55
+ * set in every froxel, out of `MAX_CLUSTERED_LIGHTS` in the scene.
48
56
  */
49
57
  export const MAX_LIGHTS_PER_CLUSTER = (CLUSTER_TEXELS - 1) * 4;
50
58
 
@@ -162,7 +170,7 @@ export const LIGHT_REGION_TEXELS = MAX_CLUSTERED_LIGHTS * LIGHT_TEXELS;
162
170
  */
163
171
  export const TABLE_WIDTH = 320;
164
172
 
165
- /** Rows: three of light records and 54 of cluster runs. 292 KB of `RGBA32UI`, allocated once. */
173
+ /** Rows: five of light records and 216 of cluster runs. 1.1 MB of `RGBA32UI`, allocated once. */
166
174
  export const TABLE_HEIGHT = (LIGHT_REGION_TEXELS + CLUSTER_COUNT * CLUSTER_TEXELS) / TABLE_WIDTH;
167
175
 
168
176
  /** The table, as the `Uint32Array` both a texture upload and a readback comparison want. */
@@ -247,6 +255,33 @@ export function clusterViewBounds(
247
255
  out[5] = zFar;
248
256
  }
249
257
 
258
+ /**
259
+ * `clusterViewBounds` for a slice whose depths and half extents the caller already has. The same
260
+ * arithmetic in the same order, so the same box to the bit; the binner's inner loop uses this.
261
+ */
262
+ function sliceBounds(
263
+ i: number,
264
+ j: number,
265
+ zNear: number,
266
+ zFar: number,
267
+ halfWNear: number,
268
+ halfWFar: number,
269
+ halfHNear: number,
270
+ halfHFar: number,
271
+ out: Float32Array,
272
+ ): void {
273
+ const xLo = -1 + (2 * i) / CLUSTER_X;
274
+ const xHi = -1 + (2 * (i + 1)) / CLUSTER_X;
275
+ const yLo = -1 + (2 * j) / CLUSTER_Y;
276
+ const yHi = -1 + (2 * (j + 1)) / CLUSTER_Y;
277
+ out[0] = Math.min(xLo * halfWNear, xLo * halfWFar, xHi * halfWNear, xHi * halfWFar);
278
+ out[3] = Math.max(xLo * halfWNear, xLo * halfWFar, xHi * halfWNear, xHi * halfWFar);
279
+ out[1] = Math.min(yLo * halfHNear, yLo * halfHFar, yHi * halfHNear, yHi * halfHFar);
280
+ out[4] = Math.max(yLo * halfHNear, yLo * halfHFar, yHi * halfHNear, yHi * halfHFar);
281
+ out[2] = zNear;
282
+ out[5] = zFar;
283
+ }
284
+
250
285
  /** The light arrays the binner reads, in the shape `Environment` already carries them. */
251
286
  export interface ClusterLightSet {
252
287
  readonly count: number;
@@ -282,6 +317,16 @@ export interface ClusterLightSet {
282
317
  * rule forbids allocating there.
283
318
  */
284
319
  const bounds = new Float32Array(6);
320
+ /**
321
+ * Where each slice begins, for the frame being binned: `sliceNearDepth` for every slice and the
322
+ * far plane, computed once. The same expression as a call per cluster, so the same numbers.
323
+ *
324
+ * **Once a binning rather than once a cluster**, which was two `pow` for every light and every
325
+ * cluster it might touch. A courtyard of candles within a few metres of the camera is 640,000 of
326
+ * those pairs, and the binning took 47 ms of a frame. On WebGL2 it runs every `bindMeshPass`, so a
327
+ * probe grid baked at night binned for a minute.
328
+ */
329
+ const sliceDepths = new Float64Array(CLUSTER_Z + 1);
285
330
  /** View-space light centres, kept for the overflow rule, which needs them after the fact. */
286
331
  const viewX = new Float32Array(MAX_CLUSTERED_LIGHTS);
287
332
  const viewY = new Float32Array(MAX_CLUSTERED_LIGHTS);
@@ -312,19 +357,6 @@ function distanceSqToBounds(x: number, y: number, z: number, b: Float32Array): n
312
357
  return dx * dx + dy * dy + dz * dz;
313
358
  }
314
359
 
315
- /** Insertion sort over a cluster's index slots. At most 28 integers, so this is not a hot loop. */
316
- function sortIndices(table: Uint32Array, base: number, count: number): void {
317
- for (let n = 1; n < count; n++) {
318
- const value = table[base + 4 + n] ?? 0;
319
- let m = n - 1;
320
- while (m >= 0 && (table[base + 4 + m] ?? 0) > value) {
321
- table[base + 4 + m + 1] = table[base + 4 + m] ?? 0;
322
- m--;
323
- }
324
- table[base + 4 + m + 1] = value;
325
- }
326
- }
327
-
328
360
  /**
329
361
  * Fill the table with this frame's light assignment, and answer how many lights it carried.
330
362
  *
@@ -393,6 +425,7 @@ export function buildLightClusters(
393
425
  * replaces; agreeing about the invisible bytes is what keeps the comparison exact.
394
426
  */
395
427
  table.fill(0, LIGHT_REGION_TEXELS * 4);
428
+ for (let k = 0; k <= CLUSTER_Z; k++) sliceDepths[k] = sliceNearDepth(k, near, far);
396
429
 
397
430
  for (let light = 0; light < count; light++) {
398
431
  const x = lights.positions[light * 3] ?? 0;
@@ -492,8 +525,10 @@ export function buildLightClusters(
492
525
  const radiusSq = radius * radius;
493
526
  for (let k = kLo; k <= kHi; k++) {
494
527
  /* The same two depths `clusterViewBounds` builds this slice's box from. */
495
- const halfHNear = sliceNearDepth(k, near, far) * tanHalfFovY;
496
- const halfHFar = sliceNearDepth(k + 1, near, far) * tanHalfFovY;
528
+ const zNear = sliceDepths[k] ?? 0;
529
+ const zFar = sliceDepths[k + 1] ?? 0;
530
+ const halfHNear = zNear * tanHalfFovY;
531
+ const halfHFar = zFar * tanHalfFovY;
497
532
  const halfWNear = halfHNear * aspect;
498
533
  const halfWFar = halfHFar * aspect;
499
534
  const iLo = tileOf(
@@ -514,17 +549,9 @@ export function buildLightClusters(
514
549
  );
515
550
  for (let j = jLo; j <= jHi; j++) {
516
551
  for (let i = iLo; i <= iHi; i++) {
517
- clusterViewBounds(i, j, k, near, far, tanHalfFovY, aspect, bounds);
552
+ sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar, bounds);
518
553
  if (distanceSqToBounds(vx, vy, depth, bounds) > radiusSq) continue;
519
- insert(
520
- table,
521
- i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y,
522
- light,
523
- near,
524
- far,
525
- tanHalfFovY,
526
- aspect,
527
- );
554
+ insert(table, i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y, light, bounds);
528
555
  }
529
556
  }
530
557
  }
@@ -539,47 +566,45 @@ function tileOf(ndc: number, tiles: number): number {
539
566
  }
540
567
 
541
568
  /**
542
- * Put a light into a cluster, applying the overflow rule.
569
+ * Each member's squared distance from its cluster's centre, slot for slot with the table, for the
570
+ * frame being binned. Allocated on the first binning, so a consumer that never clusters pays nothing.
571
+ *
572
+ * **Measured rather than assumed rare.** The overflow rule used to recompute the cluster's box and
573
+ * all sixteen members' distances on every insert into a full cluster, on the argument that full
574
+ * clusters are rare. Three hundred candles within a few metres of the camera fill 2,152 of the
575
+ * 3,456, and the binning spent 47 ms a frame there. Each distance is the same number whenever it is
576
+ * computed, so it is computed once, at the insert that placed the member.
577
+ */
578
+ let memberDistance: Float64Array | null = null;
579
+
580
+ /**
581
+ * Put a light into a cluster, applying the overflow rule. `box` is the cluster's bounds, which the
582
+ * caller has just tested the light against.
543
583
  *
544
584
  * Lights arrive in increasing index, so the common path appends and the list is already sorted.
545
585
  * The sort only runs on the rare path where a replacement broke that order.
546
586
  */
547
- function insert(
548
- table: Uint32Array,
549
- cluster: number,
550
- light: number,
551
- near: number,
552
- far: number,
553
- tanHalfFovY: number,
554
- aspect: number,
555
- ): void {
587
+ function insert(table: Uint32Array, cluster: number, light: number, box: Float32Array): void {
556
588
  const base = clusterBase(cluster);
557
589
  const count = table[base] ?? 0;
590
+ const distances = (memberDistance ??= new Float64Array(CLUSTER_COUNT * MAX_LIGHTS_PER_CLUSTER));
591
+ const at = cluster * MAX_LIGHTS_PER_CLUSTER;
592
+ const cx = ((box[0] ?? 0) + (box[3] ?? 0)) * 0.5;
593
+ const cy = ((box[1] ?? 0) + (box[4] ?? 0)) * 0.5;
594
+ const cz = ((box[2] ?? 0) + (box[5] ?? 0)) * 0.5;
595
+ const mine = centreDistanceSq(light, cx, cy, cz);
558
596
  if (count < MAX_LIGHTS_PER_CLUSTER) {
559
597
  table[base + 4 + count] = light;
598
+ distances[at + count] = mine;
560
599
  table[base] = count + 1;
561
600
  return;
562
601
  }
563
602
 
564
- /*
565
- * Full. Drop the farthest member if this one is nearer, measured from the cluster's own centre.
566
- * Rare enough that recomputing the members' distances beats carrying 96,768 floats of scratch
567
- * for a case most frames never reach.
568
- */
569
- const k = Math.floor(cluster / (CLUSTER_X * CLUSTER_Y));
570
- const j = Math.floor((cluster - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X);
571
- const i = cluster - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;
572
- clusterViewBounds(i, j, k, near, far, tanHalfFovY, aspect, bounds);
573
- const cx = ((bounds[0] ?? 0) + (bounds[3] ?? 0)) * 0.5;
574
- const cy = ((bounds[1] ?? 0) + (bounds[4] ?? 0)) * 0.5;
575
- const cz = ((bounds[2] ?? 0) + (bounds[5] ?? 0)) * 0.5;
576
-
577
- const mine = centreDistanceSq(light, cx, cy, cz);
603
+ /* Full. Drop the farthest member if this one is nearer, measured from the cluster's own centre. */
578
604
  let worstSlot = -1;
579
605
  let worst = mine;
580
606
  for (let n = 0; n < MAX_LIGHTS_PER_CLUSTER; n++) {
581
- const held = table[base + 4 + n] ?? 0;
582
- const d = centreDistanceSq(held, cx, cy, cz);
607
+ const d = distances[at + n] ?? 0;
583
608
  if (d > worst) {
584
609
  worst = d;
585
610
  worstSlot = n;
@@ -587,7 +612,27 @@ function insert(
587
612
  }
588
613
  if (worstSlot < 0) return;
589
614
  table[base + 4 + worstSlot] = light;
590
- sortIndices(table, base, MAX_LIGHTS_PER_CLUSTER);
615
+ distances[at + worstSlot] = mine;
616
+ sortMembers(table, base, distances, at);
617
+ }
618
+
619
+ /**
620
+ * Sort a full cluster's index slots ascending, carrying each member's distance with it. An insertion
621
+ * sort: sixteen integers, so this is not a hot loop.
622
+ */
623
+ function sortMembers(table: Uint32Array, base: number, distances: Float64Array, at: number): void {
624
+ for (let n = 1; n < MAX_LIGHTS_PER_CLUSTER; n++) {
625
+ const value = table[base + 4 + n] ?? 0;
626
+ const distance = distances[at + n] ?? 0;
627
+ let m = n - 1;
628
+ while (m >= 0 && (table[base + 4 + m] ?? 0) > value) {
629
+ table[base + 4 + m + 1] = table[base + 4 + m] ?? 0;
630
+ distances[at + m + 1] = distances[at + m] ?? 0;
631
+ m--;
632
+ }
633
+ table[base + 4 + m + 1] = value;
634
+ distances[at + m + 1] = distance;
635
+ }
591
636
  }
592
637
 
593
638
  function centreDistanceSq(light: number, cx: number, cy: number, cz: number): number {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Which shadow casters cut their shadow out of their texture's alpha, decided once for both backends.
3
+ *
4
+ * **A caster with an albedo and a cutoff above zero is a cutout**: a leaf card, a chain link, a
5
+ * fence, whose shape is in its alpha and not in its geometry. The same test the colour pass makes
6
+ * when it discards, so a surface that is a hole in the picture is a hole in its shadow. Written once,
7
+ * here, because two backends deciding it separately is how they come to disagree about a leaf
8
+ * (AGENTS.md, 2026-08-13).
9
+ *
10
+ * Called per draw, so the answer is written into one object and returned again: a draw allocates
11
+ * nothing. What that gives up is holding two answers at once, which no caller does; the depth pass
12
+ * reads it and binds before it asks again.
13
+ */
14
+ import type { SurfaceMaterial } from './surfaceTexture.ts';
15
+
16
+ /** A cutout caster's texture, its cutoff and its UV scale. */
17
+ export interface CutoutCaster<Texture> {
18
+ readonly albedo: Texture;
19
+ readonly cutoff: number;
20
+ readonly u: number;
21
+ readonly v: number;
22
+ }
23
+
24
+ const answer: { albedo: unknown; cutoff: number; u: number; v: number } = {
25
+ albedo: null,
26
+ cutoff: 0,
27
+ u: 1,
28
+ v: 1,
29
+ };
30
+
31
+ /** The cutout `material` casts, or null for an ordinary caster. */
32
+ export function cutoutOf<Texture>(
33
+ material: SurfaceMaterial<Texture> | null | undefined,
34
+ ): CutoutCaster<Texture> | null {
35
+ if (material === null || material === undefined) return null;
36
+ const albedo = material.albedo ?? null;
37
+ const cutoff = material.cutout ?? 0;
38
+ if (albedo === null || !(cutoff > 0)) return null;
39
+ answer.albedo = albedo;
40
+ answer.cutoff = cutoff;
41
+ answer.u = material.uScale ?? 1;
42
+ answer.v = material.vScale ?? 1;
43
+ return answer as CutoutCaster<Texture>;
44
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * One brick of DriftLight's volume: at every sample, the light that arrives from every light that
3
+ * reaches it, and the direction it mostly arrives from.
4
+ *
5
+ * **Two texels a sample.** The first is the summed colour, and in its fourth channel whether the
6
+ * sample is a sample at all: one standing inside solid geometry is not, and its zero lets the
7
+ * shader's filtered lookup leave it out rather than blend darkness into the wall beside it. The
8
+ * second is the arrival direction, weighted by each light's brightness, so it points at a lone light
9
+ * with a length of one and shrinks towards zero as lights surround the sample from all sides. The
10
+ * shader turns the pair back into the light on a surface of any facing: exactly Lambert for a single
11
+ * light, a quarter of the sum, the mean of a clamped cosine, for light from everywhere.
12
+ *
13
+ * **What it gives up is the specular.** Two numbers of direction cannot say where a highlight
14
+ * falls, which is why the exact lights near the camera are shaded exactly and this stands in only
15
+ * past them, where a highlight from a candle is a fraction of a pixel.
16
+ */
17
+
18
+ import type { PointLightFalloff } from './falloff.ts';
19
+ import { pointLightShape } from './falloff.ts';
20
+ import type { LightFieldLayout, LightReach } from './layout.ts';
21
+ import { BRICK_SAMPLES } from './layout.ts';
22
+ import type { DistanceAt } from './visibility.ts';
23
+ import { softVisibility } from './visibility.ts';
24
+
25
+ /** Samples in a brick, and so texels in each of its two blocks. */
26
+ export const BRICK_TEXELS = BRICK_SAMPLES * BRICK_SAMPLES * BRICK_SAMPLES;
27
+
28
+ /** What the bake reads of a light, which a `PointLightSource` already carries. */
29
+ export interface FieldLight extends LightReach {
30
+ readonly r: number;
31
+ readonly g: number;
32
+ readonly b: number;
33
+ /** The emitter's physical radius, which widens its penumbra. */
34
+ readonly sourceRadius: number;
35
+ }
36
+
37
+ /**
38
+ * Fill `light` and `direction`, `BRICK_TEXELS * 4` floats each, x fastest, for brick `brick`.
39
+ * `distance` is the scene's distance field, or null for no occlusion.
40
+ */
41
+ export function bakeBrick(
42
+ layout: LightFieldLayout,
43
+ brick: number,
44
+ lights: readonly FieldLight[],
45
+ falloff: PointLightFalloff,
46
+ distance: DistanceAt | null,
47
+ light: Float32Array,
48
+ direction: Float32Array,
49
+ ): void {
50
+ const { spacing, span, origin } = layout;
51
+ const bx = origin[0] + (layout.brickCoords[brick * 3] as number) * span;
52
+ const by = origin[1] + (layout.brickCoords[brick * 3 + 1] as number) * span;
53
+ const bz = origin[2] + (layout.brickCoords[brick * 3 + 2] as number) * span;
54
+ const first = layout.lightStart[brick] as number;
55
+ const last = layout.lightStart[brick + 1] as number;
56
+
57
+ for (let k = 0; k < BRICK_SAMPLES; k++) {
58
+ for (let j = 0; j < BRICK_SAMPLES; j++) {
59
+ for (let i = 0; i < BRICK_SAMPLES; i++) {
60
+ const texel = (i + BRICK_SAMPLES * (j + BRICK_SAMPLES * k)) * 4;
61
+ const px = bx + i * spacing;
62
+ const py = by + j * spacing;
63
+ const pz = bz + k * spacing;
64
+ light.fill(0, texel, texel + 4);
65
+ direction.fill(0, texel, texel + 4);
66
+ if (distance !== null && distance(px, py, pz) < 0) continue;
67
+
68
+ let r = 0;
69
+ let g = 0;
70
+ let b = 0;
71
+ let towardX = 0;
72
+ let towardY = 0;
73
+ let towardZ = 0;
74
+ let weight = 0;
75
+ for (let listed = first; listed < last; listed++) {
76
+ const source = lights[layout.lights[listed] as number];
77
+ if (source === undefined) continue;
78
+ const dx = source.x - px;
79
+ const dy = source.y - py;
80
+ const dz = source.z - pz;
81
+ const dist = Math.hypot(dx, dy, dz);
82
+ if (dist >= source.radius) continue;
83
+ const shape = pointLightShape(dist, source.radius, falloff);
84
+ if (shape <= 0) continue;
85
+ const seen =
86
+ distance === null
87
+ ? 1
88
+ : softVisibility(
89
+ distance,
90
+ px,
91
+ py,
92
+ pz,
93
+ source.x,
94
+ source.y,
95
+ source.z,
96
+ source.sourceRadius,
97
+ );
98
+ if (seen <= 0) continue;
99
+ const lr = source.r * shape * seen;
100
+ const lg = source.g * shape * seen;
101
+ const lb = source.b * shape * seen;
102
+ r += lr;
103
+ g += lg;
104
+ b += lb;
105
+ const bright = 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
106
+ if (dist > 1e-6 && bright > 0) {
107
+ towardX += (bright * dx) / dist;
108
+ towardY += (bright * dy) / dist;
109
+ towardZ += (bright * dz) / dist;
110
+ weight += bright;
111
+ }
112
+ }
113
+ light[texel] = r;
114
+ light[texel + 1] = g;
115
+ light[texel + 2] = b;
116
+ light[texel + 3] = 1;
117
+ if (weight > 0) {
118
+ direction[texel] = towardX / weight;
119
+ direction[texel + 1] = towardY / weight;
120
+ direction[texel + 2] = towardZ / weight;
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * How a point light's brightness falls with distance, as the lit shader draws it, for the light
3
+ * DriftLight sums into its volume.
4
+ *
5
+ * **One formula in two languages, tied by a test.** The frame shades a light in GLSL; the volume is
6
+ * summed in TypeScript. If they disagree the summed light and the exact light differ where they
7
+ * crossfade, and a seam follows the camera across every surface. `falloff.test.ts` reads the
8
+ * shader's lines and asserts these numbers against them.
9
+ *
10
+ * Both of the renderer's falloffs: `smooth`, the default, is a linear ramp squared, reaching zero at
11
+ * the radius; `inverseSquare` is physical, windowed so it still reaches zero at the radius, and
12
+ * floored at ten centimetres so a surface touching a light is not infinitely bright.
13
+ */
14
+
15
+ export type PointLightFalloff = 'smooth' | 'inverseSquare';
16
+
17
+ /** The shader's `shape` for an unshaped point light: no cone, no photometric profile. */
18
+ export function pointLightShape(dist: number, radius: number, falloff: PointLightFalloff): number {
19
+ if (falloff === 'inverseSquare') {
20
+ const ratio = dist / Math.max(radius, 1e-4);
21
+ const window = Math.min(1, Math.max(0, 1 - ratio ** 4));
22
+ return (window * window) / Math.max(dist * dist, 0.01);
23
+ }
24
+ const linear = Math.min(1, Math.max(0, 1 - dist / radius));
25
+ return linear * linear;
26
+ }