@driftengine/core 4.4.1 → 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 (38) hide show
  1. package/CHANGELOG.json +26 -0
  2. package/README.md +1 -1
  3. package/dist/render/backend/webgl2/renderer.d.ts +3 -0
  4. package/dist/render/backend/webgl2/renderer.js +13 -0
  5. package/dist/render/backend/webgpu/postPass.d.ts +19 -3
  6. package/dist/render/backend/webgpu/postPass.js +17 -4
  7. package/dist/render/backend/webgpu/renderer.d.ts +10 -0
  8. package/dist/render/backend/webgpu/renderer.js +67 -4
  9. package/dist/render/flameLight.js +15 -2
  10. package/dist/render/pointShadowBudget.js +51 -41
  11. package/dist/render/recon/disocclusion.d.ts +36 -3
  12. package/dist/render/recon/disocclusion.js +56 -4
  13. package/dist/render/recon/resolve.js +95 -12
  14. package/dist/render/sceneTarget.d.ts +2 -0
  15. package/dist/render/sceneTarget.js +1 -1
  16. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +31 -6
  17. package/dist/render/shaders/generated/temporalResolve.wgsl.js +31 -6
  18. package/dist/render/shaders/recon/resolve.wgsl.d.ts +1 -1
  19. package/dist/render/shaders/recon/resolve.wgsl.js +112 -8
  20. package/dist/render/shaders/temporalResolve.js +133 -5
  21. package/dist/render/temporalAa.d.ts +145 -0
  22. package/dist/render/temporalAa.js +190 -0
  23. package/dist/render/temporalPass.d.ts +16 -14
  24. package/dist/render/temporalPass.js +59 -14
  25. package/package.json +6 -6
  26. package/src/render/backend/webgl2/renderer.ts +21 -1
  27. package/src/render/backend/webgpu/postPass.ts +34 -4
  28. package/src/render/backend/webgpu/renderer.ts +70 -4
  29. package/src/render/flameLight.ts +15 -2
  30. package/src/render/pointShadowBudget.ts +49 -39
  31. package/src/render/recon/disocclusion.ts +54 -3
  32. package/src/render/recon/resolve.ts +137 -10
  33. package/src/render/sceneTarget.ts +8 -1
  34. package/src/render/shaders/generated/temporalResolve.wgsl.ts +31 -6
  35. package/src/render/shaders/recon/resolve.wgsl.ts +112 -8
  36. package/src/render/shaders/temporalResolve.ts +141 -5
  37. package/src/render/temporalAa.ts +227 -0
  38. package/src/render/temporalPass.ts +88 -12
package/CHANGELOG.json CHANGED
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "version": "4.4.2",
4
+ "date": "2026-09-28",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "short": "A reconstructed frame no longer flickers or softens along the edges of still objects: the far side of every silhouette keeps its history.",
9
+ "text": "**The disocclusion test compared two different surfaces at every edge.** The reconstruction reprojects each output pixel by the nearest of its nine samples, the usual dilation, and then asked whether last frame held the same surface by reading last frame's depth under the output pixel rather than where the dilated sample had stood. At a silhouette those are the object and what is behind it, so the far side of every edge, every surface turned away from the eye and every part thinner than a sample dropped its history on a still frame: a paused picture flashed white along its outlines and read softer than a single frame of it.\n\nThe test now searches last frame's three by three around where the dilated sample stood, allows for how steeply the surface recedes, and weights the normal test by how planar the surface really is, so a thin rod no longer fails it. Measured on a consumer reconstructing at 1.5: a paused frame went from 3,269 flickering pixels to 338, and a still panel from up to 1,787 to 7, with no added ghosting in motion. The cost is 0.065 ms of GPU time at 2280 by 1291."
10
+ },
11
+ {
12
+ "kind": "fixed",
13
+ "short": "Temporal antialiasing no longer flashes on detail finer than a pixel while the camera is still: a paused grille, lace or crack of light holds steady.",
14
+ "text": "**A still pixel's neighbourhood now widens by its own proven flicker.** A thread finer than a pixel is caught by the jitter in some phases and missed in others, and in a phase that misses it the nine taps round the pixel miss it too: the box collapses onto the background, the settled history is clipped away, and a paused frame flashed at every fine edge. A still camera over a still scene samples every pixel the same way in every period of the jitter, so each pixel now keeps a small record of its own samples, and once two periods running have repeated, its box widens by the spread it has shown and it keeps more of its history. A surface that moves, a flame, or anything arriving breaks the repetition, and a sample beyond what the pixel has shown disqualifies the frame it lands in.\n\nMeasured on a scene of fine ironwork and fabric: a held frame went from 839 flickering pixels to 3 on WebGPU and from 1,802 to 14 on WebGL2, and the device check's edge figures are unchanged. It costs two half-float record targets beside the history. A moving camera is resolved exactly as before, and so is WebGL2 without float colour targets."
15
+ },
16
+ {
17
+ "kind": "fixed",
18
+ "short": "A point light's shadow no longer shows a straight-edged patch of wrong shadow while its map is redrawn: a map being sampled is re-baked whole or not at all.",
19
+ "text": "**A re-bake spread across frames was sampled half done.** Faces land in the layer the shader reads as they are drawn, and the origin they were drawn from is published only with the last one, so a map re-baked two faces a frame was read from its old origin with part of it drawn from the new one. A cube face is a 90 degree frustum, so the wrong part was a straight-edged quadrilateral of shadow on the ground under the lamp, and with the face budget spent elsewhere it could hold for over a second.\n\nA map that already holds an image is now re-baked whole or not at all, and a light being shaded may take one whole cube a frame outside `pointShadowFacesPerFrame`, the allowance a wandering light already had. A map with no image yet still fills a few faces a frame, since nothing samples it until it is complete. What it costs is six faces in the frame a shaded light goes stale, rather than two in each of three."
20
+ },
21
+ {
22
+ "kind": "fixed",
23
+ "short": "A flame's shadow no longer switches its own fixture in and out as the flame sways: the default near plane clears what the fire burns from at every point of the sway.",
24
+ "text": "**The near plane moved with the flame, and the fixture under it did not.** `createFlame` puts the light a third of the way up the flame, 0.83 of its width above what it burns from, with a near plane of one width, and sways it by 0.15 of a width on each axis. At the top of the sway the cut sat 7 mm under a 40 cm brazier's coals and at the bottom 13 cm, so the bowl beneath went in and out of the shadow map depending on where the flame stood when the map was last drawn, and the floor under the brazier took a dark disc on some re-bakes and none on others.\n\nThe default `shadowNear` now adds the sway, so it clears the same margin under the fire wherever the flame has wandered. A fixture reaching further below the flame than a sixth of its width sets its own `shadowNear` from its own depth."
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "version": "4.4.1",
4
30
  "date": "2026-09-28",
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The runtime everything needs: renderer, loop, camera, input, geometry. Collision and dynamics live in [`@driftengine/physics`](../physics/README.md), which this package depends on and re-exports whole, so reaching for them through this barrel works as it always did.
4
4
 
5
- **Cost: 789.9 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
5
+ **Cost: 795.8 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
6
6
  3% — the number is a fact about the build rather than a claim in a document.
7
7
 
8
8
  Part of [DriftEngine](../../README.md). See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md)
@@ -947,6 +947,9 @@ export declare class WebGL2Renderer implements RendererApi {
947
947
  * Scratch, filled in `endFrame`, which may not allocate.
948
948
  */
949
949
  private frameProjection;
950
+ /** The temporal resolve's depth-to-metres row, from the inverse of `frameProjection`. */
951
+ private readonly taaInvProjection;
952
+ private readonly taaDepthToViewZ;
950
953
  private readonly aoInvProjection;
951
954
  private readonly aoProjScale;
952
955
  /**
@@ -626,6 +626,9 @@ export class WebGL2Renderer {
626
626
  * Scratch, filled in `endFrame`, which may not allocate.
627
627
  */
628
628
  frameProjection = null;
629
+ /** The temporal resolve's depth-to-metres row, from the inverse of `frameProjection`. */
630
+ taaInvProjection = mat4.create();
631
+ taaDepthToViewZ = Float32Array.of(0, 0, 0, 1);
629
632
  aoInvProjection = mat4.create();
630
633
  aoProjScale = new Float32Array(2);
631
634
  /**
@@ -4943,9 +4946,19 @@ export class WebGL2Renderer {
4943
4946
  * its early exit on it — so this is present every jittered frame rather than absent on some,
4944
4947
  * and the pass still runs and fills the history for the next one.
4945
4948
  */
4949
+ /* The anti-flicker's same-surface test reads depths in metres, as the occlusion does. */
4950
+ if (this.frameProjection !== null) {
4951
+ mat4.invert(this.taaInvProjection, this.frameProjection);
4952
+ this.taaDepthToViewZ[0] = this.taaInvProjection[10] ?? 0;
4953
+ this.taaDepthToViewZ[1] = this.taaInvProjection[14] ?? 0;
4954
+ this.taaDepthToViewZ[2] = this.taaInvProjection[11] ?? 0;
4955
+ this.taaDepthToViewZ[3] = this.taaInvProjection[15] ?? 1;
4956
+ }
4946
4957
  temporal = {
4947
4958
  reprojection: this.reprojection,
4948
4959
  blend: this.temporalHistoryUsable ? TEMPORAL_HISTORY_BLEND : 0,
4960
+ periodStart: this.temporalHistory.periodStart,
4961
+ depthToViewZ: this.taaDepthToViewZ,
4949
4962
  };
4950
4963
  }
4951
4964
  /*
@@ -174,14 +174,20 @@ export declare const ADDITIVE_BLEND: GPUBlendState;
174
174
  /** A fullscreen pass over one or more inputs, writing to one colour target. */
175
175
  export declare function postPipeline(cache: PipelineCache, device: GPUDevice, layout: GPUBindGroupLayout, key: string, fragment: string, format: GPUTextureFormat, blend?: GPUBlendState): GPURenderPipeline;
176
176
  /**
177
- * The temporal resolve's block and its three inputs.
177
+ * The same over several colour targets, in the fragment's output order: the temporal resolve
178
+ * writes its picture and each pixel's flicker record in one pass. `targets` is the caller's, held
179
+ * rather than built per call, since this is asked for every frame.
180
+ */
181
+ export declare function postPipelineTargets(cache: PipelineCache, device: GPUDevice, layout: GPUBindGroupLayout, key: string, fragment: string, targets: GPUColorTargetState[]): GPURenderPipeline;
182
+ /**
183
+ * The temporal resolve's block and its four inputs.
178
184
  *
179
185
  * Named like the occlusion's beside it, and for the same reason `BLOOM_DOWNSAMPLE_FIELDS` is: a
180
186
  * block read by assumption rather than by name is a uniform written to the wrong offset, which
181
187
  * draws a plausible picture.
182
188
  */
183
189
  export declare const TAA_FRAG_FIELDS: UniformFields;
184
- export declare const TAA_FRAG_SIZE: 80;
190
+ export declare const TAA_FRAG_SIZE: 112;
185
191
  export declare const TAA_UNIFORMS: 1;
186
192
  export declare const TAA_TEXTURES: {
187
193
  readonly uScene: {
@@ -194,11 +200,21 @@ export declare const TAA_TEXTURES: {
194
200
  readonly sampler: 35;
195
201
  readonly type: "sampler2D";
196
202
  };
197
- readonly uDepth: {
203
+ readonly uFlicker: {
198
204
  readonly texture: 36;
199
205
  readonly sampler: 37;
200
206
  readonly type: "sampler2D";
201
207
  };
208
+ readonly uFlickerMotion: {
209
+ readonly texture: 38;
210
+ readonly sampler: 39;
211
+ readonly type: "sampler2D";
212
+ };
213
+ readonly uDepth: {
214
+ readonly texture: 40;
215
+ readonly sampler: 41;
216
+ readonly type: "sampler2D";
217
+ };
202
218
  };
203
219
  /**
204
220
  * The order-independent resolve's inputs. It has no uniform block of its own — the whole pass is
@@ -244,7 +244,20 @@ export const ADDITIVE_BLEND = {
244
244
  };
245
245
  /** A fullscreen pass over one or more inputs, writing to one colour target. */
246
246
  export function postPipeline(cache, device, layout, key, fragment, format, blend) {
247
- return cache.get(key, () => ({
247
+ return cache.get(key, () => describePost(device, layout, key, fragment, [
248
+ blend === undefined ? { format } : { format, blend },
249
+ ]));
250
+ }
251
+ /**
252
+ * The same over several colour targets, in the fragment's output order: the temporal resolve
253
+ * writes its picture and each pixel's flicker record in one pass. `targets` is the caller's, held
254
+ * rather than built per call, since this is asked for every frame.
255
+ */
256
+ export function postPipelineTargets(cache, device, layout, key, fragment, targets) {
257
+ return cache.get(key, () => describePost(device, layout, key, fragment, targets));
258
+ }
259
+ function describePost(device, layout, key, fragment, targets) {
260
+ return {
248
261
  label: key,
249
262
  layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
250
263
  vertex: {
@@ -254,7 +267,7 @@ export function postPipeline(cache, device, layout, key, fragment, format, blend
254
267
  fragment: {
255
268
  module: shaderModule(device, { label: key, code: fragment }),
256
269
  entryPoint: 'main',
257
- targets: [blend === undefined ? { format } : { format, blend }],
270
+ targets,
258
271
  },
259
272
  /*
260
273
  * No depth at all, which is what makes this different from every other pipeline here: the
@@ -262,10 +275,10 @@ export function postPipeline(cache, device, layout, key, fragment, format, blend
262
275
  * disables the test, the write and culling by hand for the same draw.
263
276
  */
264
277
  primitive: { topology: 'triangle-list', cullMode: 'none' },
265
- }));
278
+ };
266
279
  }
267
280
  /**
268
- * The temporal resolve's block and its three inputs.
281
+ * The temporal resolve's block and its four inputs.
269
282
  *
270
283
  * Named like the occlusion's beside it, and for the same reason `BLOOM_DOWNSAMPLE_FIELDS` is: a
271
284
  * block read by assumption rather than by name is a uniform written to the wrong offset, which
@@ -596,6 +596,16 @@ export declare class WebGPURenderer implements RendererApi {
596
596
  private taaUniforms;
597
597
  private taaTextures;
598
598
  private taaViews;
599
+ /** Each pixel's flicker record, paired with the history and swapped with it: see `temporalAa.ts`. */
600
+ private taaRecords;
601
+ private taaRecordViews;
602
+ /** The rest of each pixel's record — depth, pull and recent swing — beside the first. */
603
+ private taaMotions;
604
+ private taaMotionViews;
605
+ /** The resolve's depth-to-metres row, from the inverse of `frameProjection`. */
606
+ private readonly taaInvProjection;
607
+ /** The resolve's two targets, the picture and the record, held for the pipeline cache's sake. */
608
+ private taaTargets;
599
609
  /** Group `i` reads history `i` and the pass writes into the other one. */
600
610
  private taaBindGroups;
601
611
  private taaWrite;
@@ -51,7 +51,7 @@ import { ExposurePass } from './exposurePass.js';
51
51
  import { AO_STORE } from '../../shaders/ambientOcclusion.js';
52
52
  import { clampAutoExposure } from '../../shaders/exposure.js';
53
53
  import { clampLocalExposure } from '../../shaders/localExposure.js';
54
- import { createDepthResolveLayout, createPostStageLayout, depthResolvePipeline, postPipeline, sceneColorFormat, AO_BLUR_FIELDS, AO_BLUR_FRAG_WGSL, AO_BLUR_SIZE, AO_BLUR_TEXTURES, AO_BLUR_UNIFORMS, AO_FRAG_FIELDS, AO_FRAG_SIZE, AO_FRAG_WGSL, AO_TEXTURES, AO_UNIFORMS, ADDITIVE_BLEND, BLOOM_DOWNSAMPLE_FIELDS, BLOOM_DOWNSAMPLE_FRAG_WGSL, BLOOM_PREFILTER_FIELDS, BLOOM_PREFILTER_FRAG_WGSL, BLOOM_STAGE_SIZE, BLOOM_TEXTURES, BLOOM_UNIFORMS, BLOOM_UPSAMPLE_FIELDS, BLOOM_UPSAMPLE_FRAG_WGSL, RESOLVED_DEPTH_FORMAT, RUSH_FRAG_FIELDS, RUSH_FRAG_SIZE, RUSH_FRAG_WGSL, RUSH_TEXTURES, RUSH_UNIFORMS, TAA_FRAG_FIELDS, TAA_FRAG_SIZE, TAA_TEXTURES, TAA_UNIFORMS, TEMPORAL_RESOLVE_FRAG_WGSL, MEDIUM_FRAG_FIELDS, MEDIUM_FRAG_SIZE, MEDIUM_FRAG_WGSL, MEDIUM_TEXTURES, MEDIUM_TRANSMIT_BLEND, MEDIUM_UNIFORMS, MEDIUM_UPSAMPLE_FIELDS, MEDIUM_UPSAMPLE_FRAG_WGSL, MEDIUM_UPSAMPLE_SIZE, MEDIUM_UPSAMPLE_TEXTURES, MEDIUM_UPSAMPLE_UNIFORMS, OIT_COMPOSITE_BLEND, OIT_RESOLVE_FRAG_WGSL, OIT_TEXTURES, DECAL_FRAG_FIELDS, DECAL_FRAG_SIZE, DECAL_MULTIPLY_BLEND, DECAL_PROJECT_FRAG_WGSL, DECAL_TEXTURES, DECAL_UNIFORMS, PREMULTIPLIED_OVER_BLEND, SSR_FRAG_FIELDS, SSR_FRAG_SIZE, SSR_RESOLVE_FRAG_WGSL, SSR_RESOLVE_TEXTURES, SSR_TEXTURES, SSR_TRACE_FRAG_WGSL, SSR_UNIFORMS, createOitResolveLayout, createSsrResolveLayout, } from './postPass.js';
54
+ import { createDepthResolveLayout, createPostStageLayout, depthResolvePipeline, postPipeline, postPipelineTargets, sceneColorFormat, AO_BLUR_FIELDS, AO_BLUR_FRAG_WGSL, AO_BLUR_SIZE, AO_BLUR_TEXTURES, AO_BLUR_UNIFORMS, AO_FRAG_FIELDS, AO_FRAG_SIZE, AO_FRAG_WGSL, AO_TEXTURES, AO_UNIFORMS, ADDITIVE_BLEND, BLOOM_DOWNSAMPLE_FIELDS, BLOOM_DOWNSAMPLE_FRAG_WGSL, BLOOM_PREFILTER_FIELDS, BLOOM_PREFILTER_FRAG_WGSL, BLOOM_STAGE_SIZE, BLOOM_TEXTURES, BLOOM_UNIFORMS, BLOOM_UPSAMPLE_FIELDS, BLOOM_UPSAMPLE_FRAG_WGSL, RESOLVED_DEPTH_FORMAT, RUSH_FRAG_FIELDS, RUSH_FRAG_SIZE, RUSH_FRAG_WGSL, RUSH_TEXTURES, RUSH_UNIFORMS, TAA_FRAG_FIELDS, TAA_FRAG_SIZE, TAA_TEXTURES, TAA_UNIFORMS, TEMPORAL_RESOLVE_FRAG_WGSL, MEDIUM_FRAG_FIELDS, MEDIUM_FRAG_SIZE, MEDIUM_FRAG_WGSL, MEDIUM_TEXTURES, MEDIUM_TRANSMIT_BLEND, MEDIUM_UNIFORMS, MEDIUM_UPSAMPLE_FIELDS, MEDIUM_UPSAMPLE_FRAG_WGSL, MEDIUM_UPSAMPLE_SIZE, MEDIUM_UPSAMPLE_TEXTURES, MEDIUM_UPSAMPLE_UNIFORMS, OIT_COMPOSITE_BLEND, OIT_RESOLVE_FRAG_WGSL, OIT_TEXTURES, DECAL_FRAG_FIELDS, DECAL_FRAG_SIZE, DECAL_MULTIPLY_BLEND, DECAL_PROJECT_FRAG_WGSL, DECAL_TEXTURES, DECAL_UNIFORMS, PREMULTIPLIED_OVER_BLEND, SSR_FRAG_FIELDS, SSR_FRAG_SIZE, SSR_RESOLVE_FRAG_WGSL, SSR_RESOLVE_TEXTURES, SSR_TEXTURES, SSR_TRACE_FRAG_WGSL, SSR_UNIFORMS, createOitResolveLayout, createSsrResolveLayout, } from './postPass.js';
55
55
  import { GRADE_PLACEHOLDER_SIZE, identityGradeLut, validateGradeLut, } from '../../colourGrade.js';
56
56
  import { FILM_LOOK_WITHOUT_COMPOSITE, clampGrain, clampVignette, grainSeed, } from '../../shaders/filmLook.js';
57
57
  import { createProbeMipBindGroupLayout, createProbePrefilterBindGroupLayout, PREFILTER_UNIFORM_OFFSETS, probeLevels, probeMipPipeline, probePrefilterPipeline, PROBE_FACES, PROBE_FACE_BASIS, PROBE_FAR_M, PROBE_FOV_DEG, PROBE_MIP_UNIFORM_SIZE, PROBE_NEAR_M, } from './probePass.js';
@@ -1349,6 +1349,16 @@ export class WebGPURenderer {
1349
1349
  taaUniforms;
1350
1350
  taaTextures = [null, null];
1351
1351
  taaViews = [null, null];
1352
+ /** Each pixel's flicker record, paired with the history and swapped with it: see `temporalAa.ts`. */
1353
+ taaRecords = [null, null];
1354
+ taaRecordViews = [null, null];
1355
+ /** The rest of each pixel's record — depth, pull and recent swing — beside the first. */
1356
+ taaMotions = [null, null];
1357
+ taaMotionViews = [null, null];
1358
+ /** The resolve's depth-to-metres row, from the inverse of `frameProjection`. */
1359
+ taaInvProjection = new Float32Array(16);
1360
+ /** The resolve's two targets, the picture and the record, held for the pipeline cache's sake. */
1361
+ taaTargets = [];
1352
1362
  /** Group `i` reads history `i` and the pass writes into the other one. */
1353
1363
  taaBindGroups = [null, null];
1354
1364
  taaWrite = 0;
@@ -5230,6 +5240,8 @@ export class WebGPURenderer {
5230
5240
  this.taaLayout = createPostStageLayout(device, TAA_UNIFORMS, TAA_FRAG_SIZE, [
5231
5241
  { binding: TAA_TEXTURES.uScene },
5232
5242
  { binding: TAA_TEXTURES.uHistory },
5243
+ { binding: TAA_TEXTURES.uFlicker },
5244
+ { binding: TAA_TEXTURES.uFlickerMotion },
5233
5245
  { binding: TAA_TEXTURES.uDepth, filterable: false },
5234
5246
  ]);
5235
5247
  this.taaUniforms = device.createBuffer({
@@ -8647,8 +8659,10 @@ export class WebGPURenderer {
8647
8659
  return;
8648
8660
  const write = this.taaWrite;
8649
8661
  const target = this.taaViews[write];
8662
+ const record = this.taaRecordViews[write];
8663
+ const motion = this.taaMotionViews[write];
8650
8664
  const group = this.taaBindGroups[1 - write];
8651
- if (target === null || group === null)
8665
+ if (target === null || record === null || motion === null || group === null)
8652
8666
  return;
8653
8667
  const { device } = this.surface;
8654
8668
  const f = this.taaFloats;
@@ -8657,15 +8671,34 @@ export class WebGPURenderer {
8657
8671
  f[at('uTexel')] = 1 / this.renderWidth;
8658
8672
  f[at('uTexel') + 1] = 1 / this.renderHeight;
8659
8673
  f[at('uHistoryBlend')] = this.temporalHistoryUsable ? TEMPORAL_HISTORY_BLEND : 0;
8674
+ f[at('uPeriodStart')] = this.temporalHistory.periodStart ? 1 : 0;
8675
+ /* The anti-flicker's same-surface test reads depths in metres, as the occlusion does. */
8676
+ const depthRow = at('uDepthToViewZ');
8677
+ if (this.frameProjection !== null) {
8678
+ mat4.invert(this.taaInvProjection, this.frameProjection);
8679
+ f[depthRow] = this.taaInvProjection[10] ?? 0;
8680
+ f[depthRow + 1] = this.taaInvProjection[14] ?? 0;
8681
+ f[depthRow + 2] = this.taaInvProjection[11] ?? 0;
8682
+ f[depthRow + 3] = this.taaInvProjection[15] ?? 1;
8683
+ }
8684
+ else {
8685
+ f[depthRow] = 0;
8686
+ f[depthRow + 1] = 0;
8687
+ f[depthRow + 2] = 0;
8688
+ f[depthRow + 3] = 1;
8689
+ }
8690
+ f[at('uAntiFlicker')] = 1;
8660
8691
  device.queue.writeBuffer(this.taaUniforms, 0, this.taaStaging);
8661
8692
  const pass = encoder.beginRenderPass({
8662
8693
  label: 'post.taa',
8663
8694
  timestampWrites: this.gpuTimer.writesFor(),
8664
8695
  colorAttachments: [
8665
8696
  { view: target, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] },
8697
+ { view: record, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
8698
+ { view: motion, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
8666
8699
  ],
8667
8700
  });
8668
- pass.setPipeline(postPipeline(this.pipelines, device, this.taaLayout, 'post.taa', TEMPORAL_RESOLVE_FRAG_WGSL, sceneColorFormat(this.quality.hdrScene)));
8701
+ pass.setPipeline(postPipelineTargets(this.pipelines, device, this.taaLayout, 'post.taa', TEMPORAL_RESOLVE_FRAG_WGSL, this.taaTargets));
8669
8702
  pass.setBindGroup(0, group, [0]);
8670
8703
  pass.draw(3);
8671
8704
  pass.end();
@@ -12292,7 +12325,28 @@ export class WebGPURenderer {
12292
12325
  });
12293
12326
  this.taaTextures[i] = texture;
12294
12327
  this.taaViews[i] = texture.createView();
12328
+ /* The records in half float, whatever the picture's format: one of them holds metres. */
12329
+ this.taaRecords[i]?.destroy();
12330
+ const record = device.createTexture({
12331
+ label: `post.taaRecord${i}`,
12332
+ size: [width, height],
12333
+ format: 'rgba16float',
12334
+ usage: USAGE,
12335
+ });
12336
+ this.taaRecords[i] = record;
12337
+ this.taaRecordViews[i] = record.createView();
12338
+ this.taaMotions[i]?.destroy();
12339
+ /* Two numbers a pixel: the depth its sample stood at, in metres, and how far that swings. */
12340
+ const motion = device.createTexture({
12341
+ label: `post.taaMotion${i}`,
12342
+ size: [width, height],
12343
+ format: 'rg16float',
12344
+ usage: USAGE,
12345
+ });
12346
+ this.taaMotions[i] = motion;
12347
+ this.taaMotionViews[i] = motion.createView();
12295
12348
  }
12349
+ this.taaTargets = [{ format }, { format: 'rgba16float' }, { format: 'rg16float' }];
12296
12350
  this.taaWrite = 0;
12297
12351
  /* A resized target is a new texture, so whatever the history held is gone. */
12298
12352
  this.temporalHistory.invalidate();
@@ -12478,8 +12532,15 @@ export class WebGPURenderer {
12478
12532
  const scene = this.sceneColorView;
12479
12533
  const history = this.taaViews[i];
12480
12534
  const resolved = this.taaViews[1 - i];
12481
- if (scene === null || history === null || resolved === null)
12535
+ const record = this.taaRecordViews[i];
12536
+ const motion = this.taaMotionViews[i];
12537
+ if (scene === null ||
12538
+ history === null ||
12539
+ resolved === null ||
12540
+ record === null ||
12541
+ motion === null) {
12482
12542
  continue;
12543
+ }
12483
12544
  this.taaBindGroups[i] = device.createBindGroup({
12484
12545
  label: `post.taaBindGroup${i}`,
12485
12546
  layout: this.taaLayout,
@@ -12487,6 +12548,8 @@ export class WebGPURenderer {
12487
12548
  { binding: TAA_UNIFORMS, resource: { buffer: this.taaUniforms, size: TAA_FRAG_SIZE } },
12488
12549
  ...entry(TAA_TEXTURES.uScene, scene),
12489
12550
  ...entry(TAA_TEXTURES.uHistory, history),
12551
+ ...entry(TAA_TEXTURES.uFlicker, record),
12552
+ ...entry(TAA_TEXTURES.uFlickerMotion, motion),
12490
12553
  ...entry(TAA_TEXTURES.uDepth, this.resolvedDepthView, true),
12491
12554
  ],
12492
12555
  });
@@ -11,6 +11,7 @@ export function flameFrequencyHz(diameterM) {
11
11
  export function createFlame(options) {
12
12
  const height = HEIGHT_PER_DIAMETER * options.diameterM;
13
13
  const centre = [options.x, options.y + height / 3, options.z];
14
+ const swayM = (options.sway ?? SWAY_PER_HEIGHT) * height;
14
15
  /* A seed spread over the whole turn, so seeds 1 and 2 are not neighbours in phase. */
15
16
  const phase = ((options.seed ?? 0) * 2.399963) % (Math.PI * 2);
16
17
  const light = {
@@ -23,7 +24,19 @@ export function createFlame(options) {
23
24
  radius: options.radius,
24
25
  /* Off, because this helper is the flicker; the selection's own would apply a second one. */
25
26
  flicker: 0,
26
- shadowNear: Math.max(0.05, options.diameterM),
27
+ /*
28
+ * **Clear of what the fire burns from wherever the light has wandered**, which is the sway added
29
+ * to the near plane a still flame would want. The shadow map is baked from where the light
30
+ * stood, so the cut moves with it, and each axis moves by at most `swayM`: a near plane of D
31
+ * alone left a brazier's bowl 7 mm inside it at the top of the sway and 13 cm outside at the
32
+ * bottom, and the bowl's shadow on the floor under the brazier came and went with every
33
+ * re-bake — a dark disc on some, none on others.
34
+ *
35
+ * What it gives up is `swayM` of shadow nearest the light, which is inside the fixture. What
36
+ * would make it wrong is a fixture reaching further below the flame than D / 6: this clears
37
+ * that far and no further, so a deep bowl sets its own `shadowNear` from its own depth.
38
+ */
39
+ shadowNear: Math.max(0.05, options.diameterM) + swayM,
27
40
  sourceRadius: options.diameterM / 2,
28
41
  ...(options.castsShadow === false ? { castsShadow: false } : {}),
29
42
  };
@@ -41,7 +54,7 @@ export function createFlame(options) {
41
54
  color: [options.color[0], options.color[1], options.color[2]],
42
55
  flicker: options.flicker ?? 0.18,
43
56
  phase,
44
- swayM: (options.sway ?? SWAY_PER_HEIGHT) * height,
57
+ swayM,
45
58
  };
46
59
  }
47
60
  /** Where the flame's light is and how bright, at `timeSeconds`. Writes `flame.light` in place. */
@@ -177,15 +177,15 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
177
177
  * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
178
178
  *
179
179
  * A light that wanders on a curve is not stale on *every* frame — near the turning points of
180
- * its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
181
- * lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
182
- * then travelled smoothly through the fast part of the wander and stepped through the slow
183
- * part, which is not motion and not stillness: it reads as a vibration. Reported that way,
184
- * 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.
185
186
  *
186
- * So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
187
- * earned the allowance three quiet frames before it loses it, and decays one frame at a time
188
- * 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.
189
189
  */
190
190
  const previous = scratch.staleRun[slot] ?? 0;
191
191
  const run = stale
@@ -198,37 +198,40 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
198
198
  let faces = facesPerFrame;
199
199
  let urgentSpent = false;
200
200
  /*
201
- * **One wandering light finishes its cube in the frame it started it**, the same allowance a
202
- * light with no image at all gets, and for a reason that turns out to be the same one: a map
203
- * 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.
204
203
  *
205
- * A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
206
- * cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
207
- * way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
208
- * three frames means the published origin steps once every third frame. The shader shoots from
209
- * that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
210
- * "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
211
- * 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.
212
210
  *
213
- * 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.
214
214
  *
215
- * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
216
- * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
217
- * that most recently went stale, which is the one somebody is standing next to; the rest keep
218
- * dribbling under the ordinary budget, and their shadows step rather than travel. That is the
219
- * right way round: a shadow you are looking at moves, and a shadow across the square does not
220
- * 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.
221
220
  *
222
- * It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
223
- * chronic ones last — so a light arriving in range still gets its first image ahead of a flame
224
- * 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.
225
228
  *
226
- * **What would make it wrong** is a caster set heavy enough that six passes over it does not
227
- * fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
228
- * 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`
229
232
  * raised or this given up, and `gpuTiming` is how to tell which.
230
233
  */
231
- let trackingSpent = false;
234
+ let refreshSpent = false;
232
235
  for (let i = 0; i < planned && faces > 0; i++) {
233
236
  const slot = scratch.order[i] ?? -1;
234
237
  /*
@@ -241,15 +244,22 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
241
244
  if (light === undefined || map === undefined)
242
245
  continue;
243
246
  const sampled = slot < pool.sampledCount;
244
- const cold = sampled && scratch.ready[slot] === 0;
245
- const urgent = cold && !urgentSpent;
246
- /* A light being shaded whose map will be stale again next frame however it is served. */
247
- const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
248
- if (urgent)
249
- urgentSpent = true;
250
- if (tracking)
251
- trackingSpent = true;
252
- 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);
253
263
  }
254
264
  /*
255
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