@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
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Procedural gradient sky drawn as a single attribute-less fullscreen triangle.
3
- * Ray direction is reconstructed per-fragment from the inverse view-projection,
4
- * so the sky is correct under any camera. Drawn last at the far plane, so early-z wins —
5
- * and the far plane is 0 under reversed depth and 1 otherwise, which is why the value is
6
- * interpolated rather than written. A sky that says 1.0 in a reversed engine is drawn at the
7
- * *near* plane and covers the world; measured, that was 743,669 changed pixels of 921,600.
2
+ * The engine's own convention, which is what WebGPU always gets and what the WGSL is generated
3
+ * from. `scripts/wgsl.ts` reads this constant, so it stays a constant.
8
4
  */
9
5
  export declare const SKY_VERT: string;
6
+ /**
7
+ * The same shader for a context that may not have been granted reversed depth.
8
+ *
9
+ * WebGL2 compiles GLSL directly and is the only backend that can run either way, so it asks for
10
+ * the one matching what it got. WebGPU never calls this: it uses the generated WGSL, where the far
11
+ * plane is reversed by construction.
12
+ */
13
+ export declare function skyVertFor(reversed: boolean): string;
10
14
  export declare const SKY_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vNdc;\n\nuniform mat4 uInvViewProj;\nuniform vec3 uCameraPos;\nuniform vec3 uTopColor;\nuniform vec3 uHorizonColor;\nuniform vec3 uDeepColor;\nuniform vec3 uSunDir;\nuniform vec3 uSunColor;\nuniform float uSunDiscExponent;\nuniform vec3 uMoonDir;\nuniform vec3 uMoonColor;\nuniform float uMoonAngularRadius;\nuniform float uMoonPhase;\nuniform float uNightFactor;\nuniform vec2 uCloudOffset;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFactor;\n\nout vec4 outColor;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n float a = hash21(i);\n float b = hash21(i + vec2(1.0, 0.0));\n float c = hash21(i + vec2(0.0, 1.0));\n float d = hash21(i + vec2(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\n/** Three octaves is the ceiling \u2014 drop to two before cutting anything else. */\nfloat fbm(vec2 p) {\n float v = valueNoise(p) * 0.5;\n v += valueNoise(p * 2.03) * 0.3;\n v += valueNoise(p * 4.01) * 0.2;\n return v;\n}\n\nvoid main() {\n vec4 world = uInvViewProj * vec4(vNdc, 1.0, 1.0);\n vec3 dir = normalize(world.xyz / world.w - uCameraPos);\n\n float t = dir.y;\n vec3 col = t >= 0.0\n ? mix(uHorizonColor, uTopColor, pow(min(t, 1.0), 0.55))\n : mix(uHorizonColor, uDeepColor, min(-t * 1.8, 1.0));\n\n // Celestial bodies belong to the sky, not to the void underneath. Seeing the\n // sun below the horizon made the world read as unbounded in every direction;\n // fading them out at the horizon leaves the deep reading as depth instead.\n float aboveHorizon = smoothstep(-0.04, 0.06, dir.y);\n\n /*\n * Sunset.\n *\n * Low sunlight crosses far more atmosphere than high sunlight, and that path\n * scatters the blue out of it \u2014 which is why the sun reddens as it drops and\n * why the glow around it spreads into a band rather than staying a disc. Both\n * effects peak right at the horizon and are gone within a few degrees of it,\n * so they are driven by the sun's own elevation rather than by the clock.\n *\n * TWO EDGES, BECAUSE A SUNSET ENDS. This shipped with only the upper one, so every\n * sun below the horizon read as maximum sunset and stayed there all the way to\n * straight down \u2014 and a sun direction of (0, -1, 0) is what a night world writes to\n * mean \"there is no sun in this sky\". A consumer measured the result on a black\n * sun: 87% more red in the sky than the same frame with the sun pointing up, a warm\n * band over a third of the picture in an arctic snowfield, and the world's own\n * blue-grey horizon arriving with its channel order reversed. Four night worlds were\n * not started over it, and this repository's own storm sea shipped it. The lower\n * edge is where astronomical twilight ends, near -0.31, so the term now fades out\n * over the same range the sky it models does.\n *\n * What it gives up: the glow is a function of the sun's elevation and nothing else,\n * so a caller cannot place one where its own sun is not. That is deliberate \u2014 a knob\n * here would let a world disagree with itself \u2014 but it is also what makes this wrong\n * for a body that is not our sun in our atmosphere. A world lit by something that\n * scatters differently needs its own term, not a wider edge here.\n *\n * Written ascending-and-inverted rather than as a descending smoothstep, whose\n * result GLSL ES leaves undefined when edge0 >= edge1. Every implementation seen so\n * far computes the ramp anybody would expect, which is why the descending form\n * survived this long, but the spec does not owe us that.\n */\n float sunset = (1.0 - smoothstep(-0.03, 0.30, uSunDir.y)) *\n smoothstep(-0.35, -0.05, uSunDir.y);\n vec3 ember = vec3(1.0, 0.36, 0.13);\n\n float s = max(dot(dir, uSunDir), 0.0);\n // The disc itself reddens; the last sliver above the horizon is nearly blood\n // orange, which is the moment the whole effect is remembered for.\n vec3 discColor = mix(uSunColor, uSunColor * ember, sunset * 0.85);\n col += discColor * (\n pow(s, uSunDiscExponent) * 1.2 +\n pow(s, max(uSunDiscExponent / 75.0, 1.0)) * 0.12\n ) * aboveHorizon;\n\n /*\n * The band. Brightest toward the sun's bearing and hugging the horizon,\n * falling off both with angle around the compass and with height \u2014 a sunset\n * is a direction, not a uniform warm wash, and washing the whole dome is what\n * makes a fake one look like a colour filter.\n */\n vec2 sunBearing = normalize(uSunDir.xz + vec2(1e-5));\n vec2 viewBearing = normalize(dir.xz + vec2(1e-5));\n float toward = max(dot(viewBearing, sunBearing), 0.0);\n /*\n * Measured from the horizon, and falling three times faster below it than above.\n *\n * This was abs(dir.y), which is symmetric, so the band was mirrored twenty-seven\n * degrees down into the deep \u2014 visible in any world whose ground does not cover\n * the lower half of the frame, and captured in the -Y face of every environment\n * probe, where it tinted reflections warm. Nothing in the sky mirrors a sunset\n * below the horizon; what is down there is ground, and the ground is drawn by\n * somebody else.\n *\n * Not gated by aboveHorizon like the sun disc and the moon are: that factor is\n * only 0.4 at a dir.y of zero, which is exactly where this term is brightest, so\n * multiplying by it would cut the band's core to a third and change every sunset\n * to fix a night. Falling faster below instead leaves everything at or above the\n * horizon bit-for-bit as it was, and the horizon line itself unbroken. What it\n * gives up: a camera under the world, or above a planet seen from outside its\n * atmosphere, sees the glow end sooner than the geometry does.\n */\n float fromHorizon = dir.y >= 0.0 ? dir.y : -dir.y * 3.0;\n float band = pow(1.0 - min(fromHorizon * 2.2, 1.0), 2.6);\n col += ember * sunset * band * (0.18 + 0.75 * pow(toward, 2.2));\n\n /*\n * Belt of Venus: the sky opposite a setting sun takes a cool pink-violet from\n * light already reddened on its way past. Subtle, but its absence is why an\n * artificial sunset feels like it is only happening on one side.\n */\n float away = max(-dot(viewBearing, sunBearing), 0.0);\n col += vec3(0.42, 0.28, 0.45) * sunset * band * away * 0.22;\n\n /*\n * Moon: a shaded procedural sphere-disc, not one dot subtracted from another.\n * The local sphere normal gives a curved terminator and limb; lunar phase\n * supplies the light direction around that sphere. Low-frequency noise adds\n * maria without a texture file, while a faint earthshine keeps the dark limb\n * just legible instead of cutting a mathematically black hole in the sky.\n */\n vec3 moonReference = abs(uMoonDir.y) < 0.98\n ? vec3(0.0, 1.0, 0.0)\n : vec3(1.0, 0.0, 0.0);\n vec3 moonRight = normalize(cross(moonReference, uMoonDir));\n vec3 moonUp = cross(uMoonDir, moonRight);\n float moonRadius = clamp(uMoonAngularRadius, 0.001, 0.5);\n vec2 moonUv = vec2(dot(dir, moonRight), dot(dir, moonUp)) / moonRadius;\n float moonRadiusSq = dot(moonUv, moonUv);\n float moonFacing = step(0.0, dot(dir, uMoonDir));\n float moonDisc = (1.0 - smoothstep(0.90, 1.0, moonRadiusSq)) * moonFacing;\n if (moonDisc > 0.0) {\n float sphereZ = sqrt(max(1.0 - moonRadiusSq, 0.0));\n vec3 surfaceNormal = normalize(vec3(moonUv, sphereZ));\n float phaseAngle = uMoonPhase * 6.28318530718;\n vec3 phaseLight = vec3(sin(phaseAngle), 0.0, -cos(phaseAngle));\n float phaseShade = max(dot(surfaceNormal, phaseLight), 0.0);\n float maria = valueNoise(moonUv * 3.2 + vec2(4.7, 9.1)) * 0.68 +\n valueNoise(moonUv * 9.3 - vec2(7.4, 2.6)) * 0.32;\n float albedo = mix(0.56, 1.02, maria);\n float limb = mix(0.72, 1.0, sphereZ);\n float earthshine = 0.035;\n float lunarLight = earthshine + (1.0 - earthshine) * phaseShade;\n col += uMoonColor * albedo * limb * lunarLight * moonDisc *\n 1.35 * uNightFactor * aboveHorizon;\n }\n float phaseIllumination = 0.5 - cos(uMoonPhase * 6.28318530718) * 0.5;\n float moonHalo = (1.0 - smoothstep(1.0, 3.0, length(moonUv))) * moonFacing;\n col += uMoonColor * moonHalo * 0.08 * phaseIllumination *\n uNightFactor * aboveHorizon;\n\n if (dir.y > 0.0) {\n // Stars. Stereographic projection: unlike dir.xz/dir.y it stays finite and\n // near-uniform all the way to the horizon, so stars don't smear into grain.\n // Each cell holds one jittered *point* \u2014 lighting the whole cell would draw\n // square blocks, which is what \"random pixels\" looks like.\n if (uNightFactor > 0.0) {\n vec2 p = dir.xz / (1.0 + dir.y) * 26.0;\n vec2 cell = floor(p);\n float h = hash21(cell);\n if (h > 0.93) {\n vec2 jitter = vec2(hash21(cell + 17.3), hash21(cell + 41.7));\n float d = length(fract(p) - jitter);\n float star = smoothstep(0.10, 0.0, d) * (0.4 + h * 0.6);\n // Fade out near the horizon, where haze would swallow them anyway.\n col += vec3(0.86, 0.91, 1.0) * star * uNightFactor * smoothstep(0.03, 0.3, dir.y);\n }\n }\n\n // Clouds: banded value noise on a layer above the camera, carried by the\n // world's wind \u2014 the same signal the smoke and the grass answer to, so the\n // largest moving thing in frame agrees with the smallest.\n // The floor on dir.y bounds the perspective stretch near the horizon.\n if (dir.y > 0.02) {\n vec2 uv = dir.xz / max(dir.y, 0.16) * 0.09 + uCloudOffset * 0.012;\n float band = smoothstep(0.46, 0.60, fbm(uv));\n float fade = smoothstep(0.02, 0.30, dir.y);\n vec3 cloudCol = mix(uHorizonColor, vec3(1.0), 0.45) * mix(1.0, 0.32, uNightFactor);\n col = mix(col, cloudCol, band * fade * 0.8);\n }\n }\n\n // There is no outdoor sky inside a water column. Retain a little vertical\n // gradient so the surface remains legible above and depth reads darker\n // below, while celestial bodies and clouds disappear with the same smooth\n // surface transition used by the geometry passes.\n float waterLight = mix(0.62, 1.08, clamp(dir.y * 0.5 + 0.5, 0.0, 1.0));\n col = mix(col, uUnderwaterColor * waterLight, uUnderwaterFactor);\n\n outColor = vec4(col, 1.0);\n}\n";
@@ -7,7 +7,24 @@ import { glslFarDepth } from '../depthConvention.js';
7
7
  * interpolated rather than written. A sky that says 1.0 in a reversed engine is drawn at the
8
8
  * *near* plane and covers the world; measured, that was 743,669 changed pixels of 921,600.
9
9
  */
10
- export const SKY_VERT = `#version 300 es
10
+ /**
11
+ * The source, as a function of which way depth actually runs.
12
+ *
13
+ * **Because on WebGL2 that is a runtime fact and this file is imported once.** Reversed depth there
14
+ * needs `EXT_clip_control`; a context that is not granted it runs conventional depth, and the
15
+ * renderer has a `reversedDepth` field saying so precisely because the two can disagree. This
16
+ * template used to bake `glslFarDepth()` at import, which reads the compile-time `REVERSED_DEPTH` —
17
+ * so on such a context the sky was written at `0.0` while the depth test ran conventionally, where
18
+ * `0.0` is the middle of the range rather than the far plane. The sky then beat everything in the
19
+ * far half of the scene.
20
+ *
21
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**: the voxel sandbox drew
22
+ * only sky, the gilded chamber a single orange frame, and a consumer's world one flat colour with
23
+ * its interface still on top. Three symptoms, one cause, and the docstring above already named the
24
+ * mechanism in the other direction.
25
+ */
26
+ function skySource(farDepth) {
27
+ return `#version 300 es
11
28
  const vec2 POS[3] = vec2[3](vec2(-1., -1.), vec2(3., -1.), vec2(-1., 3.));
12
29
 
13
30
  out vec2 vNdc;
@@ -15,9 +32,25 @@ out vec2 vNdc;
15
32
  void main() {
16
33
  vNdc = POS[gl_VertexID];
17
34
  /* The far plane, whichever way depth runs: see glslFarDepth. */
18
- gl_Position = vec4(vNdc, ${glslFarDepth()}, 1.0);
35
+ gl_Position = vec4(vNdc, ${farDepth}, 1.0);
19
36
  }
20
37
  `;
38
+ }
39
+ /**
40
+ * The engine's own convention, which is what WebGPU always gets and what the WGSL is generated
41
+ * from. `scripts/wgsl.ts` reads this constant, so it stays a constant.
42
+ */
43
+ export const SKY_VERT = skySource(glslFarDepth());
44
+ /**
45
+ * The same shader for a context that may not have been granted reversed depth.
46
+ *
47
+ * WebGL2 compiles GLSL directly and is the only backend that can run either way, so it asks for
48
+ * the one matching what it got. WebGPU never calls this: it uses the generated WGSL, where the far
49
+ * plane is reversed by construction.
50
+ */
51
+ export function skyVertFor(reversed) {
52
+ return reversed ? SKY_VERT : skySource('1.0');
53
+ }
21
54
  export const SKY_FRAG = `#version 300 es
22
55
  precision highp float;
23
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.1.4",
3
+ "version": "4.1.5",
4
4
  "description": "DriftEngine reusable browser 3D runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.1.4",
33
- "@driftengine/physics": "4.1.4",
32
+ "@driftengine/drft": "4.1.5",
33
+ "@driftengine/physics": "4.1.5",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -63,8 +63,8 @@
63
63
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
64
64
  },
65
65
  "devDependencies": {
66
- "@driftengine/nav": "4.1.4",
67
- "@driftengine/network": "4.1.4",
68
- "@driftengine/texture": "4.1.4"
66
+ "@driftengine/nav": "4.1.5",
67
+ "@driftengine/network": "4.1.5",
68
+ "@driftengine/texture": "4.1.5"
69
69
  }
70
70
  }
@@ -2,10 +2,10 @@ import { FrameBudget } from '../budget.ts';
2
2
  import { MaterialChanges, ownsMaterial } from '../materialChanges.ts';
3
3
  import { mat4 } from 'gl-matrix';
4
4
  import {
5
- DEPTH_CLEAR,
6
5
  DEPTH_OFFSET_SIGN,
7
6
  GL_DEPTH_REMAP,
8
7
  MAX_DEPTH_LAYER,
8
+ depthClearFor,
9
9
  depthOffsetForLayer,
10
10
  GL_SHADOW_REMAP,
11
11
  REVERSED_DEPTH,
@@ -133,7 +133,7 @@ import {
133
133
  import type { PhotometricProfile } from '../../iesProfile.ts';
134
134
  import { equirectToCubeFaces } from '../../equirectToCube.ts';
135
135
 
136
- import { SKY_FRAG, SKY_VERT } from '../../shaders/sky.ts';
136
+ import { SKY_FRAG, skyVertFor } from '../../shaders/sky.ts';
137
137
  import {
138
138
  DEPTH_FRAG,
139
139
  DEPTH_INSTANCED_VERT,
@@ -1370,6 +1370,23 @@ export class WebGL2Renderer implements RendererApi {
1370
1370
  * near plane needs the granted answer and not the wished-for one.
1371
1371
  */
1372
1372
  reversedDepth = false;
1373
+ /**
1374
+ * What to clear depth to, for the convention this context was actually granted.
1375
+ *
1376
+ * **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
1377
+ * 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
1378
+ * context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
1379
+ * set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
1380
+ * and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
1381
+ *
1382
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
1383
+ * drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
1384
+ * beside it was already runtime; this was the half that was not, and the asymmetry is what made
1385
+ * it total rather than subtle.
1386
+ */
1387
+ private get depthClear(): number {
1388
+ return depthClearFor(this.reversedDepth);
1389
+ }
1373
1390
  /** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
1374
1391
  private readonly sceneViewProj = new Float32Array(16);
1375
1392
  /**
@@ -2520,7 +2537,10 @@ export class WebGL2Renderer implements RendererApi {
2520
2537
  }
2521
2538
  }
2522
2539
  this.filmUniforms = uniformLocations(gl, this.filmProgram, 'film');
2523
- this.skyProgram = compileProgram(gl, SKY_VERT, SKY_FRAG, 'sky');
2540
+ /* The variant matching what this context was granted, not what the engine wants. See
2541
+ `skyVertFor`: a sky written at the reversed far plane on a conventional context sits in the
2542
+ middle of the depth range and paints over everything behind it. */
2543
+ this.skyProgram = compileProgram(gl, skyVertFor(this.reversedDepth), SKY_FRAG, 'sky');
2524
2544
  this.skyUniforms = uniformLocations(gl, this.skyProgram, 'sky');
2525
2545
  this.scatterProgram = compileProgram(gl, SCATTER_VERT, SCATTER_FRAG, 'scatter');
2526
2546
  this.scatterUniforms = uniformLocations(gl, this.scatterProgram, 'scatter');
@@ -4134,12 +4154,13 @@ export class WebGL2Renderer implements RendererApi {
4134
4154
  gl.uniform1i(uniforms[POINT_SHADOW_SAMPLER] ?? null, firstUnit);
4135
4155
 
4136
4156
  gl.uniform1iv(uniforms['uPointShadowLayer[0]'] ?? null, resolved.layers);
4137
- gl.uniform1fv(uniforms['uPointShadowFar[0]'] ?? null, resolved.far);
4157
+ /* Origin and far plane in one row; see `uPointShadowProjection` in the preamble. */
4158
+ gl.uniform4fv(uniforms['uPointShadowProjection[0]'] ?? null, resolved.projections);
4138
4159
  gl.uniform1fv(uniforms['uPointShadowNear[0]'] ?? null, resolved.near);
4139
4160
  gl.uniform1fv(uniforms['uPointShadowSize[0]'] ?? null, resolved.sourceRadius);
4140
4161
  gl.uniform1fv(uniforms['uPointShadowWeight[0]'] ?? null, resolved.presence);
4141
4162
  gl.uniform1iv(uniforms['uLivePointShadowLayer[0]'] ?? null, resolved.liveLayers);
4142
- gl.uniform1fv(uniforms['uLivePointShadowFar[0]'] ?? null, resolved.liveFar);
4163
+ gl.uniform4fv(uniforms['uLivePointShadowProjection[0]'] ?? null, resolved.liveProjections);
4143
4164
  gl.uniform1fv(uniforms['uLivePointShadowNear[0]'] ?? null, resolved.liveNear);
4144
4165
  gl.uniform1fv(uniforms['uLivePointShadowSize[0]'] ?? null, resolved.liveSourceRadius);
4145
4166
  gl.uniform1fv(uniforms['uLivePointShadowWeight[0]'] ?? null, resolved.liveWeights);
@@ -4669,7 +4690,7 @@ export class WebGL2Renderer implements RendererApi {
4669
4690
  shadowMap.end(this.gl);
4670
4691
  /* Back to the frame's sense; see `beginShadowPass` for why it left it. */
4671
4692
  this.gl.depthFunc(glDepthFuncEqual(this.gl));
4672
- this.gl.clearDepth(DEPTH_CLEAR);
4693
+ this.gl.clearDepth(this.depthClear);
4673
4694
  this.gpuTimer.end();
4674
4695
  this.shadowPassActive = false;
4675
4696
  this.activeShadowMap = null;
@@ -5117,7 +5138,7 @@ export class WebGL2Renderer implements RendererApi {
5117
5138
  } finally {
5118
5139
  /* Back to the frame's sense, which `beginShadowPass` leaves the same way. */
5119
5140
  gl.depthFunc(glDepthFuncEqual(gl));
5120
- gl.clearDepth(DEPTH_CLEAR);
5141
+ gl.clearDepth(this.depthClear);
5121
5142
  this.pointShadowCasters = null;
5122
5143
  this.depthPassCullsFaces = true;
5123
5144
  }
@@ -289,6 +289,19 @@ export class GpuPointShadowMap implements PointShadowSource {
289
289
  return this.image.sourceRadius;
290
290
  }
291
291
 
292
+ /** Where the image was rendered from, not where the light is now. See the image. */
293
+ get originX(): number {
294
+ return this.image.originX;
295
+ }
296
+
297
+ get originY(): number {
298
+ return this.image.originY;
299
+ }
300
+
301
+ get originZ(): number {
302
+ return this.image.originZ;
303
+ }
304
+
292
305
  get hasBaked(): boolean {
293
306
  return this.image.hasBaked;
294
307
  }
@@ -7720,7 +7720,12 @@ export class WebGPURenderer implements RendererApi {
7720
7720
  this.clearShadowSelection(f, i, 'uLivePointShadowLayer', 'uLivePointShadowWeight');
7721
7721
  } else {
7722
7722
  this.writeShadowSelection(f, i, 'uPointShadow', resolved.layers, resolved.presence);
7723
- this.writeShadowParams(f, 'uPointShadowFar', resolved.far);
7723
+ /*
7724
+ * A `vec4` row carrying the bake origin and the far plane, so `scatterInto` rather than
7725
+ * `writeShadowParams` — it reads the stride out of the generated layout instead of
7726
+ * counting rows, which is what AGENTS.md's 2026-09-20 alignment rule asks for.
7727
+ */
7728
+ scatterInto(f, this.fragment.fields['uPointShadowProjection'], resolved.projections, 4);
7724
7729
  this.writeShadowParams(f, 'uPointShadowNear', resolved.near);
7725
7730
  this.writeShadowParams(f, 'uPointShadowSize', resolved.sourceRadius);
7726
7731
  this.writeShadowSelection(
@@ -7730,7 +7735,12 @@ export class WebGPURenderer implements RendererApi {
7730
7735
  resolved.liveLayers,
7731
7736
  resolved.liveWeights,
7732
7737
  );
7733
- this.writeShadowParams(f, 'uLivePointShadowFar', resolved.liveFar);
7738
+ scatterInto(
7739
+ f,
7740
+ this.fragment.fields['uLivePointShadowProjection'],
7741
+ resolved.liveProjections,
7742
+ 4,
7743
+ );
7734
7744
  this.writeShadowParams(f, 'uLivePointShadowNear', resolved.liveNear);
7735
7745
  this.writeShadowParams(f, 'uLivePointShadowSize', resolved.liveSourceRadius);
7736
7746
  }
@@ -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
@@ -990,7 +990,7 @@ export const MAIN_GLSL = `void main() {
990
990
  #if POINT_SHADOWS
991
991
  float grazing = 1.0 - ndl;
992
992
  float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;
993
- vec3 shadowFrom = (vWorldPos + n * normalOffset) - lightPos;
993
+ vec3 receiverPoint = vWorldPos + n * normalOffset;
994
994
 
995
995
  /*
996
996
  * uPointShadowWeight is how present the cubemap is, and it is the difference
@@ -1031,8 +1031,15 @@ export const MAIN_GLSL = `void main() {
1031
1031
  pointShadow(
1032
1032
  uPointShadows,
1033
1033
  float(layer),
1034
- shadowFrom,
1035
- 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,
1036
1043
  uPointShadowNear[shadowRead],
1037
1044
  uPointShadowSize[shadowRead]
1038
1045
  ),
@@ -1051,8 +1058,9 @@ export const MAIN_GLSL = `void main() {
1051
1058
  liveOccl = pointShadow(
1052
1059
  uPointShadows,
1053
1060
  float(liveLayer),
1054
- shadowFrom,
1055
- uLivePointShadowFar[shadowRead],
1061
+ /* Its own bake origin, for the reason the static layer above gives. */
1062
+ receiverPoint - uLivePointShadowProjection[shadowRead].xyz,
1063
+ uLivePointShadowProjection[shadowRead].w,
1056
1064
  uLivePointShadowNear[shadowRead],
1057
1065
  uLivePointShadowSize[shadowRead]
1058
1066
  );