@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.
- package/CHANGELOG.json +3796 -3770
- package/dist/render/backend/webgl2/renderer.d.ts +15 -0
- package/dist/render/backend/webgl2/renderer.js +28 -7
- package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
- package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
- package/dist/render/backend/webgpu/renderer.js +7 -2
- package/dist/render/depthConvention.d.ts +19 -0
- package/dist/render/depthConvention.js +21 -0
- package/dist/render/livePointShadowSet.js +8 -0
- package/dist/render/pointShadowBudget.js +59 -5
- package/dist/render/pointShadowImage.d.ts +40 -0
- package/dist/render/pointShadowImage.js +20 -0
- package/dist/render/pointShadowMap.d.ts +4 -0
- package/dist/render/pointShadowMap.js +10 -0
- package/dist/render/pointShadowSystem.js +9 -0
- package/dist/render/renderQuality.d.ts +27 -4
- package/dist/render/renderQuality.js +1 -1
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +13 -5
- package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
- package/dist/render/shaders/flat/pointShadow.js +42 -30
- package/dist/render/shaders/flat/preamble.js +26 -2
- package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
- package/dist/render/shaders/generated/flat.wgsl.js +24 -24
- package/dist/render/shaders/sky.d.ts +10 -6
- package/dist/render/shaders/sky.js +35 -2
- package/package.json +6 -6
- package/src/render/backend/webgl2/renderer.ts +28 -7
- package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
- package/src/render/backend/webgpu/renderer.ts +12 -2
- package/src/render/depthConvention.ts +22 -0
- package/src/render/livePointShadowSet.ts +7 -0
- package/src/render/pointShadowBudget.ts +58 -5
- package/src/render/pointShadowImage.ts +51 -0
- package/src/render/pointShadowMap.ts +13 -0
- package/src/render/pointShadowSystem.ts +8 -0
- package/src/render/renderQuality.ts +28 -5
- package/src/render/shaders/flat/main.ts +13 -5
- package/src/render/shaders/flat/pointShadow.ts +42 -30
- package/src/render/shaders/flat/preamble.ts +26 -2
- package/src/render/shaders/generated/flat.wgsl.ts +24 -24
- package/src/render/shaders/sky.ts +37 -2
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
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, ${
|
|
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.
|
|
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.
|
|
33
|
-
"@driftengine/physics": "4.1.
|
|
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.
|
|
67
|
-
"@driftengine/network": "4.1.
|
|
68
|
-
"@driftengine/texture": "4.1.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
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
|
);
|