@driftengine/core 4.4.0 → 4.4.2

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 (49) hide show
  1. package/CHANGELOG.json +47 -0
  2. package/README.md +1 -1
  3. package/dist/render/ambientOcclusionPass.js +5 -1
  4. package/dist/render/backend/webgl2/renderer.d.ts +3 -0
  5. package/dist/render/backend/webgl2/renderer.js +13 -0
  6. package/dist/render/backend/webgpu/postPass.d.ts +20 -4
  7. package/dist/render/backend/webgpu/postPass.js +17 -4
  8. package/dist/render/backend/webgpu/renderer.d.ts +10 -0
  9. package/dist/render/backend/webgpu/renderer.js +74 -7
  10. package/dist/render/flameLight.js +15 -2
  11. package/dist/render/pointShadowBudget.d.ts +12 -2
  12. package/dist/render/pointShadowBudget.js +67 -51
  13. package/dist/render/recon/disocclusion.d.ts +36 -3
  14. package/dist/render/recon/disocclusion.js +56 -4
  15. package/dist/render/recon/resolve.js +95 -12
  16. package/dist/render/sceneTarget.d.ts +2 -0
  17. package/dist/render/sceneTarget.js +1 -1
  18. package/dist/render/shaders/ambientOcclusion.d.ts +39 -1
  19. package/dist/render/shaders/ambientOcclusion.js +69 -9
  20. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +8 -3
  21. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +8 -3
  22. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +31 -6
  23. package/dist/render/shaders/generated/temporalResolve.wgsl.js +31 -6
  24. package/dist/render/shaders/recon/resolve.wgsl.d.ts +1 -1
  25. package/dist/render/shaders/recon/resolve.wgsl.js +112 -8
  26. package/dist/render/shaders/temporalResolve.js +133 -5
  27. package/dist/render/temporalAa.d.ts +145 -0
  28. package/dist/render/temporalAa.js +190 -0
  29. package/dist/render/temporalPass.d.ts +16 -14
  30. package/dist/render/temporalPass.js +59 -14
  31. package/package.json +6 -6
  32. package/src/render/ambientOcclusionPass.ts +5 -1
  33. package/src/render/backend/webgl2/renderer.ts +21 -1
  34. package/src/render/backend/webgpu/postPass.ts +34 -4
  35. package/src/render/backend/webgpu/renderer.ts +77 -7
  36. package/src/render/flameLight.ts +15 -2
  37. package/src/render/pointShadowBudget.ts +65 -47
  38. package/src/render/recon/disocclusion.ts +54 -3
  39. package/src/render/recon/resolve.ts +137 -10
  40. package/src/render/sceneTarget.ts +8 -1
  41. package/src/render/shaders/ambientOcclusion.ts +81 -9
  42. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +8 -3
  43. package/src/render/shaders/generated/temporalResolve.wgsl.ts +31 -6
  44. package/src/render/shaders/recon/resolve.wgsl.ts +112 -8
  45. package/src/render/shaders/temporalResolve.ts +141 -5
  46. package/src/render/temporalAa.ts +227 -0
  47. package/src/render/temporalPass.ts +88 -12
  48. package/dist/render/shaders/agx.d.ts +0 -31
  49. package/dist/render/shaders/agx.js +0 -81
@@ -84,7 +84,8 @@ chronic) {
84
84
  return written;
85
85
  }
86
86
  /**
87
- * The shaded lights that also cast, in the order they were shaded.
87
+ * The shaded lights that also cast, each at its own place in the shaded list, and −1 at the place
88
+ * of a light that does not.
88
89
  *
89
90
  * `castsShadow: false` says a light is not worth a cubemap. `selectPointLights` honours
90
91
  * that when it builds the *eligible* list, and that was taken for the whole promise — but
@@ -96,20 +97,25 @@ chronic) {
96
97
  * It showed up as a shadow with nothing to throw it — a lamp that rides the camera
97
98
  * declines to cast for exactly that reason, and cast anyway.
98
99
  *
99
- * Writes into `out` and returns how many it wrote; allocation-free, because this runs
100
+ * **The place is kept rather than the gap closed, because the place is the shader's index.** The
101
+ * pool publishes each map under its light's position in this list, and the shader reads a light's
102
+ * shadow at the light's position in the shaded one. Closing the gap made the two numberings
103
+ * disagree after the first light that declined: every lamp shaded after it was published one
104
+ * place down, the first on the declining light and each of the rest on its neighbour. A consumer
105
+ * whose headlights decline, and are nearly always shaded first, could only keep its lamps honest by
106
+ * dropping the shadows of every lamp ranked after them, and which lamps those were changed as the
107
+ * car moved.
108
+ *
109
+ * Writes into `out` and returns how many places it wrote; allocation-free, because this runs
100
110
  * every frame.
101
111
  */
102
112
  export function selectCastingLights(lights, activeWorldIndices, activeCount, out) {
103
- let written = 0;
104
- for (let i = 0; i < activeCount && written < out.length; i++) {
113
+ const count = Math.min(activeCount, out.length);
114
+ for (let i = 0; i < count; i++) {
105
115
  const worldIndex = activeWorldIndices[i] ?? -1;
106
- if (worldIndex < 0)
107
- continue;
108
- if (lights[worldIndex]?.castsShadow === false)
109
- continue;
110
- out[written++] = worldIndex;
116
+ out[i] = worldIndex >= 0 && lights[worldIndex]?.castsShadow !== false ? worldIndex : -1;
111
117
  }
112
- return written;
118
+ return count;
113
119
  }
114
120
  /**
115
121
  * How many consecutive frames a slot must be stale before it is treated as chronically so.
@@ -171,15 +177,15 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
171
177
  * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
172
178
  *
173
179
  * 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.
180
+ * its travel it moves less in a frame than the rebake tolerance. When the run decided whether a
181
+ * wanderer got its whole cube, a run that reset to zero dropped it back to two faces a frame
182
+ * there, and the shadow stepped through the slow part of the wander: reported as a vibration
183
+ * against a brazier. Every image-holding map is re-baked whole now, so the run only orders the
184
+ * queue, and the hysteresis keeps a wanderer behind the lights that settle instead of letting
185
+ * it jump ahead at every turning point.
179
186
  *
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.
187
+ * It climbs to twice the threshold, which keeps a light chronic for three quiet frames, and
188
+ * decays one frame at a time so a light that genuinely settles rejoins the ordinary order.
183
189
  */
184
190
  const previous = scratch.staleRun[slot] ?? 0;
185
191
  const run = stale
@@ -192,37 +198,40 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
192
198
  let faces = facesPerFrame;
193
199
  let urgentSpent = false;
194
200
  /*
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.
201
+ * **A map that holds an image is re-baked whole or not at all**, and one being shaded may take a
202
+ * whole cube a frame outside the budget to make that possible.
198
203
  *
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.
204
+ * Faces land in the layer the shader samples as they are drawn, and `planBake` publishes the
205
+ * origin they were drawn from only with the last one. So a re-bake spread over frames is a cube
206
+ * the shader reads from the old origin with part of it drawn from the new one — and a face is a
207
+ * 90 degree frustum, so the part drawn from somewhere else is a straight-edged quadrilateral of
208
+ * wrong shadow on the ground under the lamp. With the budget spent elsewhere it could hold for a
209
+ * second and more. Reported under a brazier as a dark shape that came and went.
206
210
  *
207
- * Given the cube in one frame the origin moves every frame and the shadow travels instead.
211
+ * A map with no image yet is not sampled until its sixth face lands (see
212
+ * `PointShadowSystem.bind`), so it still fills a few faces a frame, and a cold one being shaded
213
+ * still jumps the queue with all six.
208
214
  *
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
+ * The allowance used to be for a *chronically* stale light only — a wandering flame — whose
216
+ * dribbled cube published a new origin once every third frame, so its shadow stepped at 20 Hz
217
+ * where the flame wanders about once a second: reported as motion that was "really fast, not
218
+ * smooth, snappy, and unrealistic". Any shaded light is owed the same now, because a cube read
219
+ * part-way is wrong in the same place for the same reason whether or not it will be stale again.
215
220
  *
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.
221
+ * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
222
+ * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. The first in the plan's order
223
+ * takes it, and `planPointShadowBakes` puts the lights that settled ahead of the ones that never
224
+ * do, so a flame refining its image cannot hold a correction hostage. The rest keep sampling a
225
+ * complete image from a few centimetres away until their turn, which is the trade
226
+ * `pointShadowRebakeDistance` already makes. A warm map, held but not shaded, is re-baked when a
227
+ * whole cube fits the budget or once it is shaded, and not before.
219
228
  *
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`
229
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not fit
230
+ * the frame. Measured against a game whose whole static world is about 8,500 triangles in a
231
+ * handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
223
232
  * raised or this given up, and `gpuTiming` is how to tell which.
224
233
  */
225
- let trackingSpent = false;
234
+ let refreshSpent = false;
226
235
  for (let i = 0; i < planned && faces > 0; i++) {
227
236
  const slot = scratch.order[i] ?? -1;
228
237
  /*
@@ -235,15 +244,22 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
235
244
  if (light === undefined || map === undefined)
236
245
  continue;
237
246
  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)
243
- urgentSpent = true;
244
- if (tracking)
245
- trackingSpent = true;
246
- faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
247
+ if (scratch.ready[slot] !== 1) {
248
+ /* No image to spoil: a few faces a frame, or all six at once for one being shaded. */
249
+ const urgent = sampled && !urgentSpent;
250
+ if (urgent)
251
+ urgentSpent = true;
252
+ faces -= bake(map, light, staticCasters, urgent ? faceCount : faces);
253
+ continue;
254
+ }
255
+ if (faces >= faceCount) {
256
+ faces -= bake(map, light, staticCasters, faceCount);
257
+ continue;
258
+ }
259
+ if (!sampled || refreshSpent)
260
+ continue;
261
+ refreshSpent = true;
262
+ faces -= bake(map, light, staticCasters, faceCount);
247
263
  }
248
264
  /*
249
265
  * Live maps contain movers only and are composed with the static maps in the shader. A second
@@ -12,10 +12,15 @@
12
12
  * a different part of a texel than the one it stands for, which at a grazing angle is a different
13
13
  * depth; the tolerance grows with the motion's length.
14
14
  *
15
+ * **And in proportion to the surface's own slope.** A new jitter samples a surface somewhere else, so
16
+ * on one turned from the eye two frames' samples of it differ by up to a texel's worth of its depth
17
+ * with nothing moved. `surfaceSlope` measures that step on the surface's own side of any silhouette.
18
+ *
15
19
  * **Normals, for the case depth cannot see.** A leaf's two sides are at one depth, and the history of
16
20
  * the front is not the back. The forward frame has no normal buffer, so both normals come from their
17
21
  * frame's depth — `worldPositionFromDepth` and `normalFromPositions` — in world space, where the two
18
- * frames agree.
22
+ * frames agree. **A normal differenced across an edge is not a normal**, so the caller weighs each by
23
+ * `planarity`: at an edge, a sliver, or anything finer than a texel the depth test decides alone.
19
24
  *
20
25
  * **Continuous in every input**, because a step is a line on screen along which the history is kept on
21
26
  * one frame and dropped on the next: every threshold is a smoothstep, never a comparison.
@@ -38,14 +43,42 @@ export interface DisocclusionParams {
38
43
  * normals trusted fully within sixty degrees and not at all past ninety.
39
44
  */
40
45
  export declare const DEFAULT_DISOCCLUSION: DisocclusionParams;
46
+ /**
47
+ * The band over which three texels stop being one plane: how far their two steps in inverse depth
48
+ * fail to cancel, as a share of the two together. None of it is a plane at any angle; all of it is an
49
+ * edge — one side steps, the other does not — or a sliver, both stepping the same way.
50
+ *
51
+ * **A share and not a size**, which is the lesson of the rods in `resolve.test.ts`: a part a
52
+ * hundredth of the depth in front of a wall passes the depth test, and a normal differenced across it
53
+ * still leans by its height over a texel's width — nearly edge-on where texels are small. What would
54
+ * make this wrong is a curved surface judged at its apex, where both steps lean one way and the
55
+ * normal is sound; there it only gives the normals less say, which the depth test does not need.
56
+ */
57
+ export declare const PLANAR_FROM = 0.25;
58
+ export declare const PLANAR_TO = 0.5;
59
+ /**
60
+ * How sure it is that `centre` and its neighbours either side along one axis lie on one plane, one to
61
+ * zero. Inverse depth is affine across a plane on screen, so a plane's two steps cancel whatever its
62
+ * angle. One where a neighbour is missing — zero, off the picture or undrawn — or where there is no
63
+ * step to compare: nothing to judge by, so a normal keeps the say it always had.
64
+ */
65
+ export declare function planarity(before: number, centre: number, after: number): number;
66
+ /**
67
+ * How far a surface's depth moves across one texel along an axis, relative to the depth: the smaller
68
+ * of the steps to its two neighbours, because beside a silhouette one of them is the surface behind.
69
+ * Zero at a sliver — nearer or farther than both, so each step is to another surface — and one
70
+ * neighbour's step where only one is there.
71
+ */
72
+ export declare function surfaceSlope(before: number, centre: number, after: number): number;
41
73
  /**
42
74
  * One where the history is trustworthy, zero where it is not, and continuous between.
43
75
  *
44
76
  * `currDepth` is the view depth this surface should have had last frame; `histDepth` the view depth
45
77
  * last frame held where it lands, or zero where it landed off the screen; `motionLength` the motion's
46
- * length in uv; `normalDot` the cosine between the two frames' world normals there.
78
+ * length in uv; `normalDot` the cosine between the two frames' world normals there; `slope` the
79
+ * surface's `surfaceSlope`, which widens the tolerance one for one.
47
80
  */
48
- export declare function disocclusionWeight(currDepth: number, histDepth: number, motionLength: number, normalDot: number, params: DisocclusionParams): number;
81
+ export declare function disocclusionWeight(currDepth: number, histDepth: number, motionLength: number, normalDot: number, params: DisocclusionParams, slope?: number): number;
49
82
  /**
50
83
  * The world position a depth stands for: `(u, v, clipDepth)` through the inverse view-projection,
51
84
  * in whatever depth convention that matrix was built for. Returns false where the matrix has no
@@ -12,10 +12,15 @@
12
12
  * a different part of a texel than the one it stands for, which at a grazing angle is a different
13
13
  * depth; the tolerance grows with the motion's length.
14
14
  *
15
+ * **And in proportion to the surface's own slope.** A new jitter samples a surface somewhere else, so
16
+ * on one turned from the eye two frames' samples of it differ by up to a texel's worth of its depth
17
+ * with nothing moved. `surfaceSlope` measures that step on the surface's own side of any silhouette.
18
+ *
15
19
  * **Normals, for the case depth cannot see.** A leaf's two sides are at one depth, and the history of
16
20
  * the front is not the back. The forward frame has no normal buffer, so both normals come from their
17
21
  * frame's depth — `worldPositionFromDepth` and `normalFromPositions` — in world space, where the two
18
- * frames agree.
22
+ * frames agree. **A normal differenced across an edge is not a normal**, so the caller weighs each by
23
+ * `planarity`: at an edge, a sliver, or anything finer than a texel the depth test decides alone.
19
24
  *
20
25
  * **Continuous in every input**, because a step is a line on screen along which the history is kept on
21
26
  * one frame and dropped on the next: every threshold is a smoothstep, never a comparison.
@@ -37,19 +42,66 @@ function smoothstep(edge0, edge1, x) {
37
42
  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
38
43
  return t * t * (3 - 2 * t);
39
44
  }
45
+ /**
46
+ * The band over which three texels stop being one plane: how far their two steps in inverse depth
47
+ * fail to cancel, as a share of the two together. None of it is a plane at any angle; all of it is an
48
+ * edge — one side steps, the other does not — or a sliver, both stepping the same way.
49
+ *
50
+ * **A share and not a size**, which is the lesson of the rods in `resolve.test.ts`: a part a
51
+ * hundredth of the depth in front of a wall passes the depth test, and a normal differenced across it
52
+ * still leans by its height over a texel's width — nearly edge-on where texels are small. What would
53
+ * make this wrong is a curved surface judged at its apex, where both steps lean one way and the
54
+ * normal is sound; there it only gives the normals less say, which the depth test does not need.
55
+ */
56
+ export const PLANAR_FROM = 0.25;
57
+ export const PLANAR_TO = 0.5;
58
+ /**
59
+ * How sure it is that `centre` and its neighbours either side along one axis lie on one plane, one to
60
+ * zero. Inverse depth is affine across a plane on screen, so a plane's two steps cancel whatever its
61
+ * angle. One where a neighbour is missing — zero, off the picture or undrawn — or where there is no
62
+ * step to compare: nothing to judge by, so a normal keeps the say it always had.
63
+ */
64
+ export function planarity(before, centre, after) {
65
+ if (!(before > 0) || !(centre > 0) || !(after > 0))
66
+ return 1;
67
+ const a = 1 / after - 1 / centre;
68
+ const b = 1 / before - 1 / centre;
69
+ const spread = Math.abs(a) + Math.abs(b);
70
+ if (!(spread > 0))
71
+ return 1;
72
+ return 1 - smoothstep(PLANAR_FROM, PLANAR_TO, Math.abs(a + b) / spread);
73
+ }
74
+ /**
75
+ * How far a surface's depth moves across one texel along an axis, relative to the depth: the smaller
76
+ * of the steps to its two neighbours, because beside a silhouette one of them is the surface behind.
77
+ * Zero at a sliver — nearer or farther than both, so each step is to another surface — and one
78
+ * neighbour's step where only one is there.
79
+ */
80
+ export function surfaceSlope(before, centre, after) {
81
+ const a = after > 0 ? after - centre : Number.NaN;
82
+ const b = before > 0 ? before - centre : Number.NaN;
83
+ if (Number.isNaN(a))
84
+ return Number.isNaN(b) ? 0 : Math.abs(b) / centre;
85
+ if (Number.isNaN(b))
86
+ return Math.abs(a) / centre;
87
+ if (a * b > 0)
88
+ return 0;
89
+ return Math.min(Math.abs(a), Math.abs(b)) / centre;
90
+ }
40
91
  /**
41
92
  * One where the history is trustworthy, zero where it is not, and continuous between.
42
93
  *
43
94
  * `currDepth` is the view depth this surface should have had last frame; `histDepth` the view depth
44
95
  * last frame held where it lands, or zero where it landed off the screen; `motionLength` the motion's
45
- * length in uv; `normalDot` the cosine between the two frames' world normals there.
96
+ * length in uv; `normalDot` the cosine between the two frames' world normals there; `slope` the
97
+ * surface's `surfaceSlope`, which widens the tolerance one for one.
46
98
  */
47
- export function disocclusionWeight(currDepth, histDepth, motionLength, normalDot, params) {
99
+ export function disocclusionWeight(currDepth, histDepth, motionLength, normalDot, params, slope = 0) {
48
100
  if (!(currDepth > 0) || !(histDepth > 0) || !(motionLength >= 0) || Number.isNaN(normalDot)) {
49
101
  return 0;
50
102
  }
51
103
  const disagreement = Math.abs(currDepth - histDepth) / currDepth;
52
- const tolerance = params.depthTolerance + params.motionScale * motionLength;
104
+ const tolerance = params.depthTolerance + params.motionScale * motionLength + slope;
53
105
  const depth = 1 - smoothstep(tolerance, 2 * tolerance, disagreement);
54
106
  const normal = smoothstep(params.normalFloor, params.normalCeiling, normalDot);
55
107
  return depth * normal;
@@ -21,8 +21,10 @@
21
21
  * previous view depth in its third channel; otherwise it is the camera's, through the surface's
22
22
  * world position.
23
23
  * 4. Where the surface was is read from the history — its colour and the weight it had gathered — and
24
- * last frame's depth there says whether it is the same surface; the two frames' normals, from their
25
- * depths, catch thin geometry. The disocclusion scales the history's weight.
24
+ * last frame's depth says whether it is the same surface: the nearest of the nine texels around
25
+ * where the dilated texel was, forgiven its own slope across a texel. The two frames' normals, from
26
+ * their depths, catch thin geometry, each with the say its planarity gives it. The disocclusion
27
+ * scales the history's weight.
26
28
  * 5. The history is clipped to the box, its weight capped so a tenth of each frame is always new, and
27
29
  * the two are mixed by their weights. Where the pixel has gathered less than one sample's worth,
28
30
  * the render read bicubically fills the gap. The history keeps colour and weight; the picture shown
@@ -33,7 +35,7 @@
33
35
  * buffer before a sample reaches this arithmetic, as the temporal resolve already does.
34
36
  */
35
37
  import { clipToBox, rgbToYCoCg, varianceBox, yCoCgToRgb } from './clamp.js';
36
- import { disocclusionWeight, normalFromPositions, worldPositionFromDepth } from './disocclusion.js';
38
+ import { disocclusionWeight, normalFromPositions, planarity, surfaceSlope, worldPositionFromDepth, } from './disocclusion.js';
37
39
  import { sampleBilinear, sampleHistoryBicubic5 } from './reproject.js';
38
40
  /**
39
41
  * A tenth of each frame kept, as the temporal resolve keeps; a box of a standard deviation and a
@@ -131,6 +133,39 @@ function worldAt(depths, inverse, jitter, width, height, x, y, out) {
131
133
  m[15];
132
134
  return 1 / w;
133
135
  }
136
+ /**
137
+ * A texel's view depth at its unjittered uv, or 0 — `worldAt` without the position, which is all a
138
+ * search over depths needs: one row of the inverse rather than four. Zero off the picture, where
139
+ * `worldAt` would clamp, because a neighbour that is not there is not a step.
140
+ */
141
+ function viewDepthAt(depths, inverse, jitter, width, height, x, y) {
142
+ if (x < 0 || y < 0 || x >= width || y >= height)
143
+ return 0;
144
+ const u = (x + 0.5 - jitter[0]) / width;
145
+ const v = (y + 0.5 - jitter[1]) / height;
146
+ const m = inverse;
147
+ const w = m[3] * (u * 2 - 1) +
148
+ m[7] * (v * 2 - 1) +
149
+ m[11] * depths[y * width + x] +
150
+ m[15];
151
+ return Math.abs(w) > 1e-12 ? 1 / w : 0;
152
+ }
153
+ /** What a texel's four neighbours say about its surface: see `planarity` and `surfaceSlope`. */
154
+ const SURFACE = { planar: 1, slope: 0 };
155
+ function surfaceAt(depths, inverse, jitter, width, height, x, y, centre) {
156
+ let planar = 1;
157
+ let slope = 0;
158
+ for (let axis = 0; axis < 2; axis += 1) {
159
+ const ax = 1 - axis;
160
+ const ay = axis;
161
+ const after = viewDepthAt(depths, inverse, jitter, width, height, x + ax, y + ay);
162
+ const before = viewDepthAt(depths, inverse, jitter, width, height, x - ax, y - ay);
163
+ planar *= planarity(before, centre, after);
164
+ slope = Math.max(slope, surfaceSlope(before, centre, after));
165
+ }
166
+ SURFACE.planar = planar;
167
+ SURFACE.slope = slope;
168
+ }
134
169
  /** The world normal at a texel, from its depth and its right and upper neighbours'. */
135
170
  function normalAt(depths, inverse, jitter, eye, width, height, x, y, out) {
136
171
  if (worldAt(depths, inverse, jitter, width, height, x, y, WORLD) <= 0)
@@ -190,18 +225,66 @@ function historyWeight(frame, u, v, dx, dy) {
190
225
  if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1))
191
226
  return 0;
192
227
  sampleHistoryBicubic5(frame.history, pu * frame.outputWidth - 0.5, pv * frame.outputHeight - 0.5, frame.outputWidth, frame.outputHeight, 4, HISTORY);
193
- const px = clampTexel(Math.round(pu * rw + frame.previousJitter[0] - 0.5), rw);
194
- const py = clampTexel(Math.round(pv * rh + frame.previousJitter[1] - 0.5), rh);
195
- const held = worldAt(frame.previousDepth, frame.previousInverseViewProj, frame.previousJitter, rw, rh, px, py, RIGHT);
228
+ /*
229
+ * **Last frame's depth is searched for this surface, around where the dilated texel was.** The
230
+ * surface being judged is the dilated texel's, which beside an edge is a neighbour's, so judging
231
+ * it by the texel under the pixel compares two surfaces: the side of every silhouette away from the
232
+ * eye dropped its history on every frame of a still picture. And a new jitter samples a surface
233
+ * somewhere else, so the nearest of last frame's nine is taken rather than the one the rounding
234
+ * picks. What it gives up is a disocclusion narrower than a texel, which keeps a history that the
235
+ * neighbourhood clip then bounds.
236
+ */
237
+ const su = (dx + 0.5 - frame.jitter[0]) / rw + mu;
238
+ const sv = (dy + 0.5 - frame.jitter[1]) / rh + mv;
239
+ const cx = clampTexel(Math.round(su * rw + frame.previousJitter[0] - 0.5), rw);
240
+ const cy = clampTexel(Math.round(sv * rh + frame.previousJitter[1] - 0.5), rh);
241
+ /*
242
+ * The first texel looked at answers alone when it agrees within the tolerance, which it does
243
+ * everywhere but at an edge: the depth test trusts it fully whichever texel would have won, so
244
+ * only an edge pays for the other eight.
245
+ */
246
+ let held = viewDepthAt(frame.previousDepth, frame.previousInverseViewProj, frame.previousJitter, rw, rh, cx, cy);
247
+ let px = cx;
248
+ let py = cy;
249
+ if (!(held > 0 && Math.abs(held - expected) <= frame.disocclusion.depthTolerance * expected)) {
250
+ held = 0;
251
+ let nearest = Number.POSITIVE_INFINITY;
252
+ for (let j = -1; j <= 1; j += 1) {
253
+ for (let i = -1; i <= 1; i += 1) {
254
+ const tx = clampTexel(cx + i, rw);
255
+ const ty = clampTexel(cy + j, rh);
256
+ const depth = viewDepthAt(frame.previousDepth, frame.previousInverseViewProj, frame.previousJitter, rw, rh, tx, ty);
257
+ if (!(depth > 0))
258
+ continue;
259
+ const off = Math.abs(depth - expected);
260
+ if (off < nearest) {
261
+ nearest = off;
262
+ held = depth;
263
+ px = tx;
264
+ py = ty;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ const here = viewDepthAt(frame.depth, frame.inverseViewProj, frame.jitter, rw, rh, dx, dy);
270
+ surfaceAt(frame.depth, frame.inverseViewProj, frame.jitter, rw, rh, dx, dy, here);
271
+ const slope = SURFACE.slope;
272
+ let planar = SURFACE.planar;
273
+ if (planar > 0) {
274
+ surfaceAt(frame.previousDepth, frame.previousInverseViewProj, frame.previousJitter, rw, rh, px, py, held);
275
+ planar *= SURFACE.planar;
276
+ }
277
+ /* A normal has the say its planarity gives it; at an edge or a sliver, none, and none is computed. */
196
278
  let normalDot = 1;
197
- if (normalAt(frame.depth, frame.inverseViewProj, frame.jitter, frame.eye, rw, rh, dx, dy, NORMAL) &&
279
+ if (planar > 0 &&
280
+ normalAt(frame.depth, frame.inverseViewProj, frame.jitter, frame.eye, rw, rh, dx, dy, NORMAL) &&
198
281
  normalAt(frame.previousDepth, frame.previousInverseViewProj, frame.previousJitter, frame.previousEye, rw, rh, px, py, PREVIOUS_NORMAL)) {
199
- normalDot =
200
- NORMAL[0] * PREVIOUS_NORMAL[0] +
201
- NORMAL[1] * PREVIOUS_NORMAL[1] +
202
- NORMAL[2] * PREVIOUS_NORMAL[2];
282
+ const cosine = NORMAL[0] * PREVIOUS_NORMAL[0] +
283
+ NORMAL[1] * PREVIOUS_NORMAL[1] +
284
+ NORMAL[2] * PREVIOUS_NORMAL[2];
285
+ normalDot = 1 - planar * (1 - cosine);
203
286
  }
204
- const trust = disocclusionWeight(expected, held, Math.hypot(mu, mv), normalDot, frame.disocclusion);
287
+ const trust = disocclusionWeight(expected, held, Math.hypot(mu, mv), normalDot, frame.disocclusion, slope);
205
288
  return HISTORY[3] * trust;
206
289
  }
207
290
  /**
@@ -303,6 +303,8 @@ export declare class SceneTarget {
303
303
  }, veilColor?: Vec3, veilAlpha?: number, temporal?: {
304
304
  readonly reprojection: Float32Array;
305
305
  readonly blend: number;
306
+ readonly periodStart: boolean;
307
+ readonly depthToViewZ: Float32Array;
306
308
  }): void;
307
309
  dispose(): void;
308
310
  }
@@ -711,7 +711,7 @@ export class SceneTarget {
711
711
  let scene = this.texture;
712
712
  if (temporal !== undefined && this.texture !== null && this.depth !== null) {
713
713
  this.temporal ??= new TemporalPass(gl, this.floatColor);
714
- scene = this.temporal.temporalResolve(this.texture, this.depth, temporal.reprojection, this.width, this.height, temporal.blend);
714
+ scene = this.temporal.temporalResolve(this.texture, this.depth, temporal.reprojection, this.width, this.height, temporal.blend, temporal.periodStart, temporal.depthToViewZ);
715
715
  }
716
716
  let bloomTexture = null;
717
717
  if (bloom !== undefined && scene !== null) {
@@ -24,7 +24,8 @@
24
24
  * wherever the depth test cuts the window short, as a dotted line along every edge.
25
25
  */
26
26
  /**
27
- * The estimate. Writes one channel: 1 is open sky, 0 is fully enclosed.
27
+ * The estimate. Writes one channel at `AO_STORE`: that is open sky, 0 is fully enclosed, and the
28
+ * second axis of the blur decodes it.
28
29
  *
29
30
  * **Horizons, not neighbours: ground-truth ambient occlusion (Jimenez et al. 2016).** Each pixel
30
31
  * walks two lines across the screen, both ways, and keeps the highest thing it can see along each
@@ -62,3 +63,40 @@ export declare function gtaoArc(h0: number, h1: number, normal: number): number;
62
63
  export declare function nextHorizon(horizon: number, cosine: number): number;
63
64
  /** The shader's thickness heuristic. See `AO_THIN` inside `AO_FRAG`. */
64
65
  export declare const AO_THIN = 0.2;
66
+ /**
67
+ * What the estimate is multiplied by before it is stored, and divided by once the blur is done.
68
+ *
69
+ * **Half, because an unblurred estimate is allowed past one.** A pixel's two lines see a plane
70
+ * turned away from the eye from two directions, and the closed form credits one of them more than
71
+ * the whole sky and the other less: it is the average over every turn that is exactly one, and the
72
+ * average is what the blur is for. Stored as it came, an eight-bit target clipped every turn above
73
+ * one before the blur could average it, and every surface seen edge on came back a few percent
74
+ * shaded with nothing on it. A line reaches π/2 at most, so half fits the whole range.
75
+ *
76
+ * What it gives up is one bit of the intermediate: steps of 1/128 rather than 1/256 until the
77
+ * second axis decodes, which the eight taps a pixel averages carry below what a frame can show.
78
+ */
79
+ export declare const AO_STORE = 0.5;
80
+ /** How far a neighbour may sit from this pixel's surface, as a fraction of its distance. */
81
+ export declare const AO_BLUR_DEPTH_TOLERANCE = 0.02;
82
+ /**
83
+ * The blur's weight for a neighbour `offset` pixels along the axis, from view depths: this pixel's
84
+ * own, the two either side of it, and the neighbour's. The shader's lines written out again.
85
+ *
86
+ * **The neighbour is compared with this pixel's plane, not with this pixel's depth.** Comparing
87
+ * depths refused every neighbour of a surface seen edge on: a floor forty metres out changes its
88
+ * depth by four percent a row, twice the tolerance, so the pass down the frame kept the centre alone
89
+ * and left the rotation tile's rows standing — four-pixel stripes across every ramp and far floor,
90
+ * reported as strips on a track. On a plane 1/z is affine in the screen, so one step of it predicts
91
+ * the plane's depth at every tap exactly, and a tap is refused only for leaving the plane.
92
+ *
93
+ * The step is taken from whichever neighbour is closer to the centre in 1/z. At a silhouette that is
94
+ * the one on this pixel's own side, so the far surface cannot bend the prediction toward it; on a
95
+ * plane the two agree to a quantisation step, which moves a weight by nothing a frame shows. What it
96
+ * gives up is a crease: past the fold between a floor and a wall the prediction runs on along the
97
+ * floor, so the wall's taps fade out over the tolerance rather than being kept.
98
+ *
99
+ * **Written here rather than in the shader**, because a comment inside the GLSL is part of the string
100
+ * a consumer ships.
101
+ */
102
+ export declare function blurWeight(centre: number, before: number, after: number, neighbour: number, offset: number): number;