@driftengine/core 4.7.2 → 4.7.4

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 (35) hide show
  1. package/CHANGELOG.json +42 -0
  2. package/dist/geometry/meshBuilder.js +3 -2
  3. package/dist/math/exact.js +3 -3
  4. package/dist/render/backend/webgl2/renderer.js +7 -17
  5. package/dist/render/backend/webgpu/clusterBinner.js +5 -41
  6. package/dist/render/backend/webgpu/renderer.js +9 -17
  7. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  8. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +9 -5
  9. package/dist/render/clusteredLights.d.ts +136 -13
  10. package/dist/render/clusteredLights.js +225 -51
  11. package/dist/render/lightBudget.d.ts +8 -0
  12. package/dist/render/lightBudget.js +10 -3
  13. package/dist/render/mesh.js +0 -2
  14. package/dist/render/plumeRenderer.js +0 -29
  15. package/dist/render/shaders/flat/main.d.ts +1 -1
  16. package/dist/render/shaders/flat/main.js +77 -38
  17. package/dist/render/shaders/flat/preamble.js +2 -1
  18. package/dist/render/shaders/generated/flat.wgsl.js +1 -1
  19. package/dist/scene/node.js +1 -1
  20. package/package.json +6 -6
  21. package/src/geometry/meshBuilder.ts +3 -2
  22. package/src/math/exact.ts +3 -3
  23. package/src/render/backend/webgl2/renderer.ts +10 -26
  24. package/src/render/backend/webgpu/clusterBinner.ts +11 -44
  25. package/src/render/backend/webgpu/linePass.ts +0 -1
  26. package/src/render/backend/webgpu/renderer.ts +14 -25
  27. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +9 -5
  28. package/src/render/clusteredLights.ts +272 -58
  29. package/src/render/lightBudget.ts +23 -3
  30. package/src/render/mesh.ts +0 -2
  31. package/src/render/plumeRenderer.ts +0 -30
  32. package/src/render/shaders/flat/main.ts +77 -38
  33. package/src/render/shaders/flat/preamble.ts +2 -0
  34. package/src/render/shaders/generated/flat.wgsl.ts +1 -1
  35. package/src/scene/node.ts +1 -1
@@ -69,8 +69,16 @@ export const MAX_LIGHTS_PER_CLUSTER = (CLUSTER_TEXELS - 1) * 4;
69
69
  export const MAX_CLUSTERED_LIGHTS = 320;
70
70
 
71
71
  /**
72
- * Texels one light's record occupies: position and radius, colour and emitter size, then the
73
- * weight and the shadow slot.
72
+ * Texels one light's record occupies: position and radius, then colour with the emitter size and
73
+ * the weight, then the shadow slot and the fixture: direction, cone, profile, cookie and azimuth.
74
+ *
75
+ * **A plain point light is read in two of the five.** The lit pass fetches this record once per
76
+ * light per fragment, and at eight megapixels with lights crowded together that fetch is the
77
+ * frame: 320 lamps of radius 12 cost 26.2 ms of a 4K main pass when all five texels were read for
78
+ * every light, and 12.7 ms read in two, on the RX 9070 XT `demo/dev/clusterStress.html` was
79
+ * measured on. So the second texel carries a flag, `LIGHT_FIXTURE_FLAG`, and the last three are
80
+ * fetched only for a light that has a shadow slot, a cone, a profile or a cookie. What a plain light
81
+ * would have read there is what the shader assumes in their place, so the picture is the same.
74
82
  *
75
83
  * **The shadow slot is an index into the existing sixteen-wide shadow uniforms, not a layer.**
76
84
  * Clustering lifts how many lights *shade* a fragment; it does not lift how many shadow maps
@@ -109,6 +117,11 @@ export const LIGHT_TEXELS = 5;
109
117
  * rotating through that direction would snap its pattern round. The two obvious constructions put
110
118
  * that singularity where these fixtures actually point: `cross(worldUp, dir)` fails for a light
111
119
  * aimed straight down, which is a street light.
120
+ *
121
+ * **Reordered 2026-10-02 so a plain light's fields are the first eight**, which is what lets the
122
+ * shader stop after two texels. Nine values are needed by every light and eight fit in two
123
+ * texels, so the emitter size and the weight share slot 7 as two halves; see `packSizeAndWeight`.
124
+ * Slot 19 is spare.
112
125
  */
113
126
  export const LIGHT_RECORD = {
114
127
  positionX: 0,
@@ -118,23 +131,101 @@ export const LIGHT_RECORD = {
118
131
  colorR: 4,
119
132
  colorG: 5,
120
133
  colorB: 6,
121
- sourceRadius: 7,
122
- weight: 8,
123
- shadowSlot: 9,
124
- directionX: 10,
125
- directionY: 11,
126
- directionZ: 12,
127
- cosInner: 13,
128
- cosOuter: 14,
129
- iesProfile: 15,
134
+ /** The emitter size and the weight, two halves in one word, and the fixture flag. */
135
+ sizeAndWeight: 7,
136
+ shadowSlot: 8,
137
+ directionX: 9,
138
+ directionY: 10,
139
+ directionZ: 11,
140
+ cosInner: 12,
141
+ cosOuter: 13,
142
+ iesProfile: 14,
143
+ /** A tile of the cookie atlas, or negative for none. */
144
+ cookie: 15,
130
145
  /** The fixture's azimuth zero, in world space. See the note above on why it cannot be derived. */
131
146
  iesAxisX: 16,
132
147
  iesAxisY: 17,
133
148
  iesAxisZ: 18,
134
- /** A tile of the cookie atlas, or negative for none. Slot 19, the one the fifth texel spared. */
135
- cookie: 19,
136
149
  } as const;
137
150
 
151
+ /** Texels the lit pass reads for a light whose record carries no fixture. */
152
+ export const PLAIN_LIGHT_TEXELS = 2;
153
+
154
+ /**
155
+ * The bit of `sizeAndWeight` that says the record's last three texels hold something: the sign of
156
+ * the emitter size's half, which a size never needs.
157
+ */
158
+ export const LIGHT_FIXTURE_FLAG = 0x8000;
159
+
160
+ const halfScratch = new Float32Array(1);
161
+ const halfScratchBits = new Uint32Array(halfScratch.buffer);
162
+
163
+ /**
164
+ * A number as the sixteen bits of an IEEE half: rounded to the nearest, ties to even.
165
+ *
166
+ * **Computed here, once, and copied by both binners rather than converted on the GPU.** WGSL and
167
+ * GLSL both leave the rounding of `pack2x16float` to the implementation, and two producers of one
168
+ * table that round differently would make `scripts/cluster-check.mjs` compare noise.
169
+ *
170
+ * **A value only a subnormal half could hold is flushed to a signed zero.** A GPU may flush a
171
+ * subnormal it unpacks, and the fixed arm reads the same value from a uniform where nothing would;
172
+ * flushing it here means both arms read zero. Nothing is lost that a pixel could show: the largest
173
+ * value flushed is 6.1e-5, a weight no lamp is visible at.
174
+ */
175
+ export function halfBits(value: number): number {
176
+ halfScratch[0] = value;
177
+ const x = halfScratchBits[0] ?? 0;
178
+ const sign = (x >>> 16) & 0x8000;
179
+ const exponent = (x >>> 23) & 0xff;
180
+ const mantissa = x & 0x7fffff;
181
+ if (exponent === 0xff) return sign | 0x7c00 | (mantissa === 0 ? 0 : 0x200);
182
+ const e = exponent - 127 + 15;
183
+ if (e >= 0x1f) return sign | 0x7c00;
184
+ if (e <= 0) return sign;
185
+ const truncated = (e << 10) | (mantissa >>> 13);
186
+ const rest = mantissa & 0x1fff;
187
+ /* A carry out of the mantissa lands in the exponent, which is the correct rounding up. */
188
+ const rounded =
189
+ rest > 0x1000 || (rest === 0x1000 && (truncated & 1) === 1) ? truncated + 1 : truncated;
190
+ return sign | rounded;
191
+ }
192
+
193
+ /** The number a half's bits hold. Exact: every half is also a float. */
194
+ export function halfValue(half: number): number {
195
+ const sign = (half & 0x8000) === 0 ? 1 : -1;
196
+ const exponent = (half >>> 10) & 0x1f;
197
+ const mantissa = half & 0x3ff;
198
+ if (exponent === 0) return sign * mantissa * 2 ** -24;
199
+ if (exponent === 0x1f) return mantissa === 0 ? sign * Infinity : Number.NaN;
200
+ return sign * (1 + mantissa / 1024) * 2 ** (exponent - 15);
201
+ }
202
+
203
+ /**
204
+ * A lamp's emitter size as both arms read it: its magnitude, at half precision.
205
+ *
206
+ * **The fixed arm is rounded too, and that is what keeps the two arms one picture.**
207
+ * `resolvePointLights` passes the sixteen uniform lights through this and `lampWeight`, so a light
208
+ * moved from the uniform path to the froxel table shades with the same numbers. A negative size was
209
+ * never meaningful and now reads as its magnitude on both.
210
+ */
211
+ export function lampSourceRadius(value: number): number {
212
+ return halfValue(halfBits(Math.abs(value)));
213
+ }
214
+
215
+ /** A lamp's weight as both arms read it: at half precision, sign kept. See `lampSourceRadius`. */
216
+ export function lampWeight(value: number): number {
217
+ return halfValue(halfBits(value));
218
+ }
219
+
220
+ /**
221
+ * Slot 7 of a record: the emitter size's half in the low bits, the weight's in the high, and the
222
+ * fixture flag in the size's sign. The shader unpacks it with `unpackHalf2x16`, which is exact.
223
+ */
224
+ export function packSizeAndWeight(sourceRadius: number, weight: number, fixture: boolean): number {
225
+ const size = halfBits(Math.abs(sourceRadius)) & 0x7fff;
226
+ return ((halfBits(weight) << 16) | size | (fixture ? LIGHT_FIXTURE_FLAG : 0)) >>> 0;
227
+ }
228
+
138
229
  /**
139
230
  * The cone a light with no cone has: one that admits every direction, exactly.
140
231
  *
@@ -312,6 +403,71 @@ export interface ClusterLightSet {
312
403
  readonly iesProfiles?: Float32Array;
313
404
  }
314
405
 
406
+ /** Where `fillClusterLightSet` reads a frame's lights from: the arrays `Environment` carries. */
407
+ export interface ClusterLightArrays {
408
+ readonly lightCount?: number;
409
+ readonly lightPositions: Float32Array;
410
+ readonly lightColors: Float32Array;
411
+ readonly lightRadii: Float32Array;
412
+ readonly lightWeights: Float32Array;
413
+ readonly lightSourceRadii?: Float32Array;
414
+ readonly lightDirections?: Float32Array;
415
+ readonly lightConeCos?: Float32Array;
416
+ readonly lightIesProfiles?: Float32Array;
417
+ readonly lightIesAxes?: Float32Array;
418
+ readonly lightCookies?: Float32Array;
419
+ }
420
+
421
+ /** A `ClusterLightSet` a renderer refills every frame. */
422
+ export type ClusterLightScratch = { -readonly [K in keyof ClusterLightSet]: ClusterLightSet[K] };
423
+
424
+ /** No emitter size at all, which is what the fixed arm reads for a light that was given none. */
425
+ const NO_SOURCE_SIZES = new Float32Array(0);
426
+
427
+ export function createClusterLightSet(): ClusterLightScratch {
428
+ return {
429
+ count: 0,
430
+ positions: NO_SOURCE_SIZES,
431
+ colors: NO_SOURCE_SIZES,
432
+ radii: NO_SOURCE_SIZES,
433
+ sourceRadii: NO_SOURCE_SIZES,
434
+ weights: NO_SOURCE_SIZES,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * A frame's lights as the binner reads them, in place, holding references and allocating nothing.
440
+ *
441
+ * **One assembly for both renderers, and it used to be two that had each dropped the same five
442
+ * fields.** Each backend copied the position, colour, radius, size and weight arrays and nothing
443
+ * else, so a spot's direction and cone, a profile, its axis and a cookie never reached the record:
444
+ * with clustering on, every fixture shaded as a bare point light. On a probe page holding one
445
+ * spot with a profile and a cookie the two arms differed by 357,706 pixels, the clustered one
446
+ * flooding the scene the cone was meant to cut. The record and both shaders were right throughout;
447
+ * the light never arrived.
448
+ *
449
+ * **And a light given no emitter size has none**, as the fixed arm reads it. This read the light's
450
+ * radius in its place, an emitter as wide as the light reaches, which widened every lamp's
451
+ * highlight on a specular surface on the clustered arm alone.
452
+ */
453
+ export function fillClusterLightSet(
454
+ env: ClusterLightArrays,
455
+ out: ClusterLightScratch,
456
+ ): ClusterLightSet {
457
+ out.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
458
+ out.positions = env.lightPositions;
459
+ out.colors = env.lightColors;
460
+ out.radii = env.lightRadii;
461
+ out.sourceRadii = env.lightSourceRadii ?? NO_SOURCE_SIZES;
462
+ out.weights = env.lightWeights;
463
+ out.directions = env.lightDirections;
464
+ out.coneCos = env.lightConeCos;
465
+ out.iesProfiles = env.lightIesProfiles;
466
+ out.iesAxes = env.lightIesAxes;
467
+ out.cookies = env.lightCookies;
468
+ return out;
469
+ }
470
+
315
471
  /*
316
472
  * Scratch, at module scope, because `buildLightClusters` runs per frame on WebGL2 and the house
317
473
  * rule forbids allocating there.
@@ -357,6 +513,108 @@ function distanceSqToBounds(x: number, y: number, z: number, b: Float32Array): n
357
513
  return dx * dx + dy * dy + dz * dz;
358
514
  }
359
515
 
516
+ /**
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`.
519
+ *
520
+ * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
521
+ * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
522
+ * them was paying for three texels the shader then ignored.
523
+ */
524
+ export function lightHasFixture(
525
+ lights: ClusterLightSet,
526
+ light: number,
527
+ shadowSlots: number,
528
+ ): boolean {
529
+ const cone = lights.coneCos;
530
+ return (
531
+ light < shadowSlots ||
532
+ (cone?.[light * 2] ?? POINT_LIGHT_COS_INNER) !== POINT_LIGHT_COS_INNER ||
533
+ (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
534
+ (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
535
+ (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0
536
+ );
537
+ }
538
+
539
+ /**
540
+ * The value of `uClustered` for a frame: 0 with clustering off, 1 when every light is plain, 2 when
541
+ * any carries a fixture.
542
+ *
543
+ * **The third state is a uniform the shader can branch on for free.** The flag test on each light is
544
+ * a branch on a value read from a texture, and a compiler may run both sides of one; 1.3 ms of a 4K
545
+ * main pass of 320 plain lamps was that test. Asked once a frame here, a scene whose lights are all
546
+ * plain never reaches it.
547
+ */
548
+ export function clusteredMode(lights: ClusterLightSet, count: number, shadowSlots: number): 1 | 2 {
549
+ for (let light = 0; light < count; light++) {
550
+ if (lightHasFixture(lights, light, shadowSlots)) return 2;
551
+ }
552
+ return 1;
553
+ }
554
+
555
+ /**
556
+ * One light's record, at `at` in `out`, as the lit pass reads it.
557
+ *
558
+ * **The one writer.** The CPU binner calls this into the table and the GPU binner's staging calls
559
+ * it into the buffer its kernel copies verbatim, so a field can only move in both at once; the
560
+ * 2026-08-17 rule is that two implementations of one decision drift invisibly when the constants
561
+ * are identical, and these were two.
562
+ *
563
+ * **A light is plain unless it has a shadow slot, a cone, a profile or a cookie**, and then the
564
+ * flag is clear and the shader reads two texels. Every slot is still written, so a record holds no
565
+ * bytes from a light that was there last frame and the two binners' tables stay comparable.
566
+ *
567
+ * The shadow slot is signed and carried as a float's bits like everything else in the record. The
568
+ * first `MAX_POINT_LIGHTS` lights keep the shadow slots they already have, because
569
+ * `resolvePointLights` orders them so the ones worth shadowing come first, and everything past that
570
+ * is a light without a map, which is what a scene with two hundred lamps has anyway.
571
+ */
572
+ export function writeLightRecord(
573
+ lights: ClusterLightSet,
574
+ light: number,
575
+ shadowSlots: number,
576
+ out: Uint32Array,
577
+ at: number,
578
+ ): void {
579
+ const directions = lights.directions;
580
+ const cone = lights.coneCos;
581
+ const axes = lights.iesAxes;
582
+ const shadowSlot = light < shadowSlots ? light : NO_SHADOW_SLOT;
583
+ const cosInner = cone?.[light * 2] ?? POINT_LIGHT_COS_INNER;
584
+ const cosOuter = cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
585
+ const profile = lights.iesProfiles?.[light] ?? NO_IES_PROFILE;
586
+ const cookie = lights.cookies?.[light] ?? NO_IES_PROFILE;
587
+ const fixture = lightHasFixture(lights, light, shadowSlots);
588
+
589
+ out[at + LIGHT_RECORD.positionX] = floatBits(lights.positions[light * 3] ?? 0);
590
+ out[at + LIGHT_RECORD.positionY] = floatBits(lights.positions[light * 3 + 1] ?? 0);
591
+ out[at + LIGHT_RECORD.positionZ] = floatBits(lights.positions[light * 3 + 2] ?? 0);
592
+ out[at + LIGHT_RECORD.radius] = floatBits(lights.radii[light] ?? 0);
593
+ out[at + LIGHT_RECORD.colorR] = floatBits(lights.colors[light * 3] ?? 0);
594
+ out[at + LIGHT_RECORD.colorG] = floatBits(lights.colors[light * 3 + 1] ?? 0);
595
+ out[at + LIGHT_RECORD.colorB] = floatBits(lights.colors[light * 3 + 2] ?? 0);
596
+ out[at + LIGHT_RECORD.sizeAndWeight] = packSizeAndWeight(
597
+ lights.sourceRadii[light] ?? 0,
598
+ lights.weights[light] ?? 1,
599
+ fixture,
600
+ );
601
+ out[at + LIGHT_RECORD.shadowSlot] = floatBits(shadowSlot);
602
+ /* A light that declared no cone gets the one that admits everything; see the constants. */
603
+ out[at + LIGHT_RECORD.directionX] = floatBits(directions?.[light * 3] ?? 0);
604
+ out[at + LIGHT_RECORD.directionY] = floatBits(directions?.[light * 3 + 1] ?? 0);
605
+ out[at + LIGHT_RECORD.directionZ] = floatBits(directions?.[light * 3 + 2] ?? 0);
606
+ out[at + LIGHT_RECORD.cosInner] = floatBits(cosInner);
607
+ out[at + LIGHT_RECORD.cosOuter] = floatBits(cosOuter);
608
+ out[at + LIGHT_RECORD.iesProfile] = floatBits(profile);
609
+ out[at + LIGHT_RECORD.cookie] = floatBits(cookie);
610
+ /* Zero where a consumer gave none, which the shader reads as "no usable reference" and falls
611
+ back to the first plane, the behaviour every symmetric profile has anyway. */
612
+ out[at + LIGHT_RECORD.iesAxisX] = floatBits(axes?.[light * 3] ?? 0);
613
+ out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
614
+ out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
615
+ out[at + 19] = 0;
616
+ }
617
+
360
618
  /**
361
619
  * Fill the table with this frame's light assignment, and answer how many lights it carried.
362
620
  *
@@ -434,51 +692,7 @@ export function buildLightClusters(
434
692
  const radius = lights.radii[light] ?? 0;
435
693
 
436
694
  /* The record, as the shader reads it. World space; see the note above. */
437
- const record = lightBase(light);
438
- table[record] = floatBits(x);
439
- table[record + 1] = floatBits(y);
440
- table[record + 2] = floatBits(z);
441
- table[record + 3] = floatBits(radius);
442
- table[record + 4] = floatBits(lights.colors[light * 3] ?? 0);
443
- table[record + 5] = floatBits(lights.colors[light * 3 + 1] ?? 0);
444
- table[record + 6] = floatBits(lights.colors[light * 3 + 2] ?? 0);
445
- table[record + 7] = floatBits(lights.sourceRadii[light] ?? 0);
446
- table[record + 8] = floatBits(lights.weights[light] ?? 1);
447
- /*
448
- * Signed, and carried as a float's bits like everything else in the record so the shader
449
- * reads the whole texel one way. The first `MAX_POINT_LIGHTS` lights keep the shadow slots
450
- * they already have — `resolvePointLights` orders them so the ones worth shadowing come
451
- * first — and everything past that is a light without a map, which is what a scene with two
452
- * hundred lamps has anyway.
453
- */
454
- table[record + LIGHT_RECORD.shadowSlot] = floatBits(
455
- light < shadowSlots ? light : NO_SHADOW_SLOT,
456
- );
457
-
458
- /*
459
- * The cone, and a light that declared none gets the one that admits everything. Written for
460
- * every light rather than only for spots, because the shader reads the same six slots either
461
- * way and a record left at whatever the last frame put there is a cone nobody asked for.
462
- */
463
- const cone = lights.coneCos;
464
- const directions = lights.directions;
465
- const iesAxes = lights.iesAxes;
466
- table[record + LIGHT_RECORD.directionX] = floatBits(directions?.[light * 3] ?? 0);
467
- table[record + LIGHT_RECORD.directionY] = floatBits(directions?.[light * 3 + 1] ?? 0);
468
- table[record + LIGHT_RECORD.directionZ] = floatBits(directions?.[light * 3 + 2] ?? 0);
469
- table[record + LIGHT_RECORD.cosInner] = floatBits(cone?.[light * 2] ?? POINT_LIGHT_COS_INNER);
470
- table[record + LIGHT_RECORD.cosOuter] = floatBits(
471
- cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER,
472
- );
473
- table[record + LIGHT_RECORD.iesProfile] = floatBits(
474
- lights.iesProfiles?.[light] ?? NO_IES_PROFILE,
475
- );
476
- /* Zero where a consumer gave none, which the shader reads as "no usable reference" and falls
477
- back to the first plane — the behaviour every symmetric profile has anyway. */
478
- table[record + LIGHT_RECORD.iesAxisX] = floatBits(iesAxes?.[light * 3] ?? 0);
479
- table[record + LIGHT_RECORD.iesAxisY] = floatBits(iesAxes?.[light * 3 + 1] ?? 0);
480
- table[record + LIGHT_RECORD.iesAxisZ] = floatBits(iesAxes?.[light * 3 + 2] ?? 0);
481
- table[record + LIGHT_RECORD.cookie] = floatBits(lights.cookies?.[light] ?? NO_IES_PROFILE);
695
+ writeLightRecord(lights, light, shadowSlots, table, lightBase(light));
482
696
 
483
697
  /* Column-major, as gl-matrix builds it. The view looks down -z, so depth is -vz. */
484
698
  const vx = (view[0] ?? 0) * x + (view[4] ?? 0) * y + (view[8] ?? 0) * z + (view[12] ?? 0);
@@ -1,7 +1,12 @@
1
1
  /** Fixed point-light budget shared by CPU selection and the flat shader. */
2
2
  import type { AreaLightBuffer } from './areaLights.ts';
3
3
  import type { ResolvedAreaShadows } from './areaShadowSet.ts';
4
- import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from './clusteredLights.ts';
4
+ import {
5
+ POINT_LIGHT_COS_INNER,
6
+ POINT_LIGHT_COS_OUTER,
7
+ lampSourceRadius,
8
+ lampWeight,
9
+ } from './clusteredLights.ts';
5
10
 
6
11
  /**
7
12
  * How many point lights the shading pass shades against at once.
@@ -531,6 +536,14 @@ export interface ResolvedPointLights {
531
536
  iesProfiles: Float32Array;
532
537
  iesAxes: Float32Array;
533
538
  cookies: Float32Array;
539
+ /**
540
+ * Owned, `MAX_POINT_LIGHTS` long: the emitter sizes and weights as the froxel record carries
541
+ * them, at half precision. `sourceRadii` and `weights` point here once a light set resolves, so
542
+ * a lamp on the uniform path and the same lamp in the table shade with the same numbers. See
543
+ * `lampSourceRadius`.
544
+ */
545
+ readonly heldSourceRadii: Float32Array;
546
+ readonly heldWeights: Float32Array;
534
547
  }
535
548
 
536
549
  /** One per GL binder, refilled per call: holds references, allocates nothing. */
@@ -547,6 +560,8 @@ const glLights: ResolvedPointLights = {
547
560
  iesProfiles: NO_PROFILES,
548
561
  iesAxes: NO_IES_AXES,
549
562
  cookies: NO_COOKIES,
563
+ heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
564
+ heldWeights: new Float32Array(MAX_POINT_LIGHTS),
550
565
  };
551
566
 
552
567
  /**
@@ -659,8 +674,13 @@ export function resolvePointLights(
659
674
  out.positions = positions;
660
675
  out.colors = colors;
661
676
  out.radii = radii;
662
- out.sourceRadii = sourceRadii;
663
- out.weights = weights;
677
+ /* Rounded as the clustered record rounds them; see `heldSourceRadii`. */
678
+ for (let light = 0; light < MAX_POINT_LIGHTS; light++) {
679
+ out.heldSourceRadii[light] = lampSourceRadius(sourceRadii[light] ?? 0);
680
+ out.heldWeights[light] = lampWeight(weights[light] ?? 1);
681
+ }
682
+ out.sourceRadii = out.heldSourceRadii;
683
+ out.weights = out.heldWeights;
664
684
  out.directions = directions;
665
685
  out.coneCos = coneCos;
666
686
  out.iesProfiles = iesProfiles;
@@ -30,8 +30,6 @@ import { validateMeshData } from '@driftengine/drft';
30
30
  * well. Both halves are fixed — `flat.ts` normalises the lobe, and this is the width that
31
31
  * actually matches.
32
32
  */
33
- /* The engine's fixed lobe, stated once in `vertexDefaults.ts` so both backends read one number. */
34
- const DEFAULT_ROUGHNESS = ABSENT_ATTRIBUTE['roughness']?.[0] ?? 0.4277;
35
33
 
36
34
  /** Attribute locations are fixed engine-wide (`layout(location = n)` in GLSL). */
37
35
  const ATTR_POSITION = 0;
@@ -62,39 +62,9 @@ export interface PlumeOptions {
62
62
  tint?: Vec3;
63
63
  }
64
64
 
65
- /**
66
- * Blades in each plume's cross, and the vertices that costs.
67
- *
68
- * **Two, crossed at 90°, instead of one card turned to face the viewer.** A single
69
- * billboard has no three-dimensional structure to see: orbiting a fire showed the
70
- * identical silhouette from every angle, because every card shared one camera-derived
71
- * axis and the whole plume swivelled as a sheet. Reported twice: the fire did not read
72
- * as three-dimensional, it read as something that moved with the camera — and after a
73
- * first attempt that only *leaned* edge-on cards toward the viewer, it still did,
74
- * correctly, because leaning still rotates whichever cards are edge-on, so the ensemble
75
- * still tracks the viewer.
76
- *
77
- * A cross cannot track anything: both blades are fixed in world space, so turning
78
- * around a fire genuinely brings one broadside as the other goes edge-on, which is
79
- * the parallax a volume has. It also removes the reason billboarding existed — a
80
- * blade going edge-on no longer vanishes, because its partner is square-on at that
81
- * exact moment.
82
- *
83
- * Two rather than three because the plumes blend additively and are drawn in
84
- * quantity: the third blade costs 50% more geometry for a difference the eye does not
85
- * separate at the sizes these are drawn.
86
- */
87
- const BLADES = 2;
88
65
  /* The shared count, so both backends step a plume's vertices the same way. */
89
66
  const VERTS_PER_PLUME = PLUME_VERTS;
90
67
 
91
- const CORNERS = [
92
- [-1, -1],
93
- [1, -1],
94
- [1, 1],
95
- [-1, 1],
96
- ] as const;
97
-
98
68
  export class PlumeRenderer {
99
69
  private readonly program: WebGLProgram;
100
70
  private readonly uniforms: Record<string, WebGLUniformLocation>;
@@ -939,7 +939,7 @@ export const MAIN_GLSL = `void main() {
939
939
  int clusterBaseTexel = LIGHT_REGION_TEXELS
940
940
  + (clusterTileX + clusterTileY * CLUSTER_X + clusterSlice * CLUSTER_X * CLUSTER_Y)
941
941
  * CLUSTER_TEXELS;
942
- int clusterLights = uClustered == 1 ? int(clusterTexel(clusterBaseTexel).x) : 0;
942
+ int clusterLights = uClustered != 0 ? int(clusterTexel(clusterBaseTexel).x) : 0;
943
943
 
944
944
  /* The fixture atlas's size, asked once: its cookie band and its profile rows are found from it. */
945
945
  vec2 fixtureSize = vec2(textureSize(uFixtureAtlas, 0));
@@ -968,7 +968,7 @@ export const MAIN_GLSL = `void main() {
968
968
  float iesProfile;
969
969
  vec3 iesAxis;
970
970
  float cookie;
971
- if (uClustered == 1) {
971
+ if (uClustered != 0) {
972
972
  if (slot >= clusterLights) break;
973
973
  /*
974
974
  * Four indices to a texel. Selected with compares rather than by indexing the vector,
@@ -983,32 +983,65 @@ export const MAIN_GLSL = `void main() {
983
983
  i = int(packed);
984
984
  int record = i * LIGHT_TEXELS;
985
985
  uvec4 rec0 = clusterTexel(record);
986
- uvec4 rec1 = clusterTexel(record + 1);
987
- uvec4 rec2 = clusterTexel(record + 2);
988
986
  lightPos = vec3(
989
987
  uintBitsToFloat(rec0.x), uintBitsToFloat(rec0.y), uintBitsToFloat(rec0.z));
990
988
  lightRadius = uintBitsToFloat(rec0.w);
989
+ /*
990
+ * **Out of reach is decided on the first texel, before the rest of the record is read.**
991
+ * A froxel holds every light whose sphere touches its box, so most of the lights a fragment
992
+ * walks never reach it, and each one used to cost all five texels before the falloff below
993
+ * said so. Both falloffs are zero at and past the radius, so this skips only lights the
994
+ * \`continue\` after the falloff would have skipped anyway: 17.5 ms of a 4K main pass with
995
+ * 320 lamps was 12.3 with this and the profile guard below, byte for byte the same picture.
996
+ */
997
+ if (length(lightPos - vWorldPos) >= lightRadius) continue;
998
+ /*
999
+ * **The second texel, and for a plain point light the last one.** The emitter size and the
1000
+ * weight are two halves in one word, which is how nine numbers fit in two texels;
1001
+ * \`unpackHalf2x16\` widens them exactly, and the fixed arm reads the same two values rounded
1002
+ * the same way, so the arms stay one picture. See \`packSizeAndWeight\`.
1003
+ */
1004
+ uvec4 rec1 = clusterTexel(record + 1);
991
1005
  lightColor = vec3(
992
1006
  uintBitsToFloat(rec1.x), uintBitsToFloat(rec1.y), uintBitsToFloat(rec1.z));
993
- lightSourceRadius = uintBitsToFloat(rec1.w);
994
- lightWeight = uintBitsToFloat(rec2.x);
995
- shadowSlot = int(uintBitsToFloat(rec2.y));
1007
+ vec2 sizeAndWeight = unpackHalf2x16(rec1.w);
1008
+ lightSourceRadius = abs(sizeAndWeight.x);
1009
+ lightWeight = sizeAndWeight.y;
996
1010
  /*
997
- * The fourth texel, which is the cone. See LIGHT_RECORD in clusteredLights.ts: the
998
- * direction spans rec2.z, rec2.w and rec3.x, because the record is a run of floats rather
999
- * than a struct and a vec3 does not respect texel boundaries.
1011
+ * **The fixture, read only for a light that has one.** What a plain light's record holds in
1012
+ * the last three texels is exactly these values, so a light carrying no shadow slot, cone,
1013
+ * profile or cookie shades the same whether they are read or assumed, and at heavy overlap
1014
+ * reading them was a third of the pass. A branch on a value from a texture around
1015
+ * \`texelFetch\` is not the 2026-08-07 case: an integer fetch takes no derivative.
1016
+ *
1017
+ * **Inside a branch on \`uClustered\` first**, which is 2 only in a frame where some light
1018
+ * carries a fixture. The test on each light's flag is a branch on a value read from a
1019
+ * texture, which a compiler may run both sides of, and it cost 1.3 ms of a 4K pass whose 320
1020
+ * lamps were all plain; a uniform branch costs nothing, so such a frame never reaches it.
1000
1021
  */
1001
- uvec4 rec3 = clusterTexel(record + 3);
1002
- lightDir = vec3(
1003
- uintBitsToFloat(rec2.z), uintBitsToFloat(rec2.w), uintBitsToFloat(rec3.x));
1004
- lightCone = vec2(uintBitsToFloat(rec3.y), uintBitsToFloat(rec3.z));
1005
- iesProfile = uintBitsToFloat(rec3.w);
1006
- /* The fifth texel, which is the photometric azimuth. See LIGHT_RECORD in clusteredLights.ts
1007
- for why it costs a texel rather than being derived from the direction. */
1008
- uvec4 rec4 = clusterTexel(record + 4);
1009
- iesAxis = vec3(
1010
- uintBitsToFloat(rec4.x), uintBitsToFloat(rec4.y), uintBitsToFloat(rec4.z));
1011
- cookie = uintBitsToFloat(rec4.w);
1022
+ shadowSlot = -1;
1023
+ lightDir = vec3(0.0);
1024
+ lightCone = vec2(-1.0, -2.0);
1025
+ iesProfile = -1.0;
1026
+ iesAxis = vec3(0.0);
1027
+ cookie = -1.0;
1028
+ if (uClustered == 2) {
1029
+ if ((rec1.w & LIGHT_FIXTURE_FLAG) != 0u) {
1030
+ uvec4 rec2 = clusterTexel(record + 2);
1031
+ uvec4 rec3 = clusterTexel(record + 3);
1032
+ uvec4 rec4 = clusterTexel(record + 4);
1033
+ shadowSlot = int(uintBitsToFloat(rec2.x));
1034
+ lightDir = vec3(
1035
+ uintBitsToFloat(rec2.y), uintBitsToFloat(rec2.z), uintBitsToFloat(rec2.w));
1036
+ lightCone = vec2(uintBitsToFloat(rec3.x), uintBitsToFloat(rec3.y));
1037
+ iesProfile = uintBitsToFloat(rec3.z);
1038
+ cookie = uintBitsToFloat(rec3.w);
1039
+ /* The photometric azimuth. See LIGHT_RECORD in clusteredLights.ts for why it costs a
1040
+ texel rather than being derived from the direction. */
1041
+ iesAxis = vec3(
1042
+ uintBitsToFloat(rec4.x), uintBitsToFloat(rec4.y), uintBitsToFloat(rec4.z));
1043
+ }
1044
+ }
1012
1045
  } else {
1013
1046
  i = slot;
1014
1047
  if (i >= uLightCount || i >= MAX_LIGHTS) break;
@@ -1227,20 +1260,25 @@ export const MAIN_GLSL = `void main() {
1227
1260
  * and the result is selected afterwards with a \`mix\` on a compare — which is arithmetic
1228
1261
  * rather than control flow.
1229
1262
  *
1230
- * **What it costs** is one texture fetch per light per fragment on every scene, whether or
1231
- * not anything loaded a profile. **What would make it wrong** is a scene where that fetch is
1232
- * measurable against the shadow taps in the same loop, which would be a scene with many
1233
- * lights and no shadows at all — at which point the honest answer is a permutation flag, and
1234
- * \`ARCHITECTURE.md\` section 1 has the arithmetic for whether it earns one.
1263
+ * **And skipped outright where nothing loaded a profile**, behind \`uIesAtlasRows\`, which is
1264
+ * one while the atlas holds only its row of ones. A uniform is provably uniform control
1265
+ * flow, so this branch is not the one the rule forbids. It was paid on every scene until a
1266
+ * 4K frame of 320 lamps and no shadows measured it at 0.7 ms of the main pass, rising to
1267
+ * 2.3 ms with the lamps overlapping, which is the scene this note used to say would make
1268
+ * the fetch wrong. Every light then reads 1, which is what \`step(0.0, iesProfile)\` gave
1269
+ * each of them anyway.
1235
1270
  */
1236
1271
  float iesRow = max(iesProfile, 0.0);
1237
1272
  /* The direction the fixture throws toward this fragment, which both angles are measured on. */
1238
1273
  vec3 iesRay = -toLight / max(dist, 1e-4);
1239
1274
  /* The angle from the fixture's axis, as a fraction of the 0-to-180 arc every row spans. */
1240
- float iesAngle = acos(clamp(dot(iesRay, lightDir), -1.0, 1.0));
1241
- /* Clamped to the row's first and last texel centres, which is what the edge clamp of a
1242
- texture exactly one row wide gave; the atlas can be wider now, with cookies beside it. */
1243
- float iesU = clamp(iesAngle / PI_IES * IES_WIDTH, 0.5, IES_WIDTH - 0.5) / fixtureSize.x;
1275
+ float iesU = 0.0;
1276
+ if (uIesAtlasRows > 1.0) {
1277
+ float iesAngle = acos(clamp(dot(iesRay, lightDir), -1.0, 1.0));
1278
+ /* Clamped to the row's first and last texel centres, which is what the edge clamp of a
1279
+ texture exactly one row wide gave; the atlas can be wider now, with cookies beside it. */
1280
+ iesU = clamp(iesAngle / PI_IES * IES_WIDTH, 0.5, IES_WIDTH - 0.5) / fixtureSize.x;
1281
+ }
1244
1282
  /*
1245
1283
  * **The azimuth about the fixture's own axis**, behind a branch on a uniform so a scene whose
1246
1284
  * profiles are all axially symmetric pays none of this arithmetic. A \`uniform float\` is
@@ -1315,14 +1353,15 @@ export const MAIN_GLSL = `void main() {
1315
1353
  * version this replaced took two explicit fetches and a \`mix\`, and cost **18,315 gzipped
1316
1354
  * bytes on \`core-only\`** across the sixteen fragment permutations.
1317
1355
  */
1318
- float iesBase = iesRow * (uIesPlaneCount + 1.0);
1319
- /* The profiles are the atlas's bottom rows, below any cookies. */
1320
- float iesTop = fixtureSize.y - uIesAtlasRows;
1321
- float iesV = uIesAtlasRows <= 1.0
1322
- ? (iesTop + 0.5) / fixtureSize.y
1323
- : (iesTop + iesBase + iesTurns * uIesPlaneCount + 0.5) / fixtureSize.y;
1324
- float iesGain = textureLod(uFixtureAtlas, vec2(iesU, iesV), 0.0).r;
1325
- float photometric = mix(1.0, iesGain, step(0.0, iesProfile));
1356
+ float photometric = 1.0;
1357
+ if (uIesAtlasRows > 1.0) {
1358
+ float iesBase = iesRow * (uIesPlaneCount + 1.0);
1359
+ /* The profiles are the atlas's bottom rows, below any cookies. */
1360
+ float iesTop = fixtureSize.y - uIesAtlasRows;
1361
+ float iesV = (iesTop + iesBase + iesTurns * uIesPlaneCount + 0.5) / fixtureSize.y;
1362
+ float iesGain = textureLod(uFixtureAtlas, vec2(iesU, iesV), 0.0).r;
1363
+ photometric = mix(1.0, iesGain, step(0.0, iesProfile));
1364
+ }
1326
1365
 
1327
1366
  float shape = (uLightFalloff == 1 ? falloff : falloff * falloff) * coneFalloff * photometric;
1328
1367
  glassGlow += lightColor * backNdl * shape * lightWeight * shaded * lampGlass;
@@ -6,6 +6,7 @@ import {
6
6
  CLUSTER_X,
7
7
  CLUSTER_Y,
8
8
  CLUSTER_Z,
9
+ LIGHT_FIXTURE_FLAG,
9
10
  LIGHT_REGION_TEXELS,
10
11
  LIGHT_TEXELS,
11
12
  MAX_LIGHTS_PER_CLUSTER,
@@ -659,6 +660,7 @@ uniform float uHighlightGain;
659
660
  #define CLUSTER_TEXELS ${CLUSTER_TEXELS}
660
661
  #define MAX_LIGHTS_PER_CLUSTER ${MAX_LIGHTS_PER_CLUSTER}
661
662
  #define LIGHT_TEXELS ${LIGHT_TEXELS}
663
+ #define LIGHT_FIXTURE_FLAG ${LIGHT_FIXTURE_FLAG}u
662
664
  #define LIGHT_REGION_TEXELS ${LIGHT_REGION_TEXELS}
663
665
  #define CLUSTER_TABLE_WIDTH ${TABLE_WIDTH}
664
666
  uniform highp usampler2D uClusterTable; // wgsl:share shadow