@driftengine/core 4.7.2 → 4.7.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.7.2",
3
+ "version": "4.7.3",
4
4
  "description": "The runtime of DriftEngine, a WebGPU game engine with a WebGL2 fallback: renderer, loop, input, cameras, scene graph and physics",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.7.2",
33
- "@driftengine/physics": "4.7.2",
32
+ "@driftengine/drft": "4.7.3",
33
+ "@driftengine/physics": "4.7.3",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -67,8 +67,8 @@
67
67
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
68
68
  },
69
69
  "devDependencies": {
70
- "@driftengine/nav": "4.7.2",
71
- "@driftengine/network": "4.7.2",
72
- "@driftengine/texture": "4.7.2"
70
+ "@driftengine/nav": "4.7.3",
71
+ "@driftengine/network": "4.7.3",
72
+ "@driftengine/texture": "4.7.3"
73
73
  }
74
74
  }
@@ -56,12 +56,13 @@ import { boundsVisible } from '../../visibility.ts';
56
56
  import { createPassRegistry, drainRegistry, passAt, registerIn, unregisterIn } from '../../pass.ts';
57
57
  import type { ComputeDefinition, ComputeHandle } from '../../compute.ts';
58
58
  import {
59
- MAX_CLUSTERED_LIGHTS,
60
59
  TABLE_HEIGHT,
61
60
  TABLE_WIDTH,
62
61
  buildLightClusters,
62
+ clusteredMode,
63
+ createClusterLightSet,
63
64
  createClusterTable,
64
- type ClusterLightSet,
65
+ fillClusterLightSet,
65
66
  } from '../../clusteredLights.ts';
66
67
  import type {
67
68
  PassContext,
@@ -2211,21 +2212,7 @@ export class WebGL2Renderer implements RendererApi {
2211
2212
  private clusterTexture: WebGLTexture | null = null;
2212
2213
  private clusterTable: Uint32Array | null = null;
2213
2214
  /** Refilled per frame; holds references and allocates nothing. See `resolvePointLights`. */
2214
- private readonly clusterLights: {
2215
- count: number;
2216
- positions: Float32Array;
2217
- colors: Float32Array;
2218
- radii: Float32Array;
2219
- sourceRadii: Float32Array;
2220
- weights: Float32Array;
2221
- } = {
2222
- count: 0,
2223
- positions: new Float32Array(0),
2224
- colors: new Float32Array(0),
2225
- radii: new Float32Array(0),
2226
- sourceRadii: new Float32Array(0),
2227
- weights: new Float32Array(0),
2228
- };
2215
+ private readonly clusterLights = createClusterLightSet();
2229
2216
  /** near, far, tan(fovY/2), aspect — written per frame, uploaded as one vec4. */
2230
2217
  private readonly clusterFrustum = new Float32Array(4);
2231
2218
 
@@ -2378,15 +2365,11 @@ export class WebGL2Renderer implements RendererApi {
2378
2365
  gl.uniformMatrix4fv(u['uView'] ?? null, false, camera.view);
2379
2366
 
2380
2367
  this.clusterTable ??= createClusterTable();
2381
- const lights = this.clusterLights;
2382
- lights.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
2383
- lights.positions = env.lightPositions;
2384
- lights.colors = env.lightColors;
2385
- lights.radii = env.lightRadii;
2386
- lights.sourceRadii = env.lightSourceRadii ?? env.lightRadii;
2387
- lights.weights = env.lightWeights;
2368
+ const lights = fillClusterLightSet(env, this.clusterLights);
2369
+ /* A slot is read only under `POINT_SHADOWS`; see `lightHasFixture`. */
2370
+ const shadowSlots = this.quality.pointShadows ? this.lightBudget.maxLights : 0;
2388
2371
  buildLightClusters(
2389
- lights as ClusterLightSet,
2372
+ lights,
2390
2373
  camera.view as Float32Array,
2391
2374
  near,
2392
2375
  far,
@@ -2400,8 +2383,9 @@ export class WebGL2Renderer implements RendererApi {
2400
2383
  * to name, and saying so here is what stops the shader having to discover it. The shader
2401
2384
  * guards the read as well, for a build that reaches it another way.
2402
2385
  */
2403
- this.lightBudget.maxLights,
2386
+ shadowSlots,
2404
2387
  );
2388
+ gl.uniform1i(u['uClustered'] ?? null, clusteredMode(lights, lights.count, shadowSlots));
2405
2389
  gl.texSubImage2D(
2406
2390
  gl.TEXTURE_2D,
2407
2391
  0,
@@ -1,14 +1,11 @@
1
1
  import {
2
2
  CLUSTER_COUNT,
3
- LIGHT_RECORD,
4
3
  LIGHT_TEXELS,
5
- NO_IES_PROFILE,
6
- POINT_LIGHT_COS_INNER,
7
- POINT_LIGHT_COS_OUTER,
8
4
  MAX_CLUSTERED_LIGHTS,
9
5
  TABLE_HEIGHT,
10
6
  TABLE_WIDTH,
11
7
  type ClusterLightSet,
8
+ writeLightRecord,
12
9
  } from '../../clusteredLights.ts';
13
10
  import { MAX_POINT_LIGHTS } from '../../lightBudget.ts';
14
11
  import type { ComputeContext, ComputeDefinition, ComputeDevice } from '../../compute.ts';
@@ -77,7 +74,8 @@ export class ClusterBinner {
77
74
  private readonly paramsStaging = new ArrayBuffer(PARAMS_BYTES);
78
75
  private readonly paramsFloats = new Float32Array(this.paramsStaging);
79
76
  private readonly paramsUints = new Uint32Array(this.paramsStaging);
80
- private readonly lightStaging = new Float32Array(MAX_CLUSTERED_LIGHTS * LIGHT_TEXELS * 4);
77
+ /* The records as bits, because one slot holds two halves that must not pass through a float. */
78
+ private readonly lightStaging = new Uint32Array(MAX_CLUSTERED_LIGHTS * LIGHT_TEXELS * 4);
81
79
 
82
80
  private frame: ClusterBinnerFrame | null = null;
83
81
 
@@ -172,48 +170,17 @@ export class ClusterBinner {
172
170
  const count = Math.min(frame.lights.count, MAX_CLUSTERED_LIGHTS);
173
171
 
174
172
  /*
175
- * The records, in exactly the order `buildLightClusters` writes them, because the shader
173
+ * The records, by the function `buildLightClusters` writes them with, because the shader
176
174
  * copies them into the table verbatim and the lit pass reads one layout on both backends.
177
175
  */
178
176
  for (let light = 0; light < count; light++) {
179
- const at = light * LIGHT_TEXELS * 4;
180
- this.lightStaging[at] = frame.lights.positions[light * 3] ?? 0;
181
- this.lightStaging[at + 1] = frame.lights.positions[light * 3 + 1] ?? 0;
182
- this.lightStaging[at + 2] = frame.lights.positions[light * 3 + 2] ?? 0;
183
- this.lightStaging[at + 3] = frame.lights.radii[light] ?? 0;
184
- this.lightStaging[at + 4] = frame.lights.colors[light * 3] ?? 0;
185
- this.lightStaging[at + 5] = frame.lights.colors[light * 3 + 1] ?? 0;
186
- this.lightStaging[at + 6] = frame.lights.colors[light * 3 + 2] ?? 0;
187
- this.lightStaging[at + 7] = frame.lights.sourceRadii[light] ?? 0;
188
- this.lightStaging[at + LIGHT_RECORD.weight] = frame.lights.weights[light] ?? 1;
189
- this.lightStaging[at + LIGHT_RECORD.shadowSlot] = light < frame.shadowSlots ? light : -1;
190
- /*
191
- * The cone, addressed through `LIGHT_RECORD` rather than by number. Two writers fill this
192
- * record — the CPU binner in `clusteredLights.ts` and this — and the 2026-08-17 rule is that
193
- * two implementations of one decision drift invisibly when the constants are identical. They
194
- * now read the same table of offsets, so a field can only move in both at once.
195
- *
196
- * `POINT_LIGHT_COS_OUTER` and its inner twin are what make a light with no cone collapse to
197
- * exactly the arithmetic a point light had. `scripts/cluster-check.mjs` compares the two
198
- * tables byte for byte and is what would catch this being wrong anyway.
199
- */
200
- const directions = frame.lights.directions;
201
- const cone = frame.lights.coneCos;
202
- this.lightStaging[at + LIGHT_RECORD.directionX] = directions?.[light * 3] ?? 0;
203
- this.lightStaging[at + LIGHT_RECORD.directionY] = directions?.[light * 3 + 1] ?? 0;
204
- this.lightStaging[at + LIGHT_RECORD.directionZ] = directions?.[light * 3 + 2] ?? 0;
205
- this.lightStaging[at + LIGHT_RECORD.cosInner] = cone?.[light * 2] ?? POINT_LIGHT_COS_INNER;
206
- this.lightStaging[at + LIGHT_RECORD.cosOuter] =
207
- cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
208
- this.lightStaging[at + LIGHT_RECORD.iesProfile] =
209
- frame.lights.iesProfiles?.[light] ?? NO_IES_PROFILE;
210
- /* The azimuth reference an asymmetric profile is oriented by; zero where a consumer gave
211
- none, which the shader reads as no usable reference and leaves on the first plane. */
212
- const axes = frame.lights.iesAxes;
213
- this.lightStaging[at + LIGHT_RECORD.iesAxisX] = axes?.[light * 3] ?? 0;
214
- this.lightStaging[at + LIGHT_RECORD.iesAxisY] = axes?.[light * 3 + 1] ?? 0;
215
- this.lightStaging[at + LIGHT_RECORD.iesAxisZ] = axes?.[light * 3 + 2] ?? 0;
216
- this.lightStaging[at + LIGHT_RECORD.cookie] = frame.lights.cookies?.[light] ?? NO_IES_PROFILE;
177
+ writeLightRecord(
178
+ frame.lights,
179
+ light,
180
+ frame.shadowSlots,
181
+ this.lightStaging,
182
+ light * LIGHT_TEXELS * 4,
183
+ );
217
184
  }
218
185
 
219
186
  this.paramsFloats.set(frame.view, 0);
@@ -553,7 +553,11 @@ import { OcclusionBuffer } from '../../occlusion.ts';
553
553
  import { boundsVisible } from '../../visibility.ts';
554
554
  import { createPassRegistry, drainRegistry, passAt, registerIn, unregisterIn } from '../../pass.ts';
555
555
  import { ClusterBinner } from './clusterBinner.ts';
556
- import { MAX_CLUSTERED_LIGHTS, type ClusterLightSet } from '../../clusteredLights.ts';
556
+ import {
557
+ clusteredMode,
558
+ createClusterLightSet,
559
+ fillClusterLightSet,
560
+ } from '../../clusteredLights.ts';
557
561
  import {
558
562
  computeAt,
559
563
  createComputeRegistry,
@@ -5169,6 +5173,8 @@ export class WebGPURenderer implements RendererApi {
5169
5173
  iesProfiles: new Float32Array(0),
5170
5174
  iesAxes: new Float32Array(0),
5171
5175
  cookies: new Float32Array(0),
5176
+ heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
5177
+ heldWeights: new Float32Array(MAX_POINT_LIGHTS),
5172
5178
  };
5173
5179
  /** Refilled per frame rather than allocated; see `resolveAtmosphere`. */
5174
5180
  private readonly medium: ResolvedAtmosphere = {
@@ -13548,21 +13554,7 @@ export class WebGPURenderer implements RendererApi {
13548
13554
  /** near, far, tan(fovY/2), aspect. Written per frame into the fragment block. */
13549
13555
  private readonly clusterFrustum = new Float32Array(4);
13550
13556
  /** Refilled per frame; holds references and allocates nothing. */
13551
- private readonly clusterLights: {
13552
- count: number;
13553
- positions: Float32Array;
13554
- colors: Float32Array;
13555
- radii: Float32Array;
13556
- sourceRadii: Float32Array;
13557
- weights: Float32Array;
13558
- } = {
13559
- count: 0,
13560
- positions: new Float32Array(0),
13561
- colors: new Float32Array(0),
13562
- radii: new Float32Array(0),
13563
- sourceRadii: new Float32Array(0),
13564
- weights: new Float32Array(0),
13565
- };
13557
+ private readonly clusterLights = createClusterLightSet();
13566
13558
 
13567
13559
  /**
13568
13560
  * Reused per dispatch rather than allocated, for the reason `passContext` gives beside it: a
@@ -13662,21 +13654,18 @@ export class WebGPURenderer implements RendererApi {
13662
13654
  f[frustumAt + 3] = aspect;
13663
13655
  f.set(camera.view as Float32Array, at('uView'));
13664
13656
 
13665
- const set = this.clusterLights;
13666
- set.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
13667
- set.positions = env.lightPositions;
13668
- set.colors = env.lightColors;
13669
- set.radii = env.lightRadii;
13670
- set.sourceRadii = env.lightSourceRadii ?? env.lightRadii;
13671
- set.weights = env.lightWeights;
13657
+ const set = fillClusterLightSet(env, this.clusterLights);
13658
+ /* A slot is read only under `POINT_SHADOWS`; see `lightHasFixture`. */
13659
+ const shadowSlots = this.quality.pointShadows ? MAX_POINT_LIGHTS : 0;
13660
+ i[at('uClustered')] = clusteredMode(set, set.count, shadowSlots);
13672
13661
  binner.setFrame({
13673
- lights: set as ClusterLightSet,
13662
+ lights: set,
13674
13663
  view: camera.view as Float32Array,
13675
13664
  near,
13676
13665
  far,
13677
13666
  tanHalfFovY,
13678
13667
  aspect,
13679
- shadowSlots: MAX_POINT_LIGHTS,
13668
+ shadowSlots,
13680
13669
  });
13681
13670
  this.dispatchCompute(this.clusterHandle);
13682
13671
  }
@@ -54,8 +54,12 @@ struct Params {
54
54
  }
55
55
 
56
56
  @group(0) @binding(0) var<uniform> params: Params;
57
- /** Three vec4s a light, in exactly the record order \`buildLightClusters\` writes. */
58
- @group(0) @binding(1) var<storage, read> lights: array<vec4<f32>>;
57
+ /**
58
+ * The records as \`writeLightRecord\` lays them out, as bits. **\`u32\`, not \`f32\`**, because one
59
+ * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to
60
+ * flush on the way through; read as bits, the copy into the table cannot change them.
61
+ */
62
+ @group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;
59
63
  /**
60
64
  * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.
61
65
  *
@@ -81,7 +85,7 @@ fn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {
81
85
 
82
86
  /** A light's view-space centre, with depth positive along the view direction. */
83
87
  fn viewOf(light: u32) -> vec3<f32> {
84
- let world = lights[light * LIGHT_TEXELS];
88
+ let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);
85
89
  let v = params.view * vec4<f32>(world.xyz, 1.0);
86
90
  return vec3<f32>(v.x, v.y, -v.z);
87
91
  }
@@ -113,7 +117,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
113
117
  if (id < params.lightCount) {
114
118
  let record = id * LIGHT_TEXELS;
115
119
  for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {
116
- textureStore(table, texelAt(record + t), bitcast<vec4<u32>>(lights[record + t]));
120
+ textureStore(table, texelAt(record + t), lights[record + t]);
117
121
  }
118
122
  }
119
123
 
@@ -157,7 +161,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
157
161
 
158
162
  /* Increasing light index, which is what makes the list sorted without a sort. */
159
163
  for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {
160
- let radius = lights[light * LIGHT_TEXELS].w;
164
+ let radius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);
161
165
  let v = viewOf(light);
162
166
  if (v.z + radius <= 0.0) {
163
167
  continue;
@@ -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);