@driftengine/core 4.7.2 → 4.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.json +42 -0
  2. package/dist/geometry/meshBuilder.js +3 -2
  3. package/dist/math/exact.js +3 -3
  4. package/dist/render/backend/webgl2/renderer.js +7 -17
  5. package/dist/render/backend/webgpu/clusterBinner.js +5 -41
  6. package/dist/render/backend/webgpu/renderer.js +9 -17
  7. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  8. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +9 -5
  9. package/dist/render/clusteredLights.d.ts +136 -13
  10. package/dist/render/clusteredLights.js +225 -51
  11. package/dist/render/lightBudget.d.ts +8 -0
  12. package/dist/render/lightBudget.js +10 -3
  13. package/dist/render/mesh.js +0 -2
  14. package/dist/render/plumeRenderer.js +0 -29
  15. package/dist/render/shaders/flat/main.d.ts +1 -1
  16. package/dist/render/shaders/flat/main.js +77 -38
  17. package/dist/render/shaders/flat/preamble.js +2 -1
  18. package/dist/render/shaders/generated/flat.wgsl.js +1 -1
  19. package/dist/scene/node.js +1 -1
  20. package/package.json +6 -6
  21. package/src/geometry/meshBuilder.ts +3 -2
  22. package/src/math/exact.ts +3 -3
  23. package/src/render/backend/webgl2/renderer.ts +10 -26
  24. package/src/render/backend/webgpu/clusterBinner.ts +11 -44
  25. package/src/render/backend/webgpu/linePass.ts +0 -1
  26. package/src/render/backend/webgpu/renderer.ts +14 -25
  27. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +9 -5
  28. package/src/render/clusteredLights.ts +272 -58
  29. package/src/render/lightBudget.ts +23 -3
  30. package/src/render/mesh.ts +0 -2
  31. package/src/render/plumeRenderer.ts +0 -30
  32. package/src/render/shaders/flat/main.ts +77 -38
  33. package/src/render/shaders/flat/preamble.ts +2 -0
  34. package/src/render/shaders/generated/flat.wgsl.ts +1 -1
  35. package/src/scene/node.ts +1 -1
@@ -63,8 +63,16 @@ export const MAX_LIGHTS_PER_CLUSTER = (CLUSTER_TEXELS - 1) * 4;
63
63
  */
64
64
  export 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 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 const LIGHT_RECORD = {
107
120
  positionX: 0,
@@ -111,22 +124,96 @@ export const LIGHT_RECORD = {
111
124
  colorR: 4,
112
125
  colorG: 5,
113
126
  colorB: 6,
114
- sourceRadius: 7,
115
- weight: 8,
116
- shadowSlot: 9,
117
- directionX: 10,
118
- directionY: 11,
119
- directionZ: 12,
120
- cosInner: 13,
121
- cosOuter: 14,
122
- iesProfile: 15,
127
+ /** The emitter size and the weight, two halves in one word, and the fixture flag. */
128
+ sizeAndWeight: 7,
129
+ shadowSlot: 8,
130
+ directionX: 9,
131
+ directionY: 10,
132
+ directionZ: 11,
133
+ cosInner: 12,
134
+ cosOuter: 13,
135
+ iesProfile: 14,
136
+ /** A tile of the cookie atlas, or negative for none. */
137
+ cookie: 15,
123
138
  /** The fixture's azimuth zero, in world space. See the note above on why it cannot be derived. */
124
139
  iesAxisX: 16,
125
140
  iesAxisY: 17,
126
141
  iesAxisZ: 18,
127
- /** A tile of the cookie atlas, or negative for none. Slot 19, the one the fifth texel spared. */
128
- cookie: 19,
129
142
  };
143
+ /** Texels the lit pass reads for a light whose record carries no fixture. */
144
+ export 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 const LIGHT_FIXTURE_FLAG = 0x8000;
150
+ const halfScratch = new Float32Array(1);
151
+ const halfScratchBits = new Uint32Array(halfScratch.buffer);
152
+ /**
153
+ * A number as the sixteen bits of an IEEE half: rounded to the nearest, ties to even.
154
+ *
155
+ * **Computed here, once, and copied by both binners rather than converted on the GPU.** WGSL and
156
+ * GLSL both leave the rounding of `pack2x16float` to the implementation, and two producers of one
157
+ * table that round differently would make `scripts/cluster-check.mjs` compare noise.
158
+ *
159
+ * **A value only a subnormal half could hold is flushed to a signed zero.** A GPU may flush a
160
+ * subnormal it unpacks, and the fixed arm reads the same value from a uniform where nothing would;
161
+ * flushing it here means both arms read zero. Nothing is lost that a pixel could show: the largest
162
+ * value flushed is 6.1e-5, a weight no lamp is visible at.
163
+ */
164
+ export function halfBits(value) {
165
+ halfScratch[0] = value;
166
+ const x = halfScratchBits[0] ?? 0;
167
+ const sign = (x >>> 16) & 0x8000;
168
+ const exponent = (x >>> 23) & 0xff;
169
+ const mantissa = x & 0x7fffff;
170
+ if (exponent === 0xff)
171
+ return sign | 0x7c00 | (mantissa === 0 ? 0 : 0x200);
172
+ const e = exponent - 127 + 15;
173
+ if (e >= 0x1f)
174
+ return sign | 0x7c00;
175
+ if (e <= 0)
176
+ return sign;
177
+ const truncated = (e << 10) | (mantissa >>> 13);
178
+ const rest = mantissa & 0x1fff;
179
+ /* A carry out of the mantissa lands in the exponent, which is the correct rounding up. */
180
+ const rounded = rest > 0x1000 || (rest === 0x1000 && (truncated & 1) === 1) ? truncated + 1 : truncated;
181
+ return sign | rounded;
182
+ }
183
+ /** The number a half's bits hold. Exact: every half is also a float. */
184
+ export function halfValue(half) {
185
+ const sign = (half & 0x8000) === 0 ? 1 : -1;
186
+ const exponent = (half >>> 10) & 0x1f;
187
+ const mantissa = half & 0x3ff;
188
+ if (exponent === 0)
189
+ return sign * mantissa * 2 ** -24;
190
+ if (exponent === 0x1f)
191
+ return mantissa === 0 ? sign * Infinity : Number.NaN;
192
+ return sign * (1 + mantissa / 1024) * 2 ** (exponent - 15);
193
+ }
194
+ /**
195
+ * A lamp's emitter size as both arms read it: its magnitude, at half precision.
196
+ *
197
+ * **The fixed arm is rounded too, and that is what keeps the two arms one picture.**
198
+ * `resolvePointLights` passes the sixteen uniform lights through this and `lampWeight`, so a light
199
+ * moved from the uniform path to the froxel table shades with the same numbers. A negative size was
200
+ * never meaningful and now reads as its magnitude on both.
201
+ */
202
+ export function lampSourceRadius(value) {
203
+ return halfValue(halfBits(Math.abs(value)));
204
+ }
205
+ /** A lamp's weight as both arms read it: at half precision, sign kept. See `lampSourceRadius`. */
206
+ export function lampWeight(value) {
207
+ return halfValue(halfBits(value));
208
+ }
209
+ /**
210
+ * Slot 7 of a record: the emitter size's half in the low bits, the weight's in the high, and the
211
+ * fixture flag in the size's sign. The shader unpacks it with `unpackHalf2x16`, which is exact.
212
+ */
213
+ export function packSizeAndWeight(sourceRadius, weight, fixture) {
214
+ const size = halfBits(Math.abs(sourceRadius)) & 0x7fff;
215
+ return ((halfBits(weight) << 16) | size | (fixture ? LIGHT_FIXTURE_FLAG : 0)) >>> 0;
216
+ }
130
217
  /**
131
218
  * The cone a light with no cone has: one that admits every direction, exactly.
132
219
  *
@@ -240,6 +327,47 @@ function sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar
240
327
  out[2] = zNear;
241
328
  out[5] = zFar;
242
329
  }
330
+ /** No emitter size at all, which is what the fixed arm reads for a light that was given none. */
331
+ const NO_SOURCE_SIZES = new Float32Array(0);
332
+ export function createClusterLightSet() {
333
+ return {
334
+ count: 0,
335
+ positions: NO_SOURCE_SIZES,
336
+ colors: NO_SOURCE_SIZES,
337
+ radii: NO_SOURCE_SIZES,
338
+ sourceRadii: NO_SOURCE_SIZES,
339
+ weights: NO_SOURCE_SIZES,
340
+ };
341
+ }
342
+ /**
343
+ * A frame's lights as the binner reads them, in place, holding references and allocating nothing.
344
+ *
345
+ * **One assembly for both renderers, and it used to be two that had each dropped the same five
346
+ * fields.** Each backend copied the position, colour, radius, size and weight arrays and nothing
347
+ * else, so a spot's direction and cone, a profile, its axis and a cookie never reached the record:
348
+ * with clustering on, every fixture shaded as a bare point light. On a probe page holding one
349
+ * spot with a profile and a cookie the two arms differed by 357,706 pixels, the clustered one
350
+ * flooding the scene the cone was meant to cut. The record and both shaders were right throughout;
351
+ * the light never arrived.
352
+ *
353
+ * **And a light given no emitter size has none**, as the fixed arm reads it. This read the light's
354
+ * radius in its place, an emitter as wide as the light reaches, which widened every lamp's
355
+ * highlight on a specular surface on the clustered arm alone.
356
+ */
357
+ export function fillClusterLightSet(env, out) {
358
+ out.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
359
+ out.positions = env.lightPositions;
360
+ out.colors = env.lightColors;
361
+ out.radii = env.lightRadii;
362
+ out.sourceRadii = env.lightSourceRadii ?? NO_SOURCE_SIZES;
363
+ out.weights = env.lightWeights;
364
+ out.directions = env.lightDirections;
365
+ out.coneCos = env.lightConeCos;
366
+ out.iesProfiles = env.lightIesProfiles;
367
+ out.iesAxes = env.lightIesAxes;
368
+ out.cookies = env.lightCookies;
369
+ return out;
370
+ }
243
371
  /*
244
372
  * Scratch, at module scope, because `buildLightClusters` runs per frame on WebGL2 and the house
245
373
  * rule forbids allocating there.
@@ -281,6 +409,89 @@ function distanceSqToBounds(x, y, z, b) {
281
409
  const dz = z < (b[2] ?? 0) ? (b[2] ?? 0) - z : z > (b[5] ?? 0) ? z - (b[5] ?? 0) : 0;
282
410
  return dx * dx + dy * dy + dz * dz;
283
411
  }
412
+ /**
413
+ * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
414
+ * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
415
+ *
416
+ * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
417
+ * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
418
+ * them was paying for three texels the shader then ignored.
419
+ */
420
+ export function lightHasFixture(lights, light, shadowSlots) {
421
+ const cone = lights.coneCos;
422
+ return (light < shadowSlots ||
423
+ (cone?.[light * 2] ?? POINT_LIGHT_COS_INNER) !== POINT_LIGHT_COS_INNER ||
424
+ (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
425
+ (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
426
+ (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0);
427
+ }
428
+ /**
429
+ * The value of `uClustered` for a frame: 0 with clustering off, 1 when every light is plain, 2 when
430
+ * any carries a fixture.
431
+ *
432
+ * **The third state is a uniform the shader can branch on for free.** The flag test on each light is
433
+ * a branch on a value read from a texture, and a compiler may run both sides of one; 1.3 ms of a 4K
434
+ * main pass of 320 plain lamps was that test. Asked once a frame here, a scene whose lights are all
435
+ * plain never reaches it.
436
+ */
437
+ export function clusteredMode(lights, count, shadowSlots) {
438
+ for (let light = 0; light < count; light++) {
439
+ if (lightHasFixture(lights, light, shadowSlots))
440
+ return 2;
441
+ }
442
+ return 1;
443
+ }
444
+ /**
445
+ * One light's record, at `at` in `out`, as the lit pass reads it.
446
+ *
447
+ * **The one writer.** The CPU binner calls this into the table and the GPU binner's staging calls
448
+ * it into the buffer its kernel copies verbatim, so a field can only move in both at once; the
449
+ * 2026-08-17 rule is that two implementations of one decision drift invisibly when the constants
450
+ * are identical, and these were two.
451
+ *
452
+ * **A light is plain unless it has a shadow slot, a cone, a profile or a cookie**, and then the
453
+ * flag is clear and the shader reads two texels. Every slot is still written, so a record holds no
454
+ * bytes from a light that was there last frame and the two binners' tables stay comparable.
455
+ *
456
+ * The shadow slot is signed and carried as a float's bits like everything else in the record. The
457
+ * first `MAX_POINT_LIGHTS` lights keep the shadow slots they already have, because
458
+ * `resolvePointLights` orders them so the ones worth shadowing come first, and everything past that
459
+ * is a light without a map, which is what a scene with two hundred lamps has anyway.
460
+ */
461
+ export function writeLightRecord(lights, light, shadowSlots, out, at) {
462
+ const directions = lights.directions;
463
+ const cone = lights.coneCos;
464
+ const axes = lights.iesAxes;
465
+ const shadowSlot = light < shadowSlots ? light : NO_SHADOW_SLOT;
466
+ const cosInner = cone?.[light * 2] ?? POINT_LIGHT_COS_INNER;
467
+ const cosOuter = cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
468
+ const profile = lights.iesProfiles?.[light] ?? NO_IES_PROFILE;
469
+ const cookie = lights.cookies?.[light] ?? NO_IES_PROFILE;
470
+ const fixture = lightHasFixture(lights, light, shadowSlots);
471
+ out[at + LIGHT_RECORD.positionX] = floatBits(lights.positions[light * 3] ?? 0);
472
+ out[at + LIGHT_RECORD.positionY] = floatBits(lights.positions[light * 3 + 1] ?? 0);
473
+ out[at + LIGHT_RECORD.positionZ] = floatBits(lights.positions[light * 3 + 2] ?? 0);
474
+ out[at + LIGHT_RECORD.radius] = floatBits(lights.radii[light] ?? 0);
475
+ out[at + LIGHT_RECORD.colorR] = floatBits(lights.colors[light * 3] ?? 0);
476
+ out[at + LIGHT_RECORD.colorG] = floatBits(lights.colors[light * 3 + 1] ?? 0);
477
+ out[at + LIGHT_RECORD.colorB] = floatBits(lights.colors[light * 3 + 2] ?? 0);
478
+ out[at + LIGHT_RECORD.sizeAndWeight] = packSizeAndWeight(lights.sourceRadii[light] ?? 0, lights.weights[light] ?? 1, fixture);
479
+ out[at + LIGHT_RECORD.shadowSlot] = floatBits(shadowSlot);
480
+ /* A light that declared no cone gets the one that admits everything; see the constants. */
481
+ out[at + LIGHT_RECORD.directionX] = floatBits(directions?.[light * 3] ?? 0);
482
+ out[at + LIGHT_RECORD.directionY] = floatBits(directions?.[light * 3 + 1] ?? 0);
483
+ out[at + LIGHT_RECORD.directionZ] = floatBits(directions?.[light * 3 + 2] ?? 0);
484
+ out[at + LIGHT_RECORD.cosInner] = floatBits(cosInner);
485
+ out[at + LIGHT_RECORD.cosOuter] = floatBits(cosOuter);
486
+ out[at + LIGHT_RECORD.iesProfile] = floatBits(profile);
487
+ out[at + LIGHT_RECORD.cookie] = floatBits(cookie);
488
+ /* Zero where a consumer gave none, which the shader reads as "no usable reference" and falls
489
+ back to the first plane, the behaviour every symmetric profile has anyway. */
490
+ out[at + LIGHT_RECORD.iesAxisX] = floatBits(axes?.[light * 3] ?? 0);
491
+ out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
492
+ out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
493
+ out[at + 19] = 0;
494
+ }
284
495
  /**
285
496
  * Fill the table with this frame's light assignment, and answer how many lights it carried.
286
497
  *
@@ -347,44 +558,7 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
347
558
  const z = lights.positions[light * 3 + 2] ?? 0;
348
559
  const radius = lights.radii[light] ?? 0;
349
560
  /* The record, as the shader reads it. World space; see the note above. */
350
- const record = lightBase(light);
351
- table[record] = floatBits(x);
352
- table[record + 1] = floatBits(y);
353
- table[record + 2] = floatBits(z);
354
- table[record + 3] = floatBits(radius);
355
- table[record + 4] = floatBits(lights.colors[light * 3] ?? 0);
356
- table[record + 5] = floatBits(lights.colors[light * 3 + 1] ?? 0);
357
- table[record + 6] = floatBits(lights.colors[light * 3 + 2] ?? 0);
358
- table[record + 7] = floatBits(lights.sourceRadii[light] ?? 0);
359
- table[record + 8] = floatBits(lights.weights[light] ?? 1);
360
- /*
361
- * Signed, and carried as a float's bits like everything else in the record so the shader
362
- * reads the whole texel one way. The first `MAX_POINT_LIGHTS` lights keep the shadow slots
363
- * they already have — `resolvePointLights` orders them so the ones worth shadowing come
364
- * first — and everything past that is a light without a map, which is what a scene with two
365
- * hundred lamps has anyway.
366
- */
367
- table[record + LIGHT_RECORD.shadowSlot] = floatBits(light < shadowSlots ? light : NO_SHADOW_SLOT);
368
- /*
369
- * The cone, and a light that declared none gets the one that admits everything. Written for
370
- * every light rather than only for spots, because the shader reads the same six slots either
371
- * way and a record left at whatever the last frame put there is a cone nobody asked for.
372
- */
373
- const cone = lights.coneCos;
374
- const directions = lights.directions;
375
- const iesAxes = lights.iesAxes;
376
- table[record + LIGHT_RECORD.directionX] = floatBits(directions?.[light * 3] ?? 0);
377
- table[record + LIGHT_RECORD.directionY] = floatBits(directions?.[light * 3 + 1] ?? 0);
378
- table[record + LIGHT_RECORD.directionZ] = floatBits(directions?.[light * 3 + 2] ?? 0);
379
- table[record + LIGHT_RECORD.cosInner] = floatBits(cone?.[light * 2] ?? POINT_LIGHT_COS_INNER);
380
- table[record + LIGHT_RECORD.cosOuter] = floatBits(cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER);
381
- table[record + LIGHT_RECORD.iesProfile] = floatBits(lights.iesProfiles?.[light] ?? NO_IES_PROFILE);
382
- /* Zero where a consumer gave none, which the shader reads as "no usable reference" and falls
383
- back to the first plane — the behaviour every symmetric profile has anyway. */
384
- table[record + LIGHT_RECORD.iesAxisX] = floatBits(iesAxes?.[light * 3] ?? 0);
385
- table[record + LIGHT_RECORD.iesAxisY] = floatBits(iesAxes?.[light * 3 + 1] ?? 0);
386
- table[record + LIGHT_RECORD.iesAxisZ] = floatBits(iesAxes?.[light * 3 + 2] ?? 0);
387
- table[record + LIGHT_RECORD.cookie] = floatBits(lights.cookies?.[light] ?? NO_IES_PROFILE);
561
+ writeLightRecord(lights, light, shadowSlots, table, lightBase(light));
388
562
  /* Column-major, as gl-matrix builds it. The view looks down -z, so depth is -vz. */
389
563
  const vx = (view[0] ?? 0) * x + (view[4] ?? 0) * y + (view[8] ?? 0) * z + (view[12] ?? 0);
390
564
  const vy = (view[1] ?? 0) * x + (view[5] ?? 0) * y + (view[9] ?? 0) * z + (view[13] ?? 0);
@@ -394,6 +394,14 @@ export interface ResolvedPointLights {
394
394
  iesProfiles: Float32Array;
395
395
  iesAxes: Float32Array;
396
396
  cookies: Float32Array;
397
+ /**
398
+ * Owned, `MAX_POINT_LIGHTS` long: the emitter sizes and weights as the froxel record carries
399
+ * them, at half precision. `sourceRadii` and `weights` point here once a light set resolves, so
400
+ * a lamp on the uniform path and the same lamp in the table shade with the same numbers. See
401
+ * `lampSourceRadius`.
402
+ */
403
+ readonly heldSourceRadii: Float32Array;
404
+ readonly heldWeights: Float32Array;
397
405
  }
398
406
  /**
399
407
  * Choose which array each slot actually gets, with every fallback applied.
@@ -1,4 +1,4 @@
1
- import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from './clusteredLights.js';
1
+ import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER, lampSourceRadius, lampWeight, } from './clusteredLights.js';
2
2
  /**
3
3
  * How many point lights the shading pass shades against at once.
4
4
  *
@@ -430,6 +430,8 @@ const glLights = {
430
430
  iesProfiles: NO_PROFILES,
431
431
  iesAxes: NO_IES_AXES,
432
432
  cookies: NO_COOKIES,
433
+ heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
434
+ heldWeights: new Float32Array(MAX_POINT_LIGHTS),
433
435
  };
434
436
  /**
435
437
  * Choose which array each slot actually gets, with every fallback applied.
@@ -525,8 +527,13 @@ export function resolvePointLights(lights, falloff, out) {
525
527
  out.positions = positions;
526
528
  out.colors = colors;
527
529
  out.radii = radii;
528
- out.sourceRadii = sourceRadii;
529
- out.weights = weights;
530
+ /* Rounded as the clustered record rounds them; see `heldSourceRadii`. */
531
+ for (let light = 0; light < MAX_POINT_LIGHTS; light++) {
532
+ out.heldSourceRadii[light] = lampSourceRadius(sourceRadii[light] ?? 0);
533
+ out.heldWeights[light] = lampWeight(weights[light] ?? 1);
534
+ }
535
+ out.sourceRadii = out.heldSourceRadii;
536
+ out.weights = out.heldWeights;
530
537
  out.directions = directions;
531
538
  out.coneCos = coneCos;
532
539
  out.iesProfiles = iesProfiles;
@@ -23,8 +23,6 @@ import { validateMeshData } from '@driftengine/drft';
23
23
  * well. Both halves are fixed — `flat.ts` normalises the lobe, and this is the width that
24
24
  * actually matches.
25
25
  */
26
- /* The engine's fixed lobe, stated once in `vertexDefaults.ts` so both backends read one number. */
27
- const DEFAULT_ROUGHNESS = ABSENT_ATTRIBUTE['roughness']?.[0] ?? 0.4277;
28
26
  /** Attribute locations are fixed engine-wide (`layout(location = n)` in GLSL). */
29
27
  const ATTR_POSITION = 0;
30
28
  const ATTR_NORMAL = 1;
@@ -3,37 +3,8 @@ import { compileProgram, uniformLocations } from './shader.js';
3
3
  import { bindAtmosphere } from './atmosphere.js';
4
4
  import { plumeShaders } from './plumeMaterial.js';
5
5
  import { buildPlumeGeometry } from './plumeGeometry.js';
6
- /**
7
- * Blades in each plume's cross, and the vertices that costs.
8
- *
9
- * **Two, crossed at 90°, instead of one card turned to face the viewer.** A single
10
- * billboard has no three-dimensional structure to see: orbiting a fire showed the
11
- * identical silhouette from every angle, because every card shared one camera-derived
12
- * axis and the whole plume swivelled as a sheet. Reported twice: the fire did not read
13
- * as three-dimensional, it read as something that moved with the camera — and after a
14
- * first attempt that only *leaned* edge-on cards toward the viewer, it still did,
15
- * correctly, because leaning still rotates whichever cards are edge-on, so the ensemble
16
- * still tracks the viewer.
17
- *
18
- * A cross cannot track anything: both blades are fixed in world space, so turning
19
- * around a fire genuinely brings one broadside as the other goes edge-on, which is
20
- * the parallax a volume has. It also removes the reason billboarding existed — a
21
- * blade going edge-on no longer vanishes, because its partner is square-on at that
22
- * exact moment.
23
- *
24
- * Two rather than three because the plumes blend additively and are drawn in
25
- * quantity: the third blade costs 50% more geometry for a difference the eye does not
26
- * separate at the sizes these are drawn.
27
- */
28
- const BLADES = 2;
29
6
  /* The shared count, so both backends step a plume's vertices the same way. */
30
7
  const VERTS_PER_PLUME = PLUME_VERTS;
31
- const CORNERS = [
32
- [-1, -1],
33
- [1, -1],
34
- [1, 1],
35
- [-1, 1],
36
- ];
37
8
  export class PlumeRenderer {
38
9
  program;
39
10
  uniforms;