@driftengine/core 4.8.4 → 4.8.6

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 (138) hide show
  1. package/CHANGELOG.json +4332 -4250
  2. package/dist/build/shaderComments.js +0 -0
  3. package/dist/index.d.ts +10 -2
  4. package/dist/index.js +11 -1
  5. package/dist/render/ambientHarmonics.d.ts +46 -0
  6. package/dist/render/ambientHarmonics.js +80 -0
  7. package/dist/render/areaLights.d.ts +19 -0
  8. package/dist/render/areaLights.js +20 -0
  9. package/dist/render/backend/api.d.ts +13 -2
  10. package/dist/render/backend/webgl2/instanced.d.ts +2 -0
  11. package/dist/render/backend/webgl2/instanced.js +11 -2
  12. package/dist/render/backend/webgl2/renderer.d.ts +74 -1
  13. package/dist/render/backend/webgl2/renderer.js +229 -37
  14. package/dist/render/backend/webgl2/skinScatter.d.ts +14 -0
  15. package/dist/render/backend/webgl2/skinScatter.js +88 -26
  16. package/dist/render/backend/webgpu/buffers.js +2 -1
  17. package/dist/render/backend/webgpu/clusterBinner.js +3 -2
  18. package/dist/render/backend/webgpu/flatPass.d.ts +7 -5
  19. package/dist/render/backend/webgpu/flatPass.js +15 -13
  20. package/dist/render/backend/webgpu/particlePass.d.ts +37 -4
  21. package/dist/render/backend/webgpu/particlePass.js +80 -12
  22. package/dist/render/backend/webgpu/postPass.d.ts +6 -1
  23. package/dist/render/backend/webgpu/postPass.js +7 -2
  24. package/dist/render/backend/webgpu/renderer.d.ts +58 -7
  25. package/dist/render/backend/webgpu/renderer.js +329 -78
  26. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  27. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +41 -1
  28. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +40 -28
  29. package/dist/render/backend/webgpu/skinPaletteRing.js +69 -32
  30. package/dist/render/backend/webgpu/skinScatterPass.d.ts +10 -2
  31. package/dist/render/backend/webgpu/skinScatterPass.js +59 -9
  32. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +2 -1
  33. package/dist/render/backend/webgpu/surfaceTexturePass.js +18 -4
  34. package/dist/render/bloomChain.d.ts +44 -0
  35. package/dist/render/bloomChain.js +28 -0
  36. package/dist/render/bloomPass.d.ts +3 -1
  37. package/dist/render/bloomPass.js +16 -2
  38. package/dist/render/clusteredLights.d.ts +55 -1
  39. package/dist/render/clusteredLights.js +182 -8
  40. package/dist/render/fixtureAtlas.js +1 -1
  41. package/dist/render/instanceCull.js +7 -0
  42. package/dist/render/instances.d.ts +32 -10
  43. package/dist/render/instances.js +17 -8
  44. package/dist/render/lightBudget.d.ts +26 -1
  45. package/dist/render/lightBudget.js +26 -3
  46. package/dist/render/lightmap.d.ts +102 -0
  47. package/dist/render/lightmap.js +118 -0
  48. package/dist/render/mesh.js +2 -1
  49. package/dist/render/particleBatch.d.ts +59 -3
  50. package/dist/render/particleBatch.js +53 -2
  51. package/dist/render/particleMaterial.d.ts +6 -1
  52. package/dist/render/particleMaterial.js +10 -2
  53. package/dist/render/particlePool.d.ts +11 -0
  54. package/dist/render/particlePool.js +2 -0
  55. package/dist/render/particleSort.d.ts +34 -0
  56. package/dist/render/particleSort.js +96 -0
  57. package/dist/render/sceneTarget.d.ts +5 -0
  58. package/dist/render/sceneTarget.js +8 -1
  59. package/dist/render/shaders/bloom.d.ts +8 -4
  60. package/dist/render/shaders/bloom.js +20 -3
  61. package/dist/render/shaders/flat/areaLight.d.ts +10 -0
  62. package/dist/render/shaders/flat/areaLight.js +96 -0
  63. package/dist/render/shaders/flat/index.js +30 -4
  64. package/dist/render/shaders/flat/main.d.ts +1 -1
  65. package/dist/render/shaders/flat/main.js +107 -105
  66. package/dist/render/shaders/flat/models.d.ts +16 -4
  67. package/dist/render/shaders/flat/models.js +64 -4
  68. package/dist/render/shaders/flat/normalMap.d.ts +10 -0
  69. package/dist/render/shaders/flat/normalMap.js +25 -0
  70. package/dist/render/shaders/flat/preamble.js +74 -37
  71. package/dist/render/shaders/flat/skin.js +6 -5
  72. package/dist/render/shaders/generated/bloom.wgsl.d.ts +13 -3
  73. package/dist/render/shaders/generated/bloom.wgsl.js +13 -3
  74. package/dist/render/shaders/generated/flat.wgsl.d.ts +1631 -2044
  75. package/dist/render/shaders/generated/flat.wgsl.js +1669 -2082
  76. package/dist/render/shaders/generated/particle.wgsl.d.ts +115 -1
  77. package/dist/render/shaders/generated/particle.wgsl.js +115 -1
  78. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +12 -2
  79. package/dist/render/shaders/generated/skinBlur.wgsl.js +12 -2
  80. package/dist/render/shaders/particle.d.ts +17 -1
  81. package/dist/render/shaders/particle.js +133 -1
  82. package/dist/render/shaders/skinBlur.js +25 -3
  83. package/dist/render/skinBlur.d.ts +8 -5
  84. package/dist/render/skinBlur.js +8 -5
  85. package/dist/render/surfaceModel.d.ts +21 -1
  86. package/dist/render/surfaceModel.js +22 -0
  87. package/dist/render/surfaceTexture.d.ts +14 -1
  88. package/dist/render/surfaceTexture.js +15 -2
  89. package/package.json +6 -6
  90. package/src/build/shaderComments.ts +0 -0
  91. package/src/index.ts +20 -1
  92. package/src/render/ambientHarmonics.ts +92 -0
  93. package/src/render/areaLights.ts +39 -0
  94. package/src/render/backend/api.ts +14 -1
  95. package/src/render/backend/webgl2/instanced.ts +12 -2
  96. package/src/render/backend/webgl2/renderer.ts +292 -35
  97. package/src/render/backend/webgl2/skinScatter.ts +120 -43
  98. package/src/render/backend/webgpu/buffers.ts +2 -1
  99. package/src/render/backend/webgpu/clusterBinner.ts +3 -1
  100. package/src/render/backend/webgpu/flatPass.ts +18 -13
  101. package/src/render/backend/webgpu/particlePass.ts +118 -12
  102. package/src/render/backend/webgpu/postPass.ts +7 -2
  103. package/src/render/backend/webgpu/renderer.ts +396 -95
  104. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +41 -1
  105. package/src/render/backend/webgpu/skinPaletteRing.ts +65 -33
  106. package/src/render/backend/webgpu/skinScatterPass.ts +59 -9
  107. package/src/render/backend/webgpu/surfaceTexturePass.ts +26 -5
  108. package/src/render/bloomChain.ts +69 -0
  109. package/src/render/bloomPass.ts +26 -2
  110. package/src/render/clusteredLights.ts +221 -7
  111. package/src/render/fixtureAtlas.ts +1 -1
  112. package/src/render/instanceCull.ts +5 -0
  113. package/src/render/instances.ts +40 -11
  114. package/src/render/lightBudget.ts +48 -2
  115. package/src/render/lightmap.ts +202 -0
  116. package/src/render/mesh.ts +2 -1
  117. package/src/render/particleBatch.ts +95 -4
  118. package/src/render/particleMaterial.ts +11 -1
  119. package/src/render/particlePool.ts +13 -0
  120. package/src/render/particleSort.ts +133 -0
  121. package/src/render/sceneTarget.ts +18 -1
  122. package/src/render/shaders/bloom.ts +20 -3
  123. package/src/render/shaders/flat/areaLight.ts +96 -0
  124. package/src/render/shaders/flat/index.ts +30 -4
  125. package/src/render/shaders/flat/main.ts +107 -105
  126. package/src/render/shaders/flat/models.ts +73 -8
  127. package/src/render/shaders/flat/normalMap.ts +25 -0
  128. package/src/render/shaders/flat/preamble.ts +74 -37
  129. package/src/render/shaders/flat/skin.ts +6 -5
  130. package/src/render/shaders/generated/bloom.wgsl.ts +13 -3
  131. package/src/render/shaders/generated/flat.wgsl.ts +1669 -2082
  132. package/src/render/shaders/generated/particle.wgsl.ts +116 -1
  133. package/src/render/shaders/generated/skinBlur.wgsl.ts +12 -2
  134. package/src/render/shaders/particle.ts +134 -1
  135. package/src/render/shaders/skinBlur.ts +25 -3
  136. package/src/render/skinBlur.ts +8 -5
  137. package/src/render/surfaceModel.ts +36 -1
  138. package/src/render/surfaceTexture.ts +41 -3
@@ -57,6 +57,50 @@ export declare function bloomProfileWarning(quality: {
57
57
  readonly hdrScene: boolean;
58
58
  readonly bloomThreshold: number;
59
59
  }): string | null;
60
+ /**
61
+ * How a frame's bloom answers the light it is given, beside the strength and threshold `setBloom`
62
+ * already takes. Absent, or a field absent, is the response every frame had before 4.8.6.
63
+ *
64
+ * **Why it exists: one band over the colour less the threshold washes a stage built for another
65
+ * engine.** Unreal's standard bloom keeps `saturate((L − threshold) / 2)` of a colour rather than
66
+ * the colour less the threshold, and spreads it over six bands, each in a tint of its own. A
67
+ * stage authored against that — a threshold of 0.1 and tints summing below one — came out white
68
+ * here, because subtracting so low a threshold keeps nearly every light in the frame at full
69
+ * strength. These are the two things that differ, as numbers.
70
+ */
71
+ export interface BloomResponse {
72
+ /**
73
+ * Scene units over which a colour comes in, from none of it at the threshold to all of it this
74
+ * far past. **0, the default, subtracts instead**: a colour keeps what it has past the threshold,
75
+ * the response since bloom shipped. Unreal's is 2 after exposure, so a frame exposed by `e` that
76
+ * wants it passes `2 / e`, as it passes the threshold over the exposure.
77
+ *
78
+ * Brightness is still the largest channel rather than luminance, for the reason
79
+ * `shaders/bloom.ts` gives; on a neutral colour the two are equal.
80
+ */
81
+ readonly ramp?: number;
82
+ /**
83
+ * Each level's colour, three numbers a level, finest first: the halo at a few pixels first and
84
+ * at a twentieth of the frame last. **White, the default, is the identity**; a level not named
85
+ * takes white. Not clamped above one, which is how a tint carries a strength past the 0..1 the
86
+ * dial gives — a stage asking for twice the light names tints twice as bright.
87
+ *
88
+ * At most `BLOOM_LEVELS` levels. A frame too small for that many uses the first it has room for.
89
+ */
90
+ readonly tints?: ArrayLike<number>;
91
+ }
92
+ /** Floats `resolveBloomResponse` writes: the ramp, then a tint a level. */
93
+ export declare const BLOOM_RESPONSE_FLOATS: number;
94
+ /**
95
+ * A response as both backends bind it, into a caller-owned `out` of `BLOOM_RESPONSE_FLOATS`: the
96
+ * ramp, then `BLOOM_LEVELS` tints, white where none was named. Refuses a tint list that is not
97
+ * whole colours or names more levels than the pyramid has, naming the length — a caller error with
98
+ * one correct outcome. A ramp that is not a positive number is the subtraction; a tint channel
99
+ * that is not a number or is below zero is zero.
100
+ */
101
+ export declare function resolveBloomResponse(response: BloomResponse | null | undefined, out: Float32Array): void;
102
+ /** The response every frame had before 4.8.6: the subtraction, every level white. */
103
+ export declare function defaultBloomResponse(): Float32Array;
60
104
  /** One level of the pyramid: level 0 is half the frame and each one after it half again. */
61
105
  export interface BloomLevelSize {
62
106
  readonly width: number;
@@ -67,6 +67,34 @@ export function bloomProfileWarning(quality) {
67
67
  }
68
68
  return null;
69
69
  }
70
+ /** Floats `resolveBloomResponse` writes: the ramp, then a tint a level. */
71
+ export const BLOOM_RESPONSE_FLOATS = 1 + BLOOM_LEVELS * 3;
72
+ /**
73
+ * A response as both backends bind it, into a caller-owned `out` of `BLOOM_RESPONSE_FLOATS`: the
74
+ * ramp, then `BLOOM_LEVELS` tints, white where none was named. Refuses a tint list that is not
75
+ * whole colours or names more levels than the pyramid has, naming the length — a caller error with
76
+ * one correct outcome. A ramp that is not a positive number is the subtraction; a tint channel
77
+ * that is not a number or is below zero is zero.
78
+ */
79
+ export function resolveBloomResponse(response, out) {
80
+ const ramp = response?.ramp ?? 0;
81
+ out[0] = Number.isFinite(ramp) && ramp > 0 ? ramp : 0;
82
+ const tints = response?.tints;
83
+ if (tints !== undefined && (tints.length % 3 !== 0 || tints.length > BLOOM_LEVELS * 3)) {
84
+ throw new Error(`setBloom: ${tints.length} tint values is not up to ${BLOOM_LEVELS} whole colours, three ` +
85
+ 'numbers a level, finest first');
86
+ }
87
+ for (let i = 0; i < BLOOM_LEVELS * 3; i++) {
88
+ const value = tints !== undefined && i < tints.length ? tints[i] : 1;
89
+ out[1 + i] = Number.isFinite(value) && value > 0 ? value : 0;
90
+ }
91
+ }
92
+ /** The response every frame had before 4.8.6: the subtraction, every level white. */
93
+ export function defaultBloomResponse() {
94
+ const out = new Float32Array(BLOOM_RESPONSE_FLOATS);
95
+ resolveBloomResponse(null, out);
96
+ return out;
97
+ }
70
98
  /**
71
99
  * The pyramid for a frame of this size, largest first, or an empty list where none would fit.
72
100
  *
@@ -46,7 +46,9 @@ export declare class BloomPass {
46
46
  * `threshold` is in scene units, so it only separates a light from white paint on a target
47
47
  * that kept the range. The caller owns that decision and the warning that goes with it.
48
48
  */
49
- run(scene: WebGLTexture, width: number, height: number, threshold: number): WebGLTexture | null;
49
+ run(scene: WebGLTexture, width: number, height: number, threshold: number,
50
+ /** The ramp, then each level's tint: `resolveBloomResponse`'s floats. */
51
+ response: Float32Array): WebGLTexture | null;
50
52
  /** One source texel in UV, which is what the thirteen taps are laid out in. */
51
53
  private uploadTexel;
52
54
  private release;
@@ -130,7 +130,9 @@ export class BloomPass {
130
130
  * `threshold` is in scene units, so it only separates a light from white paint on a target
131
131
  * that kept the range. The caller owns that decision and the warning that goes with it.
132
132
  */
133
- run(scene, width, height, threshold) {
133
+ run(scene, width, height, threshold,
134
+ /** The ramp, then each level's tint: `resolveBloomResponse`'s floats. */
135
+ response) {
134
136
  if (!this.ensureSize(width, height))
135
137
  return null;
136
138
  const { gl } = this;
@@ -153,6 +155,7 @@ export class BloomPass {
153
155
  gl.bindTexture(gl.TEXTURE_2D, scene);
154
156
  gl.uniform1i(this.prefilterUniforms['uSource'] ?? null, 0);
155
157
  gl.uniform1f(this.prefilterUniforms['uThreshold'] ?? null, threshold);
158
+ gl.uniform1f(this.prefilterUniforms['uRamp'] ?? null, response[0] ?? 0);
156
159
  this.uploadTexel(this.prefilterUniforms, width, height);
157
160
  gl.drawArrays(gl.TRIANGLES, 0, 3);
158
161
  /* Down the chain. Each level reads the one above it, which was written a draw ago. */
@@ -174,8 +177,13 @@ export class BloomPass {
174
177
  * every octave at once. Additive blending rather than a second target to ping-pong through:
175
178
  * a level is only ever read while a *different* level is bound, so there is no feedback.
176
179
  */
180
+ /*
181
+ * Each level's tint rides the blend: the level being added onto is multiplied by its own as the
182
+ * octave below is added, and the deepest by `uScale` on the first draw, the only one reading it
183
+ * before anything has been added to it. White everywhere is `ONE, ONE` exactly.
184
+ */
177
185
  gl.enable(gl.BLEND);
178
- gl.blendFunc(gl.ONE, gl.ONE);
186
+ gl.blendFunc(gl.ONE, gl.CONSTANT_COLOR);
179
187
  gl.useProgram(this.upsampleProgram);
180
188
  gl.uniform1i(this.upsampleUniforms['uSource'] ?? null, 0);
181
189
  gl.uniform1f(this.upsampleUniforms['uRadius'] ?? null, BLOOM_FILTER_RADIUS_UV);
@@ -184,11 +192,17 @@ export class BloomPass {
184
192
  const source = levels[index];
185
193
  if (target === undefined || source === undefined)
186
194
  break;
195
+ const deepest = index === levels.length - 1;
196
+ const into = 1 + (index - 1) * 3;
197
+ const from = 1 + index * 3;
198
+ gl.blendColor(response[into] ?? 1, response[into + 1] ?? 1, response[into + 2] ?? 1, 1);
199
+ gl.uniform3f(this.upsampleUniforms['uScale'] ?? null, deepest ? (response[from] ?? 1) : 1, deepest ? (response[from + 1] ?? 1) : 1, deepest ? (response[from + 2] ?? 1) : 1);
187
200
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
188
201
  gl.viewport(0, 0, target.width, target.height);
189
202
  gl.bindTexture(gl.TEXTURE_2D, source.texture);
190
203
  gl.drawArrays(gl.TRIANGLES, 0, 3);
191
204
  }
205
+ gl.blendColor(0, 0, 0, 0);
192
206
  gl.disable(gl.BLEND);
193
207
  gl.bindVertexArray(null);
194
208
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
@@ -1,3 +1,4 @@
1
+ import type { AreaLightBuffer } from './areaLights.ts';
1
2
  /**
2
3
  * The froxel table: what it looks like in memory, and the CPU binner that fills it.
3
4
  *
@@ -139,7 +140,21 @@ export declare const LIGHT_RECORD: {
139
140
  readonly iesAxisX: 16;
140
141
  readonly iesAxisY: 17;
141
142
  readonly iesAxisZ: 18;
143
+ /**
144
+ * The light's lighting channels, a mask (`PointLightSet.lightChannels`), in the slot that was
145
+ * spare. A light naming channels other than the default is read as carrying a fixture, so its
146
+ * last texels are fetched; a plain light's are not, and the shader takes the default for it.
147
+ */
148
+ readonly channels: 19;
142
149
  };
150
+ /** The lighting channel every light and every surface is on unless it says otherwise. */
151
+ export declare const DEFAULT_LIGHT_CHANNELS = 1;
152
+ /**
153
+ * A light's channels as the shader compares them: a whole mask from 1 to 255, or the default where
154
+ * none was named or what was named is not one. The froxel record and the uniform path both write
155
+ * this, so a light named badly is on channel 1 in both rather than on two different guesses.
156
+ */
157
+ export declare function lightChannelsOf(value: number | undefined): number;
143
158
  /** Texels the lit pass reads for a light whose record carries no fixture. */
144
159
  export declare const PLAIN_LIGHT_TEXELS = 2;
145
160
  /**
@@ -270,6 +285,16 @@ export interface ClusterLightSet {
270
285
  readonly cookies?: Float32Array;
271
286
  /** One per light: an index into the IES atlas, or −1. */
272
287
  readonly iesProfiles?: Float32Array;
288
+ /** One per light: its lighting channels. See `PointLightSet.lightChannels`. */
289
+ readonly channels?: Float32Array;
290
+ /**
291
+ * The rectangles shaded through the table, after the `count` point lights: those of `areas` from
292
+ * `areaFrom` on, `areaCount` of them. The ones before `areaFrom` are the fixed arm's, which is
293
+ * where a rectangle's shadow lives. Absent, there are none.
294
+ */
295
+ readonly areas?: AreaLightBuffer | null;
296
+ readonly areaFrom?: number;
297
+ readonly areaCount?: number;
273
298
  }
274
299
  /** Where `fillClusterLightSet` reads a frame's lights from: the arrays `Environment` carries. */
275
300
  export interface ClusterLightArrays {
@@ -284,6 +309,8 @@ export interface ClusterLightArrays {
284
309
  readonly lightIesProfiles?: Float32Array;
285
310
  readonly lightIesAxes?: Float32Array;
286
311
  readonly lightCookies?: Float32Array;
312
+ readonly lightChannels?: Float32Array;
313
+ readonly areaLights?: AreaLightBuffer | null;
287
314
  }
288
315
  /** A `ClusterLightSet` a renderer refills every frame. */
289
316
  export type ClusterLightScratch = {
@@ -305,7 +332,9 @@ export declare function createClusterLightSet(): ClusterLightScratch;
305
332
  * radius in its place, an emitter as wide as the light reaches, which widened every lamp's
306
333
  * highlight on a specular surface on the clustered arm alone.
307
334
  */
308
- export declare function fillClusterLightSet(env: ClusterLightArrays, out: ClusterLightScratch): ClusterLightSet;
335
+ export declare function fillClusterLightSet(env: ClusterLightArrays, out: ClusterLightScratch,
336
+ /** How many rectangles the fixed arm shades; the rest of the buffer is clustered. */
337
+ areaFrom?: number): ClusterLightSet;
309
338
  /**
310
339
  * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
311
340
  * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
@@ -343,6 +372,8 @@ export declare function clusteredMode(lights: ClusterLightSet, count: number, sh
343
372
  * is a light without a map, which is what a scene with two hundred lamps has anyway.
344
373
  */
345
374
  export declare function writeLightRecord(lights: ClusterLightSet, light: number, shadowSlots: number, out: Uint32Array, at: number): void;
375
+ /** How many lights the table carries: the point lights and the clustered rectangles after them. */
376
+ export declare function clusteredLightTotal(lights: ClusterLightSet): number;
346
377
  /**
347
378
  * Fill the table with this frame's light assignment, and answer how many lights it carried.
348
379
  *
@@ -389,3 +420,26 @@ export declare function writeLightRecord(lights: ClusterLightSet, light: number,
389
420
  * Fills a caller-owned table and allocates nothing.
390
421
  */
391
422
  export declare function buildLightClusters(lights: ClusterLightSet, view: Float32Array, near: number, far: number, tanHalfFovY: number, aspect: number, table: Uint32Array, shadowSlots: number): number;
423
+ /**
424
+ * How far a froxel lies outside a spot's cone, in metres, or a negative number where the cone
425
+ * reaches it: positive is a froxel the light cannot shade. Everything in view space with depth
426
+ * positive, as the binner holds it; `axis` is unit length and `cosA`, `sinA` are the outer cone's.
427
+ *
428
+ * **The froxel's bounding sphere against the infinite cone**, after Wronski's cone test for
429
+ * clustered spots. With `V` from the apex to the sphere's centre at an angle `θ` from the axis and
430
+ * `A` the cone's half-angle, the centre's distance from the cone is `|V| sin(θ − A)` — which is
431
+ * `cosA |V⊥| − (V·axis) sinA` — while `θ − A` is under a right angle, and `|V|` past it, where the
432
+ * nearest point of the cone is its apex. Inside the cone it is negative. So the gap is exact for
433
+ * the sphere, and the sphere holds the froxel: the test can only keep a light, never drop one the
434
+ * shader would draw. The apex case is what drops a froxel behind a spot whose sphere reaches it,
435
+ * which the formula alone underestimates.
436
+ *
437
+ * **What it gives up** is tightness at the near end of the frustum, where a froxel is a long thin
438
+ * box whose sphere is much larger than it: a spot grazing such a froxel is kept. **What would make
439
+ * it wrong** is a profile showing those near froxels hold the cost; testing the box's corners is
440
+ * the tight version, at eight dot products a pair.
441
+ *
442
+ * Exported so the conformance page can tell a disagreement at the cone's edge from a real one; the
443
+ * GPU binner writes the same arithmetic in WGSL.
444
+ */
445
+ export declare function coneBoxGap(px: number, py: number, pz: number, axisX: number, axisY: number, axisZ: number, cosA: number, sinA: number, box: Float32Array): number;
@@ -139,7 +139,26 @@ export const LIGHT_RECORD = {
139
139
  iesAxisX: 16,
140
140
  iesAxisY: 17,
141
141
  iesAxisZ: 18,
142
+ /**
143
+ * The light's lighting channels, a mask (`PointLightSet.lightChannels`), in the slot that was
144
+ * spare. A light naming channels other than the default is read as carrying a fixture, so its
145
+ * last texels are fetched; a plain light's are not, and the shader takes the default for it.
146
+ */
147
+ channels: 19,
142
148
  };
149
+ /** The lighting channel every light and every surface is on unless it says otherwise. */
150
+ export const DEFAULT_LIGHT_CHANNELS = 1;
151
+ /**
152
+ * A light's channels as the shader compares them: a whole mask from 1 to 255, or the default where
153
+ * none was named or what was named is not one. The froxel record and the uniform path both write
154
+ * this, so a light named badly is on channel 1 in both rather than on two different guesses.
155
+ */
156
+ export function lightChannelsOf(value) {
157
+ if (value === undefined || !Number.isInteger(value) || value < 1 || value > 255) {
158
+ return DEFAULT_LIGHT_CHANNELS;
159
+ }
160
+ return value;
161
+ }
143
162
  /** Texels the lit pass reads for a light whose record carries no fixture. */
144
163
  export const PLAIN_LIGHT_TEXELS = 2;
145
164
  /**
@@ -354,8 +373,14 @@ export function createClusterLightSet() {
354
373
  * radius in its place, an emitter as wide as the light reaches, which widened every lamp's
355
374
  * highlight on a specular surface on the clustered arm alone.
356
375
  */
357
- export function fillClusterLightSet(env, out) {
376
+ export function fillClusterLightSet(env, out,
377
+ /** How many rectangles the fixed arm shades; the rest of the buffer is clustered. */
378
+ areaFrom = 0) {
358
379
  out.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
380
+ const areas = env.areaLights ?? null;
381
+ out.areas = areas;
382
+ out.areaFrom = areaFrom;
383
+ out.areaCount = Math.max(0, Math.min((areas?.count ?? 0) - areaFrom, MAX_CLUSTERED_LIGHTS - out.count));
359
384
  out.positions = env.lightPositions;
360
385
  out.colors = env.lightColors;
361
386
  out.radii = env.lightRadii;
@@ -366,6 +391,7 @@ export function fillClusterLightSet(env, out) {
366
391
  out.iesProfiles = env.lightIesProfiles;
367
392
  out.iesAxes = env.lightIesAxes;
368
393
  out.cookies = env.lightCookies;
394
+ out.channels = env.lightChannels;
369
395
  return out;
370
396
  }
371
397
  /*
@@ -418,12 +444,16 @@ function distanceSqToBounds(x, y, z, b) {
418
444
  * them was paying for three texels the shader then ignored.
419
445
  */
420
446
  export function lightHasFixture(lights, light, shadowSlots) {
447
+ /* A rectangle's record always carries its axes and extents past the second texel. */
448
+ if (light >= lights.count)
449
+ return true;
421
450
  const cone = lights.coneCos;
422
451
  return (light < shadowSlots ||
423
452
  (cone?.[light * 2] ?? POINT_LIGHT_COS_INNER) !== POINT_LIGHT_COS_INNER ||
424
453
  (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
425
454
  (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
426
- (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0);
455
+ (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0 ||
456
+ lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS);
427
457
  }
428
458
  /**
429
459
  * The value of `uClustered` for a frame: 0 with clustering off, 1 when every light is plain, 2 when
@@ -459,6 +489,10 @@ export function clusteredMode(lights, count, shadowSlots) {
459
489
  * is a light without a map, which is what a scene with two hundred lamps has anyway.
460
490
  */
461
491
  export function writeLightRecord(lights, light, shadowSlots, out, at) {
492
+ if (light >= lights.count) {
493
+ writeAreaRecord(lights, light, out, at);
494
+ return;
495
+ }
462
496
  const directions = lights.directions;
463
497
  const cone = lights.coneCos;
464
498
  const axes = lights.iesAxes;
@@ -490,7 +524,64 @@ export function writeLightRecord(lights, light, shadowSlots, out, at) {
490
524
  out[at + LIGHT_RECORD.iesAxisX] = floatBits(axes?.[light * 3] ?? 0);
491
525
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
492
526
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
493
- out[at + 19] = 0;
527
+ out[at + LIGHT_RECORD.channels] = floatBits(lightChannelsOf(lights.channels?.[light]));
528
+ }
529
+ /**
530
+ * Which rectangle of the area buffer clustered light `light` is, for a light past the point
531
+ * lights. See `ClusterLightSet.areas`.
532
+ */
533
+ function areaSlot(lights, light) {
534
+ return (lights.areaFrom ?? 0) + light - lights.count;
535
+ }
536
+ /**
537
+ * A rectangle's record, which the shader tells from a lamp's by its **negative radius** — its
538
+ * reach, negated — and reads to its fifth texel: sidedness where a lamp keeps its shadow slot,
539
+ * the right axis where a spot keeps its direction, the half extents where its cone is, the up
540
+ * axis where the azimuth is, and the channels last. Never shadowed: a rectangle's occlusion is
541
+ * the fixed arm's, which is why the first `maxAreaLights` stay there.
542
+ */
543
+ function writeAreaRecord(lights, light, out, at) {
544
+ const areas = lights.areas;
545
+ const a = areaSlot(lights, light);
546
+ const pos = areas?.positions;
547
+ const color = areas?.colors;
548
+ const right = areas?.right;
549
+ const up = areas?.up;
550
+ const sizes = areas?.sizes;
551
+ out[at + LIGHT_RECORD.positionX] = floatBits(pos?.[a * 3] ?? 0);
552
+ out[at + LIGHT_RECORD.positionY] = floatBits(pos?.[a * 3 + 1] ?? 0);
553
+ out[at + LIGHT_RECORD.positionZ] = floatBits(pos?.[a * 3 + 2] ?? 0);
554
+ out[at + LIGHT_RECORD.radius] = floatBits(-areaReach(lights, light));
555
+ out[at + LIGHT_RECORD.colorR] = floatBits(color?.[a * 3] ?? 0);
556
+ out[at + LIGHT_RECORD.colorG] = floatBits(color?.[a * 3 + 1] ?? 0);
557
+ out[at + LIGHT_RECORD.colorB] = floatBits(color?.[a * 3 + 2] ?? 0);
558
+ out[at + LIGHT_RECORD.sizeAndWeight] = packSizeAndWeight(0, 1, true);
559
+ out[at + LIGHT_RECORD.shadowSlot] = floatBits(areas?.twoSided[a] ?? 0);
560
+ out[at + LIGHT_RECORD.directionX] = floatBits(right?.[a * 3] ?? 1);
561
+ out[at + LIGHT_RECORD.directionY] = floatBits(right?.[a * 3 + 1] ?? 0);
562
+ out[at + LIGHT_RECORD.directionZ] = floatBits(right?.[a * 3 + 2] ?? 0);
563
+ out[at + LIGHT_RECORD.cosInner] = floatBits(sizes?.[a * 2] ?? 0);
564
+ out[at + LIGHT_RECORD.cosOuter] = floatBits(sizes?.[a * 2 + 1] ?? 0);
565
+ out[at + LIGHT_RECORD.iesProfile] = floatBits(NO_IES_PROFILE);
566
+ out[at + LIGHT_RECORD.cookie] = floatBits(NO_IES_PROFILE);
567
+ out[at + LIGHT_RECORD.iesAxisX] = floatBits(up?.[a * 3] ?? 0);
568
+ out[at + LIGHT_RECORD.iesAxisY] = floatBits(up?.[a * 3 + 1] ?? 1);
569
+ out[at + LIGHT_RECORD.iesAxisZ] = floatBits(up?.[a * 3 + 2] ?? 0);
570
+ out[at + LIGHT_RECORD.channels] = floatBits(DEFAULT_LIGHT_CHANNELS);
571
+ }
572
+ /** A clustered rectangle's reach, which its record carries negated. See `AreaLightSource.range`. */
573
+ function areaReach(lights, light) {
574
+ const areas = lights.areas;
575
+ const a = areaSlot(lights, light);
576
+ const named = areas?.ranges?.[a] ?? 0;
577
+ if (named > 0)
578
+ return named;
579
+ const sizes = areas?.sizes;
580
+ return Math.hypot(sizes?.[a * 2] ?? 0, sizes?.[a * 2 + 1] ?? 0);
581
+ }
582
+ /** How many lights the table carries: the point lights and the clustered rectangles after them. */
583
+ export function clusteredLightTotal(lights) {
584
+ return Math.min(lights.count + (lights.areaCount ?? 0), MAX_CLUSTERED_LIGHTS);
494
585
  }
495
586
  /**
496
587
  * Fill the table with this frame's light assignment, and answer how many lights it carried.
@@ -538,7 +629,7 @@ export function writeLightRecord(lights, light, shadowSlots, out, at) {
538
629
  * Fills a caller-owned table and allocates nothing.
539
630
  */
540
631
  export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect, table, shadowSlots) {
541
- const count = Math.min(lights.count, MAX_CLUSTERED_LIGHTS);
632
+ const count = clusteredLightTotal(lights);
542
633
  /*
543
634
  * The whole cluster region, not just the counts.
544
635
  *
@@ -553,10 +644,14 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
553
644
  for (let k = 0; k <= CLUSTER_Z; k++)
554
645
  sliceDepths[k] = sliceNearDepth(k, near, far);
555
646
  for (let light = 0; light < count; light++) {
556
- const x = lights.positions[light * 3] ?? 0;
557
- const y = lights.positions[light * 3 + 1] ?? 0;
558
- const z = lights.positions[light * 3 + 2] ?? 0;
559
- const radius = lights.radii[light] ?? 0;
647
+ /* A rectangle past the lamps is placed by its centre and binned by its reach. */
648
+ const area = light >= lights.count;
649
+ const from = area ? (lights.areas?.positions ?? lights.positions) : lights.positions;
650
+ const slot = area ? areaSlot(lights, light) : light;
651
+ const x = from[slot * 3] ?? 0;
652
+ const y = from[slot * 3 + 1] ?? 0;
653
+ const z = from[slot * 3 + 2] ?? 0;
654
+ const radius = area ? areaReach(lights, light) : (lights.radii[light] ?? 0);
560
655
  /* The record, as the shader reads it. World space; see the note above. */
561
656
  writeLightRecord(lights, light, shadowSlots, table, lightBase(light));
562
657
  /* Column-major, as gl-matrix builds it. The view looks down -z, so depth is -vz. */
@@ -570,6 +665,39 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
570
665
  /* Entirely behind the camera, so no cluster can reach it. */
571
666
  if (depth + radius <= 0)
572
667
  continue;
668
+ /*
669
+ * **A spot is binned by its cone as well as its sphere.** A 10° spot reaching thirty metres
670
+ * touches every froxel its sphere does and lights a sliver of them, and a stage rigged with
671
+ * hundreds of such fixtures put every one of them in nearly every froxel near the floor: 313
672
+ * lights cost 11.7 ms of a 1600x900 frame. The cone is tested against each froxel's bounding
673
+ * sphere in view space, which is conservative, by the construction `coneBoxGap` describes.
674
+ */
675
+ const cosOuter = lights.coneCos?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
676
+ const directions = lights.directions;
677
+ let coneCos = POINT_LIGHT_COS_OUTER;
678
+ let coneSin = 0;
679
+ let axisX = 0;
680
+ let axisY = 0;
681
+ let axisZ = 0;
682
+ if (!area && directions !== undefined && cosOuter > -1) {
683
+ const dx = directions[light * 3] ?? 0;
684
+ const dy = directions[light * 3 + 1] ?? 0;
685
+ const dz = directions[light * 3 + 2] ?? 0;
686
+ const ax = (view[0] ?? 0) * dx + (view[4] ?? 0) * dy + (view[8] ?? 0) * dz;
687
+ const ay = (view[1] ?? 0) * dx + (view[5] ?? 0) * dy + (view[9] ?? 0) * dz;
688
+ const az = -((view[2] ?? 0) * dx + (view[6] ?? 0) * dy + (view[10] ?? 0) * dz);
689
+ const length = Math.sqrt(ax * ax + ay * ay + az * az);
690
+ /* The shader measures the cone against the direction as given, so a long one is a wider
691
+ cone: `dot(L, d) >= c` is `dot(L, d / |d|) >= c / |d|`. */
692
+ if (length > 1e-6 && cosOuter / length > -1) {
693
+ coneCos = cosOuter / length;
694
+ coneSin = Math.sqrt(Math.max(0, 1 - coneCos * coneCos));
695
+ axisX = ax / length;
696
+ axisY = ay / length;
697
+ axisZ = az / length;
698
+ }
699
+ }
700
+ const spot = coneCos > -1;
573
701
  /*
574
702
  * The slices the sphere could possibly touch. The tiles are derived per slice, below.
575
703
  *
@@ -617,6 +745,10 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
617
745
  sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar, bounds);
618
746
  if (distanceSqToBounds(vx, vy, depth, bounds) > radiusSq)
619
747
  continue;
748
+ if (spot &&
749
+ coneBoxGap(vx, vy, depth, axisX, axisY, axisZ, coneCos, coneSin, bounds) > 0) {
750
+ continue;
751
+ }
620
752
  insert(table, i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y, light, bounds);
621
753
  }
622
754
  }
@@ -624,6 +756,48 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
624
756
  }
625
757
  return count;
626
758
  }
759
+ /**
760
+ * How far a froxel lies outside a spot's cone, in metres, or a negative number where the cone
761
+ * reaches it: positive is a froxel the light cannot shade. Everything in view space with depth
762
+ * positive, as the binner holds it; `axis` is unit length and `cosA`, `sinA` are the outer cone's.
763
+ *
764
+ * **The froxel's bounding sphere against the infinite cone**, after Wronski's cone test for
765
+ * clustered spots. With `V` from the apex to the sphere's centre at an angle `θ` from the axis and
766
+ * `A` the cone's half-angle, the centre's distance from the cone is `|V| sin(θ − A)` — which is
767
+ * `cosA |V⊥| − (V·axis) sinA` — while `θ − A` is under a right angle, and `|V|` past it, where the
768
+ * nearest point of the cone is its apex. Inside the cone it is negative. So the gap is exact for
769
+ * the sphere, and the sphere holds the froxel: the test can only keep a light, never drop one the
770
+ * shader would draw. The apex case is what drops a froxel behind a spot whose sphere reaches it,
771
+ * which the formula alone underestimates.
772
+ *
773
+ * **What it gives up** is tightness at the near end of the frustum, where a froxel is a long thin
774
+ * box whose sphere is much larger than it: a spot grazing such a froxel is kept. **What would make
775
+ * it wrong** is a profile showing those near froxels hold the cost; testing the box's corners is
776
+ * the tight version, at eight dot products a pair.
777
+ *
778
+ * Exported so the conformance page can tell a disagreement at the cone's edge from a real one; the
779
+ * GPU binner writes the same arithmetic in WGSL.
780
+ */
781
+ export function coneBoxGap(px, py, pz, axisX, axisY, axisZ, cosA, sinA, box) {
782
+ const b0 = box[0] ?? 0;
783
+ const b1 = box[1] ?? 0;
784
+ const b2 = box[2] ?? 0;
785
+ const b3 = box[3] ?? 0;
786
+ const b4 = box[4] ?? 0;
787
+ const b5 = box[5] ?? 0;
788
+ const ex = (b3 - b0) * 0.5;
789
+ const ey = (b4 - b1) * 0.5;
790
+ const ez = (b5 - b2) * 0.5;
791
+ const vx = (b0 + b3) * 0.5 - px;
792
+ const vy = (b1 + b4) * 0.5 - py;
793
+ const vz = (b2 + b5) * 0.5 - pz;
794
+ const along = vx * axisX + vy * axisY + vz * axisZ;
795
+ const lengthSq = vx * vx + vy * vy + vz * vz;
796
+ const across = Math.sqrt(Math.max(lengthSq - along * along, 0));
797
+ const reach = Math.sqrt(lengthSq);
798
+ const distance = along < -sinA * reach ? reach : cosA * across - along * sinA;
799
+ return distance - Math.sqrt(ex * ex + ey * ey + ez * ez);
800
+ }
627
801
  /** An NDC coordinate to a tile index, clamped. */
628
802
  function tileOf(ndc, tiles) {
629
803
  const tile = Math.floor((ndc + 1) * 0.5 * tiles);
@@ -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.
@@ -44,6 +44,13 @@ export function cullInstances(data, local, frustum, out) {
44
44
  out.tints[kept * 3] = tints[i * 3];
45
45
  out.tints[kept * 3 + 1] = tints[i * 3 + 1];
46
46
  out.tints[kept * 3 + 2] = tints[i * 3 + 2];
47
+ if (out.alphas !== undefined)
48
+ out.alphas[kept] = data.alphas?.[i] ?? 1;
49
+ const regions = data.lightmapRegions;
50
+ if (regions !== undefined && out.lightmapRegions !== undefined) {
51
+ for (let k = 0; k < 4; k++)
52
+ out.lightmapRegions[kept * 4 + k] = regions[i * 4 + k];
53
+ }
47
54
  kept++;
48
55
  }
49
56
  out.count = kept;
@@ -10,10 +10,11 @@
10
10
  * plant. This one carries a full transform, because a vehicle pitches and rolls on its suspension
11
11
  * and a yaw cannot say so.
12
12
  *
13
- * **What it gives up** is a matrix per instance where a scatter spends five floats: 76 bytes
13
+ * **What it gives up** is a matrix per instance where a scatter spends five floats: 80 bytes
14
14
  * against 44, and no wind. **What would make it wrong** is a caller wanting per-instance
15
- * anything else — an opacity, a morph weight — which wants another attribute, and the sixteen
16
- * WebGL2 guarantees are already spent.
15
+ * anything beyond an opacity — a morph weight, a texture cell — which wants another attribute,
16
+ * and the sixteen WebGL2 guarantees are already spent. The opacity rides the float the stride was
17
+ * padded with, which is why it costs nothing.
17
18
  */
18
19
  export interface MeshInstances {
19
20
  /**
@@ -30,6 +31,26 @@ export interface MeshInstances {
30
31
  * — the alternative is a second batch per colour, which is the thing this exists to avoid.
31
32
  */
32
33
  readonly tints: Float32Array;
34
+ /**
35
+ * One float each, the instance's opacity from 0 to 1, which a blended draw
36
+ * (`drawTranslucentInstanced`) multiplies into the batch's own. **Absent, or an instance past its
37
+ * end, is 1** — wholly opaque, which is what every instance was before 4.8.6.
38
+ *
39
+ * What lets one draw carry particles fading at different rates: without it a consumer drew a
40
+ * batch per opacity level, each a draw and a material of its own. It is the instance's twin of
41
+ * the per-vertex alpha lane (`MeshData.channel`), multiplied in where that lane is.
42
+ */
43
+ readonly alphas?: Float32Array;
44
+ /**
45
+ * Four floats each, the instance's region of its lightmap page: `[scaleU, scaleV, biasU, biasV]`,
46
+ * read by a draw whose material is a `lightmapModel` — instances of one mesh in one material sit
47
+ * in different regions of one page, each applied before the material's own region. **They ride
48
+ * the tint and the opacity**, which a lightmapped instance gives up (see `lightmap.ts`): where
49
+ * these are given, those are not read. **A lightmapped batch needs them**, since a bake is
50
+ * different light at each instance; one drawn without them reads its tints as its regions, and
51
+ * either mismatch is said once on the console.
52
+ */
53
+ readonly lightmapRegions?: Float32Array;
33
54
  /** How many instances the arrays hold. Fixed at creation; the buffers are sized from it. */
34
55
  readonly capacity: number;
35
56
  /** How many are live. The rest of the buffer is neither uploaded nor drawn. */
@@ -45,19 +66,20 @@ export interface MeshInstances {
45
66
  export declare function createMeshInstances(capacity: number): MeshInstances;
46
67
  /**
47
68
  * Floats one instance occupies in the interleaved vertex buffer: sixteen of matrix, three of
48
- * tint, and one of padding.
69
+ * tint, and the opacity.
49
70
  *
50
- * **Padded to twenty so the stride is 80 bytes rather than 76.** A vertex buffer's stride must be
51
- * a multiple of four on both backends, which 76 already is — the padding is for the *attribute*
52
- * offsets: the tint sits at byte 64 and a three-float attribute ending at 76 leaves the next
53
- * instance's first column starting there, which is legal but puts every second instance on an
54
- * offset no driver aligns well. One wasted float an instance is 4 bytes against 76.
71
+ * **Twenty so the stride is 80 bytes rather than 76.** A vertex buffer's stride must be a multiple
72
+ * of four on both backends, which 76 already is — the twentieth float was padding for the
73
+ * *attribute* offsets: the tint sits at byte 64 and a three-float attribute ending at 76 leaves the
74
+ * next instance's first column starting there, which is legal but puts every second instance on an
75
+ * offset no driver aligns well. Since 4.8.6 it carries the instance's opacity, read with the tint
76
+ * as one four-float attribute, so the opacity cost no attribute and no byte.
55
77
  */
56
78
  export declare const INSTANCE_FLOATS = 20;
57
79
  /** Bytes one instance occupies. See `INSTANCE_FLOATS`. */
58
80
  export declare const INSTANCE_STRIDE: number;
59
81
  /**
60
- * Interleave `count` instances into `out`, matrix then tint, at `INSTANCE_FLOATS` apart.
82
+ * Interleave `count` instances into `out`, matrix, tint and opacity, at `INSTANCE_FLOATS` apart.
61
83
  *
62
84
  * **Interleaved rather than two buffers** so a batch's upload is one `writeBuffer` and an
63
85
  * instance's colour sits beside the matrix that places it. Shared by both backends, because