@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/CHANGELOG.json CHANGED
@@ -1,4 +1,20 @@
1
1
  [
2
+ {
3
+ "version": "4.7.3",
4
+ "date": "2026-10-02",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "short": "Clustered lighting at high resolution costs about a third less: a light out of reach is skipped after one texel, and a plain point light is read in two of its record's five.",
9
+ "text": "**The lit pass decides a clustered light is out of reach on the first texel of its record**, before reading the rest, and skips the photometric lookup when no profile is loaded. **A plain point light's emitter size and weight share one word as two halves**, so it is read in two texels of five; a light with a shadow slot, a cone, a profile or a cookie reads the other three, and only in a frame that has one. The uniform arm rounds size and weight the same way, so the two arms still draw one picture, and scenes of plain lights are byte-identical to 4.7.2 on both backends. At 3840 by 2160 with 320 lamps the main pass went from 17.5 to 12.0 ms at radius 8, from 26.2 to 17.2 at radius 12 and from 34.8 to 25.1 at radius 24, on WebGPU; building the clusters was 0.15 ms of the first. `demo/dev/clusterStress.html` is the workload and `scripts/cluster-stress.mjs` times every pass of it."
10
+ },
11
+ {
12
+ "kind": "fixed",
13
+ "short": "With clustered lights on, spots, photometric profiles and cookies shade as they do with it off: both renderers had been handing the binner every light as a plain point light.",
14
+ "text": "**Both renderers built the clustered light set from five arrays** and left out the direction, the cone, the profile, its axis and the cookie, so the record and the shader were right and the fixture never arrived: every spot lit the scene as a bare bulb. On a page holding one spot with a profile and a cookie the two arms differed by 357,706 pixels, and now differ by none on either backend. `fillClusterLightSet` is the one assembly both use. **A light given no emitter size now has none on both arms**, where the clustered arm read the light's radius in its place and widened every highlight it laid on a specular surface."
15
+ }
16
+ ]
17
+ },
2
18
  {
3
19
  "version": "4.7.2",
4
20
  "date": "2026-10-02",
@@ -21,7 +21,7 @@ import { batchBoxVisible, cullsInstances } from '../../instanceCull.js';
21
21
  import { OcclusionBuffer } from '../../occlusion.js';
22
22
  import { boundsVisible } from '../../visibility.js';
23
23
  import { createPassRegistry, drainRegistry, passAt, registerIn, unregisterIn } from '../../pass.js';
24
- import { MAX_CLUSTERED_LIGHTS, TABLE_HEIGHT, TABLE_WIDTH, buildLightClusters, createClusterTable, } from '../../clusteredLights.js';
24
+ import { TABLE_HEIGHT, TABLE_WIDTH, buildLightClusters, clusteredMode, createClusterLightSet, createClusterTable, fillClusterLightSet, } from '../../clusteredLights.js';
25
25
  import { clamp } from '../../../math/scalar.js';
26
26
  import { attachContextLoss } from '../../contextLoss.js';
27
27
  import { isWeakGpuFamily, webgl2RendererName } from '../../gpuCapability.js';
@@ -1464,14 +1464,7 @@ export class WebGL2Renderer {
1464
1464
  clusterTexture = null;
1465
1465
  clusterTable = null;
1466
1466
  /** Refilled per frame; holds references and allocates nothing. See `resolvePointLights`. */
1467
- clusterLights = {
1468
- count: 0,
1469
- positions: new Float32Array(0),
1470
- colors: new Float32Array(0),
1471
- radii: new Float32Array(0),
1472
- sourceRadii: new Float32Array(0),
1473
- weights: new Float32Array(0),
1474
- };
1467
+ clusterLights = createClusterLightSet();
1475
1468
  /** near, far, tan(fovY/2), aspect — written per frame, uploaded as one vec4. */
1476
1469
  clusterFrustum = new Float32Array(4);
1477
1470
  /**
@@ -1605,13 +1598,9 @@ export class WebGL2Renderer {
1605
1598
  gl.uniform4fv(u['uClusterFrustum'] ?? null, this.clusterFrustum);
1606
1599
  gl.uniformMatrix4fv(u['uView'] ?? null, false, camera.view);
1607
1600
  this.clusterTable ??= createClusterTable();
1608
- const lights = this.clusterLights;
1609
- lights.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
1610
- lights.positions = env.lightPositions;
1611
- lights.colors = env.lightColors;
1612
- lights.radii = env.lightRadii;
1613
- lights.sourceRadii = env.lightSourceRadii ?? env.lightRadii;
1614
- lights.weights = env.lightWeights;
1601
+ const lights = fillClusterLightSet(env, this.clusterLights);
1602
+ /* A slot is read only under `POINT_SHADOWS`; see `lightHasFixture`. */
1603
+ const shadowSlots = this.quality.pointShadows ? this.lightBudget.maxLights : 0;
1615
1604
  buildLightClusters(lights, camera.view, near, far, tanHalfFovY, aspect, this.clusterTable,
1616
1605
  /*
1617
1606
  * The budget this renderer's shader was built at, not the engine's own. A froxel record
@@ -1620,7 +1609,8 @@ export class WebGL2Renderer {
1620
1609
  * to name, and saying so here is what stops the shader having to discover it. The shader
1621
1610
  * guards the read as well, for a build that reaches it another way.
1622
1611
  */
1623
- this.lightBudget.maxLights);
1612
+ shadowSlots);
1613
+ gl.uniform1i(u['uClustered'] ?? null, clusteredMode(lights, lights.count, shadowSlots));
1624
1614
  gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, TABLE_WIDTH, TABLE_HEIGHT, gl.RGBA_INTEGER, gl.UNSIGNED_INT, this.clusterTable);
1625
1615
  }
1626
1616
  /**
@@ -1,4 +1,4 @@
1
- import { CLUSTER_COUNT, LIGHT_RECORD, LIGHT_TEXELS, NO_IES_PROFILE, POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER, MAX_CLUSTERED_LIGHTS, TABLE_HEIGHT, TABLE_WIDTH, } from '../../clusteredLights.js';
1
+ import { CLUSTER_COUNT, LIGHT_TEXELS, MAX_CLUSTERED_LIGHTS, TABLE_HEIGHT, TABLE_WIDTH, writeLightRecord, } from '../../clusteredLights.js';
2
2
  import { MAX_POINT_LIGHTS } from '../../lightBudget.js';
3
3
  import { CLUSTER_BIN_WGSL } from './shaders/clusterBin.wgsl.js';
4
4
  /**
@@ -45,7 +45,8 @@ export class ClusterBinner {
45
45
  paramsStaging = new ArrayBuffer(PARAMS_BYTES);
46
46
  paramsFloats = new Float32Array(this.paramsStaging);
47
47
  paramsUints = new Uint32Array(this.paramsStaging);
48
- lightStaging = new Float32Array(MAX_CLUSTERED_LIGHTS * LIGHT_TEXELS * 4);
48
+ /* The records as bits, because one slot holds two halves that must not pass through a float. */
49
+ lightStaging = new Uint32Array(MAX_CLUSTERED_LIGHTS * LIGHT_TEXELS * 4);
49
50
  frame = null;
50
51
  /** The table's view, for whoever binds it into the lit pass. Null before `init`. */
51
52
  get tableView() {
@@ -130,48 +131,11 @@ export class ClusterBinner {
130
131
  return;
131
132
  const count = Math.min(frame.lights.count, MAX_CLUSTERED_LIGHTS);
132
133
  /*
133
- * The records, in exactly the order `buildLightClusters` writes them, because the shader
134
+ * The records, by the function `buildLightClusters` writes them with, because the shader
134
135
  * copies them into the table verbatim and the lit pass reads one layout on both backends.
135
136
  */
136
137
  for (let light = 0; light < count; light++) {
137
- const at = light * LIGHT_TEXELS * 4;
138
- this.lightStaging[at] = frame.lights.positions[light * 3] ?? 0;
139
- this.lightStaging[at + 1] = frame.lights.positions[light * 3 + 1] ?? 0;
140
- this.lightStaging[at + 2] = frame.lights.positions[light * 3 + 2] ?? 0;
141
- this.lightStaging[at + 3] = frame.lights.radii[light] ?? 0;
142
- this.lightStaging[at + 4] = frame.lights.colors[light * 3] ?? 0;
143
- this.lightStaging[at + 5] = frame.lights.colors[light * 3 + 1] ?? 0;
144
- this.lightStaging[at + 6] = frame.lights.colors[light * 3 + 2] ?? 0;
145
- this.lightStaging[at + 7] = frame.lights.sourceRadii[light] ?? 0;
146
- this.lightStaging[at + LIGHT_RECORD.weight] = frame.lights.weights[light] ?? 1;
147
- this.lightStaging[at + LIGHT_RECORD.shadowSlot] = light < frame.shadowSlots ? light : -1;
148
- /*
149
- * The cone, addressed through `LIGHT_RECORD` rather than by number. Two writers fill this
150
- * record — the CPU binner in `clusteredLights.ts` and this — and the 2026-08-17 rule is that
151
- * two implementations of one decision drift invisibly when the constants are identical. They
152
- * now read the same table of offsets, so a field can only move in both at once.
153
- *
154
- * `POINT_LIGHT_COS_OUTER` and its inner twin are what make a light with no cone collapse to
155
- * exactly the arithmetic a point light had. `scripts/cluster-check.mjs` compares the two
156
- * tables byte for byte and is what would catch this being wrong anyway.
157
- */
158
- const directions = frame.lights.directions;
159
- const cone = frame.lights.coneCos;
160
- this.lightStaging[at + LIGHT_RECORD.directionX] = directions?.[light * 3] ?? 0;
161
- this.lightStaging[at + LIGHT_RECORD.directionY] = directions?.[light * 3 + 1] ?? 0;
162
- this.lightStaging[at + LIGHT_RECORD.directionZ] = directions?.[light * 3 + 2] ?? 0;
163
- this.lightStaging[at + LIGHT_RECORD.cosInner] = cone?.[light * 2] ?? POINT_LIGHT_COS_INNER;
164
- this.lightStaging[at + LIGHT_RECORD.cosOuter] =
165
- cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
166
- this.lightStaging[at + LIGHT_RECORD.iesProfile] =
167
- frame.lights.iesProfiles?.[light] ?? NO_IES_PROFILE;
168
- /* The azimuth reference an asymmetric profile is oriented by; zero where a consumer gave
169
- none, which the shader reads as no usable reference and leaves on the first plane. */
170
- const axes = frame.lights.iesAxes;
171
- this.lightStaging[at + LIGHT_RECORD.iesAxisX] = axes?.[light * 3] ?? 0;
172
- this.lightStaging[at + LIGHT_RECORD.iesAxisY] = axes?.[light * 3 + 1] ?? 0;
173
- this.lightStaging[at + LIGHT_RECORD.iesAxisZ] = axes?.[light * 3 + 2] ?? 0;
174
- this.lightStaging[at + LIGHT_RECORD.cookie] = frame.lights.cookies?.[light] ?? NO_IES_PROFILE;
138
+ writeLightRecord(frame.lights, light, frame.shadowSlots, this.lightStaging, light * LIGHT_TEXELS * 4);
175
139
  }
176
140
  this.paramsFloats.set(frame.view, 0);
177
141
  this.paramsFloats[16] = frame.near;
@@ -131,7 +131,7 @@ import { OcclusionBuffer } from '../../occlusion.js';
131
131
  import { boundsVisible } from '../../visibility.js';
132
132
  import { createPassRegistry, drainRegistry, passAt, registerIn, unregisterIn } from '../../pass.js';
133
133
  import { ClusterBinner } from './clusterBinner.js';
134
- import { MAX_CLUSTERED_LIGHTS } from '../../clusteredLights.js';
134
+ import { clusteredMode, createClusterLightSet, fillClusterLightSet, } from '../../clusteredLights.js';
135
135
  import { computeAt, createComputeRegistry, } from '../../compute.js';
136
136
  import { GpuInstancedBatch } from './instanced.js';
137
137
  import { cutoutOf } from '../../cutoutCaster.js';
@@ -4158,6 +4158,8 @@ export class WebGPURenderer {
4158
4158
  iesProfiles: new Float32Array(0),
4159
4159
  iesAxes: new Float32Array(0),
4160
4160
  cookies: new Float32Array(0),
4161
+ heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
4162
+ heldWeights: new Float32Array(MAX_POINT_LIGHTS),
4161
4163
  };
4162
4164
  /** Refilled per frame rather than allocated; see `resolveAtmosphere`. */
4163
4165
  medium = {
@@ -11325,14 +11327,7 @@ export class WebGPURenderer {
11325
11327
  /** near, far, tan(fovY/2), aspect. Written per frame into the fragment block. */
11326
11328
  clusterFrustum = new Float32Array(4);
11327
11329
  /** Refilled per frame; holds references and allocates nothing. */
11328
- clusterLights = {
11329
- count: 0,
11330
- positions: new Float32Array(0),
11331
- colors: new Float32Array(0),
11332
- radii: new Float32Array(0),
11333
- sourceRadii: new Float32Array(0),
11334
- weights: new Float32Array(0),
11335
- };
11330
+ clusterLights = createClusterLightSet();
11336
11331
  /**
11337
11332
  * Reused per dispatch rather than allocated, for the reason `passContext` gives beside it: a
11338
11333
  * fresh object per dispatch is an allocation on a per-frame path. The contract in
@@ -11422,13 +11417,10 @@ export class WebGPURenderer {
11422
11417
  f[frustumAt + 2] = tanHalfFovY;
11423
11418
  f[frustumAt + 3] = aspect;
11424
11419
  f.set(camera.view, at('uView'));
11425
- const set = this.clusterLights;
11426
- set.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
11427
- set.positions = env.lightPositions;
11428
- set.colors = env.lightColors;
11429
- set.radii = env.lightRadii;
11430
- set.sourceRadii = env.lightSourceRadii ?? env.lightRadii;
11431
- set.weights = env.lightWeights;
11420
+ const set = fillClusterLightSet(env, this.clusterLights);
11421
+ /* A slot is read only under `POINT_SHADOWS`; see `lightHasFixture`. */
11422
+ const shadowSlots = this.quality.pointShadows ? MAX_POINT_LIGHTS : 0;
11423
+ i[at('uClustered')] = clusteredMode(set, set.count, shadowSlots);
11432
11424
  binner.setFrame({
11433
11425
  lights: set,
11434
11426
  view: camera.view,
@@ -11436,7 +11428,7 @@ export class WebGPURenderer {
11436
11428
  far,
11437
11429
  tanHalfFovY,
11438
11430
  aspect,
11439
- shadowSlots: MAX_POINT_LIGHTS,
11431
+ shadowSlots,
11440
11432
  });
11441
11433
  this.dispatchCompute(this.clusterHandle);
11442
11434
  }
@@ -20,4 +20,4 @@
20
20
  * sort. `scripts/cluster-check.mjs` is what holds that, and the 2026-08-17 rule about two
21
21
  * implementations of one decision is why it exists at all.
22
22
  */
23
- export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 5u;\nconst LIGHT_REGION_TEXELS: u32 = 1600u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/** Three vec4s a light, in exactly the record order `buildLightClusters` writes. */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<f32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = lights[light * LIGHT_TEXELS];\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), bitcast<vec4<u32>>(lights[record + t]));\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n let radius = lights[light * LIGHT_TEXELS].w;\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
23
+ export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 5u;\nconst LIGHT_REGION_TEXELS: u32 = 1600u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n let radius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
@@ -43,8 +43,12 @@ struct Params {
43
43
  }
44
44
 
45
45
  @group(0) @binding(0) var<uniform> params: Params;
46
- /** Three vec4s a light, in exactly the record order \`buildLightClusters\` writes. */
47
- @group(0) @binding(1) var<storage, read> lights: array<vec4<f32>>;
46
+ /**
47
+ * The records as \`writeLightRecord\` lays them out, as bits. **\`u32\`, not \`f32\`**, because one
48
+ * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to
49
+ * flush on the way through; read as bits, the copy into the table cannot change them.
50
+ */
51
+ @group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;
48
52
  /**
49
53
  * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.
50
54
  *
@@ -70,7 +74,7 @@ fn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {
70
74
 
71
75
  /** A light's view-space centre, with depth positive along the view direction. */
72
76
  fn viewOf(light: u32) -> vec3<f32> {
73
- let world = lights[light * LIGHT_TEXELS];
77
+ let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);
74
78
  let v = params.view * vec4<f32>(world.xyz, 1.0);
75
79
  return vec3<f32>(v.x, v.y, -v.z);
76
80
  }
@@ -102,7 +106,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
102
106
  if (id < params.lightCount) {
103
107
  let record = id * LIGHT_TEXELS;
104
108
  for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {
105
- textureStore(table, texelAt(record + t), bitcast<vec4<u32>>(lights[record + t]));
109
+ textureStore(table, texelAt(record + t), lights[record + t]);
106
110
  }
107
111
  }
108
112
 
@@ -146,7 +150,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
146
150
 
147
151
  /* Increasing light index, which is what makes the list sorted without a sort. */
148
152
  for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {
149
- let radius = lights[light * LIGHT_TEXELS].w;
153
+ let radius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);
150
154
  let v = viewOf(light);
151
155
  if (v.z + radius <= 0.0) {
152
156
  continue;
@@ -63,8 +63,16 @@ export declare const MAX_LIGHTS_PER_CLUSTER: number;
63
63
  */
64
64
  export declare const MAX_CLUSTERED_LIGHTS = 320;
65
65
  /**
66
- * Texels one light's record occupies: position and radius, colour and emitter size, then the
67
- * weight and the shadow slot.
66
+ * Texels one light's record occupies: position and radius, then colour with the emitter size and
67
+ * the weight, then the shadow slot and the fixture: direction, cone, profile, cookie and azimuth.
68
+ *
69
+ * **A plain point light is read in two of the five.** The lit pass fetches this record once per
70
+ * light per fragment, and at eight megapixels with lights crowded together that fetch is the
71
+ * frame: 320 lamps of radius 12 cost 26.2 ms of a 4K main pass when all five texels were read for
72
+ * every light, and 12.7 ms read in two, on the RX 9070 XT `demo/dev/clusterStress.html` was
73
+ * measured on. So the second texel carries a flag, `LIGHT_FIXTURE_FLAG`, and the last three are
74
+ * fetched only for a light that has a shadow slot, a cone, a profile or a cookie. What a plain light
75
+ * would have read there is what the shader assumes in their place, so the picture is the same.
68
76
  *
69
77
  * **The shadow slot is an index into the existing sixteen-wide shadow uniforms, not a layer.**
70
78
  * Clustering lifts how many lights *shade* a fragment; it does not lift how many shadow maps
@@ -102,6 +110,11 @@ export declare const LIGHT_TEXELS = 5;
102
110
  * rotating through that direction would snap its pattern round. The two obvious constructions put
103
111
  * that singularity where these fixtures actually point: `cross(worldUp, dir)` fails for a light
104
112
  * aimed straight down, which is a street light.
113
+ *
114
+ * **Reordered 2026-10-02 so a plain light's fields are the first eight**, which is what lets the
115
+ * shader stop after two texels. Nine values are needed by every light and eight fit in two
116
+ * texels, so the emitter size and the weight share slot 7 as two halves; see `packSizeAndWeight`.
117
+ * Slot 19 is spare.
105
118
  */
106
119
  export declare const LIGHT_RECORD: {
107
120
  readonly positionX: 0;
@@ -111,22 +124,60 @@ export declare const LIGHT_RECORD: {
111
124
  readonly colorR: 4;
112
125
  readonly colorG: 5;
113
126
  readonly colorB: 6;
114
- readonly sourceRadius: 7;
115
- readonly weight: 8;
116
- readonly shadowSlot: 9;
117
- readonly directionX: 10;
118
- readonly directionY: 11;
119
- readonly directionZ: 12;
120
- readonly cosInner: 13;
121
- readonly cosOuter: 14;
122
- readonly iesProfile: 15;
127
+ /** The emitter size and the weight, two halves in one word, and the fixture flag. */
128
+ readonly sizeAndWeight: 7;
129
+ readonly shadowSlot: 8;
130
+ readonly directionX: 9;
131
+ readonly directionY: 10;
132
+ readonly directionZ: 11;
133
+ readonly cosInner: 12;
134
+ readonly cosOuter: 13;
135
+ readonly iesProfile: 14;
136
+ /** A tile of the cookie atlas, or negative for none. */
137
+ readonly cookie: 15;
123
138
  /** The fixture's azimuth zero, in world space. See the note above on why it cannot be derived. */
124
139
  readonly iesAxisX: 16;
125
140
  readonly iesAxisY: 17;
126
141
  readonly iesAxisZ: 18;
127
- /** A tile of the cookie atlas, or negative for none. Slot 19, the one the fifth texel spared. */
128
- readonly cookie: 19;
129
142
  };
143
+ /** Texels the lit pass reads for a light whose record carries no fixture. */
144
+ export declare const PLAIN_LIGHT_TEXELS = 2;
145
+ /**
146
+ * The bit of `sizeAndWeight` that says the record's last three texels hold something: the sign of
147
+ * the emitter size's half, which a size never needs.
148
+ */
149
+ export declare const LIGHT_FIXTURE_FLAG = 32768;
150
+ /**
151
+ * A number as the sixteen bits of an IEEE half: rounded to the nearest, ties to even.
152
+ *
153
+ * **Computed here, once, and copied by both binners rather than converted on the GPU.** WGSL and
154
+ * GLSL both leave the rounding of `pack2x16float` to the implementation, and two producers of one
155
+ * table that round differently would make `scripts/cluster-check.mjs` compare noise.
156
+ *
157
+ * **A value only a subnormal half could hold is flushed to a signed zero.** A GPU may flush a
158
+ * subnormal it unpacks, and the fixed arm reads the same value from a uniform where nothing would;
159
+ * flushing it here means both arms read zero. Nothing is lost that a pixel could show: the largest
160
+ * value flushed is 6.1e-5, a weight no lamp is visible at.
161
+ */
162
+ export declare function halfBits(value: number): number;
163
+ /** The number a half's bits hold. Exact: every half is also a float. */
164
+ export declare function halfValue(half: number): number;
165
+ /**
166
+ * A lamp's emitter size as both arms read it: its magnitude, at half precision.
167
+ *
168
+ * **The fixed arm is rounded too, and that is what keeps the two arms one picture.**
169
+ * `resolvePointLights` passes the sixteen uniform lights through this and `lampWeight`, so a light
170
+ * moved from the uniform path to the froxel table shades with the same numbers. A negative size was
171
+ * never meaningful and now reads as its magnitude on both.
172
+ */
173
+ export declare function lampSourceRadius(value: number): number;
174
+ /** A lamp's weight as both arms read it: at half precision, sign kept. See `lampSourceRadius`. */
175
+ export declare function lampWeight(value: number): number;
176
+ /**
177
+ * Slot 7 of a record: the emitter size's half in the low bits, the weight's in the high, and the
178
+ * fixture flag in the size's sign. The shader unpacks it with `unpackHalf2x16`, which is exact.
179
+ */
180
+ export declare function packSizeAndWeight(sourceRadius: number, weight: number, fixture: boolean): number;
130
181
  /**
131
182
  * The cone a light with no cone has: one that admits every direction, exactly.
132
183
  *
@@ -220,6 +271,78 @@ export interface ClusterLightSet {
220
271
  /** One per light: an index into the IES atlas, or −1. */
221
272
  readonly iesProfiles?: Float32Array;
222
273
  }
274
+ /** Where `fillClusterLightSet` reads a frame's lights from: the arrays `Environment` carries. */
275
+ export interface ClusterLightArrays {
276
+ readonly lightCount?: number;
277
+ readonly lightPositions: Float32Array;
278
+ readonly lightColors: Float32Array;
279
+ readonly lightRadii: Float32Array;
280
+ readonly lightWeights: Float32Array;
281
+ readonly lightSourceRadii?: Float32Array;
282
+ readonly lightDirections?: Float32Array;
283
+ readonly lightConeCos?: Float32Array;
284
+ readonly lightIesProfiles?: Float32Array;
285
+ readonly lightIesAxes?: Float32Array;
286
+ readonly lightCookies?: Float32Array;
287
+ }
288
+ /** A `ClusterLightSet` a renderer refills every frame. */
289
+ export type ClusterLightScratch = {
290
+ -readonly [K in keyof ClusterLightSet]: ClusterLightSet[K];
291
+ };
292
+ export declare function createClusterLightSet(): ClusterLightScratch;
293
+ /**
294
+ * A frame's lights as the binner reads them, in place, holding references and allocating nothing.
295
+ *
296
+ * **One assembly for both renderers, and it used to be two that had each dropped the same five
297
+ * fields.** Each backend copied the position, colour, radius, size and weight arrays and nothing
298
+ * else, so a spot's direction and cone, a profile, its axis and a cookie never reached the record:
299
+ * with clustering on, every fixture shaded as a bare point light. On a probe page holding one
300
+ * spot with a profile and a cookie the two arms differed by 357,706 pixels, the clustered one
301
+ * flooding the scene the cone was meant to cut. The record and both shaders were right throughout;
302
+ * the light never arrived.
303
+ *
304
+ * **And a light given no emitter size has none**, as the fixed arm reads it. This read the light's
305
+ * radius in its place, an emitter as wide as the light reaches, which widened every lamp's
306
+ * highlight on a specular surface on the clustered arm alone.
307
+ */
308
+ export declare function fillClusterLightSet(env: ClusterLightArrays, out: ClusterLightScratch): ClusterLightSet;
309
+ /**
310
+ * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
311
+ * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
312
+ *
313
+ * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
314
+ * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
315
+ * them was paying for three texels the shader then ignored.
316
+ */
317
+ export declare function lightHasFixture(lights: ClusterLightSet, light: number, shadowSlots: number): boolean;
318
+ /**
319
+ * The value of `uClustered` for a frame: 0 with clustering off, 1 when every light is plain, 2 when
320
+ * any carries a fixture.
321
+ *
322
+ * **The third state is a uniform the shader can branch on for free.** The flag test on each light is
323
+ * a branch on a value read from a texture, and a compiler may run both sides of one; 1.3 ms of a 4K
324
+ * main pass of 320 plain lamps was that test. Asked once a frame here, a scene whose lights are all
325
+ * plain never reaches it.
326
+ */
327
+ export declare function clusteredMode(lights: ClusterLightSet, count: number, shadowSlots: number): 1 | 2;
328
+ /**
329
+ * One light's record, at `at` in `out`, as the lit pass reads it.
330
+ *
331
+ * **The one writer.** The CPU binner calls this into the table and the GPU binner's staging calls
332
+ * it into the buffer its kernel copies verbatim, so a field can only move in both at once; the
333
+ * 2026-08-17 rule is that two implementations of one decision drift invisibly when the constants
334
+ * are identical, and these were two.
335
+ *
336
+ * **A light is plain unless it has a shadow slot, a cone, a profile or a cookie**, and then the
337
+ * flag is clear and the shader reads two texels. Every slot is still written, so a record holds no
338
+ * bytes from a light that was there last frame and the two binners' tables stay comparable.
339
+ *
340
+ * The shadow slot is signed and carried as a float's bits like everything else in the record. The
341
+ * first `MAX_POINT_LIGHTS` lights keep the shadow slots they already have, because
342
+ * `resolvePointLights` orders them so the ones worth shadowing come first, and everything past that
343
+ * is a light without a map, which is what a scene with two hundred lamps has anyway.
344
+ */
345
+ export declare function writeLightRecord(lights: ClusterLightSet, light: number, shadowSlots: number, out: Uint32Array, at: number): void;
223
346
  /**
224
347
  * Fill the table with this frame's light assignment, and answer how many lights it carried.
225
348
  *