@driftengine/core 4.1.4 → 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 (42) hide show
  1. package/CHANGELOG.json +3796 -3770
  2. package/dist/render/backend/webgl2/renderer.d.ts +15 -0
  3. package/dist/render/backend/webgl2/renderer.js +28 -7
  4. package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
  5. package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
  6. package/dist/render/backend/webgpu/renderer.js +7 -2
  7. package/dist/render/depthConvention.d.ts +19 -0
  8. package/dist/render/depthConvention.js +21 -0
  9. package/dist/render/livePointShadowSet.js +8 -0
  10. package/dist/render/pointShadowBudget.js +59 -5
  11. package/dist/render/pointShadowImage.d.ts +40 -0
  12. package/dist/render/pointShadowImage.js +20 -0
  13. package/dist/render/pointShadowMap.d.ts +4 -0
  14. package/dist/render/pointShadowMap.js +10 -0
  15. package/dist/render/pointShadowSystem.js +9 -0
  16. package/dist/render/renderQuality.d.ts +27 -4
  17. package/dist/render/renderQuality.js +1 -1
  18. package/dist/render/shaders/flat/main.d.ts +1 -1
  19. package/dist/render/shaders/flat/main.js +13 -5
  20. package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
  21. package/dist/render/shaders/flat/pointShadow.js +42 -30
  22. package/dist/render/shaders/flat/preamble.js +26 -2
  23. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
  24. package/dist/render/shaders/generated/flat.wgsl.js +24 -24
  25. package/dist/render/shaders/sky.d.ts +10 -6
  26. package/dist/render/shaders/sky.js +35 -2
  27. package/package.json +6 -6
  28. package/src/render/backend/webgl2/renderer.ts +28 -7
  29. package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
  30. package/src/render/backend/webgpu/renderer.ts +12 -2
  31. package/src/render/depthConvention.ts +22 -0
  32. package/src/render/livePointShadowSet.ts +7 -0
  33. package/src/render/pointShadowBudget.ts +58 -5
  34. package/src/render/pointShadowImage.ts +51 -0
  35. package/src/render/pointShadowMap.ts +13 -0
  36. package/src/render/pointShadowSystem.ts +8 -0
  37. package/src/render/renderQuality.ts +28 -5
  38. package/src/render/shaders/flat/main.ts +13 -5
  39. package/src/render/shaders/flat/pointShadow.ts +42 -30
  40. package/src/render/shaders/flat/preamble.ts +26 -2
  41. package/src/render/shaders/generated/flat.wgsl.ts +24 -24
  42. package/src/render/shaders/sky.ts +37 -2
@@ -1066,6 +1066,21 @@ export declare class WebGL2Renderer implements RendererApi {
1066
1066
  * near plane needs the granted answer and not the wished-for one.
1067
1067
  */
1068
1068
  reversedDepth: boolean;
1069
+ /**
1070
+ * What to clear depth to, for the convention this context was actually granted.
1071
+ *
1072
+ * **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
1073
+ * 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
1074
+ * context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
1075
+ * set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
1076
+ * and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
1077
+ *
1078
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
1079
+ * drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
1080
+ * beside it was already runtime; this was the half that was not, and the asymmetry is what made
1081
+ * it total rather than subtle.
1082
+ */
1083
+ private get depthClear();
1069
1084
  /** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
1070
1085
  private readonly sceneViewProj;
1071
1086
  /**
@@ -1,7 +1,7 @@
1
1
  import { FrameBudget } from '../budget.js';
2
2
  import { MaterialChanges, ownsMaterial } from '../materialChanges.js';
3
3
  import { mat4 } from 'gl-matrix';
4
- import { DEPTH_CLEAR, DEPTH_OFFSET_SIGN, GL_DEPTH_REMAP, MAX_DEPTH_LAYER, depthOffsetForLayer, GL_SHADOW_REMAP, REVERSED_DEPTH, SHADOW_DEPTH_CLEAR, glDepthFuncEqual, glShadowDepthFunc, } from '../../depthConvention.js';
4
+ import { DEPTH_OFFSET_SIGN, GL_DEPTH_REMAP, MAX_DEPTH_LAYER, depthClearFor, depthOffsetForLayer, GL_SHADOW_REMAP, REVERSED_DEPTH, SHADOW_DEPTH_CLEAR, glDepthFuncEqual, glShadowDepthFunc, } from '../../depthConvention.js';
5
5
  import { PickableSet } from '../../pickable.js';
6
6
  import { TEMPORAL_HISTORY_BLEND, TemporalHistory, jitterOffset, jitterProjection, } from '../../temporalAa.js';
7
7
  import { DecalPass } from '../../decalPass.js';
@@ -45,7 +45,7 @@ import { packIesAtlas } from '../../iesProfile.js';
45
45
  import { MAX_AREA_LIGHTS } from '../../areaLights.js';
46
46
  import { areaShadowLayerCount, castingRange, createResolvedAreaShadows, firstAreaShadowLayer, AreaShadowSet, } from '../../areaShadowSet.js';
47
47
  import { equirectToCubeFaces } from '../../equirectToCube.js';
48
- import { SKY_FRAG, SKY_VERT } from '../../shaders/sky.js';
48
+ import { SKY_FRAG, skyVertFor } from '../../shaders/sky.js';
49
49
  import { DEPTH_FRAG, DEPTH_INSTANCED_VERT, DEPTH_SKINNED_VERT, DEPTH_VERT, } from '../../shaders/depth.js';
50
50
  import { PointShadowArray } from '../../pointShadowArray.js';
51
51
  import { ShadowMap } from '../../shadowMap.js';
@@ -749,6 +749,23 @@ export class WebGL2Renderer {
749
749
  * near plane needs the granted answer and not the wished-for one.
750
750
  */
751
751
  reversedDepth = false;
752
+ /**
753
+ * What to clear depth to, for the convention this context was actually granted.
754
+ *
755
+ * **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
756
+ * 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
757
+ * context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
758
+ * set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
759
+ * and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
760
+ *
761
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
762
+ * drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
763
+ * beside it was already runtime; this was the half that was not, and the asymmetry is what made
764
+ * it total rather than subtle.
765
+ */
766
+ get depthClear() {
767
+ return depthClearFor(this.reversedDepth);
768
+ }
752
769
  /** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
753
770
  sceneViewProj = new Float32Array(16);
754
771
  /**
@@ -1745,7 +1762,10 @@ export class WebGL2Renderer {
1745
1762
  }
1746
1763
  }
1747
1764
  this.filmUniforms = uniformLocations(gl, this.filmProgram, 'film');
1748
- this.skyProgram = compileProgram(gl, SKY_VERT, SKY_FRAG, 'sky');
1765
+ /* The variant matching what this context was granted, not what the engine wants. See
1766
+ `skyVertFor`: a sky written at the reversed far plane on a conventional context sits in the
1767
+ middle of the depth range and paints over everything behind it. */
1768
+ this.skyProgram = compileProgram(gl, skyVertFor(this.reversedDepth), SKY_FRAG, 'sky');
1749
1769
  this.skyUniforms = uniformLocations(gl, this.skyProgram, 'sky');
1750
1770
  this.scatterProgram = compileProgram(gl, SCATTER_VERT, SCATTER_FRAG, 'scatter');
1751
1771
  this.scatterUniforms = uniformLocations(gl, this.scatterProgram, 'scatter');
@@ -3139,12 +3159,13 @@ export class WebGL2Renderer {
3139
3159
  gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.pointShadowArray?.texture ?? this.emptyTexture2DArray);
3140
3160
  gl.uniform1i(uniforms[POINT_SHADOW_SAMPLER] ?? null, firstUnit);
3141
3161
  gl.uniform1iv(uniforms['uPointShadowLayer[0]'] ?? null, resolved.layers);
3142
- gl.uniform1fv(uniforms['uPointShadowFar[0]'] ?? null, resolved.far);
3162
+ /* Origin and far plane in one row; see `uPointShadowProjection` in the preamble. */
3163
+ gl.uniform4fv(uniforms['uPointShadowProjection[0]'] ?? null, resolved.projections);
3143
3164
  gl.uniform1fv(uniforms['uPointShadowNear[0]'] ?? null, resolved.near);
3144
3165
  gl.uniform1fv(uniforms['uPointShadowSize[0]'] ?? null, resolved.sourceRadius);
3145
3166
  gl.uniform1fv(uniforms['uPointShadowWeight[0]'] ?? null, resolved.presence);
3146
3167
  gl.uniform1iv(uniforms['uLivePointShadowLayer[0]'] ?? null, resolved.liveLayers);
3147
- gl.uniform1fv(uniforms['uLivePointShadowFar[0]'] ?? null, resolved.liveFar);
3168
+ gl.uniform4fv(uniforms['uLivePointShadowProjection[0]'] ?? null, resolved.liveProjections);
3148
3169
  gl.uniform1fv(uniforms['uLivePointShadowNear[0]'] ?? null, resolved.liveNear);
3149
3170
  gl.uniform1fv(uniforms['uLivePointShadowSize[0]'] ?? null, resolved.liveSourceRadius);
3150
3171
  gl.uniform1fv(uniforms['uLivePointShadowWeight[0]'] ?? null, resolved.liveWeights);
@@ -3573,7 +3594,7 @@ export class WebGL2Renderer {
3573
3594
  shadowMap.end(this.gl);
3574
3595
  /* Back to the frame's sense; see `beginShadowPass` for why it left it. */
3575
3596
  this.gl.depthFunc(glDepthFuncEqual(this.gl));
3576
- this.gl.clearDepth(DEPTH_CLEAR);
3597
+ this.gl.clearDepth(this.depthClear);
3577
3598
  this.gpuTimer.end();
3578
3599
  this.shadowPassActive = false;
3579
3600
  this.activeShadowMap = null;
@@ -3949,7 +3970,7 @@ export class WebGL2Renderer {
3949
3970
  finally {
3950
3971
  /* Back to the frame's sense, which `beginShadowPass` leaves the same way. */
3951
3972
  gl.depthFunc(glDepthFuncEqual(gl));
3952
- gl.clearDepth(DEPTH_CLEAR);
3973
+ gl.clearDepth(this.depthClear);
3953
3974
  this.pointShadowCasters = null;
3954
3975
  this.depthPassCullsFaces = true;
3955
3976
  }
@@ -67,6 +67,10 @@ export declare class GpuPointShadowMap implements PointShadowSource {
67
67
  get far(): number;
68
68
  get near(): number;
69
69
  get sourceRadius(): number;
70
+ /** Where the image was rendered from, not where the light is now. See the image. */
71
+ get originX(): number;
72
+ get originY(): number;
73
+ get originZ(): number;
70
74
  get hasBaked(): boolean;
71
75
  get presence(): number;
72
76
  matchesSource(x: number, y: number, z: number, range: number, near: number, sourceRadius?: number, tolerance?: number): boolean;
@@ -229,6 +229,16 @@ export class GpuPointShadowMap {
229
229
  get sourceRadius() {
230
230
  return this.image.sourceRadius;
231
231
  }
232
+ /** Where the image was rendered from, not where the light is now. See the image. */
233
+ get originX() {
234
+ return this.image.originX;
235
+ }
236
+ get originY() {
237
+ return this.image.originY;
238
+ }
239
+ get originZ() {
240
+ return this.image.originZ;
241
+ }
232
242
  get hasBaked() {
233
243
  return this.image.hasBaked;
234
244
  }
@@ -6233,11 +6233,16 @@ export class WebGPURenderer {
6233
6233
  }
6234
6234
  else {
6235
6235
  this.writeShadowSelection(f, i, 'uPointShadow', resolved.layers, resolved.presence);
6236
- this.writeShadowParams(f, 'uPointShadowFar', resolved.far);
6236
+ /*
6237
+ * A `vec4` row carrying the bake origin and the far plane, so `scatterInto` rather than
6238
+ * `writeShadowParams` — it reads the stride out of the generated layout instead of
6239
+ * counting rows, which is what AGENTS.md's 2026-09-20 alignment rule asks for.
6240
+ */
6241
+ scatterInto(f, this.fragment.fields['uPointShadowProjection'], resolved.projections, 4);
6237
6242
  this.writeShadowParams(f, 'uPointShadowNear', resolved.near);
6238
6243
  this.writeShadowParams(f, 'uPointShadowSize', resolved.sourceRadius);
6239
6244
  this.writeShadowSelection(f, i, 'uLivePointShadow', resolved.liveLayers, resolved.liveWeights);
6240
- this.writeShadowParams(f, 'uLivePointShadowFar', resolved.liveFar);
6245
+ scatterInto(f, this.fragment.fields['uLivePointShadowProjection'], resolved.liveProjections, 4);
6241
6246
  this.writeShadowParams(f, 'uLivePointShadowNear', resolved.liveNear);
6242
6247
  this.writeShadowParams(f, 'uLivePointShadowSize', resolved.liveSourceRadius);
6243
6248
  }
@@ -101,6 +101,25 @@
101
101
  export declare const REVERSED_DEPTH = true;
102
102
  /** What a scene depth attachment is cleared to: the far plane. */
103
103
  export declare const DEPTH_CLEAR: number;
104
+ /**
105
+ * The same value for a backend that learned its convention at runtime rather than at compile time.
106
+ *
107
+ * **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
108
+ * on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
109
+ * the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
110
+ * scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
111
+ * the screen is the colour clear and nothing else.
112
+ *
113
+ * Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
114
+ * colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
115
+ * beside it was already chosen at runtime, and that asymmetry is what made this total rather than
116
+ * subtle — two halves of one convention would still have drawn a picture.
117
+ *
118
+ * **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
119
+ * backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
120
+ * and the compare to each other, which a private field on a renderer could not.
121
+ */
122
+ export declare function depthClearFor(reversed: boolean): number;
104
123
  /** The WebGPU compare for "nearer than what is there". */
105
124
  export declare const DEPTH_COMPARE: GPUCompareFunction;
106
125
  /**
@@ -101,6 +101,27 @@
101
101
  export const REVERSED_DEPTH = true;
102
102
  /** What a scene depth attachment is cleared to: the far plane. */
103
103
  export const DEPTH_CLEAR = REVERSED_DEPTH ? 0 : 1;
104
+ /**
105
+ * The same value for a backend that learned its convention at runtime rather than at compile time.
106
+ *
107
+ * **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
108
+ * on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
109
+ * the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
110
+ * scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
111
+ * the screen is the colour clear and nothing else.
112
+ *
113
+ * Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
114
+ * colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
115
+ * beside it was already chosen at runtime, and that asymmetry is what made this total rather than
116
+ * subtle — two halves of one convention would still have drawn a picture.
117
+ *
118
+ * **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
119
+ * backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
120
+ * and the compare to each other, which a private field on a renderer could not.
121
+ */
122
+ export function depthClearFor(reversed) {
123
+ return reversed ? 0 : 1;
124
+ }
104
125
  /** The WebGPU compare for "nearer than what is there". */
105
126
  export const DEPTH_COMPARE = REVERSED_DEPTH ? 'greater' : 'less';
106
127
  /**
@@ -155,6 +155,9 @@ export class LivePointShadowSet {
155
155
  out.liveFar.fill(1);
156
156
  out.liveNear.fill(0.01);
157
157
  out.liveSourceRadius.fill(0);
158
+ out.liveProjections.fill(0);
159
+ for (let k = 0; k < out.liveLayers.length; k++)
160
+ out.liveProjections[k * 4 + 3] = 1;
158
161
  /*
159
162
  * Zero, then written under the owning light below. The weights used to be a straight copy of
160
163
  * this set's own two, because the arrays were addressed by live slot; they are addressed by
@@ -188,6 +191,11 @@ export class LivePointShadowSet {
188
191
  out.liveFar[shaderSlot] = map.far;
189
192
  out.liveNear[shaderSlot] = map.near;
190
193
  out.liveSourceRadius[shaderSlot] = map.sourceRadius;
194
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
195
+ out.liveProjections[shaderSlot * 4] = map.originX;
196
+ out.liveProjections[shaderSlot * 4 + 1] = map.originY;
197
+ out.liveProjections[shaderSlot * 4 + 2] = map.originZ;
198
+ out.liveProjections[shaderSlot * 4 + 3] = map.far;
191
199
  /*
192
200
  * The handover weight *and* the image's own arrival, multiplied.
193
201
  *
@@ -167,13 +167,62 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
167
167
  scratch.staleOwner[slot] = lightIndex;
168
168
  scratch.staleRun[slot] = 0;
169
169
  }
170
- const run = stale ? Math.min((scratch.staleRun[slot] ?? 0) + 1, CHRONIC_STALE_FRAMES) : 0;
170
+ /*
171
+ * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
172
+ *
173
+ * A light that wanders on a curve is not stale on *every* frame — near the turning points of
174
+ * its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
175
+ * lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
176
+ * then travelled smoothly through the fast part of the wander and stepped through the slow
177
+ * part, which is not motion and not stillness: it reads as a vibration. Reported that way,
178
+ * against a brazier, once the tracking allowance was in.
179
+ *
180
+ * So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
181
+ * earned the allowance three quiet frames before it loses it, and decays one frame at a time
182
+ * so a light that genuinely settles stops re-baking rather than holding a budget for ever.
183
+ */
184
+ const previous = scratch.staleRun[slot] ?? 0;
185
+ const run = stale
186
+ ? Math.min(previous + 1, CHRONIC_STALE_FRAMES * 2)
187
+ : Math.max(previous - 1, 0);
171
188
  scratch.staleRun[slot] = run;
172
189
  scratch.chronic[slot] = run >= CHRONIC_STALE_FRAMES ? 1 : 0;
173
190
  }
174
191
  const planned = planPointShadowBakes(scratch.stale, scratch.ready, count, scratch.order, scratch.chronic);
175
192
  let faces = facesPerFrame;
176
193
  let urgentSpent = false;
194
+ /*
195
+ * **One wandering light finishes its cube in the frame it started it**, the same allowance a
196
+ * light with no image at all gets, and for a reason that turns out to be the same one: a map
197
+ * dribbled out at two faces a frame is a map nobody can sample correctly until it lands.
198
+ *
199
+ * A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
200
+ * cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
201
+ * way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
202
+ * three frames means the published origin steps once every third frame. The shader shoots from
203
+ * that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
204
+ * "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
205
+ * wanders about once a second.
206
+ *
207
+ * Given the cube in one frame the origin moves every frame and the shadow travels instead.
208
+ *
209
+ * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
210
+ * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
211
+ * that most recently went stale, which is the one somebody is standing next to; the rest keep
212
+ * dribbling under the ordinary budget, and their shadows step rather than travel. That is the
213
+ * right way round: a shadow you are looking at moves, and a shadow across the square does not
214
+ * cost the frame.
215
+ *
216
+ * It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
217
+ * chronic ones last — so a light arriving in range still gets its first image ahead of a flame
218
+ * refining one it already has.
219
+ *
220
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
221
+ * fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
222
+ * a handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
223
+ * raised or this given up, and `gpuTiming` is how to tell which.
224
+ */
225
+ let trackingSpent = false;
177
226
  for (let i = 0; i < planned && faces > 0; i++) {
178
227
  const slot = scratch.order[i] ?? -1;
179
228
  /*
@@ -185,11 +234,16 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
185
234
  const map = lightIndex >= 0 ? pool.mapForLight(lightIndex) : undefined;
186
235
  if (light === undefined || map === undefined)
187
236
  continue;
188
- const cold = slot < pool.sampledCount && scratch.ready[slot] === 0;
189
- const budget = cold && !urgentSpent ? faceCount : faces;
190
- if (cold && !urgentSpent)
237
+ const sampled = slot < pool.sampledCount;
238
+ const cold = sampled && scratch.ready[slot] === 0;
239
+ const urgent = cold && !urgentSpent;
240
+ /* A light being shaded whose map will be stale again next frame however it is served. */
241
+ const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
242
+ if (urgent)
191
243
  urgentSpent = true;
192
- faces -= bake(map, light, staticCasters, budget);
244
+ if (tracking)
245
+ trackingSpent = true;
246
+ faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
193
247
  }
194
248
  /*
195
249
  * Live maps contain movers only and are composed with the static maps in the shader. A second
@@ -111,6 +111,18 @@ export declare class PointShadowImage {
111
111
  private pendingRange;
112
112
  private pendingNear;
113
113
  private pendingSourceRadius;
114
+ /**
115
+ * Where this image was rendered from, which is what a sampler has to shoot its ray from.
116
+ *
117
+ * **Not the light's position**, and the difference is the whole point: `matchesSource` lets a
118
+ * light drift up to the caller's rebake tolerance from here before the image is re-rendered,
119
+ * so for any light that wanders these two are apart on nearly every frame. A shader that
120
+ * builds its direction and its distance from the live light asks this picture a question the
121
+ * picture cannot answer. See `ResolvedPointShadows.origins`.
122
+ */
123
+ get originX(): number;
124
+ get originY(): number;
125
+ get originZ(): number;
114
126
  /** Whether this map holds an image worth sampling. False until a bake completes. */
115
127
  get hasBaked(): boolean;
116
128
  /** How present the image is, 0 to 1. The shader mixes the occlusion in by it. */
@@ -222,6 +234,10 @@ export interface PointShadowSource {
222
234
  readonly far: number;
223
235
  readonly near: number;
224
236
  readonly sourceRadius: number;
237
+ /** Where the image was rendered from. See `PointShadowImage.originX`. */
238
+ readonly originX: number;
239
+ readonly originY: number;
240
+ readonly originZ: number;
225
241
  /** Whether this map holds an image worth sampling. */
226
242
  readonly hasBaked: boolean;
227
243
  /** How present the image is, 0 to 1. */
@@ -253,6 +269,28 @@ export interface ResolvedPointShadows {
253
269
  readonly far: Float32Array;
254
270
  readonly near: Float32Array;
255
271
  readonly sourceRadius: Float32Array;
272
+ /**
273
+ * The projection each bound map was rendered under: origin in `xyz`, far plane in `w`, four
274
+ * floats a light. The origin is **not** the light's position.
275
+ *
276
+ * A light is allowed to drift `pointShadowRebakeDistance` from its image before the image is
277
+ * re-rendered — that tolerance is what stops a flame re-baking six faces of the static world
278
+ * every frame — so for anything that flickers these two are apart on nearly every frame. The
279
+ * shader builds both its sample direction and the distance it compares from this point, so the
280
+ * ray it shoots is the ray the picture was drawn along and the drift costs nothing.
281
+ *
282
+ * **It used to build them from the live light**, and the drift went straight into the
283
+ * comparison: `dist` moved one for one with it against a bias of a few centimetres, and the
284
+ * single-tap blocker search moved across the occluder's silhouette, swapping `occluderDistance`
285
+ * between the caster and nothing. Reported on a brazier at night as a hard square under the
286
+ * fire, flashing in time with the flame while the camera stood still.
287
+ *
288
+ * **The far plane rides in `w` rather than in an array of its own**, because a default-block
289
+ * `float[N]` spends a whole uniform vector per element: declared separately the origin cost
290
+ * sixteen rows on each of the two sets, which the budget ladder answers on a 256-vector
291
+ * device by halving `MAX_LIGHTS`. See `uPointShadowProjection` in the preamble.
292
+ */
293
+ readonly projections: Float32Array;
256
294
  /** How present each bound map is, so a re-baked one arrives rather than appears. */
257
295
  readonly presence: Float32Array;
258
296
  /** The live pair, by the same light index: a light owns at most one of the two. */
@@ -260,6 +298,8 @@ export interface ResolvedPointShadows {
260
298
  readonly liveFar: Float32Array;
261
299
  readonly liveNear: Float32Array;
262
300
  readonly liveSourceRadius: Float32Array;
301
+ /** The live pair's own, by the same rule and for the same reason. */
302
+ readonly liveProjections: Float32Array;
263
303
  readonly liveWeights: Float32Array;
264
304
  }
265
305
  export declare function createResolvedPointShadows(lights: number): ResolvedPointShadows;
@@ -160,6 +160,24 @@ export class PointShadowImage {
160
160
  pendingRange = 0;
161
161
  pendingNear = 0;
162
162
  pendingSourceRadius = 0;
163
+ /**
164
+ * Where this image was rendered from, which is what a sampler has to shoot its ray from.
165
+ *
166
+ * **Not the light's position**, and the difference is the whole point: `matchesSource` lets a
167
+ * light drift up to the caller's rebake tolerance from here before the image is re-rendered,
168
+ * so for any light that wanders these two are apart on nearly every frame. A shader that
169
+ * builds its direction and its distance from the live light asks this picture a question the
170
+ * picture cannot answer. See `ResolvedPointShadows.origins`.
171
+ */
172
+ get originX() {
173
+ return this.x;
174
+ }
175
+ get originY() {
176
+ return this.y;
177
+ }
178
+ get originZ() {
179
+ return this.z;
180
+ }
163
181
  /** Whether this map holds an image worth sampling. False until a bake completes. */
164
182
  get hasBaked() {
165
183
  return this.baked;
@@ -329,11 +347,13 @@ export function createResolvedPointShadows(lights) {
329
347
  far: new Float32Array(lights),
330
348
  near: new Float32Array(lights),
331
349
  sourceRadius: new Float32Array(lights),
350
+ projections: new Float32Array(lights * 4),
332
351
  presence: new Float32Array(lights),
333
352
  liveLayers: new Int32Array(lights).fill(-1),
334
353
  liveFar: new Float32Array(lights),
335
354
  liveNear: new Float32Array(lights),
336
355
  liveSourceRadius: new Float32Array(lights),
356
+ liveProjections: new Float32Array(lights * 4),
337
357
  liveWeights: new Float32Array(lights),
338
358
  };
339
359
  }
@@ -46,6 +46,10 @@ export declare class PointShadowMap {
46
46
  get far(): number;
47
47
  get near(): number;
48
48
  get sourceRadius(): number;
49
+ /** Where the image was rendered from, not where the light is now. See the image. */
50
+ get originX(): number;
51
+ get originY(): number;
52
+ get originZ(): number;
49
53
  /** Whether this map holds an image worth sampling. False until a bake completes. */
50
54
  get hasBaked(): boolean;
51
55
  /** How present the image is, 0 to 1. The shader mixes the occlusion in by it. */
@@ -54,6 +54,16 @@ export class PointShadowMap {
54
54
  get sourceRadius() {
55
55
  return this.image.sourceRadius;
56
56
  }
57
+ /** Where the image was rendered from, not where the light is now. See the image. */
58
+ get originX() {
59
+ return this.image.originX;
60
+ }
61
+ get originY() {
62
+ return this.image.originY;
63
+ }
64
+ get originZ() {
65
+ return this.image.originZ;
66
+ }
57
67
  /** Whether this map holds an image worth sampling. False until a bake completes. */
58
68
  get hasBaked() {
59
69
  return this.image.hasBaked;
@@ -272,6 +272,10 @@ export class PointShadowSystem {
272
272
  out.far.fill(1);
273
273
  out.near.fill(0.01);
274
274
  out.sourceRadius.fill(0);
275
+ /* Origin zero, far plane one: the same neutral the separate `far` array is filled with. */
276
+ out.projections.fill(0);
277
+ for (let k = 0; k < MAX_POINT_LIGHTS; k++)
278
+ out.projections[k * 4 + 3] = 1;
275
279
  out.presence.fill(0);
276
280
  for (let k = 0; k < MAX_POINT_LIGHTS; k++) {
277
281
  const map = this.mapForShaderSlot(k);
@@ -299,6 +303,11 @@ export class PointShadowSystem {
299
303
  out.far[light] = map.far;
300
304
  out.near[light] = map.near;
301
305
  out.sourceRadius[light] = map.sourceRadius;
306
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
307
+ out.projections[light * 4] = map.originX;
308
+ out.projections[light * 4 + 1] = map.originY;
309
+ out.projections[light * 4 + 2] = map.originZ;
310
+ out.projections[light * 4 + 3] = map.far;
302
311
  out.presence[light] = map.presence;
303
312
  out.layers[light] = map.layer;
304
313
  }
@@ -154,10 +154,33 @@ export interface RenderQuality {
154
154
  * sampled lights flickering at once accounted for 174 of the frame's 296 draw
155
155
  * calls, and for the game being unplayable exactly where lamps cluster.
156
156
  *
157
- * The default clears a flame's own wander with room to spare, because drift is
158
- * measured from the baked origin and a wander can be caught at either extreme
159
- * of its travel. A light that really moves still re-bakes; it just does so once
160
- * per this much movement instead of once per frame.
157
+ * **It was 0.4 until 4.1.5, chosen to clear a flame's whole wander with room to spare** — so
158
+ * a flame never re-baked at all, and its shadow of the static world stood still while the live
159
+ * map under the same light, which is re-rendered every frame because it holds the movers, swung
160
+ * with the flame. One light, two shadows, visibly disagreeing about where it was. Reported as
161
+ * the runner's shadow following the fire and the brazier's not.
162
+ *
163
+ * **A millimetre instead, which is as near to "whenever it moved" as makes sense**, so a shadow
164
+ * travels with its light rather than freezing between re-bakes. A centimetre was tried first and
165
+ * left a visible vibration: a light wandering on a curve moves less than that in a frame near
166
+ * the turning points of its travel, so the shadow ran smoothly through the fast part and stepped
167
+ * through the slow part. What the tolerance is still for is not re-baking on arithmetic noise.
168
+ *
169
+ * A light that does not move never goes stale at any positive value, so the whole cost falls on
170
+ * the lights that need it, and it is bounded rather than proportional: one such light takes a
171
+ * whole cube in a frame and the rest dribble under the ordinary face budget.
172
+ *
173
+ * **What makes that affordable is not this number.** The 174-of-296 draw calls this paragraph
174
+ * used to cite was measured before `pointShadowFacesPerFrame` capped the whole static bake, and
175
+ * before `planPointShadowBakes` learned to put a chronically stale map behind every cold and
176
+ * settling one. A flame now spends leftover budget and can never hold another light's first
177
+ * image hostage. One such light also finishes its cube inside a single frame rather than
178
+ * dribbling it over three, which is what makes the motion smooth instead of stepped; see
179
+ * `pointShadowBudget.ts`.
180
+ *
181
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
182
+ * fit the frame, where a stepped shadow is the better trade and this should go back up.
183
+ * `gpuTiming` is how to tell which, and it is a number rather than an opinion.
161
184
  */
162
185
  readonly pointShadowRebakeDistance: number;
163
186
  /**
@@ -58,7 +58,7 @@ export const DEFAULT_RENDER_QUALITY = Object.freeze({
58
58
  directionalShadowDepthLayers: 2,
59
59
  directionalShadowMaxDistance: 6,
60
60
  directionalShadowMaxSlope: 3,
61
- pointShadowRebakeDistance: 0.4,
61
+ pointShadowRebakeDistance: 0.001,
62
62
  pointShadowFacesPerFrame: 2,
63
63
  /*
64
64
  * Twelve, which is `LIVE_POINT_SHADOW_MAPS` cubemaps of six faces: exactly what the live