@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.
- package/CHANGELOG.json +42 -0
- package/dist/geometry/meshBuilder.js +3 -2
- package/dist/math/exact.js +3 -3
- package/dist/render/backend/webgl2/renderer.js +7 -17
- package/dist/render/backend/webgpu/clusterBinner.js +5 -41
- package/dist/render/backend/webgpu/renderer.js +9 -17
- package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
- package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +9 -5
- package/dist/render/clusteredLights.d.ts +136 -13
- package/dist/render/clusteredLights.js +225 -51
- package/dist/render/lightBudget.d.ts +8 -0
- package/dist/render/lightBudget.js +10 -3
- package/dist/render/mesh.js +0 -2
- package/dist/render/plumeRenderer.js +0 -29
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +77 -38
- package/dist/render/shaders/flat/preamble.js +2 -1
- package/dist/render/shaders/generated/flat.wgsl.js +1 -1
- package/dist/scene/node.js +1 -1
- package/package.json +6 -6
- package/src/geometry/meshBuilder.ts +3 -2
- package/src/math/exact.ts +3 -3
- package/src/render/backend/webgl2/renderer.ts +10 -26
- package/src/render/backend/webgpu/clusterBinner.ts +11 -44
- package/src/render/backend/webgpu/linePass.ts +0 -1
- package/src/render/backend/webgpu/renderer.ts +14 -25
- package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +9 -5
- package/src/render/clusteredLights.ts +272 -58
- package/src/render/lightBudget.ts +23 -3
- package/src/render/mesh.ts +0 -2
- package/src/render/plumeRenderer.ts +0 -30
- package/src/render/shaders/flat/main.ts +77 -38
- package/src/render/shaders/flat/preamble.ts +2 -0
- package/src/render/shaders/generated/flat.wgsl.ts +1 -1
- package/src/scene/node.ts +1 -1
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "4.7.4",
|
|
4
|
+
"date": "2026-10-02",
|
|
5
|
+
"entries": [
|
|
6
|
+
{
|
|
7
|
+
"kind": "fixed",
|
|
8
|
+
"short": "A component's declared field values fill every field an entity is not given: an add with some fields, a prefab that leaves one out, a script's instantiate, a save from before the field.",
|
|
9
|
+
"text": "**`driftscript` moves to 1.14.0**, whose compiler now carries the value each component field was declared with (`seed: u32 = 7`). Before, the value was parsed and dropped, so every entity started at zero and nothing said so. A component type has `defaults`, and `defineComponent(schema, defaults)` refuses one that names no field or does not fit its column. The store fills an omitted field from them, so `world.add` with some of the fields, a prefab that leaves one out and a script's `ecs.instantiate` all start at the declared values; a `null` given for an optional field is still its absence. `deserializeWorld` gives a field the saved scene does not have its declared value, so a save loads across an added field with what the component says. DriftScript 1.14.0 also type-checks a component's defaults, which nothing did before, and refuses one that is computed (DS0275)."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"kind": "added",
|
|
13
|
+
"short": "The manual gains XR, and a Systems section that opens with saves and preferences, each chapter built on an example that runs live and keeps its behaviour in DriftScript.",
|
|
14
|
+
"text": "**XR** covers asking a browser what it can present, entering a session, and reading the head, the controllers and a tracked hand, from TypeScript and from `drift/xr`, through the simulated runtime the package tests against. It says plainly that the renderer cannot draw into a headset yet, and the xr README's sample, which called `renderer.setViewport` and `renderer.draw`, now stops where the renderer does. **Saves and preferences** covers the synchronous store and why it is synchronous, preferences checked field by field, `RemoteSaveStore` over a backend that answers later, `serializeWorld` and `deserializeWorld` with a save read back across an added field, and `drift/persistence`, around a garden bed that saves to a server the page can make slow or take offline."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"kind": "fixed",
|
|
18
|
+
"short": "Four examples behave on any machine: the vehicle starts clear of the cones, readouts fit a small frame, chemistry holds 60 fps, and DriftLight starts smoothly on a slow CPU.",
|
|
19
|
+
"text": "The vehicle spawned overlapping a cone and came to rest on it with two wheels in the air; it starts on the ramp's line. A readout is sized to the frame it is in, so the manual's 768 by 432 frames no longer cut lines off, and it can keep clear of a panel. Chemistry stepped a quarter second sixty times a second and drew 3 fps once smoke filled the air; it steps a sixteenth, about four times the clock, at 60 fps, and `stage.run` takes loop options so a slow frame catches up two ticks at most. DriftLight baked 64 bricks a frame and ran at 1 to 8 fps for its first ten seconds on a CPU three times slower; it bakes for four milliseconds a frame and stays above 18. Under that throttling 44 of the 46 examples hold 57 to 60 fps, and chemistry 16."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"kind": "changed",
|
|
23
|
+
"short": "The repository lints without a warning: oxlint's 170 are fixed, one of them a test that divided its fallback where it meant to check a length.",
|
|
24
|
+
"text": "Unused imports, constants, functions, variables and parameters are gone, products by zero are written as the terms that remain, and optional chains in tests stay optional to the end. Inside the native host, the window, the document and the canvas take their `on<type>` attributes from a base class that declares them, in place of an interface merged into each class, and fdlibm's constants are spelled with the digits a double holds, each parsing to the same value."
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "4.7.3",
|
|
30
|
+
"date": "2026-10-02",
|
|
31
|
+
"entries": [
|
|
32
|
+
{
|
|
33
|
+
"kind": "fixed",
|
|
34
|
+
"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.",
|
|
35
|
+
"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."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"kind": "fixed",
|
|
39
|
+
"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.",
|
|
40
|
+
"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."
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
},
|
|
2
44
|
{
|
|
3
45
|
"version": "4.7.2",
|
|
4
46
|
"date": "2026-10-02",
|
|
@@ -812,8 +812,9 @@ export class MeshBuilder {
|
|
|
812
812
|
// later one is transported from it, so the choice never shows.
|
|
813
813
|
const ax = Math.abs(tx) < 0.9 ? 1 : 0;
|
|
814
814
|
const ay = Math.abs(tx) < 0.9 ? 0 : 1;
|
|
815
|
-
|
|
816
|
-
|
|
815
|
+
// (ax, ay, 0) x tangent, with the terms the zero removes left out.
|
|
816
|
+
ux = ay * tz;
|
|
817
|
+
uy = -ax * tz;
|
|
817
818
|
uz = ax * ty - ay * tx;
|
|
818
819
|
seeded = true;
|
|
819
820
|
}
|
package/dist/math/exact.js
CHANGED
|
@@ -66,14 +66,14 @@ const HALF_PI = 1.5707963267948966;
|
|
|
66
66
|
const PI = 3.141592653589793;
|
|
67
67
|
/* fdlibm's `__kernel_sin` coefficients. */
|
|
68
68
|
const S1 = -1.6666666666666632e-1;
|
|
69
|
-
const S2 = 8.
|
|
69
|
+
const S2 = 8.33333333332249e-3;
|
|
70
70
|
const S3 = -1.984126982985795e-4;
|
|
71
71
|
const S4 = 2.7557313707070068e-6;
|
|
72
72
|
const S5 = -2.5050760253406863e-8;
|
|
73
73
|
const S6 = 1.5896909952115501e-10;
|
|
74
74
|
/* fdlibm's `__kernel_cos` coefficients. */
|
|
75
75
|
const C1 = 4.1666666666666602e-2;
|
|
76
|
-
const C2 = -1.
|
|
76
|
+
const C2 = -1.388888888887411e-3;
|
|
77
77
|
const C3 = 2.480158728947673e-5;
|
|
78
78
|
const C4 = -2.7557314351390663e-7;
|
|
79
79
|
const C5 = 2.0875723212981748e-9;
|
|
@@ -273,7 +273,7 @@ export function exactLog(x) {
|
|
|
273
273
|
/* |f| < 2^-20: two terms of the series are the whole answer. */
|
|
274
274
|
if (f === 0)
|
|
275
275
|
return k === 0 ? 0 : k * LN2_HI + k * LN2_LO;
|
|
276
|
-
const r = f * f * (0.5 - 0.
|
|
276
|
+
const r = f * f * (0.5 - 0.3333333333333333 * f);
|
|
277
277
|
return k === 0 ? f - r : k * LN2_HI - (r - k * LN2_LO - f);
|
|
278
278
|
}
|
|
279
279
|
const s = f / (2 + f);
|
|
@@ -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 {
|
|
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
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
47
|
-
|
|
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),
|
|
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
|
|
67
|
-
* weight
|
|
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
|
-
|
|
115
|
-
readonly
|
|
116
|
-
readonly shadowSlot:
|
|
117
|
-
readonly directionX:
|
|
118
|
-
readonly directionY:
|
|
119
|
-
readonly directionZ:
|
|
120
|
-
readonly cosInner:
|
|
121
|
-
readonly cosOuter:
|
|
122
|
-
readonly iesProfile:
|
|
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
|
*
|