@driftengine/core 4.8.5 → 4.8.7

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 (214) hide show
  1. package/CHANGELOG.json +4392 -4250
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +12 -4
  4. package/dist/index.js +12 -2
  5. package/dist/render/ambientHarmonics.d.ts +46 -0
  6. package/dist/render/ambientHarmonics.js +80 -0
  7. package/dist/render/anisotropicLobe.d.ts +10 -0
  8. package/dist/render/anisotropicLobe.js +19 -0
  9. package/dist/render/areaLights.d.ts +54 -0
  10. package/dist/render/areaLights.js +55 -0
  11. package/dist/render/backend/api.d.ts +43 -2
  12. package/dist/render/backend/webgl2/instanced.d.ts +2 -0
  13. package/dist/render/backend/webgl2/instanced.js +11 -2
  14. package/dist/render/backend/webgl2/renderer.d.ts +120 -11
  15. package/dist/render/backend/webgl2/renderer.js +356 -53
  16. package/dist/render/backend/webgl2/sceneCaptureTarget.d.ts +29 -0
  17. package/dist/render/backend/webgl2/sceneCaptureTarget.js +96 -0
  18. package/dist/render/backend/webgl2/skinScatter.d.ts +14 -0
  19. package/dist/render/backend/webgl2/skinScatter.js +88 -26
  20. package/dist/render/backend/webgpu/buffers.js +2 -1
  21. package/dist/render/backend/webgpu/clusterBinner.js +3 -2
  22. package/dist/render/backend/webgpu/flatPass.d.ts +7 -5
  23. package/dist/render/backend/webgpu/flatPass.js +15 -13
  24. package/dist/render/backend/webgpu/particlePass.d.ts +37 -4
  25. package/dist/render/backend/webgpu/particlePass.js +81 -12
  26. package/dist/render/backend/webgpu/postPass.d.ts +6 -1
  27. package/dist/render/backend/webgpu/postPass.js +7 -2
  28. package/dist/render/backend/webgpu/renderer.d.ts +93 -8
  29. package/dist/render/backend/webgpu/renderer.js +483 -93
  30. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  31. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  32. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  33. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +41 -1
  34. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +40 -28
  35. package/dist/render/backend/webgpu/skinPaletteRing.js +69 -32
  36. package/dist/render/backend/webgpu/skinScatterPass.d.ts +10 -2
  37. package/dist/render/backend/webgpu/skinScatterPass.js +59 -9
  38. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +5 -1
  39. package/dist/render/backend/webgpu/surfaceTexturePass.js +45 -5
  40. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  41. package/dist/render/bloomChain.d.ts +44 -0
  42. package/dist/render/bloomChain.js +28 -0
  43. package/dist/render/bloomPass.d.ts +3 -1
  44. package/dist/render/bloomPass.js +16 -2
  45. package/dist/render/clusteredLights.d.ts +78 -6
  46. package/dist/render/clusteredLights.js +214 -13
  47. package/dist/render/driftLight/bake.d.ts +2 -0
  48. package/dist/render/driftLight/bake.js +1 -1
  49. package/dist/render/driftLight/falloff.d.ts +4 -2
  50. package/dist/render/driftLight/falloff.js +8 -2
  51. package/dist/render/fixtureAtlas.js +1 -1
  52. package/dist/render/gi/bounceLights.d.ts +3 -2
  53. package/dist/render/gi/bounceLights.js +8 -3
  54. package/dist/render/instanceCull.js +12 -0
  55. package/dist/render/instances.d.ts +47 -11
  56. package/dist/render/instances.js +24 -9
  57. package/dist/render/lightBudget.d.ts +38 -2
  58. package/dist/render/lightBudget.js +48 -5
  59. package/dist/render/lightmap.d.ts +116 -0
  60. package/dist/render/lightmap.js +140 -0
  61. package/dist/render/mesh.js +2 -1
  62. package/dist/render/particleBatch.d.ts +59 -3
  63. package/dist/render/particleBatch.js +53 -2
  64. package/dist/render/particleMaterial.d.ts +6 -1
  65. package/dist/render/particleMaterial.js +10 -2
  66. package/dist/render/particlePool.d.ts +32 -0
  67. package/dist/render/particlePool.js +9 -0
  68. package/dist/render/particleSort.d.ts +34 -0
  69. package/dist/render/particleSort.js +96 -0
  70. package/dist/render/pointLightSelection.d.ts +13 -0
  71. package/dist/render/pointLightSelection.js +3 -1
  72. package/dist/render/renderQuality.d.ts +8 -7
  73. package/dist/render/rendererHarness.js +3 -0
  74. package/dist/render/rgb9e5.d.ts +22 -0
  75. package/dist/render/rgb9e5.js +47 -0
  76. package/dist/render/sceneCapture.d.ts +29 -0
  77. package/dist/render/sceneCapture.js +36 -0
  78. package/dist/render/sceneTarget.d.ts +5 -0
  79. package/dist/render/sceneTarget.js +8 -1
  80. package/dist/render/screenSpaceReflection.d.ts +27 -1
  81. package/dist/render/screenSpaceReflection.js +9 -0
  82. package/dist/render/shaders/bloom.d.ts +8 -4
  83. package/dist/render/shaders/bloom.js +20 -3
  84. package/dist/render/shaders/bolt.d.ts +1 -1
  85. package/dist/render/shaders/bolt.js +7 -1
  86. package/dist/render/shaders/depth.js +11 -0
  87. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  88. package/dist/render/shaders/flat/anisotropic.js +37 -3
  89. package/dist/render/shaders/flat/areaLight.d.ts +10 -0
  90. package/dist/render/shaders/flat/areaLight.js +142 -0
  91. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  92. package/dist/render/shaders/flat/glassTint.js +3 -1
  93. package/dist/render/shaders/flat/index.d.ts +5 -0
  94. package/dist/render/shaders/flat/index.js +46 -6
  95. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  96. package/dist/render/shaders/flat/lobes.js +49 -43
  97. package/dist/render/shaders/flat/main.d.ts +1 -1
  98. package/dist/render/shaders/flat/main.js +158 -121
  99. package/dist/render/shaders/flat/models.d.ts +21 -6
  100. package/dist/render/shaders/flat/models.js +161 -17
  101. package/dist/render/shaders/flat/normalMap.d.ts +10 -0
  102. package/dist/render/shaders/flat/normalMap.js +25 -0
  103. package/dist/render/shaders/flat/preamble.js +84 -40
  104. package/dist/render/shaders/flat/skin.js +6 -5
  105. package/dist/render/shaders/generated/bloom.wgsl.d.ts +13 -3
  106. package/dist/render/shaders/generated/bloom.wgsl.js +13 -3
  107. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  108. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  109. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  110. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  111. package/dist/render/shaders/generated/flat.wgsl.d.ts +1647 -2044
  112. package/dist/render/shaders/generated/flat.wgsl.js +1685 -2082
  113. package/dist/render/shaders/generated/particle.wgsl.d.ts +115 -1
  114. package/dist/render/shaders/generated/particle.wgsl.js +115 -1
  115. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +12 -2
  116. package/dist/render/shaders/generated/skinBlur.wgsl.js +12 -2
  117. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +11 -6
  118. package/dist/render/shaders/generated/ssrTrace.wgsl.js +11 -6
  119. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  120. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  121. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  122. package/dist/render/shaders/particle.d.ts +17 -1
  123. package/dist/render/shaders/particle.js +133 -1
  124. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  125. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  126. package/dist/render/shaders/skinBlur.js +25 -3
  127. package/dist/render/shaders/ssrTrace.js +20 -1
  128. package/dist/render/shaders/water.d.ts +1 -1
  129. package/dist/render/shaders/water.js +12 -3
  130. package/dist/render/skinBlur.d.ts +8 -5
  131. package/dist/render/skinBlur.js +8 -5
  132. package/dist/render/skinScatter.d.ts +3 -1
  133. package/dist/render/skinScatter.js +3 -1
  134. package/dist/render/ssrPass.js +1 -0
  135. package/dist/render/surfaceModel.d.ts +49 -8
  136. package/dist/render/surfaceModel.js +54 -8
  137. package/dist/render/surfaceTexture.d.ts +38 -1
  138. package/dist/render/surfaceTexture.js +37 -2
  139. package/package.json +6 -6
  140. package/src/index.ts +26 -3
  141. package/src/render/ambientHarmonics.ts +92 -0
  142. package/src/render/anisotropicLobe.ts +29 -0
  143. package/src/render/areaLights.ts +97 -0
  144. package/src/render/backend/api.ts +61 -1
  145. package/src/render/backend/webgl2/instanced.ts +12 -2
  146. package/src/render/backend/webgl2/renderer.ts +470 -59
  147. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  148. package/src/render/backend/webgl2/skinScatter.ts +120 -43
  149. package/src/render/backend/webgpu/buffers.ts +2 -1
  150. package/src/render/backend/webgpu/clusterBinner.ts +3 -1
  151. package/src/render/backend/webgpu/flatPass.ts +18 -13
  152. package/src/render/backend/webgpu/particlePass.ts +119 -12
  153. package/src/render/backend/webgpu/postPass.ts +7 -2
  154. package/src/render/backend/webgpu/renderer.ts +589 -113
  155. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  156. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +41 -1
  157. package/src/render/backend/webgpu/skinPaletteRing.ts +65 -33
  158. package/src/render/backend/webgpu/skinScatterPass.ts +59 -9
  159. package/src/render/backend/webgpu/surfaceTexturePass.ts +57 -6
  160. package/src/render/bloomChain.ts +69 -0
  161. package/src/render/bloomPass.ts +26 -2
  162. package/src/render/clusteredLights.ts +259 -12
  163. package/src/render/driftLight/bake.ts +3 -1
  164. package/src/render/driftLight/falloff.ts +13 -2
  165. package/src/render/fixtureAtlas.ts +1 -1
  166. package/src/render/gi/bounceLights.ts +12 -3
  167. package/src/render/instanceCull.ts +9 -0
  168. package/src/render/instances.ts +61 -12
  169. package/src/render/lightBudget.ts +88 -4
  170. package/src/render/lightmap.ts +243 -0
  171. package/src/render/mesh.ts +2 -1
  172. package/src/render/particleBatch.ts +95 -4
  173. package/src/render/particleMaterial.ts +11 -1
  174. package/src/render/particlePool.ts +36 -0
  175. package/src/render/particleSort.ts +133 -0
  176. package/src/render/pointLightSelection.ts +20 -1
  177. package/src/render/renderQuality.ts +8 -7
  178. package/src/render/rendererHarness.ts +3 -0
  179. package/src/render/rgb9e5.ts +52 -0
  180. package/src/render/sceneCapture.ts +56 -0
  181. package/src/render/sceneTarget.ts +18 -1
  182. package/src/render/screenSpaceReflection.ts +33 -1
  183. package/src/render/shaders/bloom.ts +20 -3
  184. package/src/render/shaders/bolt.ts +7 -1
  185. package/src/render/shaders/depth.ts +11 -0
  186. package/src/render/shaders/flat/anisotropic.ts +37 -3
  187. package/src/render/shaders/flat/areaLight.ts +142 -0
  188. package/src/render/shaders/flat/glassTint.ts +9 -2
  189. package/src/render/shaders/flat/index.ts +51 -6
  190. package/src/render/shaders/flat/lobes.ts +50 -43
  191. package/src/render/shaders/flat/main.ts +158 -121
  192. package/src/render/shaders/flat/models.ts +170 -21
  193. package/src/render/shaders/flat/normalMap.ts +25 -0
  194. package/src/render/shaders/flat/preamble.ts +84 -40
  195. package/src/render/shaders/flat/skin.ts +6 -5
  196. package/src/render/shaders/generated/bloom.wgsl.ts +13 -3
  197. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  198. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  199. package/src/render/shaders/generated/flat.wgsl.ts +1685 -2082
  200. package/src/render/shaders/generated/particle.wgsl.ts +116 -1
  201. package/src/render/shaders/generated/skinBlur.wgsl.ts +12 -2
  202. package/src/render/shaders/generated/ssrTrace.wgsl.ts +11 -6
  203. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  204. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  205. package/src/render/shaders/particle.ts +134 -1
  206. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  207. package/src/render/shaders/skinBlur.ts +25 -3
  208. package/src/render/shaders/ssrTrace.ts +20 -1
  209. package/src/render/shaders/water.ts +12 -3
  210. package/src/render/skinBlur.ts +8 -5
  211. package/src/render/skinScatter.ts +3 -1
  212. package/src/render/ssrPass.ts +1 -0
  213. package/src/render/surfaceModel.ts +82 -10
  214. package/src/render/surfaceTexture.ts +101 -3
@@ -159,7 +159,14 @@ export class BloomPass {
159
159
  * `threshold` is in scene units, so it only separates a light from white paint on a target
160
160
  * that kept the range. The caller owns that decision and the warning that goes with it.
161
161
  */
162
- run(scene: WebGLTexture, width: number, height: number, threshold: number): WebGLTexture | null {
162
+ run(
163
+ scene: WebGLTexture,
164
+ width: number,
165
+ height: number,
166
+ threshold: number,
167
+ /** The ramp, then each level's tint: `resolveBloomResponse`'s floats. */
168
+ response: Float32Array,
169
+ ): WebGLTexture | null {
163
170
  if (!this.ensureSize(width, height)) return null;
164
171
  const { gl } = this;
165
172
  const levels = this.levels;
@@ -182,6 +189,7 @@ export class BloomPass {
182
189
  gl.bindTexture(gl.TEXTURE_2D, scene);
183
190
  gl.uniform1i(this.prefilterUniforms['uSource'] ?? null, 0);
184
191
  gl.uniform1f(this.prefilterUniforms['uThreshold'] ?? null, threshold);
192
+ gl.uniform1f(this.prefilterUniforms['uRamp'] ?? null, response[0] ?? 0);
185
193
  this.uploadTexel(this.prefilterUniforms, width, height);
186
194
  gl.drawArrays(gl.TRIANGLES, 0, 3);
187
195
 
@@ -204,8 +212,13 @@ export class BloomPass {
204
212
  * every octave at once. Additive blending rather than a second target to ping-pong through:
205
213
  * a level is only ever read while a *different* level is bound, so there is no feedback.
206
214
  */
215
+ /*
216
+ * Each level's tint rides the blend: the level being added onto is multiplied by its own as the
217
+ * octave below is added, and the deepest by `uScale` on the first draw, the only one reading it
218
+ * before anything has been added to it. White everywhere is `ONE, ONE` exactly.
219
+ */
207
220
  gl.enable(gl.BLEND);
208
- gl.blendFunc(gl.ONE, gl.ONE);
221
+ gl.blendFunc(gl.ONE, gl.CONSTANT_COLOR);
209
222
  gl.useProgram(this.upsampleProgram);
210
223
  gl.uniform1i(this.upsampleUniforms['uSource'] ?? null, 0);
211
224
  gl.uniform1f(this.upsampleUniforms['uRadius'] ?? null, BLOOM_FILTER_RADIUS_UV);
@@ -213,12 +226,23 @@ export class BloomPass {
213
226
  const target = levels[index - 1];
214
227
  const source = levels[index];
215
228
  if (target === undefined || source === undefined) break;
229
+ const deepest = index === levels.length - 1;
230
+ const into = 1 + (index - 1) * 3;
231
+ const from = 1 + index * 3;
232
+ gl.blendColor(response[into] ?? 1, response[into + 1] ?? 1, response[into + 2] ?? 1, 1);
233
+ gl.uniform3f(
234
+ this.upsampleUniforms['uScale'] ?? null,
235
+ deepest ? (response[from] ?? 1) : 1,
236
+ deepest ? (response[from + 1] ?? 1) : 1,
237
+ deepest ? (response[from + 2] ?? 1) : 1,
238
+ );
216
239
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
217
240
  gl.viewport(0, 0, target.width, target.height);
218
241
  gl.bindTexture(gl.TEXTURE_2D, source.texture);
219
242
  gl.drawArrays(gl.TRIANGLES, 0, 3);
220
243
  }
221
244
 
245
+ gl.blendColor(0, 0, 0, 0);
222
246
  gl.disable(gl.BLEND);
223
247
  gl.bindVertexArray(null);
224
248
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
@@ -1,3 +1,5 @@
1
+ import type { AreaLightBuffer } from './areaLights.ts';
2
+
1
3
  /**
2
4
  * The froxel table: what it looks like in memory, and the CPU binner that fills it.
3
5
  *
@@ -90,7 +92,7 @@ export const MAX_CLUSTERED_LIGHTS = 320;
90
92
  * it cannot. That is what lets a light index the shadow uniforms by a number read from a texture,
91
93
  * and it is the same fact the octahedral array texture was built on.
92
94
  */
93
- export const LIGHT_TEXELS = 5;
95
+ export const LIGHT_TEXELS = 6;
94
96
 
95
97
  /**
96
98
  * Where each field of a light's record sits, as an offset in `uint`s from `lightBase`.
@@ -121,7 +123,11 @@ export const LIGHT_TEXELS = 5;
121
123
  * **Reordered 2026-10-02 so a plain light's fields are the first eight**, which is what lets the
122
124
  * shader stop after two texels. Nine values are needed by every light and eight fit in two
123
125
  * texels, so the emitter size and the weight share slot 7 as two halves; see `packSizeAndWeight`.
124
- * Slot 19 is spare.
126
+ *
127
+ * **A sixth texel arrived in 4.8.7 for a light's own falloff exponent**, the fifth's last slot
128
+ * having gone to the channels: 6 KB more of a table near 300, read only for a light whose record
129
+ * carries a fixture, which a light with an exponent does. Slots 21 and 22 are a rectangle's barn
130
+ * doors and spare on a lamp; slot 23 is spare.
125
131
  */
126
132
  export const LIGHT_RECORD = {
127
133
  positionX: 0,
@@ -146,13 +152,48 @@ export const LIGHT_RECORD = {
146
152
  iesAxisX: 16,
147
153
  iesAxisY: 17,
148
154
  iesAxisZ: 18,
155
+ /**
156
+ * The light's lighting channels, a mask (`PointLightSet.lightChannels`), in the slot that was
157
+ * spare. A light naming channels other than the default is read as carrying a fixture, so its
158
+ * last texels are fetched; a plain light's are not, and the shader takes the default for it.
159
+ */
160
+ channels: 19,
161
+ /** The light's own falloff exponent, or 0 for the frame's falloff. `PointLightSource.falloffExponent`. */
162
+ falloffExponent: 20,
163
+ /** A rectangle's barn doors: the cosine of their angle from its normal, then their length in metres. */
164
+ barnDoorCos: 21,
165
+ barnDoorLength: 22,
149
166
  } as const;
150
167
 
168
+ /** The lighting channel every light and every surface is on unless it says otherwise. */
169
+ export const DEFAULT_LIGHT_CHANNELS = 1;
170
+
171
+ /**
172
+ * A light's channels as the shader compares them: a whole mask from 1 to 255, or the default where
173
+ * none was named or what was named is not one. The froxel record and the uniform path both write
174
+ * this, so a light named badly is on channel 1 in both rather than on two different guesses.
175
+ */
176
+ export function lightChannelsOf(value: number | undefined): number {
177
+ if (value === undefined || !Number.isInteger(value) || value < 1 || value > 255) {
178
+ return DEFAULT_LIGHT_CHANNELS;
179
+ }
180
+ return value;
181
+ }
182
+
183
+ /**
184
+ * A light's own falloff exponent as the shader reads it: a positive finite number, or 0 for the
185
+ * frame's falloff where none was named or what was named is not one. The record and the uniform
186
+ * path both write this, so a light named badly falls off by the frame's rule in both.
187
+ */
188
+ export function lightFalloffExponentOf(value: number | undefined): number {
189
+ return value !== undefined && Number.isFinite(value) && value > 0 ? value : 0;
190
+ }
191
+
151
192
  /** Texels the lit pass reads for a light whose record carries no fixture. */
152
193
  export const PLAIN_LIGHT_TEXELS = 2;
153
194
 
154
195
  /**
155
- * The bit of `sizeAndWeight` that says the record's last three texels hold something: the sign of
196
+ * The bit of `sizeAndWeight` that says the record's last four texels hold something: the sign of
156
197
  * the emitter size's half, which a size never needs.
157
198
  */
158
199
  export const LIGHT_FIXTURE_FLAG = 0x8000;
@@ -401,6 +442,18 @@ export interface ClusterLightSet {
401
442
  readonly cookies?: Float32Array;
402
443
  /** One per light: an index into the IES atlas, or −1. */
403
444
  readonly iesProfiles?: Float32Array;
445
+ /** One per light: its lighting channels. See `PointLightSet.lightChannels`. */
446
+ readonly channels?: Float32Array;
447
+ /** One per light: its own falloff exponent, or 0. See `PointLightSource.falloffExponent`. */
448
+ readonly falloffExponents?: Float32Array;
449
+ /**
450
+ * The rectangles shaded through the table, after the `count` point lights: those of `areas` from
451
+ * `areaFrom` on, `areaCount` of them. The ones before `areaFrom` are the fixed arm's, which is
452
+ * where a rectangle's shadow lives. Absent, there are none.
453
+ */
454
+ readonly areas?: AreaLightBuffer | null;
455
+ readonly areaFrom?: number;
456
+ readonly areaCount?: number;
404
457
  }
405
458
 
406
459
  /** Where `fillClusterLightSet` reads a frame's lights from: the arrays `Environment` carries. */
@@ -416,6 +469,9 @@ export interface ClusterLightArrays {
416
469
  readonly lightIesProfiles?: Float32Array;
417
470
  readonly lightIesAxes?: Float32Array;
418
471
  readonly lightCookies?: Float32Array;
472
+ readonly lightChannels?: Float32Array;
473
+ readonly lightFalloffExponents?: Float32Array;
474
+ readonly areaLights?: AreaLightBuffer | null;
419
475
  }
420
476
 
421
477
  /** A `ClusterLightSet` a renderer refills every frame. */
@@ -453,8 +509,17 @@ export function createClusterLightSet(): ClusterLightScratch {
453
509
  export function fillClusterLightSet(
454
510
  env: ClusterLightArrays,
455
511
  out: ClusterLightScratch,
512
+ /** How many rectangles the fixed arm shades; the rest of the buffer is clustered. */
513
+ areaFrom = 0,
456
514
  ): ClusterLightSet {
457
515
  out.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
516
+ const areas = env.areaLights ?? null;
517
+ out.areas = areas;
518
+ out.areaFrom = areaFrom;
519
+ out.areaCount = Math.max(
520
+ 0,
521
+ Math.min((areas?.count ?? 0) - areaFrom, MAX_CLUSTERED_LIGHTS - out.count),
522
+ );
458
523
  out.positions = env.lightPositions;
459
524
  out.colors = env.lightColors;
460
525
  out.radii = env.lightRadii;
@@ -465,6 +530,8 @@ export function fillClusterLightSet(
465
530
  out.iesProfiles = env.lightIesProfiles;
466
531
  out.iesAxes = env.lightIesAxes;
467
532
  out.cookies = env.lightCookies;
533
+ out.channels = env.lightChannels;
534
+ out.falloffExponents = env.lightFalloffExponents;
468
535
  return out;
469
536
  }
470
537
 
@@ -514,8 +581,8 @@ function distanceSqToBounds(x: number, y: number, z: number, b: Float32Array): n
514
581
  }
515
582
 
516
583
  /**
517
- * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
518
- * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
584
+ * Whether a light's record needs its last four texels: it holds a shadow slot, a cone, a profile,
585
+ * a cookie, channels of its own or a falloff exponent of its own. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
519
586
  *
520
587
  * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
521
588
  * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
@@ -526,13 +593,17 @@ export function lightHasFixture(
526
593
  light: number,
527
594
  shadowSlots: number,
528
595
  ): boolean {
596
+ /* A rectangle's record always carries its axes and extents past the second texel. */
597
+ if (light >= lights.count) return true;
529
598
  const cone = lights.coneCos;
530
599
  return (
531
600
  light < shadowSlots ||
532
601
  (cone?.[light * 2] ?? POINT_LIGHT_COS_INNER) !== POINT_LIGHT_COS_INNER ||
533
602
  (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
534
603
  (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
535
- (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0
604
+ (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0 ||
605
+ lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS ||
606
+ lightFalloffExponentOf(lights.falloffExponents?.[light]) > 0
536
607
  );
537
608
  }
538
609
 
@@ -576,6 +647,10 @@ export function writeLightRecord(
576
647
  out: Uint32Array,
577
648
  at: number,
578
649
  ): void {
650
+ if (light >= lights.count) {
651
+ writeAreaRecord(lights, light, out, at);
652
+ return;
653
+ }
579
654
  const directions = lights.directions;
580
655
  const cone = lights.coneCos;
581
656
  const axes = lights.iesAxes;
@@ -612,7 +687,82 @@ export function writeLightRecord(
612
687
  out[at + LIGHT_RECORD.iesAxisX] = floatBits(axes?.[light * 3] ?? 0);
613
688
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
614
689
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
615
- out[at + 19] = 0;
690
+ out[at + LIGHT_RECORD.channels] = floatBits(lightChannelsOf(lights.channels?.[light]));
691
+ out[at + LIGHT_RECORD.falloffExponent] = floatBits(
692
+ lightFalloffExponentOf(lights.falloffExponents?.[light]),
693
+ );
694
+ out[at + LIGHT_RECORD.barnDoorCos] = 0;
695
+ out[at + LIGHT_RECORD.barnDoorLength] = 0;
696
+ out[at + LIGHT_RECORD.barnDoorLength + 1] = 0;
697
+ }
698
+
699
+ /**
700
+ * Which rectangle of the area buffer clustered light `light` is, for a light past the point
701
+ * lights. See `ClusterLightSet.areas`.
702
+ */
703
+ function areaSlot(lights: ClusterLightSet, light: number): number {
704
+ return (lights.areaFrom ?? 0) + light - lights.count;
705
+ }
706
+
707
+ /**
708
+ * A rectangle's record, which the shader tells from a lamp's by its **negative radius** — its
709
+ * reach, negated — and reads to its sixth texel: sidedness where a lamp keeps its shadow slot,
710
+ * the right axis where a spot keeps its direction, the half extents where its cone is, the up
711
+ * axis where the azimuth is, the channels, and the barn doors in the sixth. Never shadowed: a rectangle's occlusion is
712
+ * the fixed arm's, which is why the first `maxAreaLights` stay there.
713
+ */
714
+ function writeAreaRecord(
715
+ lights: ClusterLightSet,
716
+ light: number,
717
+ out: Uint32Array,
718
+ at: number,
719
+ ): void {
720
+ const areas = lights.areas;
721
+ const a = areaSlot(lights, light);
722
+ const pos = areas?.positions;
723
+ const color = areas?.colors;
724
+ const right = areas?.right;
725
+ const up = areas?.up;
726
+ const sizes = areas?.sizes;
727
+ out[at + LIGHT_RECORD.positionX] = floatBits(pos?.[a * 3] ?? 0);
728
+ out[at + LIGHT_RECORD.positionY] = floatBits(pos?.[a * 3 + 1] ?? 0);
729
+ out[at + LIGHT_RECORD.positionZ] = floatBits(pos?.[a * 3 + 2] ?? 0);
730
+ out[at + LIGHT_RECORD.radius] = floatBits(-areaReach(lights, light));
731
+ out[at + LIGHT_RECORD.colorR] = floatBits(color?.[a * 3] ?? 0);
732
+ out[at + LIGHT_RECORD.colorG] = floatBits(color?.[a * 3 + 1] ?? 0);
733
+ out[at + LIGHT_RECORD.colorB] = floatBits(color?.[a * 3 + 2] ?? 0);
734
+ out[at + LIGHT_RECORD.sizeAndWeight] = packSizeAndWeight(0, 1, true);
735
+ out[at + LIGHT_RECORD.shadowSlot] = floatBits(areas?.twoSided[a] ?? 0);
736
+ out[at + LIGHT_RECORD.directionX] = floatBits(right?.[a * 3] ?? 1);
737
+ out[at + LIGHT_RECORD.directionY] = floatBits(right?.[a * 3 + 1] ?? 0);
738
+ out[at + LIGHT_RECORD.directionZ] = floatBits(right?.[a * 3 + 2] ?? 0);
739
+ out[at + LIGHT_RECORD.cosInner] = floatBits(sizes?.[a * 2] ?? 0);
740
+ out[at + LIGHT_RECORD.cosOuter] = floatBits(sizes?.[a * 2 + 1] ?? 0);
741
+ out[at + LIGHT_RECORD.iesProfile] = floatBits(NO_IES_PROFILE);
742
+ out[at + LIGHT_RECORD.cookie] = floatBits(NO_IES_PROFILE);
743
+ out[at + LIGHT_RECORD.iesAxisX] = floatBits(up?.[a * 3] ?? 0);
744
+ out[at + LIGHT_RECORD.iesAxisY] = floatBits(up?.[a * 3 + 1] ?? 1);
745
+ out[at + LIGHT_RECORD.iesAxisZ] = floatBits(up?.[a * 3 + 2] ?? 0);
746
+ out[at + LIGHT_RECORD.channels] = floatBits(DEFAULT_LIGHT_CHANNELS);
747
+ out[at + LIGHT_RECORD.falloffExponent] = 0;
748
+ out[at + LIGHT_RECORD.barnDoorCos] = floatBits(areas?.barnDoors?.[a * 2] ?? 0);
749
+ out[at + LIGHT_RECORD.barnDoorLength] = floatBits(areas?.barnDoors?.[a * 2 + 1] ?? 0);
750
+ out[at + LIGHT_RECORD.barnDoorLength + 1] = 0;
751
+ }
752
+
753
+ /** A clustered rectangle's reach, which its record carries negated. See `AreaLightSource.range`. */
754
+ function areaReach(lights: ClusterLightSet, light: number): number {
755
+ const areas = lights.areas;
756
+ const a = areaSlot(lights, light);
757
+ const named = areas?.ranges?.[a] ?? 0;
758
+ if (named > 0) return named;
759
+ const sizes = areas?.sizes;
760
+ return Math.hypot(sizes?.[a * 2] ?? 0, sizes?.[a * 2 + 1] ?? 0);
761
+ }
762
+
763
+ /** How many lights the table carries: the point lights and the clustered rectangles after them. */
764
+ export function clusteredLightTotal(lights: ClusterLightSet): number {
765
+ return Math.min(lights.count + (lights.areaCount ?? 0), MAX_CLUSTERED_LIGHTS);
616
766
  }
617
767
 
618
768
  /**
@@ -670,7 +820,7 @@ export function buildLightClusters(
670
820
  table: Uint32Array,
671
821
  shadowSlots: number,
672
822
  ): number {
673
- const count = Math.min(lights.count, MAX_CLUSTERED_LIGHTS);
823
+ const count = clusteredLightTotal(lights);
674
824
 
675
825
  /*
676
826
  * The whole cluster region, not just the counts.
@@ -686,10 +836,14 @@ export function buildLightClusters(
686
836
  for (let k = 0; k <= CLUSTER_Z; k++) sliceDepths[k] = sliceNearDepth(k, near, far);
687
837
 
688
838
  for (let light = 0; light < count; light++) {
689
- const x = lights.positions[light * 3] ?? 0;
690
- const y = lights.positions[light * 3 + 1] ?? 0;
691
- const z = lights.positions[light * 3 + 2] ?? 0;
692
- const radius = lights.radii[light] ?? 0;
839
+ /* A rectangle past the lamps is placed by its centre and binned by its reach. */
840
+ const area = light >= lights.count;
841
+ const from = area ? (lights.areas?.positions ?? lights.positions) : lights.positions;
842
+ const slot = area ? areaSlot(lights, light) : light;
843
+ const x = from[slot * 3] ?? 0;
844
+ const y = from[slot * 3 + 1] ?? 0;
845
+ const z = from[slot * 3 + 2] ?? 0;
846
+ const radius = area ? areaReach(lights, light) : (lights.radii[light] ?? 0);
693
847
 
694
848
  /* The record, as the shader reads it. World space; see the note above. */
695
849
  writeLightRecord(lights, light, shadowSlots, table, lightBase(light));
@@ -706,6 +860,40 @@ export function buildLightClusters(
706
860
  /* Entirely behind the camera, so no cluster can reach it. */
707
861
  if (depth + radius <= 0) continue;
708
862
 
863
+ /*
864
+ * **A spot is binned by its cone as well as its sphere.** A 10° spot reaching thirty metres
865
+ * touches every froxel its sphere does and lights a sliver of them, and a stage rigged with
866
+ * hundreds of such fixtures put every one of them in nearly every froxel near the floor: 313
867
+ * lights cost 11.7 ms of a 1600x900 frame. The cone is tested against each froxel's bounding
868
+ * sphere in view space, which is conservative, by the construction `coneBoxGap` describes.
869
+ */
870
+ const cosOuter = lights.coneCos?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
871
+ const directions = lights.directions;
872
+ let coneCos = POINT_LIGHT_COS_OUTER;
873
+ let coneSin = 0;
874
+ let axisX = 0;
875
+ let axisY = 0;
876
+ let axisZ = 0;
877
+ if (!area && directions !== undefined && cosOuter > -1) {
878
+ const dx = directions[light * 3] ?? 0;
879
+ const dy = directions[light * 3 + 1] ?? 0;
880
+ const dz = directions[light * 3 + 2] ?? 0;
881
+ const ax = (view[0] ?? 0) * dx + (view[4] ?? 0) * dy + (view[8] ?? 0) * dz;
882
+ const ay = (view[1] ?? 0) * dx + (view[5] ?? 0) * dy + (view[9] ?? 0) * dz;
883
+ const az = -((view[2] ?? 0) * dx + (view[6] ?? 0) * dy + (view[10] ?? 0) * dz);
884
+ const length = Math.sqrt(ax * ax + ay * ay + az * az);
885
+ /* The shader measures the cone against the direction as given, so a long one is a wider
886
+ cone: `dot(L, d) >= c` is `dot(L, d / |d|) >= c / |d|`. */
887
+ if (length > 1e-6 && cosOuter / length > -1) {
888
+ coneCos = cosOuter / length;
889
+ coneSin = Math.sqrt(Math.max(0, 1 - coneCos * coneCos));
890
+ axisX = ax / length;
891
+ axisY = ay / length;
892
+ axisZ = az / length;
893
+ }
894
+ }
895
+ const spot = coneCos > -1;
896
+
709
897
  /*
710
898
  * The slices the sphere could possibly touch. The tiles are derived per slice, below.
711
899
  *
@@ -765,6 +953,12 @@ export function buildLightClusters(
765
953
  for (let i = iLo; i <= iHi; i++) {
766
954
  sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar, bounds);
767
955
  if (distanceSqToBounds(vx, vy, depth, bounds) > radiusSq) continue;
956
+ if (
957
+ spot &&
958
+ coneBoxGap(vx, vy, depth, axisX, axisY, axisZ, coneCos, coneSin, bounds) > 0
959
+ ) {
960
+ continue;
961
+ }
768
962
  insert(table, i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y, light, bounds);
769
963
  }
770
964
  }
@@ -773,6 +967,59 @@ export function buildLightClusters(
773
967
  return count;
774
968
  }
775
969
 
970
+ /**
971
+ * How far a froxel lies outside a spot's cone, in metres, or a negative number where the cone
972
+ * reaches it: positive is a froxel the light cannot shade. Everything in view space with depth
973
+ * positive, as the binner holds it; `axis` is unit length and `cosA`, `sinA` are the outer cone's.
974
+ *
975
+ * **The froxel's bounding sphere against the infinite cone**, after Wronski's cone test for
976
+ * clustered spots. With `V` from the apex to the sphere's centre at an angle `θ` from the axis and
977
+ * `A` the cone's half-angle, the centre's distance from the cone is `|V| sin(θ − A)` — which is
978
+ * `cosA |V⊥| − (V·axis) sinA` — while `θ − A` is under a right angle, and `|V|` past it, where the
979
+ * nearest point of the cone is its apex. Inside the cone it is negative. So the gap is exact for
980
+ * the sphere, and the sphere holds the froxel: the test can only keep a light, never drop one the
981
+ * shader would draw. The apex case is what drops a froxel behind a spot whose sphere reaches it,
982
+ * which the formula alone underestimates.
983
+ *
984
+ * **What it gives up** is tightness at the near end of the frustum, where a froxel is a long thin
985
+ * box whose sphere is much larger than it: a spot grazing such a froxel is kept. **What would make
986
+ * it wrong** is a profile showing those near froxels hold the cost; testing the box's corners is
987
+ * the tight version, at eight dot products a pair.
988
+ *
989
+ * Exported so the conformance page can tell a disagreement at the cone's edge from a real one; the
990
+ * GPU binner writes the same arithmetic in WGSL.
991
+ */
992
+ export function coneBoxGap(
993
+ px: number,
994
+ py: number,
995
+ pz: number,
996
+ axisX: number,
997
+ axisY: number,
998
+ axisZ: number,
999
+ cosA: number,
1000
+ sinA: number,
1001
+ box: Float32Array,
1002
+ ): number {
1003
+ const b0 = box[0] ?? 0;
1004
+ const b1 = box[1] ?? 0;
1005
+ const b2 = box[2] ?? 0;
1006
+ const b3 = box[3] ?? 0;
1007
+ const b4 = box[4] ?? 0;
1008
+ const b5 = box[5] ?? 0;
1009
+ const ex = (b3 - b0) * 0.5;
1010
+ const ey = (b4 - b1) * 0.5;
1011
+ const ez = (b5 - b2) * 0.5;
1012
+ const vx = (b0 + b3) * 0.5 - px;
1013
+ const vy = (b1 + b4) * 0.5 - py;
1014
+ const vz = (b2 + b5) * 0.5 - pz;
1015
+ const along = vx * axisX + vy * axisY + vz * axisZ;
1016
+ const lengthSq = vx * vx + vy * vy + vz * vz;
1017
+ const across = Math.sqrt(Math.max(lengthSq - along * along, 0));
1018
+ const reach = Math.sqrt(lengthSq);
1019
+ const distance = along < -sinA * reach ? reach : cosA * across - along * sinA;
1020
+ return distance - Math.sqrt(ex * ex + ey * ey + ez * ez);
1021
+ }
1022
+
776
1023
  /** An NDC coordinate to a tile index, clamped. */
777
1024
  function tileOf(ndc: number, tiles: number): number {
778
1025
  const tile = Math.floor((ndc + 1) * 0.5 * tiles);
@@ -32,6 +32,8 @@ export interface FieldLight extends LightReach {
32
32
  readonly b: number;
33
33
  /** The emitter's physical radius, which widens its penumbra. */
34
34
  readonly sourceRadius: number;
35
+ /** Its own falloff exponent, overriding the frame's above zero. See `pointLightShape`. */
36
+ readonly falloffExponent?: number;
35
37
  }
36
38
 
37
39
  /**
@@ -115,7 +117,7 @@ export function sampleFieldLight(
115
117
  const dz = source.z - pz;
116
118
  const dist = Math.hypot(dx, dy, dz);
117
119
  if (dist >= source.radius) continue;
118
- const shape = pointLightShape(dist, source.radius, falloff);
120
+ const shape = pointLightShape(dist, source.radius, falloff, source.falloffExponent);
119
121
  if (shape <= 0) continue;
120
122
  const seen =
121
123
  distance === null
@@ -9,13 +9,24 @@
9
9
  *
10
10
  * Both of the renderer's falloffs: `smooth`, the default, is a linear ramp squared, reaching zero at
11
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.
12
+ * floored at ten centimetres so a surface touching a light is not infinitely bright. And a light's
13
+ * own, which overrides both: `(1 - (d/R)^2)^n` with no distance term, for an exponent `n` above zero
14
+ * (`PointLightSource.falloffExponent`).
13
15
  */
14
16
 
15
17
  export type PointLightFalloff = 'smooth' | 'inverseSquare';
16
18
 
17
19
  /** 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 {
20
+ export function pointLightShape(
21
+ dist: number,
22
+ radius: number,
23
+ falloff: PointLightFalloff,
24
+ exponent = 0,
25
+ ): number {
26
+ if (exponent > 0) {
27
+ const reach = dist / Math.max(radius, 1e-4);
28
+ return Math.min(1, Math.max(0, 1 - reach * reach)) ** exponent;
29
+ }
19
30
  if (falloff === 'inverseSquare') {
20
31
  const ratio = dist / Math.max(radius, 1e-4);
21
32
  const window = Math.min(1, Math.max(0, 1 - ratio ** 4));
@@ -12,7 +12,7 @@
12
12
  * only ever reaches forward — to the next plane, and from the last to the repeat of the first — so
13
13
  * past its last row it meets the texture's edge and the clamp, never a cookie. A cookie's own
14
14
  * half-texel inset keeps it inside its band on both axes. The shader finds the bands from
15
- * `textureSize` and `uIesAtlasRows`, so the fold cost no uniform.
15
+ * `textureSize` and `uFixtureShape.x`, so the fold cost no uniform.
16
16
  *
17
17
  * **What it gives up**: every texel is four half floats where a profile needed one, a few kilobytes
18
18
  * a scene; and setting either table rebuilds the whole texture, which a consumer does once per set.
@@ -23,15 +23,20 @@
23
23
  */
24
24
 
25
25
  import type { PointLightSet } from '../lightBudget.ts';
26
- import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from '../clusteredLights.ts';
26
+ import {
27
+ POINT_LIGHT_COS_INNER,
28
+ POINT_LIGHT_COS_OUTER,
29
+ lightFalloffExponentOf,
30
+ } from '../clusteredLights.ts';
27
31
 
28
32
  /** Lights the probe bake shades a hit with. */
29
33
  export const MAX_BOUNCE_LIGHTS = 32;
30
34
  /**
31
35
  * Floats a light takes: where it is and how far it reaches; its colour at its weight and the cosine
32
- * of its inner cone; the way it points and the cosine of its outer cone.
36
+ * of its inner cone; the way it points and the cosine of its outer cone; and its own falloff
37
+ * exponent, 0 for the frame's, in a fourth row of its own (`PointLightSource.falloffExponent`).
33
38
  */
34
- export const BOUNCE_LIGHT_FLOATS = 12;
39
+ export const BOUNCE_LIGHT_FLOATS = 16;
35
40
 
36
41
  export function createBounceLights(): Float32Array {
37
42
  return new Float32Array(MAX_BOUNCE_LIGHTS * BOUNCE_LIGHT_FLOATS);
@@ -62,6 +67,10 @@ export function resolveBounceLights(lights: PointLightSet | null, out: Float32Ar
62
67
  out[at + 9] = lights.lightDirections?.[i * 3 + 1] ?? 0;
63
68
  out[at + 10] = lights.lightDirections?.[i * 3 + 2] ?? 0;
64
69
  out[at + 11] = lights.lightConeCos?.[i * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
70
+ out[at + 12] = lightFalloffExponentOf(lights.lightFalloffExponents?.[i]);
71
+ out[at + 13] = 0;
72
+ out[at + 14] = 0;
73
+ out[at + 15] = 0;
65
74
  count++;
66
75
  }
67
76
  return count;
@@ -71,6 +71,15 @@ export function cullInstances(
71
71
  out.tints[kept * 3] = tints[i * 3] as number;
72
72
  out.tints[kept * 3 + 1] = tints[i * 3 + 1] as number;
73
73
  out.tints[kept * 3 + 2] = tints[i * 3 + 2] as number;
74
+ if (out.alphas !== undefined) out.alphas[kept] = data.alphas?.[i] ?? 1;
75
+ const regions = data.lightmapRegions;
76
+ if (regions !== undefined && out.lightmapRegions !== undefined) {
77
+ for (let k = 0; k < 4; k++) out.lightmapRegions[kept * 4 + k] = regions[i * 4 + k] as number;
78
+ }
79
+ const cells = data.uvRegions;
80
+ if (cells !== undefined && out.uvRegions !== undefined) {
81
+ for (let k = 0; k < 4; k++) out.uvRegions[kept * 4 + k] = cells[i * 4 + k] as number;
82
+ }
74
83
  kept++;
75
84
  }
76
85
  out.count = kept;