@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
@@ -331,7 +331,37 @@ export function postPipeline(
331
331
  format: GPUTextureFormat,
332
332
  blend?: GPUBlendState,
333
333
  ): GPURenderPipeline {
334
- return cache.get(key, () => ({
334
+ return cache.get(key, () =>
335
+ describePost(device, layout, key, fragment, [
336
+ blend === undefined ? { format } : { format, blend },
337
+ ]),
338
+ );
339
+ }
340
+
341
+ /**
342
+ * The same over several colour targets, in the fragment's output order: the temporal resolve
343
+ * writes its picture and each pixel's flicker record in one pass. `targets` is the caller's, held
344
+ * rather than built per call, since this is asked for every frame.
345
+ */
346
+ export function postPipelineTargets(
347
+ cache: PipelineCache,
348
+ device: GPUDevice,
349
+ layout: GPUBindGroupLayout,
350
+ key: string,
351
+ fragment: string,
352
+ targets: GPUColorTargetState[],
353
+ ): GPURenderPipeline {
354
+ return cache.get(key, () => describePost(device, layout, key, fragment, targets));
355
+ }
356
+
357
+ function describePost(
358
+ device: GPUDevice,
359
+ layout: GPUBindGroupLayout,
360
+ key: string,
361
+ fragment: string,
362
+ targets: GPUColorTargetState[],
363
+ ): GPURenderPipelineDescriptor {
364
+ return {
335
365
  label: key,
336
366
  layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
337
367
  vertex: {
@@ -341,7 +371,7 @@ export function postPipeline(
341
371
  fragment: {
342
372
  module: shaderModule(device, { label: key, code: fragment }),
343
373
  entryPoint: 'main',
344
- targets: [blend === undefined ? { format } : { format, blend }],
374
+ targets,
345
375
  },
346
376
  /*
347
377
  * No depth at all, which is what makes this different from every other pipeline here: the
@@ -349,11 +379,11 @@ export function postPipeline(
349
379
  * disables the test, the write and culling by hand for the same draw.
350
380
  */
351
381
  primitive: { topology: 'triangle-list' as const, cullMode: 'none' as const },
352
- }));
382
+ };
353
383
  }
354
384
 
355
385
  /**
356
- * The temporal resolve's block and its three inputs.
386
+ * The temporal resolve's block and its four inputs.
357
387
  *
358
388
  * Named like the occlusion's beside it, and for the same reason `BLOOM_DOWNSAMPLE_FIELDS` is: a
359
389
  * block read by assumption rather than by name is a uniform written to the wrong offset, which
@@ -181,6 +181,7 @@ import { reflectionTargetSize, type ReflectionSize } from '../../planarReflectio
181
181
  import { drawingBufferSize } from '../../drawingBuffer.ts';
182
182
  import { MOTION_BLUR_MAX_UV, OUTPUT_TRANSFORM_CODE, RUSH_REACH_UV } from '../../vertexDefaults.ts';
183
183
  import { ExposurePass } from './exposurePass.ts';
184
+ import { AO_STORE } from '../../shaders/ambientOcclusion.ts';
184
185
  import { clampAutoExposure } from '../../shaders/exposure.ts';
185
186
  import { clampLocalExposure } from '../../shaders/localExposure.ts';
186
187
  import type { UniformFields } from './scatterPass.ts';
@@ -189,6 +190,7 @@ import {
189
190
  createPostStageLayout,
190
191
  depthResolvePipeline,
191
192
  postPipeline,
193
+ postPipelineTargets,
192
194
  sceneColorFormat,
193
195
  AO_BLUR_FIELDS,
194
196
  AO_BLUR_FRAG_WGSL,
@@ -1925,6 +1927,16 @@ export class WebGPURenderer implements RendererApi {
1925
1927
  private taaUniforms!: GPUBuffer;
1926
1928
  private taaTextures: (GPUTexture | null)[] = [null, null];
1927
1929
  private taaViews: (GPUTextureView | null)[] = [null, null];
1930
+ /** Each pixel's flicker record, paired with the history and swapped with it: see `temporalAa.ts`. */
1931
+ private taaRecords: (GPUTexture | null)[] = [null, null];
1932
+ private taaRecordViews: (GPUTextureView | null)[] = [null, null];
1933
+ /** The rest of each pixel's record — depth, pull and recent swing — beside the first. */
1934
+ private taaMotions: (GPUTexture | null)[] = [null, null];
1935
+ private taaMotionViews: (GPUTextureView | null)[] = [null, null];
1936
+ /** The resolve's depth-to-metres row, from the inverse of `frameProjection`. */
1937
+ private readonly taaInvProjection = new Float32Array(16);
1938
+ /** The resolve's two targets, the picture and the record, held for the pipeline cache's sake. */
1939
+ private taaTargets: GPUColorTargetState[] = [];
1928
1940
  /** Group `i` reads history `i` and the pass writes into the other one. */
1929
1941
  private taaBindGroups: (GPUBindGroup | null)[] = [null, null];
1930
1942
  private taaWrite = 0;
@@ -6394,6 +6406,8 @@ export class WebGPURenderer implements RendererApi {
6394
6406
  this.taaLayout = createPostStageLayout(device, TAA_UNIFORMS, TAA_FRAG_SIZE, [
6395
6407
  { binding: TAA_TEXTURES.uScene },
6396
6408
  { binding: TAA_TEXTURES.uHistory },
6409
+ { binding: TAA_TEXTURES.uFlicker },
6410
+ { binding: TAA_TEXTURES.uFlickerMotion },
6397
6411
  { binding: TAA_TEXTURES.uDepth, filterable: false },
6398
6412
  ]);
6399
6413
  this.taaUniforms = device.createBuffer({
@@ -10467,8 +10481,10 @@ export class WebGPURenderer implements RendererApi {
10467
10481
  if (!this.temporalJittering) return;
10468
10482
  const write = this.taaWrite;
10469
10483
  const target = this.taaViews[write];
10484
+ const record = this.taaRecordViews[write];
10485
+ const motion = this.taaMotionViews[write];
10470
10486
  const group = this.taaBindGroups[1 - write];
10471
- if (target === null || group === null) return;
10487
+ if (target === null || record === null || motion === null || group === null) return;
10472
10488
  const { device } = this.surface;
10473
10489
 
10474
10490
  const f = this.taaFloats;
@@ -10477,6 +10493,22 @@ export class WebGPURenderer implements RendererApi {
10477
10493
  f[at('uTexel')] = 1 / this.renderWidth;
10478
10494
  f[at('uTexel') + 1] = 1 / this.renderHeight;
10479
10495
  f[at('uHistoryBlend')] = this.temporalHistoryUsable ? TEMPORAL_HISTORY_BLEND : 0;
10496
+ f[at('uPeriodStart')] = this.temporalHistory.periodStart ? 1 : 0;
10497
+ /* The anti-flicker's same-surface test reads depths in metres, as the occlusion does. */
10498
+ const depthRow = at('uDepthToViewZ');
10499
+ if (this.frameProjection !== null) {
10500
+ mat4.invert(this.taaInvProjection, this.frameProjection);
10501
+ f[depthRow] = this.taaInvProjection[10] ?? 0;
10502
+ f[depthRow + 1] = this.taaInvProjection[14] ?? 0;
10503
+ f[depthRow + 2] = this.taaInvProjection[11] ?? 0;
10504
+ f[depthRow + 3] = this.taaInvProjection[15] ?? 1;
10505
+ } else {
10506
+ f[depthRow] = 0;
10507
+ f[depthRow + 1] = 0;
10508
+ f[depthRow + 2] = 0;
10509
+ f[depthRow + 3] = 1;
10510
+ }
10511
+ f[at('uAntiFlicker')] = 1;
10480
10512
  device.queue.writeBuffer(this.taaUniforms, 0, this.taaStaging);
10481
10513
 
10482
10514
  const pass = encoder.beginRenderPass({
@@ -10484,16 +10516,18 @@ export class WebGPURenderer implements RendererApi {
10484
10516
  timestampWrites: this.gpuTimer.writesFor(),
10485
10517
  colorAttachments: [
10486
10518
  { view: target, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] },
10519
+ { view: record, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
10520
+ { view: motion, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
10487
10521
  ],
10488
10522
  });
10489
10523
  pass.setPipeline(
10490
- postPipeline(
10524
+ postPipelineTargets(
10491
10525
  this.pipelines,
10492
10526
  device,
10493
10527
  this.taaLayout,
10494
10528
  'post.taa',
10495
10529
  TEMPORAL_RESOLVE_FRAG_WGSL,
10496
- sceneColorFormat(this.quality.hdrScene),
10530
+ this.taaTargets,
10497
10531
  ),
10498
10532
  );
10499
10533
  pass.setBindGroup(0, group, [0]);
@@ -11045,12 +11079,15 @@ export class WebGPURenderer implements RendererApi {
11045
11079
  otherwise halve the blur and leave half the pattern standing. */
11046
11080
  const width = Math.max(1, this.aoTarget?.width ?? 1);
11047
11081
  const height = Math.max(1, this.aoTarget?.height ?? 1);
11048
- for (const [index, step] of [
11049
- [0, [1 / width, 0]],
11050
- [1, [0, 1 / height]],
11082
+ /* Across at the estimate's own scale, which the second axis needs whole; down decoded, so the
11083
+ composite reads open sky as one. The same two `ambientOcclusionPass.ts` writes. */
11084
+ for (const [index, step, scale] of [
11085
+ [0, [1 / width, 0], 1],
11086
+ [1, [0, 1 / height], 1 / AO_STORE],
11051
11087
  ] as const) {
11052
11088
  f[slot(index) + at('uStep')] = step[0];
11053
11089
  f[slot(index) + at('uStep') + 1] = step[1];
11090
+ f[slot(index) + at('uAoScale')] = scale;
11054
11091
  f[slot(index) + at('uDepthToViewZ')] = inv[10] ?? 0;
11055
11092
  f[slot(index) + at('uDepthToViewZ') + 1] = inv[14] ?? 0;
11056
11093
  f[slot(index) + at('uDepthToViewZ') + 2] = inv[11] ?? 0;
@@ -14547,7 +14584,28 @@ export class WebGPURenderer implements RendererApi {
14547
14584
  });
14548
14585
  this.taaTextures[i] = texture;
14549
14586
  this.taaViews[i] = texture.createView();
14587
+ /* The records in half float, whatever the picture's format: one of them holds metres. */
14588
+ this.taaRecords[i]?.destroy();
14589
+ const record = device.createTexture({
14590
+ label: `post.taaRecord${i}`,
14591
+ size: [width, height],
14592
+ format: 'rgba16float',
14593
+ usage: USAGE,
14594
+ });
14595
+ this.taaRecords[i] = record;
14596
+ this.taaRecordViews[i] = record.createView();
14597
+ this.taaMotions[i]?.destroy();
14598
+ /* Two numbers a pixel: the depth its sample stood at, in metres, and how far that swings. */
14599
+ const motion = device.createTexture({
14600
+ label: `post.taaMotion${i}`,
14601
+ size: [width, height],
14602
+ format: 'rg16float',
14603
+ usage: USAGE,
14604
+ });
14605
+ this.taaMotions[i] = motion;
14606
+ this.taaMotionViews[i] = motion.createView();
14550
14607
  }
14608
+ this.taaTargets = [{ format }, { format: 'rgba16float' }, { format: 'rg16float' }];
14551
14609
  this.taaWrite = 0;
14552
14610
  /* A resized target is a new texture, so whatever the history held is gone. */
14553
14611
  this.temporalHistory.invalidate();
@@ -14743,7 +14801,17 @@ export class WebGPURenderer implements RendererApi {
14743
14801
  const scene = this.sceneColorView;
14744
14802
  const history = this.taaViews[i];
14745
14803
  const resolved = this.taaViews[1 - i];
14746
- if (scene === null || history === null || resolved === null) continue;
14804
+ const record = this.taaRecordViews[i];
14805
+ const motion = this.taaMotionViews[i];
14806
+ if (
14807
+ scene === null ||
14808
+ history === null ||
14809
+ resolved === null ||
14810
+ record === null ||
14811
+ motion === null
14812
+ ) {
14813
+ continue;
14814
+ }
14747
14815
  this.taaBindGroups[i] = device.createBindGroup({
14748
14816
  label: `post.taaBindGroup${i}`,
14749
14817
  layout: this.taaLayout,
@@ -14751,6 +14819,8 @@ export class WebGPURenderer implements RendererApi {
14751
14819
  { binding: TAA_UNIFORMS, resource: { buffer: this.taaUniforms, size: TAA_FRAG_SIZE } },
14752
14820
  ...entry(TAA_TEXTURES.uScene, scene),
14753
14821
  ...entry(TAA_TEXTURES.uHistory, history),
14822
+ ...entry(TAA_TEXTURES.uFlicker, record),
14823
+ ...entry(TAA_TEXTURES.uFlickerMotion, motion),
14754
14824
  ...entry(TAA_TEXTURES.uDepth, this.resolvedDepthView, true),
14755
14825
  ],
14756
14826
  });
@@ -82,6 +82,7 @@ export function flameFrequencyHz(diameterM: number): number {
82
82
  export function createFlame(options: FlameOptions): Flame {
83
83
  const height = HEIGHT_PER_DIAMETER * options.diameterM;
84
84
  const centre: Vec3 = [options.x, options.y + height / 3, options.z];
85
+ const swayM = (options.sway ?? SWAY_PER_HEIGHT) * height;
85
86
  /* A seed spread over the whole turn, so seeds 1 and 2 are not neighbours in phase. */
86
87
  const phase = ((options.seed ?? 0) * 2.399963) % (Math.PI * 2);
87
88
  const light: PointLightSource = {
@@ -94,7 +95,19 @@ export function createFlame(options: FlameOptions): Flame {
94
95
  radius: options.radius,
95
96
  /* Off, because this helper is the flicker; the selection's own would apply a second one. */
96
97
  flicker: 0,
97
- shadowNear: Math.max(0.05, options.diameterM),
98
+ /*
99
+ * **Clear of what the fire burns from wherever the light has wandered**, which is the sway added
100
+ * to the near plane a still flame would want. The shadow map is baked from where the light
101
+ * stood, so the cut moves with it, and each axis moves by at most `swayM`: a near plane of D
102
+ * alone left a brazier's bowl 7 mm inside it at the top of the sway and 13 cm outside at the
103
+ * bottom, and the bowl's shadow on the floor under the brazier came and went with every
104
+ * re-bake — a dark disc on some, none on others.
105
+ *
106
+ * What it gives up is `swayM` of shadow nearest the light, which is inside the fixture. What
107
+ * would make it wrong is a fixture reaching further below the flame than D / 6: this clears
108
+ * that far and no further, so a deep bowl sets its own `shadowNear` from its own depth.
109
+ */
110
+ shadowNear: Math.max(0.05, options.diameterM) + swayM,
98
111
  sourceRadius: options.diameterM / 2,
99
112
  ...(options.castsShadow === false ? { castsShadow: false } : {}),
100
113
  };
@@ -112,7 +125,7 @@ export function createFlame(options: FlameOptions): Flame {
112
125
  color: [options.color[0], options.color[1], options.color[2]],
113
126
  flicker: options.flicker ?? 0.18,
114
127
  phase,
115
- swayM: (options.sway ?? SWAY_PER_HEIGHT) * height,
128
+ swayM,
116
129
  };
117
130
  }
118
131
 
@@ -86,7 +86,8 @@ export function planPointShadowBakes(
86
86
  }
87
87
 
88
88
  /**
89
- * The shaded lights that also cast, in the order they were shaded.
89
+ * The shaded lights that also cast, each at its own place in the shaded list, and −1 at the place
90
+ * of a light that does not.
90
91
  *
91
92
  * `castsShadow: false` says a light is not worth a cubemap. `selectPointLights` honours
92
93
  * that when it builds the *eligible* list, and that was taken for the whole promise — but
@@ -98,7 +99,16 @@ export function planPointShadowBakes(
98
99
  * It showed up as a shadow with nothing to throw it — a lamp that rides the camera
99
100
  * declines to cast for exactly that reason, and cast anyway.
100
101
  *
101
- * Writes into `out` and returns how many it wrote; allocation-free, because this runs
102
+ * **The place is kept rather than the gap closed, because the place is the shader's index.** The
103
+ * pool publishes each map under its light's position in this list, and the shader reads a light's
104
+ * shadow at the light's position in the shaded one. Closing the gap made the two numberings
105
+ * disagree after the first light that declined: every lamp shaded after it was published one
106
+ * place down, the first on the declining light and each of the rest on its neighbour. A consumer
107
+ * whose headlights decline, and are nearly always shaded first, could only keep its lamps honest by
108
+ * dropping the shadows of every lamp ranked after them, and which lamps those were changed as the
109
+ * car moved.
110
+ *
111
+ * Writes into `out` and returns how many places it wrote; allocation-free, because this runs
102
112
  * every frame.
103
113
  */
104
114
  export function selectCastingLights(
@@ -107,14 +117,12 @@ export function selectCastingLights(
107
117
  activeCount: number,
108
118
  out: Int32Array,
109
119
  ): number {
110
- let written = 0;
111
- for (let i = 0; i < activeCount && written < out.length; i++) {
120
+ const count = Math.min(activeCount, out.length);
121
+ for (let i = 0; i < count; i++) {
112
122
  const worldIndex = activeWorldIndices[i] ?? -1;
113
- if (worldIndex < 0) continue;
114
- if (lights[worldIndex]?.castsShadow === false) continue;
115
- out[written++] = worldIndex;
123
+ out[i] = worldIndex >= 0 && lights[worldIndex]?.castsShadow !== false ? worldIndex : -1;
116
124
  }
117
- return written;
125
+ return count;
118
126
  }
119
127
 
120
128
  /**
@@ -248,15 +256,15 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
248
256
  * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
249
257
  *
250
258
  * A light that wanders on a curve is not stale on *every* frame — near the turning points of
251
- * its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
252
- * lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
253
- * then travelled smoothly through the fast part of the wander and stepped through the slow
254
- * part, which is not motion and not stillness: it reads as a vibration. Reported that way,
255
- * against a brazier, once the tracking allowance was in.
259
+ * its travel it moves less in a frame than the rebake tolerance. When the run decided whether a
260
+ * wanderer got its whole cube, a run that reset to zero dropped it back to two faces a frame
261
+ * there, and the shadow stepped through the slow part of the wander: reported as a vibration
262
+ * against a brazier. Every image-holding map is re-baked whole now, so the run only orders the
263
+ * queue, and the hysteresis keeps a wanderer behind the lights that settle instead of letting
264
+ * it jump ahead at every turning point.
256
265
  *
257
- * So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
258
- * earned the allowance three quiet frames before it loses it, and decays one frame at a time
259
- * so a light that genuinely settles stops re-baking rather than holding a budget for ever.
266
+ * It climbs to twice the threshold, which keeps a light chronic for three quiet frames, and
267
+ * decays one frame at a time so a light that genuinely settles rejoins the ordinary order.
260
268
  */
261
269
  const previous = scratch.staleRun[slot] ?? 0;
262
270
  const run = stale
@@ -277,37 +285,40 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
277
285
  let faces = facesPerFrame;
278
286
  let urgentSpent = false;
279
287
  /*
280
- * **One wandering light finishes its cube in the frame it started it**, the same allowance a
281
- * light with no image at all gets, and for a reason that turns out to be the same one: a map
282
- * dribbled out at two faces a frame is a map nobody can sample correctly until it lands.
288
+ * **A map that holds an image is re-baked whole or not at all**, and one being shaded may take a
289
+ * whole cube a frame outside the budget to make that possible.
283
290
  *
284
- * A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
285
- * cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
286
- * way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
287
- * three frames means the published origin steps once every third frame. The shader shoots from
288
- * that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
289
- * "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
290
- * wanders about once a second.
291
+ * Faces land in the layer the shader samples as they are drawn, and `planBake` publishes the
292
+ * origin they were drawn from only with the last one. So a re-bake spread over frames is a cube
293
+ * the shader reads from the old origin with part of it drawn from the new one — and a face is a
294
+ * 90 degree frustum, so the part drawn from somewhere else is a straight-edged quadrilateral of
295
+ * wrong shadow on the ground under the lamp. With the budget spent elsewhere it could hold for a
296
+ * second and more. Reported under a brazier as a dark shape that came and went.
291
297
  *
292
- * Given the cube in one frame the origin moves every frame and the shadow travels instead.
298
+ * A map with no image yet is not sampled until its sixth face lands (see
299
+ * `PointShadowSystem.bind`), so it still fills a few faces a frame, and a cold one being shaded
300
+ * still jumps the queue with all six.
293
301
  *
294
- * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
295
- * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
296
- * that most recently went stale, which is the one somebody is standing next to; the rest keep
297
- * dribbling under the ordinary budget, and their shadows step rather than travel. That is the
298
- * right way round: a shadow you are looking at moves, and a shadow across the square does not
299
- * cost the frame.
302
+ * The allowance used to be for a *chronically* stale light only — a wandering flame — whose
303
+ * dribbled cube published a new origin once every third frame, so its shadow stepped at 20 Hz
304
+ * where the flame wanders about once a second: reported as motion that was "really fast, not
305
+ * smooth, snappy, and unrealistic". Any shaded light is owed the same now, because a cube read
306
+ * part-way is wrong in the same place for the same reason whether or not it will be stale again.
300
307
  *
301
- * It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
302
- * chronic ones last — so a light arriving in range still gets its first image ahead of a flame
303
- * refining one it already has.
308
+ * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
309
+ * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. The first in the plan's order
310
+ * takes it, and `planPointShadowBakes` puts the lights that settled ahead of the ones that never
311
+ * do, so a flame refining its image cannot hold a correction hostage. The rest keep sampling a
312
+ * complete image from a few centimetres away until their turn, which is the trade
313
+ * `pointShadowRebakeDistance` already makes. A warm map, held but not shaded, is re-baked when a
314
+ * whole cube fits the budget or once it is shaded, and not before.
304
315
  *
305
- * **What would make it wrong** is a caster set heavy enough that six passes over it does not
306
- * fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
307
- * a handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
316
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not fit
317
+ * the frame. Measured against a game whose whole static world is about 8,500 triangles in a
318
+ * handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
308
319
  * raised or this given up, and `gpuTiming` is how to tell which.
309
320
  */
310
- let trackingSpent = false;
321
+ let refreshSpent = false;
311
322
  for (let i = 0; i < planned && faces > 0; i++) {
312
323
  const slot = scratch.order[i] ?? -1;
313
324
  /*
@@ -320,13 +331,20 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
320
331
  if (light === undefined || map === undefined) continue;
321
332
 
322
333
  const sampled = slot < pool.sampledCount;
323
- const cold = sampled && scratch.ready[slot] === 0;
324
- const urgent = cold && !urgentSpent;
325
- /* A light being shaded whose map will be stale again next frame however it is served. */
326
- const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
327
- if (urgent) urgentSpent = true;
328
- if (tracking) trackingSpent = true;
329
- faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
334
+ if (scratch.ready[slot] !== 1) {
335
+ /* No image to spoil: a few faces a frame, or all six at once for one being shaded. */
336
+ const urgent = sampled && !urgentSpent;
337
+ if (urgent) urgentSpent = true;
338
+ faces -= bake(map, light, staticCasters, urgent ? faceCount : faces);
339
+ continue;
340
+ }
341
+ if (faces >= faceCount) {
342
+ faces -= bake(map, light, staticCasters, faceCount);
343
+ continue;
344
+ }
345
+ if (!sampled || refreshSpent) continue;
346
+ refreshSpent = true;
347
+ faces -= bake(map, light, staticCasters, faceCount);
330
348
  }
331
349
 
332
350
  /*
@@ -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.
@@ -51,12 +56,57 @@ function smoothstep(edge0: number, edge1: number, x: number): number {
51
56
  return t * t * (3 - 2 * t);
52
57
  }
53
58
 
59
+ /**
60
+ * The band over which three texels stop being one plane: how far their two steps in inverse depth
61
+ * fail to cancel, as a share of the two together. None of it is a plane at any angle; all of it is an
62
+ * edge — one side steps, the other does not — or a sliver, both stepping the same way.
63
+ *
64
+ * **A share and not a size**, which is the lesson of the rods in `resolve.test.ts`: a part a
65
+ * hundredth of the depth in front of a wall passes the depth test, and a normal differenced across it
66
+ * still leans by its height over a texel's width — nearly edge-on where texels are small. What would
67
+ * make this wrong is a curved surface judged at its apex, where both steps lean one way and the
68
+ * normal is sound; there it only gives the normals less say, which the depth test does not need.
69
+ */
70
+ export const PLANAR_FROM = 0.25;
71
+ export const PLANAR_TO = 0.5;
72
+
73
+ /**
74
+ * How sure it is that `centre` and its neighbours either side along one axis lie on one plane, one to
75
+ * zero. Inverse depth is affine across a plane on screen, so a plane's two steps cancel whatever its
76
+ * angle. One where a neighbour is missing — zero, off the picture or undrawn — or where there is no
77
+ * step to compare: nothing to judge by, so a normal keeps the say it always had.
78
+ */
79
+ export function planarity(before: number, centre: number, after: number): number {
80
+ if (!(before > 0) || !(centre > 0) || !(after > 0)) return 1;
81
+ const a = 1 / after - 1 / centre;
82
+ const b = 1 / before - 1 / centre;
83
+ const spread = Math.abs(a) + Math.abs(b);
84
+ if (!(spread > 0)) return 1;
85
+ return 1 - smoothstep(PLANAR_FROM, PLANAR_TO, Math.abs(a + b) / spread);
86
+ }
87
+
88
+ /**
89
+ * How far a surface's depth moves across one texel along an axis, relative to the depth: the smaller
90
+ * of the steps to its two neighbours, because beside a silhouette one of them is the surface behind.
91
+ * Zero at a sliver — nearer or farther than both, so each step is to another surface — and one
92
+ * neighbour's step where only one is there.
93
+ */
94
+ export function surfaceSlope(before: number, centre: number, after: number): number {
95
+ const a = after > 0 ? after - centre : Number.NaN;
96
+ const b = before > 0 ? before - centre : Number.NaN;
97
+ if (Number.isNaN(a)) return Number.isNaN(b) ? 0 : Math.abs(b) / centre;
98
+ if (Number.isNaN(b)) return Math.abs(a) / centre;
99
+ if (a * b > 0) return 0;
100
+ return Math.min(Math.abs(a), Math.abs(b)) / centre;
101
+ }
102
+
54
103
  /**
55
104
  * One where the history is trustworthy, zero where it is not, and continuous between.
56
105
  *
57
106
  * `currDepth` is the view depth this surface should have had last frame; `histDepth` the view depth
58
107
  * last frame held where it lands, or zero where it landed off the screen; `motionLength` the motion's
59
- * length in uv; `normalDot` the cosine between the two frames' world normals there.
108
+ * length in uv; `normalDot` the cosine between the two frames' world normals there; `slope` the
109
+ * surface's `surfaceSlope`, which widens the tolerance one for one.
60
110
  */
61
111
  export function disocclusionWeight(
62
112
  currDepth: number,
@@ -64,12 +114,13 @@ export function disocclusionWeight(
64
114
  motionLength: number,
65
115
  normalDot: number,
66
116
  params: DisocclusionParams,
117
+ slope = 0,
67
118
  ): number {
68
119
  if (!(currDepth > 0) || !(histDepth > 0) || !(motionLength >= 0) || Number.isNaN(normalDot)) {
69
120
  return 0;
70
121
  }
71
122
  const disagreement = Math.abs(currDepth - histDepth) / currDepth;
72
- const tolerance = params.depthTolerance + params.motionScale * motionLength;
123
+ const tolerance = params.depthTolerance + params.motionScale * motionLength + slope;
73
124
  const depth = 1 - smoothstep(tolerance, 2 * tolerance, disagreement);
74
125
  const normal = smoothstep(params.normalFloor, params.normalCeiling, normalDot);
75
126
  return depth * normal;