@driftengine/core 4.1.3 → 4.1.5

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 (53) hide show
  1. package/CHANGELOG.json +3796 -3739
  2. package/README.md +1 -1
  3. package/dist/index.js +12 -2
  4. package/dist/render/backend/webgl2/renderer.d.ts +15 -0
  5. package/dist/render/backend/webgl2/renderer.js +28 -7
  6. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +46 -0
  7. package/dist/render/backend/webgpu/gpuDrivenPass.js +105 -0
  8. package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
  9. package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
  10. package/dist/render/backend/webgpu/renderer.js +17 -4
  11. package/dist/render/depthConvention.d.ts +19 -0
  12. package/dist/render/depthConvention.js +21 -0
  13. package/dist/render/livePointShadowSet.js +8 -0
  14. package/dist/render/pointShadowBudget.js +59 -5
  15. package/dist/render/pointShadowImage.d.ts +40 -0
  16. package/dist/render/pointShadowImage.js +20 -0
  17. package/dist/render/pointShadowMap.d.ts +4 -0
  18. package/dist/render/pointShadowMap.js +10 -0
  19. package/dist/render/pointShadowSystem.js +9 -0
  20. package/dist/render/renderQuality.d.ts +27 -4
  21. package/dist/render/renderQuality.js +1 -1
  22. package/dist/render/shaders/flat/main.d.ts +1 -1
  23. package/dist/render/shaders/flat/main.js +104 -10
  24. package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
  25. package/dist/render/shaders/flat/pointShadow.js +84 -46
  26. package/dist/render/shaders/flat/preamble.js +70 -9
  27. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
  28. package/dist/render/shaders/generated/flat.wgsl.js +32 -32
  29. package/dist/render/shaders/sky.d.ts +10 -6
  30. package/dist/render/shaders/sky.js +35 -2
  31. package/dist/render/textLayout.d.ts +58 -0
  32. package/dist/render/textLayout.js +83 -0
  33. package/dist/render/textRenderer.js +7 -3
  34. package/package.json +6 -6
  35. package/src/index.ts +12 -2
  36. package/src/render/backend/webgl2/renderer.ts +28 -7
  37. package/src/render/backend/webgpu/gpuDrivenPass.ts +105 -0
  38. package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
  39. package/src/render/backend/webgpu/renderer.ts +26 -4
  40. package/src/render/depthConvention.ts +22 -0
  41. package/src/render/livePointShadowSet.ts +7 -0
  42. package/src/render/pointShadowBudget.ts +58 -5
  43. package/src/render/pointShadowImage.ts +51 -0
  44. package/src/render/pointShadowMap.ts +13 -0
  45. package/src/render/pointShadowSystem.ts +8 -0
  46. package/src/render/renderQuality.ts +28 -5
  47. package/src/render/shaders/flat/main.ts +104 -10
  48. package/src/render/shaders/flat/pointShadow.ts +84 -46
  49. package/src/render/shaders/flat/preamble.ts +70 -9
  50. package/src/render/shaders/generated/flat.wgsl.ts +32 -32
  51. package/src/render/shaders/sky.ts +37 -2
  52. package/src/render/textLayout.ts +88 -0
  53. package/src/render/textRenderer.ts +22 -3
@@ -104,6 +104,28 @@ export const REVERSED_DEPTH = true;
104
104
  /** What a scene depth attachment is cleared to: the far plane. */
105
105
  export const DEPTH_CLEAR = REVERSED_DEPTH ? 0 : 1;
106
106
 
107
+ /**
108
+ * The same value for a backend that learned its convention at runtime rather than at compile time.
109
+ *
110
+ * **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
111
+ * on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
112
+ * the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
113
+ * scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
114
+ * the screen is the colour clear and nothing else.
115
+ *
116
+ * Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
117
+ * colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
118
+ * beside it was already chosen at runtime, and that asymmetry is what made this total rather than
119
+ * subtle — two halves of one convention would still have drawn a picture.
120
+ *
121
+ * **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
122
+ * backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
123
+ * and the compare to each other, which a private field on a renderer could not.
124
+ */
125
+ export function depthClearFor(reversed: boolean): number {
126
+ return reversed ? 0 : 1;
127
+ }
128
+
107
129
  /** The WebGPU compare for "nearer than what is there". */
108
130
  export const DEPTH_COMPARE: GPUCompareFunction = REVERSED_DEPTH ? 'greater' : 'less';
109
131
 
@@ -182,6 +182,8 @@ export class LivePointShadowSet<M extends PointShadowSource> {
182
182
  out.liveFar.fill(1);
183
183
  out.liveNear.fill(0.01);
184
184
  out.liveSourceRadius.fill(0);
185
+ out.liveProjections.fill(0);
186
+ for (let k = 0; k < out.liveLayers.length; k++) out.liveProjections[k * 4 + 3] = 1;
185
187
  /*
186
188
  * Zero, then written under the owning light below. The weights used to be a straight copy of
187
189
  * this set's own two, because the arrays were addressed by live slot; they are addressed by
@@ -214,6 +216,11 @@ export class LivePointShadowSet<M extends PointShadowSource> {
214
216
  out.liveFar[shaderSlot] = map.far;
215
217
  out.liveNear[shaderSlot] = map.near;
216
218
  out.liveSourceRadius[shaderSlot] = map.sourceRadius;
219
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
220
+ out.liveProjections[shaderSlot * 4] = map.originX;
221
+ out.liveProjections[shaderSlot * 4 + 1] = map.originY;
222
+ out.liveProjections[shaderSlot * 4 + 2] = map.originZ;
223
+ out.liveProjections[shaderSlot * 4 + 3] = map.far;
217
224
  /*
218
225
  * The handover weight *and* the image's own arrival, multiplied.
219
226
  *
@@ -244,7 +244,24 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
244
244
  scratch.staleOwner[slot] = lightIndex;
245
245
  scratch.staleRun[slot] = 0;
246
246
  }
247
- const run = stale ? Math.min((scratch.staleRun[slot] ?? 0) + 1, CHRONIC_STALE_FRAMES) : 0;
247
+ /*
248
+ * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
249
+ *
250
+ * A light that wanders on a curve is not stale on *every* frame — near the turning points of
251
+ * its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
252
+ * lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
253
+ * then travelled smoothly through the fast part of the wander and stepped through the slow
254
+ * part, which is not motion and not stillness: it reads as a vibration. Reported that way,
255
+ * against a brazier, once the tracking allowance was in.
256
+ *
257
+ * So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
258
+ * earned the allowance three quiet frames before it loses it, and decays one frame at a time
259
+ * so a light that genuinely settles stops re-baking rather than holding a budget for ever.
260
+ */
261
+ const previous = scratch.staleRun[slot] ?? 0;
262
+ const run = stale
263
+ ? Math.min(previous + 1, CHRONIC_STALE_FRAMES * 2)
264
+ : Math.max(previous - 1, 0);
248
265
  scratch.staleRun[slot] = run;
249
266
  scratch.chronic[slot] = run >= CHRONIC_STALE_FRAMES ? 1 : 0;
250
267
  }
@@ -259,6 +276,38 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
259
276
 
260
277
  let faces = facesPerFrame;
261
278
  let urgentSpent = false;
279
+ /*
280
+ * **One wandering light finishes its cube in the frame it started it**, the same allowance a
281
+ * light with no image at all gets, and for a reason that turns out to be the same one: a map
282
+ * dribbled out at two faces a frame is a map nobody can sample correctly until it lands.
283
+ *
284
+ * A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
285
+ * cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
286
+ * way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
287
+ * three frames means the published origin steps once every third frame. The shader shoots from
288
+ * that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
289
+ * "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
290
+ * wanders about once a second.
291
+ *
292
+ * Given the cube in one frame the origin moves every frame and the shadow travels instead.
293
+ *
294
+ * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
295
+ * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
296
+ * that most recently went stale, which is the one somebody is standing next to; the rest keep
297
+ * dribbling under the ordinary budget, and their shadows step rather than travel. That is the
298
+ * right way round: a shadow you are looking at moves, and a shadow across the square does not
299
+ * cost the frame.
300
+ *
301
+ * It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
302
+ * chronic ones last — so a light arriving in range still gets its first image ahead of a flame
303
+ * refining one it already has.
304
+ *
305
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
306
+ * fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
307
+ * a handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
308
+ * raised or this given up, and `gpuTiming` is how to tell which.
309
+ */
310
+ let trackingSpent = false;
262
311
  for (let i = 0; i < planned && faces > 0; i++) {
263
312
  const slot = scratch.order[i] ?? -1;
264
313
  /*
@@ -270,10 +319,14 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
270
319
  const map = lightIndex >= 0 ? pool.mapForLight(lightIndex) : undefined;
271
320
  if (light === undefined || map === undefined) continue;
272
321
 
273
- const cold = slot < pool.sampledCount && scratch.ready[slot] === 0;
274
- const budget = cold && !urgentSpent ? faceCount : faces;
275
- if (cold && !urgentSpent) urgentSpent = true;
276
- faces -= bake(map, light, staticCasters, budget);
322
+ const sampled = slot < pool.sampledCount;
323
+ const cold = sampled && scratch.ready[slot] === 0;
324
+ const urgent = cold && !urgentSpent;
325
+ /* A light being shaded whose map will be stale again next frame however it is served. */
326
+ const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
327
+ if (urgent) urgentSpent = true;
328
+ if (tracking) trackingSpent = true;
329
+ faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
277
330
  }
278
331
 
279
332
  /*
@@ -202,6 +202,27 @@ export class PointShadowImage {
202
202
  private pendingNear = 0;
203
203
  private pendingSourceRadius = 0;
204
204
 
205
+ /**
206
+ * Where this image was rendered from, which is what a sampler has to shoot its ray from.
207
+ *
208
+ * **Not the light's position**, and the difference is the whole point: `matchesSource` lets a
209
+ * light drift up to the caller's rebake tolerance from here before the image is re-rendered,
210
+ * so for any light that wanders these two are apart on nearly every frame. A shader that
211
+ * builds its direction and its distance from the live light asks this picture a question the
212
+ * picture cannot answer. See `ResolvedPointShadows.origins`.
213
+ */
214
+ get originX(): number {
215
+ return this.x;
216
+ }
217
+
218
+ get originY(): number {
219
+ return this.y;
220
+ }
221
+
222
+ get originZ(): number {
223
+ return this.z;
224
+ }
225
+
205
226
  /** Whether this map holds an image worth sampling. False until a bake completes. */
206
227
  get hasBaked(): boolean {
207
228
  return this.baked;
@@ -401,6 +422,10 @@ export interface PointShadowSource {
401
422
  readonly far: number;
402
423
  readonly near: number;
403
424
  readonly sourceRadius: number;
425
+ /** Where the image was rendered from. See `PointShadowImage.originX`. */
426
+ readonly originX: number;
427
+ readonly originY: number;
428
+ readonly originZ: number;
404
429
  /** Whether this map holds an image worth sampling. */
405
430
  readonly hasBaked: boolean;
406
431
  /** How present the image is, 0 to 1. */
@@ -433,6 +458,28 @@ export interface ResolvedPointShadows {
433
458
  readonly far: Float32Array;
434
459
  readonly near: Float32Array;
435
460
  readonly sourceRadius: Float32Array;
461
+ /**
462
+ * The projection each bound map was rendered under: origin in `xyz`, far plane in `w`, four
463
+ * floats a light. The origin is **not** the light's position.
464
+ *
465
+ * A light is allowed to drift `pointShadowRebakeDistance` from its image before the image is
466
+ * re-rendered — that tolerance is what stops a flame re-baking six faces of the static world
467
+ * every frame — so for anything that flickers these two are apart on nearly every frame. The
468
+ * shader builds both its sample direction and the distance it compares from this point, so the
469
+ * ray it shoots is the ray the picture was drawn along and the drift costs nothing.
470
+ *
471
+ * **It used to build them from the live light**, and the drift went straight into the
472
+ * comparison: `dist` moved one for one with it against a bias of a few centimetres, and the
473
+ * single-tap blocker search moved across the occluder's silhouette, swapping `occluderDistance`
474
+ * between the caster and nothing. Reported on a brazier at night as a hard square under the
475
+ * fire, flashing in time with the flame while the camera stood still.
476
+ *
477
+ * **The far plane rides in `w` rather than in an array of its own**, because a default-block
478
+ * `float[N]` spends a whole uniform vector per element: declared separately the origin cost
479
+ * sixteen rows on each of the two sets, which the budget ladder answers on a 256-vector
480
+ * device by halving `MAX_LIGHTS`. See `uPointShadowProjection` in the preamble.
481
+ */
482
+ readonly projections: Float32Array;
436
483
  /** How present each bound map is, so a re-baked one arrives rather than appears. */
437
484
  readonly presence: Float32Array;
438
485
  /** The live pair, by the same light index: a light owns at most one of the two. */
@@ -440,6 +487,8 @@ export interface ResolvedPointShadows {
440
487
  readonly liveFar: Float32Array;
441
488
  readonly liveNear: Float32Array;
442
489
  readonly liveSourceRadius: Float32Array;
490
+ /** The live pair's own, by the same rule and for the same reason. */
491
+ readonly liveProjections: Float32Array;
443
492
  readonly liveWeights: Float32Array;
444
493
  }
445
494
 
@@ -460,11 +509,13 @@ export function createResolvedPointShadows(lights: number): ResolvedPointShadows
460
509
  far: new Float32Array(lights),
461
510
  near: new Float32Array(lights),
462
511
  sourceRadius: new Float32Array(lights),
512
+ projections: new Float32Array(lights * 4),
463
513
  presence: new Float32Array(lights),
464
514
  liveLayers: new Int32Array(lights).fill(-1),
465
515
  liveFar: new Float32Array(lights),
466
516
  liveNear: new Float32Array(lights),
467
517
  liveSourceRadius: new Float32Array(lights),
518
+ liveProjections: new Float32Array(lights * 4),
468
519
  liveWeights: new Float32Array(lights),
469
520
  };
470
521
  }
@@ -73,6 +73,19 @@ export class PointShadowMap {
73
73
  return this.image.sourceRadius;
74
74
  }
75
75
 
76
+ /** Where the image was rendered from, not where the light is now. See the image. */
77
+ get originX(): number {
78
+ return this.image.originX;
79
+ }
80
+
81
+ get originY(): number {
82
+ return this.image.originY;
83
+ }
84
+
85
+ get originZ(): number {
86
+ return this.image.originZ;
87
+ }
88
+
76
89
  /** Whether this map holds an image worth sampling. False until a bake completes. */
77
90
  get hasBaked(): boolean {
78
91
  return this.image.hasBaked;
@@ -343,6 +343,9 @@ export class PointShadowSystem<M extends PointShadowSource> {
343
343
  out.far.fill(1);
344
344
  out.near.fill(0.01);
345
345
  out.sourceRadius.fill(0);
346
+ /* Origin zero, far plane one: the same neutral the separate `far` array is filled with. */
347
+ out.projections.fill(0);
348
+ for (let k = 0; k < MAX_POINT_LIGHTS; k++) out.projections[k * 4 + 3] = 1;
346
349
  out.presence.fill(0);
347
350
 
348
351
  for (let k = 0; k < MAX_POINT_LIGHTS; k++) {
@@ -369,6 +372,11 @@ export class PointShadowSystem<M extends PointShadowSource> {
369
372
  out.far[light] = map.far;
370
373
  out.near[light] = map.near;
371
374
  out.sourceRadius[light] = map.sourceRadius;
375
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
376
+ out.projections[light * 4] = map.originX;
377
+ out.projections[light * 4 + 1] = map.originY;
378
+ out.projections[light * 4 + 2] = map.originZ;
379
+ out.projections[light * 4 + 3] = map.far;
372
380
  out.presence[light] = map.presence;
373
381
  out.layers[light] = map.layer;
374
382
  }
@@ -162,10 +162,33 @@ export interface RenderQuality {
162
162
  * sampled lights flickering at once accounted for 174 of the frame's 296 draw
163
163
  * calls, and for the game being unplayable exactly where lamps cluster.
164
164
  *
165
- * The default clears a flame's own wander with room to spare, because drift is
166
- * measured from the baked origin and a wander can be caught at either extreme
167
- * of its travel. A light that really moves still re-bakes; it just does so once
168
- * per this much movement instead of once per frame.
165
+ * **It was 0.4 until 4.1.5, chosen to clear a flame's whole wander with room to spare** — so
166
+ * a flame never re-baked at all, and its shadow of the static world stood still while the live
167
+ * map under the same light, which is re-rendered every frame because it holds the movers, swung
168
+ * with the flame. One light, two shadows, visibly disagreeing about where it was. Reported as
169
+ * the runner's shadow following the fire and the brazier's not.
170
+ *
171
+ * **A millimetre instead, which is as near to "whenever it moved" as makes sense**, so a shadow
172
+ * travels with its light rather than freezing between re-bakes. A centimetre was tried first and
173
+ * left a visible vibration: a light wandering on a curve moves less than that in a frame near
174
+ * the turning points of its travel, so the shadow ran smoothly through the fast part and stepped
175
+ * through the slow part. What the tolerance is still for is not re-baking on arithmetic noise.
176
+ *
177
+ * A light that does not move never goes stale at any positive value, so the whole cost falls on
178
+ * the lights that need it, and it is bounded rather than proportional: one such light takes a
179
+ * whole cube in a frame and the rest dribble under the ordinary face budget.
180
+ *
181
+ * **What makes that affordable is not this number.** The 174-of-296 draw calls this paragraph
182
+ * used to cite was measured before `pointShadowFacesPerFrame` capped the whole static bake, and
183
+ * before `planPointShadowBakes` learned to put a chronically stale map behind every cold and
184
+ * settling one. A flame now spends leftover budget and can never hold another light's first
185
+ * image hostage. One such light also finishes its cube inside a single frame rather than
186
+ * dribbling it over three, which is what makes the motion smooth instead of stepped; see
187
+ * `pointShadowBudget.ts`.
188
+ *
189
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
190
+ * fit the frame, where a stepped shadow is the better trade and this should go back up.
191
+ * `gpuTiming` is how to tell which, and it is a number rather than an opinion.
169
192
  */
170
193
  readonly pointShadowRebakeDistance: number;
171
194
  /**
@@ -815,7 +838,7 @@ export const DEFAULT_RENDER_QUALITY: Readonly<RenderQuality> = Object.freeze({
815
838
  directionalShadowDepthLayers: 2,
816
839
  directionalShadowMaxDistance: 6,
817
840
  directionalShadowMaxSlope: 3,
818
- pointShadowRebakeDistance: 0.4,
841
+ pointShadowRebakeDistance: 0.001,
819
842
  pointShadowFacesPerFrame: 2,
820
843
  /*
821
844
  * Twelve, which is `LIVE_POINT_SHADOW_MAPS` cubemaps of six faces: exactly what the live
@@ -793,6 +793,27 @@ export const MAIN_GLSL = `void main() {
793
793
  */
794
794
  float lightShade = 1.0;
795
795
 
796
+ /*
797
+ * **The lamps' light twice: once as it arrives, once as their shadows leave it.**
798
+ *
799
+ * Every point light's contribution used to go straight into \`lit\`. It is accumulated here
800
+ * instead, in two sums that differ only by the shadow term, so that after the loop there is
801
+ * a single number per channel saying how much of the lamps' light survived — and *that* is
802
+ * the thing the resolve below can average across a pair of pixels without touching albedo,
803
+ * a highlight, or anything else the frame has in it.
804
+ *
805
+ * **The two sums are not an approximation.** \`lampOpen\` multiplied by
806
+ * \`lampShadowed / lampOpen\` is \`lampShadowed\`, per channel, whatever mixture of lamps
807
+ * and shadows produced it. With the resolve switched off this is arithmetically the line it
808
+ * replaces; a lamp that is shadowed and a lamp beside it that is not keep their own shares.
809
+ *
810
+ * **What it costs** is two vector adds per light and six floats of live state. **What would
811
+ * make it wrong** is a term wanting to sit between the lamps and \`lit\` — a per-light
812
+ * tone map, say — because these two are summed before anything can get between them.
813
+ */
814
+ vec3 lampOpen = vec3(0.0);
815
+ vec3 lampShadowed = vec3(0.0);
816
+
796
817
  /*
797
818
  * Which froxel this fragment is in.
798
819
  *
@@ -969,7 +990,7 @@ export const MAIN_GLSL = `void main() {
969
990
  #if POINT_SHADOWS
970
991
  float grazing = 1.0 - ndl;
971
992
  float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;
972
- vec3 shadowFrom = (vWorldPos + n * normalOffset) - lightPos;
993
+ vec3 receiverPoint = vWorldPos + n * normalOffset;
973
994
 
974
995
  /*
975
996
  * uPointShadowWeight is how present the cubemap is, and it is the difference
@@ -1010,8 +1031,15 @@ export const MAIN_GLSL = `void main() {
1010
1031
  pointShadow(
1011
1032
  uPointShadows,
1012
1033
  float(layer),
1013
- shadowFrom,
1014
- uPointShadowFar[shadowRead],
1034
+ /*
1035
+ * **From where the map was baked, not from where the light is.** The two are apart
1036
+ * by up to \`pointShadowRebakeDistance\` for anything that wanders, and both things
1037
+ * this asks the picture move with that gap: the distance it compares against the
1038
+ * stored one, and which texel the single-tap blocker search lands in. See
1039
+ * \`uPointShadowOrigin\`.
1040
+ */
1041
+ receiverPoint - uPointShadowProjection[shadowRead].xyz,
1042
+ uPointShadowProjection[shadowRead].w,
1015
1043
  uPointShadowNear[shadowRead],
1016
1044
  uPointShadowSize[shadowRead]
1017
1045
  ),
@@ -1030,8 +1058,9 @@ export const MAIN_GLSL = `void main() {
1030
1058
  liveOccl = pointShadow(
1031
1059
  uPointShadows,
1032
1060
  float(liveLayer),
1033
- shadowFrom,
1034
- uLivePointShadowFar[shadowRead],
1061
+ /* Its own bake origin, for the reason the static layer above gives. */
1062
+ receiverPoint - uLivePointShadowProjection[shadowRead].xyz,
1063
+ uLivePointShadowProjection[shadowRead].w,
1035
1064
  uLivePointShadowNear[shadowRead],
1036
1065
  uLivePointShadowSize[shadowRead]
1037
1066
  );
@@ -1192,7 +1221,13 @@ export const MAIN_GLSL = `void main() {
1192
1221
  *
1193
1222
  * At metal 0 this is one multiply by 1.0 away from the expression it replaces.
1194
1223
  */
1195
- lit += albedo * lightColor * ndl * shape * shaded * lightWeight * (1.0 - metal);
1224
+ /*
1225
+ * Held back from \`lit\` until the loop is over, so the shadow can be resolved across a
1226
+ * pair of pixels first. See the block below the loop; nothing else about this term moved.
1227
+ */
1228
+ vec3 lampDiffuse = albedo * lightColor * ndl * shape * lightWeight * (1.0 - metal);
1229
+ lampOpen += lampDiffuse;
1230
+ lampShadowed += lampDiffuse * shaded;
1196
1231
 
1197
1232
  /*
1198
1233
  * A lamp reflected in a polished surface, which point lights could not do at all.
@@ -1222,8 +1257,12 @@ export const MAIN_GLSL = `void main() {
1222
1257
  vec3(1.0),
1223
1258
  pow(1.0 - max(dot(toEyeLamp, lampHalfway), 0.0), 5.0) * metal
1224
1259
  );
1225
- lit += lightColor * sphereLobe(max(dot(n, lampHalfway), 0.0), surfaceRoughness, lightSourceRadius, dist)
1226
- * lampSpec * shape * shaded * lightWeight;
1260
+ /* Held back with the diffuse half, and shadowed by the same term it always was. */
1261
+ vec3 lampHighlight =
1262
+ lightColor * sphereLobe(max(dot(n, lampHalfway), 0.0), surfaceRoughness, lightSourceRadius, dist)
1263
+ * lampSpec * shape * lightWeight;
1264
+ lampOpen += lampHighlight;
1265
+ lampShadowed += lampHighlight * shaded;
1227
1266
  }
1228
1267
  /*
1229
1268
  * The most any light in range shadows this point, for the emissive term below —
@@ -1371,7 +1410,10 @@ export const MAIN_GLSL = `void main() {
1371
1410
  }
1372
1411
  #endif
1373
1412
 
1374
- lit += albedo * uAreaLightColor[a] * form * (1.0 - metal) * areaOccl;
1413
+ /* Held back with the point lamps', and shadowed by the same term it always was. */
1414
+ vec3 areaDiffuse = albedo * uAreaLightColor[a] * form * (1.0 - metal);
1415
+ lampOpen += areaDiffuse;
1416
+ lampShadowed += areaDiffuse * areaOccl;
1375
1417
 
1376
1418
  if (vSpecular > 0.0 || metal > 0.0) {
1377
1419
  /*
@@ -1396,11 +1438,63 @@ export const MAIN_GLSL = `void main() {
1396
1438
  if (coverage > 0.0) {
1397
1439
  vec2 areaDfg = envBrdfApprox(max(dot(n, toEyeArea), 0.0), surfaceRoughness);
1398
1440
  vec3 areaSpec = specColor * areaDfg.x + vec3(areaDfg.y);
1399
- lit += areaSpec * uAreaLightColor[a] * coverage * areaOccl;
1441
+ vec3 areaHighlight = areaSpec * uAreaLightColor[a] * coverage;
1442
+ lampOpen += areaHighlight;
1443
+ lampShadowed += areaHighlight * areaOccl;
1400
1444
  }
1401
1445
  }
1402
1446
  }
1403
1447
 
1448
+ /*
1449
+ * **The other half of the shadow filter: the two pixels of a column pair, folded together.**
1450
+ *
1451
+ * \`pointShadow\` turns its twelve taps by half a golden angle on every other column, so a
1452
+ * pair of neighbouring pixels holds twenty-four places on the disk between them and neither
1453
+ * holds more than twelve. This is where the pair becomes one filter. \`dFdx\` of a value is
1454
+ * the difference across the pair, and half of it, signed by which of the two this pixel is,
1455
+ * is exactly the average of both — so each pixel ends up shaded by all twenty-four taps
1456
+ * while paying for twelve. It is the pairing \`ambientOcclusion.ts\` describes between its
1457
+ * rotation tile and its blur, at the smallest size that needs no second pass.
1458
+ *
1459
+ * **After both lamp loops rather than beside the taps, and that is why this block exists at
1460
+ * all.** A derivative is only defined where every pixel of the quad reaches it, and neither
1461
+ * loop above reaches anything uniformly: the point loop \`continue\`s on falloff and on
1462
+ * facing, and in the clustered arm its trip count is read from a froxel, so two neighbouring
1463
+ * pixels can be shading different lights with a froxel boundary drawn across the screen
1464
+ * where they stop agreeing. A \`dFdx\` in there is the 2026-08-07 rule's own case, and it
1465
+ * would also invite a compiler to flatten the loop. Measured, too: with the turn taken from
1466
+ * a per-fragment hash instead of the column, the same resolve made the penumbra *worse*
1467
+ * than no resolve at all — 3.14 against 3.00 — because the hardware here answers a coarse
1468
+ * derivative and the second row of a quad then borrows the first row's difference. The
1469
+ * column tile is what makes that borrowing correct, so the two halves are one mechanism.
1470
+ *
1471
+ * Both lamp kinds are in the sums, because a rectangle's shadow is filtered by the same
1472
+ * twelve taps and stippled the same way.
1473
+ *
1474
+ * **Clamped, and the clamp is a guard rather than tidying.** A fragment killed by the
1475
+ * cutout or the clip plane above leaves its lane without a value for its neighbour to
1476
+ * difference against, and GLSL ES says nothing about what comes back. What could reach the
1477
+ * frame is a shadow term outside 0..1 on the pixel beside a cutout hole, which the clamp
1478
+ * turns into a pixel that is merely fully lit or fully shadowed. **What would make this
1479
+ * wrong** is a consumer whose cutout edges are most of the frame — dense foliage drawn as
1480
+ * one alpha-tested sheet — where the honest answer is to resolve in a pass of its own,
1481
+ * against a shadow term written to a target.
1482
+ *
1483
+ * The floor under the divisor costs nothing and answers the case where no lamp reached this
1484
+ * pixel at all: both sums are zero there, so the ratio is zero and multiplying it back by
1485
+ * the floor adds nothing.
1486
+ */
1487
+ vec3 lampOpenFloor = max(lampOpen, vec3(1e-6));
1488
+ vec3 lampShade = lampShadowed / lampOpenFloor;
1489
+ #if POINT_SHADOWS
1490
+ lampShade = clamp(
1491
+ lampShade - dFdx(lampShade) * (float(int(gl_FragCoord.x) & 1) - 0.5),
1492
+ 0.0,
1493
+ 1.0
1494
+ );
1495
+ #endif
1496
+ lit += lampOpenFloor * lampShade;
1497
+
1404
1498
  /*
1405
1499
  * Self-illuminated geometry, dimmed where a lamp's shadow crosses it.
1406
1500
  *