@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
|
@@ -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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
const
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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.
|
|
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
|