@driftengine/core 4.1.3 → 4.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.json +3796 -3739
  2. package/README.md +1 -1
  3. package/dist/index.js +12 -2
  4. package/dist/render/backend/webgl2/renderer.d.ts +15 -0
  5. package/dist/render/backend/webgl2/renderer.js +28 -7
  6. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +46 -0
  7. package/dist/render/backend/webgpu/gpuDrivenPass.js +105 -0
  8. package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
  9. package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
  10. package/dist/render/backend/webgpu/renderer.js +17 -4
  11. package/dist/render/depthConvention.d.ts +19 -0
  12. package/dist/render/depthConvention.js +21 -0
  13. package/dist/render/livePointShadowSet.js +8 -0
  14. package/dist/render/pointShadowBudget.js +59 -5
  15. package/dist/render/pointShadowImage.d.ts +40 -0
  16. package/dist/render/pointShadowImage.js +20 -0
  17. package/dist/render/pointShadowMap.d.ts +4 -0
  18. package/dist/render/pointShadowMap.js +10 -0
  19. package/dist/render/pointShadowSystem.js +9 -0
  20. package/dist/render/renderQuality.d.ts +27 -4
  21. package/dist/render/renderQuality.js +1 -1
  22. package/dist/render/shaders/flat/main.d.ts +1 -1
  23. package/dist/render/shaders/flat/main.js +104 -10
  24. package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
  25. package/dist/render/shaders/flat/pointShadow.js +84 -46
  26. package/dist/render/shaders/flat/preamble.js +70 -9
  27. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
  28. package/dist/render/shaders/generated/flat.wgsl.js +32 -32
  29. package/dist/render/shaders/sky.d.ts +10 -6
  30. package/dist/render/shaders/sky.js +35 -2
  31. package/dist/render/textLayout.d.ts +58 -0
  32. package/dist/render/textLayout.js +83 -0
  33. package/dist/render/textRenderer.js +7 -3
  34. package/package.json +6 -6
  35. package/src/index.ts +12 -2
  36. package/src/render/backend/webgl2/renderer.ts +28 -7
  37. package/src/render/backend/webgpu/gpuDrivenPass.ts +105 -0
  38. package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
  39. package/src/render/backend/webgpu/renderer.ts +26 -4
  40. package/src/render/depthConvention.ts +22 -0
  41. package/src/render/livePointShadowSet.ts +7 -0
  42. package/src/render/pointShadowBudget.ts +58 -5
  43. package/src/render/pointShadowImage.ts +51 -0
  44. package/src/render/pointShadowMap.ts +13 -0
  45. package/src/render/pointShadowSystem.ts +8 -0
  46. package/src/render/renderQuality.ts +28 -5
  47. package/src/render/shaders/flat/main.ts +104 -10
  48. package/src/render/shaders/flat/pointShadow.ts +84 -46
  49. package/src/render/shaders/flat/preamble.ts +70 -9
  50. package/src/render/shaders/generated/flat.wgsl.ts +32 -32
  51. package/src/render/shaders/sky.ts +37 -2
  52. package/src/render/textLayout.ts +88 -0
  53. package/src/render/textRenderer.ts +22 -3
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: 698.4 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
5
+ **Cost: 721.2 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)
package/dist/index.js CHANGED
@@ -102,8 +102,18 @@ export { createRenderer } from './render/backend/createRenderer.js';
102
102
  * A consumer who names neither pays nothing: `core-only` is measured with these exported and does
103
103
  * not move, because nothing in the forward path reaches them.
104
104
  *
105
- * It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
106
- * the standard material. `DRAFT_SCENES`, never `SCENES`, until it draws the six published ones.
105
+ * **It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
106
+ * the standard material**, and that is the sentence to read before reaching for it. There is no
107
+ * flag that routes `drawMesh` through this: a consumer packs a scene and contributes the pass, and
108
+ * `setMaterial`, `setSurfaceGrain`, `setSurfaceRelief` and a skin palette reach none of it. What
109
+ * would change that is a second copy of the standard material, which `gpuDrivenPass.ts` refuses in
110
+ * its own header.
111
+ *
112
+ * **The line that used to be here said `DRAFT_SCENES`, never `SCENES`, and it went stale the day
113
+ * the city was published.** `city` is in `SCENES` and says `pipelines: ['gpu-driven']`, so a host
114
+ * that cannot run the second pipeline reads that from the list rather than from a mount that
115
+ * refuses. The rule it was standing in for is the one above: what gates a scene is whether this
116
+ * pass can draw it, not which array it is in.
107
117
  */
108
118
  export { GPU_DRIVEN_LISTS, GpuDrivenPass, MATERIAL_FLOATS,
109
119
  /*
@@ -1066,6 +1066,21 @@ export declare class WebGL2Renderer implements RendererApi {
1066
1066
  * near plane needs the granted answer and not the wished-for one.
1067
1067
  */
1068
1068
  reversedDepth: boolean;
1069
+ /**
1070
+ * What to clear depth to, for the convention this context was actually granted.
1071
+ *
1072
+ * **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
1073
+ * 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
1074
+ * context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
1075
+ * set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
1076
+ * and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
1077
+ *
1078
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
1079
+ * drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
1080
+ * beside it was already runtime; this was the half that was not, and the asymmetry is what made
1081
+ * it total rather than subtle.
1082
+ */
1083
+ private get depthClear();
1069
1084
  /** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
1070
1085
  private readonly sceneViewProj;
1071
1086
  /**
@@ -1,7 +1,7 @@
1
1
  import { FrameBudget } from '../budget.js';
2
2
  import { MaterialChanges, ownsMaterial } from '../materialChanges.js';
3
3
  import { mat4 } from 'gl-matrix';
4
- import { DEPTH_CLEAR, DEPTH_OFFSET_SIGN, GL_DEPTH_REMAP, MAX_DEPTH_LAYER, depthOffsetForLayer, GL_SHADOW_REMAP, REVERSED_DEPTH, SHADOW_DEPTH_CLEAR, glDepthFuncEqual, glShadowDepthFunc, } from '../../depthConvention.js';
4
+ import { DEPTH_OFFSET_SIGN, GL_DEPTH_REMAP, MAX_DEPTH_LAYER, depthClearFor, depthOffsetForLayer, GL_SHADOW_REMAP, REVERSED_DEPTH, SHADOW_DEPTH_CLEAR, glDepthFuncEqual, glShadowDepthFunc, } from '../../depthConvention.js';
5
5
  import { PickableSet } from '../../pickable.js';
6
6
  import { TEMPORAL_HISTORY_BLEND, TemporalHistory, jitterOffset, jitterProjection, } from '../../temporalAa.js';
7
7
  import { DecalPass } from '../../decalPass.js';
@@ -45,7 +45,7 @@ import { packIesAtlas } from '../../iesProfile.js';
45
45
  import { MAX_AREA_LIGHTS } from '../../areaLights.js';
46
46
  import { areaShadowLayerCount, castingRange, createResolvedAreaShadows, firstAreaShadowLayer, AreaShadowSet, } from '../../areaShadowSet.js';
47
47
  import { equirectToCubeFaces } from '../../equirectToCube.js';
48
- import { SKY_FRAG, SKY_VERT } from '../../shaders/sky.js';
48
+ import { SKY_FRAG, skyVertFor } from '../../shaders/sky.js';
49
49
  import { DEPTH_FRAG, DEPTH_INSTANCED_VERT, DEPTH_SKINNED_VERT, DEPTH_VERT, } from '../../shaders/depth.js';
50
50
  import { PointShadowArray } from '../../pointShadowArray.js';
51
51
  import { ShadowMap } from '../../shadowMap.js';
@@ -749,6 +749,23 @@ export class WebGL2Renderer {
749
749
  * near plane needs the granted answer and not the wished-for one.
750
750
  */
751
751
  reversedDepth = false;
752
+ /**
753
+ * What to clear depth to, for the convention this context was actually granted.
754
+ *
755
+ * **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
756
+ * 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
757
+ * context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
758
+ * set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
759
+ * and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
760
+ *
761
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
762
+ * drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
763
+ * beside it was already runtime; this was the half that was not, and the asymmetry is what made
764
+ * it total rather than subtle.
765
+ */
766
+ get depthClear() {
767
+ return depthClearFor(this.reversedDepth);
768
+ }
752
769
  /** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
753
770
  sceneViewProj = new Float32Array(16);
754
771
  /**
@@ -1745,7 +1762,10 @@ export class WebGL2Renderer {
1745
1762
  }
1746
1763
  }
1747
1764
  this.filmUniforms = uniformLocations(gl, this.filmProgram, 'film');
1748
- this.skyProgram = compileProgram(gl, SKY_VERT, SKY_FRAG, 'sky');
1765
+ /* The variant matching what this context was granted, not what the engine wants. See
1766
+ `skyVertFor`: a sky written at the reversed far plane on a conventional context sits in the
1767
+ middle of the depth range and paints over everything behind it. */
1768
+ this.skyProgram = compileProgram(gl, skyVertFor(this.reversedDepth), SKY_FRAG, 'sky');
1749
1769
  this.skyUniforms = uniformLocations(gl, this.skyProgram, 'sky');
1750
1770
  this.scatterProgram = compileProgram(gl, SCATTER_VERT, SCATTER_FRAG, 'scatter');
1751
1771
  this.scatterUniforms = uniformLocations(gl, this.scatterProgram, 'scatter');
@@ -3139,12 +3159,13 @@ export class WebGL2Renderer {
3139
3159
  gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.pointShadowArray?.texture ?? this.emptyTexture2DArray);
3140
3160
  gl.uniform1i(uniforms[POINT_SHADOW_SAMPLER] ?? null, firstUnit);
3141
3161
  gl.uniform1iv(uniforms['uPointShadowLayer[0]'] ?? null, resolved.layers);
3142
- gl.uniform1fv(uniforms['uPointShadowFar[0]'] ?? null, resolved.far);
3162
+ /* Origin and far plane in one row; see `uPointShadowProjection` in the preamble. */
3163
+ gl.uniform4fv(uniforms['uPointShadowProjection[0]'] ?? null, resolved.projections);
3143
3164
  gl.uniform1fv(uniforms['uPointShadowNear[0]'] ?? null, resolved.near);
3144
3165
  gl.uniform1fv(uniforms['uPointShadowSize[0]'] ?? null, resolved.sourceRadius);
3145
3166
  gl.uniform1fv(uniforms['uPointShadowWeight[0]'] ?? null, resolved.presence);
3146
3167
  gl.uniform1iv(uniforms['uLivePointShadowLayer[0]'] ?? null, resolved.liveLayers);
3147
- gl.uniform1fv(uniforms['uLivePointShadowFar[0]'] ?? null, resolved.liveFar);
3168
+ gl.uniform4fv(uniforms['uLivePointShadowProjection[0]'] ?? null, resolved.liveProjections);
3148
3169
  gl.uniform1fv(uniforms['uLivePointShadowNear[0]'] ?? null, resolved.liveNear);
3149
3170
  gl.uniform1fv(uniforms['uLivePointShadowSize[0]'] ?? null, resolved.liveSourceRadius);
3150
3171
  gl.uniform1fv(uniforms['uLivePointShadowWeight[0]'] ?? null, resolved.liveWeights);
@@ -3573,7 +3594,7 @@ export class WebGL2Renderer {
3573
3594
  shadowMap.end(this.gl);
3574
3595
  /* Back to the frame's sense; see `beginShadowPass` for why it left it. */
3575
3596
  this.gl.depthFunc(glDepthFuncEqual(this.gl));
3576
- this.gl.clearDepth(DEPTH_CLEAR);
3597
+ this.gl.clearDepth(this.depthClear);
3577
3598
  this.gpuTimer.end();
3578
3599
  this.shadowPassActive = false;
3579
3600
  this.activeShadowMap = null;
@@ -3949,7 +3970,7 @@ export class WebGL2Renderer {
3949
3970
  finally {
3950
3971
  /* Back to the frame's sense, which `beginShadowPass` leaves the same way. */
3951
3972
  gl.depthFunc(glDepthFuncEqual(gl));
3952
- gl.clearDepth(DEPTH_CLEAR);
3973
+ gl.clearDepth(this.depthClear);
3953
3974
  this.pointShadowCasters = null;
3954
3975
  this.depthPassCullsFaces = true;
3955
3976
  }
@@ -470,6 +470,30 @@ export declare class GpuDrivenPass implements PassDefinition {
470
470
  * before the device answers, which nobody sees; the alternative is a frame that draws nothing.
471
471
  */
472
472
  private blendUsable;
473
+ /**
474
+ * The same question about *every other* pipeline in this pass, and the reason it exists is that
475
+ * 4.1.3 asked it about one.
476
+ *
477
+ * **The guard above was written from a log that named `gpu-driven blend`, and the four render
478
+ * pipelines and eight compute pipelines beside it were left with the shape that caused the
479
+ * report.** Reported again from an Android handset once that fix shipped: the transparent pass
480
+ * now skips itself correctly, the overlay still draws and the frame counter still counts — and
481
+ * the world is black, which is what an invalid *opaque* pipeline looks like once the blended one
482
+ * has stopped taking the command buffer with it.
483
+ *
484
+ * So the whole of `buildPipelines` is built inside one pair of scopes and nothing this pass
485
+ * encodes runs until they come back clean. The blend pipeline pushes its own pair *inside* that
486
+ * region, so the two verdicts stay separate: a device that refuses only the transparent pipeline
487
+ * still draws everything opaque, and a device that refuses anything else turns this pass off
488
+ * rather than encoding against a handle the driver has already rejected.
489
+ *
490
+ * **What it gives up** is the first frames, which draw nothing while the device is still
491
+ * answering. **What would make it wrong** is a device that never resolves an error scope; the
492
+ * `popErrorScope` check below is the same concession the blend guard makes, for the same reason.
493
+ */
494
+ private usable;
495
+ /** Why this pass refused itself, in the words the device used, or null while it has not. */
496
+ private refusal;
473
497
  /** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
474
498
  private makeBlendGroup;
475
499
  private blendResolvePipeline;
@@ -641,6 +665,28 @@ export declare class GpuDrivenPass implements PassDefinition {
641
665
  * this came from was five errors that were all the same one.
642
666
  */
643
667
  private watchBlendPipeline;
668
+ /**
669
+ * Close the scopes every other pipeline was built in, and refuse the pass where one came back.
670
+ *
671
+ * **This is `watchBlendPipeline` widened to the rest of the pass**, and the reason it is separate
672
+ * is that the two refusals mean different things: a blend pipeline nobody can compile costs the
673
+ * glass, and a raster, shadow, blit or compute pipeline nobody can compile costs the world. The
674
+ * first degrades; the second has nothing to degrade to, so it says so and draws nothing rather
675
+ * than encoding a command buffer the driver will reject whole.
676
+ *
677
+ * A device with no scopes is taken at its word, exactly as the blend guard takes it: refusing to
678
+ * draw on hardware that never said no is the worse of the two failures.
679
+ */
680
+ private watchPipelines;
681
+ /**
682
+ * Why this pass is drawing nothing, in the words the device used, or null where it is not.
683
+ *
684
+ * **Exposed because a black frame that says nothing is the bug this was reported as, twice.** A
685
+ * pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend
686
+ * can run this at all, so there is no earlier place to throw — and a scene that can read this can
687
+ * tell somebody what happened instead of presenting an empty picture.
688
+ */
689
+ refusedBecause(): string | null;
644
690
  private buildPipelines;
645
691
  /**
646
692
  * The groups that name `history` and `drawn`, rebuilt when the two swap.
@@ -445,6 +445,30 @@ export class GpuDrivenPass {
445
445
  * before the device answers, which nobody sees; the alternative is a frame that draws nothing.
446
446
  */
447
447
  blendUsable = false;
448
+ /**
449
+ * The same question about *every other* pipeline in this pass, and the reason it exists is that
450
+ * 4.1.3 asked it about one.
451
+ *
452
+ * **The guard above was written from a log that named `gpu-driven blend`, and the four render
453
+ * pipelines and eight compute pipelines beside it were left with the shape that caused the
454
+ * report.** Reported again from an Android handset once that fix shipped: the transparent pass
455
+ * now skips itself correctly, the overlay still draws and the frame counter still counts — and
456
+ * the world is black, which is what an invalid *opaque* pipeline looks like once the blended one
457
+ * has stopped taking the command buffer with it.
458
+ *
459
+ * So the whole of `buildPipelines` is built inside one pair of scopes and nothing this pass
460
+ * encodes runs until they come back clean. The blend pipeline pushes its own pair *inside* that
461
+ * region, so the two verdicts stay separate: a device that refuses only the transparent pipeline
462
+ * still draws everything opaque, and a device that refuses anything else turns this pass off
463
+ * rather than encoding against a handle the driver has already rejected.
464
+ *
465
+ * **What it gives up** is the first frames, which draw nothing while the device is still
466
+ * answering. **What would make it wrong** is a device that never resolves an error scope; the
467
+ * `popErrorScope` check below is the same concession the blend guard makes, for the same reason.
468
+ */
469
+ usable = false;
470
+ /** Why this pass refused itself, in the words the device used, or null while it has not. */
471
+ refusal = null;
448
472
  /** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
449
473
  makeBlendGroup = null;
450
474
  blendResolvePipeline = null;
@@ -687,7 +711,18 @@ export class GpuDrivenPass {
687
711
  });
688
712
  }
689
713
  this.buildBuffers(device.device);
714
+ /*
715
+ * **Every pipeline this pass owns, inside one pair of scopes.** See `usable`: the blend
716
+ * pipeline pushes its own pair within this region, so its refusal stays its own and any other
717
+ * refusal is caught here and turns the pass off instead of reaching an encoder.
718
+ */
719
+ const scoped = typeof device.device.pushErrorScope === 'function';
720
+ if (scoped) {
721
+ device.device.pushErrorScope('validation');
722
+ device.device.pushErrorScope('internal');
723
+ }
690
724
  this.buildPipelines(device.device, device.format, device.samples);
725
+ this.watchPipelines(device.device, scoped);
691
726
  if (this.width > 0)
692
727
  this.buildTargets(device.device);
693
728
  }
@@ -1000,6 +1035,50 @@ export class GpuDrivenPass {
1000
1035
  void device.popErrorScope().then((error) => settle(error, 'internal'));
1001
1036
  void device.popErrorScope().then((error) => settle(error, 'validation'));
1002
1037
  }
1038
+ /**
1039
+ * Close the scopes every other pipeline was built in, and refuse the pass where one came back.
1040
+ *
1041
+ * **This is `watchBlendPipeline` widened to the rest of the pass**, and the reason it is separate
1042
+ * is that the two refusals mean different things: a blend pipeline nobody can compile costs the
1043
+ * glass, and a raster, shadow, blit or compute pipeline nobody can compile costs the world. The
1044
+ * first degrades; the second has nothing to degrade to, so it says so and draws nothing rather
1045
+ * than encoding a command buffer the driver will reject whole.
1046
+ *
1047
+ * A device with no scopes is taken at its word, exactly as the blend guard takes it: refusing to
1048
+ * draw on hardware that never said no is the worse of the two failures.
1049
+ */
1050
+ watchPipelines(device, scoped) {
1051
+ if (!scoped || typeof device.popErrorScope !== 'function') {
1052
+ this.usable = true;
1053
+ return;
1054
+ }
1055
+ let outstanding = 2;
1056
+ const settle = (error, scope) => {
1057
+ outstanding -= 1;
1058
+ if (error !== null && this.refusal === null) {
1059
+ this.refusal = error.message;
1060
+ console.warn(`[driftengine] this device refused a gpu-driven pipeline (${scope}), so this pass draws ` +
1061
+ `nothing and the rest of the frame is unaffected: ${error.message}`);
1062
+ }
1063
+ /* Usable only once both have come back clean, which is what keeps an invalid handle out. */
1064
+ if (outstanding > 0 || this.refusal !== null)
1065
+ return;
1066
+ this.usable = true;
1067
+ };
1068
+ void device.popErrorScope().then((error) => settle(error, 'internal'));
1069
+ void device.popErrorScope().then((error) => settle(error, 'validation'));
1070
+ }
1071
+ /**
1072
+ * Why this pass is drawing nothing, in the words the device used, or null where it is not.
1073
+ *
1074
+ * **Exposed because a black frame that says nothing is the bug this was reported as, twice.** A
1075
+ * pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend
1076
+ * can run this at all, so there is no earlier place to throw — and a scene that can read this can
1077
+ * tell somebody what happened instead of presenting an empty picture.
1078
+ */
1079
+ refusedBecause() {
1080
+ return this.refusal;
1081
+ }
1003
1082
  buildPipelines(device, format, samples) {
1004
1083
  const compute = (label, code) => device.createComputePipeline({
1005
1084
  label: `gpu-driven ${label}`,
@@ -1935,6 +2014,9 @@ export class GpuDrivenPass {
1935
2014
  prepare(ctx) {
1936
2015
  if (ctx.backend !== 'webgpu')
1937
2016
  return;
2017
+ /* Nothing is encoded until the device has answered for the pipelines. See `usable`. */
2018
+ if (!this.usable)
2019
+ return;
1938
2020
  const device = this.device;
1939
2021
  if (device === null || this.levels.length === 0)
1940
2022
  return;
@@ -2165,6 +2247,26 @@ export class GpuDrivenPass {
2165
2247
  return;
2166
2248
  }
2167
2249
  case 'blendResolve': {
2250
+ /*
2251
+ * **Skipped on the same verdict as the draw, because the draw is what clears what this
2252
+ * reads.** This is the whole of the reported black frame, and the line above it predicted
2253
+ * it: the blend pass clears reveal to one, meaning nothing has covered this pixel yet, and
2254
+ * says in as many words that cleared to zero "the resolve would show no scene anywhere the
2255
+ * pass ran, which is a black frame that looks like the blend working".
2256
+ *
2257
+ * 4.1.2 skipped the draw and took its clear with it, and left this running against textures
2258
+ * nothing had written — a reveal of zero over every pixel, which is exactly "fully covered".
2259
+ * Reported from a Galaxy S23 Ultra and then an Adreno 740, both of which refuse only
2260
+ * `gpu-driven blend` and both of which drew the city into a buffer this then wiped: the
2261
+ * page's own readout said 43 fps and 1,087 clusters drawn while the screen was black.
2262
+ *
2263
+ * There is nothing to composite when nothing was rasterised, so the honest answer is not to
2264
+ * composite. **What would make it wrong** is a caller that expects this pass to have run for
2265
+ * some other reason; nothing does, and the opaque image is already in `colourView` before
2266
+ * this would load it.
2267
+ */
2268
+ if (!this.blendUsable)
2269
+ return;
2168
2270
  const pass = encoder.beginRenderPass({
2169
2271
  label: 'gpu-driven blend resolve',
2170
2272
  colorAttachments: [
@@ -2468,6 +2570,9 @@ export class GpuDrivenPass {
2468
2570
  draw(ctx) {
2469
2571
  if (ctx.backend !== 'webgpu')
2470
2572
  return;
2573
+ /* The same gate as `prepare`: an unanswered or refused pipeline set encodes nothing at all. */
2574
+ if (!this.usable)
2575
+ return;
2471
2576
  if (this.blitPipeline === null || this.blitGroup === null)
2472
2577
  return;
2473
2578
  if (ctx.outputTransform !== 0 && !this.gradeWarned) {
@@ -67,6 +67,10 @@ export declare class GpuPointShadowMap implements PointShadowSource {
67
67
  get far(): number;
68
68
  get near(): number;
69
69
  get sourceRadius(): number;
70
+ /** Where the image was rendered from, not where the light is now. See the image. */
71
+ get originX(): number;
72
+ get originY(): number;
73
+ get originZ(): number;
70
74
  get hasBaked(): boolean;
71
75
  get presence(): number;
72
76
  matchesSource(x: number, y: number, z: number, range: number, near: number, sourceRadius?: number, tolerance?: number): boolean;
@@ -229,6 +229,16 @@ export class GpuPointShadowMap {
229
229
  get sourceRadius() {
230
230
  return this.image.sourceRadius;
231
231
  }
232
+ /** Where the image was rendered from, not where the light is now. See the image. */
233
+ get originX() {
234
+ return this.image.originX;
235
+ }
236
+ get originY() {
237
+ return this.image.originY;
238
+ }
239
+ get originZ() {
240
+ return this.image.originZ;
241
+ }
232
242
  get hasBaked() {
233
243
  return this.image.hasBaked;
234
244
  }
@@ -38,6 +38,7 @@ import { POINT_SHADOW_SAMPLER, PointShadowSystem, } from '../../pointShadowSyste
38
38
  import { GpuPointShadowArray, GpuPointShadowMap } from './pointShadowPass.js';
39
39
  import { createPanelBindGroup, createPanelBindGroupLayout, createPanelCorners, panelPipeline, PANEL_FRAG_FIELDS, PANEL_FRAG_SIZE, PANEL_VERTEX_COUNT, PANEL_VERT_FIELDS, PANEL_VERT_SIZE, } from './panelPass.js';
40
40
  import { createGpuTextCube, createTextBindGroup, createTextBindGroupLayout, GpuText, textPipeline, TEXT_FRAG_FIELDS, TEXT_FRAG_SIZE, TEXT_VERT_FIELDS, TEXT_VERT_SIZE, } from './textPass.js';
41
+ import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.js';
41
42
  import { GpuSurfaceTexture } from './surfaceTexturePass.js';
42
43
  import { createSdfTextBindGroup, createSdfTextBindGroupLayout, GpuSdfText, sdfTextPipeline, SDF_TEXT_FRAG_FIELDS, SDF_TEXT_FRAG_SIZE, SDF_TEXT_VERT_FIELDS, SDF_TEXT_VERT_SIZE, } from './sdfTextPass.js';
43
44
  import { createFilmBindGroup, createFilmBindGroupLayout, filmPipeline, FILM_FRAG_FIELDS, FILM_FRAG_SIZE, FILM_VERT_FIELDS, FILM_VERT_SIZE, } from './filmPass.js';
@@ -2485,8 +2486,15 @@ export class WebGPURenderer {
2485
2486
  const at = (name) => TEXT_VERT_FIELDS[name]?.offset ?? 0;
2486
2487
  const v = this.textVerts;
2487
2488
  v.writeFloats(vertexSlot, at('uViewport'), [viewportWidth, viewportHeight]);
2488
- v.writeFloats(vertexSlot, at('uOrigin'), [originX, originY]);
2489
- v.writeFloat(vertexSlot, at('uCellSize'), style.cellSize);
2489
+ /* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
2490
+ See `deviceSnappedOrigin`. */
2491
+ v.writeFloats(vertexSlot, at('uOrigin'), [
2492
+ deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
2493
+ deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
2494
+ ]);
2495
+ /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
2496
+ decision is shared with WebGL2; only this binding is per-backend. See `textLayout.ts`. */
2497
+ v.writeFloat(vertexSlot, at('uCellSize'), deviceSnappedCellSize(style.cellSize, viewportWidth, this.surface.canvas.width));
2490
2498
  /* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
2491
2499
  v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
2492
2500
  v.writeFloat(vertexSlot, at('uReveal'), style.reveal);
@@ -6225,11 +6233,16 @@ export class WebGPURenderer {
6225
6233
  }
6226
6234
  else {
6227
6235
  this.writeShadowSelection(f, i, 'uPointShadow', resolved.layers, resolved.presence);
6228
- this.writeShadowParams(f, 'uPointShadowFar', resolved.far);
6236
+ /*
6237
+ * A `vec4` row carrying the bake origin and the far plane, so `scatterInto` rather than
6238
+ * `writeShadowParams` — it reads the stride out of the generated layout instead of
6239
+ * counting rows, which is what AGENTS.md's 2026-09-20 alignment rule asks for.
6240
+ */
6241
+ scatterInto(f, this.fragment.fields['uPointShadowProjection'], resolved.projections, 4);
6229
6242
  this.writeShadowParams(f, 'uPointShadowNear', resolved.near);
6230
6243
  this.writeShadowParams(f, 'uPointShadowSize', resolved.sourceRadius);
6231
6244
  this.writeShadowSelection(f, i, 'uLivePointShadow', resolved.liveLayers, resolved.liveWeights);
6232
- this.writeShadowParams(f, 'uLivePointShadowFar', resolved.liveFar);
6245
+ scatterInto(f, this.fragment.fields['uLivePointShadowProjection'], resolved.liveProjections, 4);
6233
6246
  this.writeShadowParams(f, 'uLivePointShadowNear', resolved.liveNear);
6234
6247
  this.writeShadowParams(f, 'uLivePointShadowSize', resolved.liveSourceRadius);
6235
6248
  }
@@ -101,6 +101,25 @@
101
101
  export declare const REVERSED_DEPTH = true;
102
102
  /** What a scene depth attachment is cleared to: the far plane. */
103
103
  export declare const DEPTH_CLEAR: number;
104
+ /**
105
+ * The same value for a backend that learned its convention at runtime rather than at compile time.
106
+ *
107
+ * **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
108
+ * on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
109
+ * the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
110
+ * scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
111
+ * the screen is the colour clear and nothing else.
112
+ *
113
+ * Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
114
+ * colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
115
+ * beside it was already chosen at runtime, and that asymmetry is what made this total rather than
116
+ * subtle — two halves of one convention would still have drawn a picture.
117
+ *
118
+ * **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
119
+ * backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
120
+ * and the compare to each other, which a private field on a renderer could not.
121
+ */
122
+ export declare function depthClearFor(reversed: boolean): number;
104
123
  /** The WebGPU compare for "nearer than what is there". */
105
124
  export declare const DEPTH_COMPARE: GPUCompareFunction;
106
125
  /**
@@ -101,6 +101,27 @@
101
101
  export const REVERSED_DEPTH = true;
102
102
  /** What a scene depth attachment is cleared to: the far plane. */
103
103
  export const DEPTH_CLEAR = REVERSED_DEPTH ? 0 : 1;
104
+ /**
105
+ * The same value for a backend that learned its convention at runtime rather than at compile time.
106
+ *
107
+ * **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
108
+ * on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
109
+ * the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
110
+ * scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
111
+ * the screen is the colour clear and nothing else.
112
+ *
113
+ * Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
114
+ * colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
115
+ * beside it was already chosen at runtime, and that asymmetry is what made this total rather than
116
+ * subtle — two halves of one convention would still have drawn a picture.
117
+ *
118
+ * **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
119
+ * backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
120
+ * and the compare to each other, which a private field on a renderer could not.
121
+ */
122
+ export function depthClearFor(reversed) {
123
+ return reversed ? 0 : 1;
124
+ }
104
125
  /** The WebGPU compare for "nearer than what is there". */
105
126
  export const DEPTH_COMPARE = REVERSED_DEPTH ? 'greater' : 'less';
106
127
  /**
@@ -155,6 +155,9 @@ export class LivePointShadowSet {
155
155
  out.liveFar.fill(1);
156
156
  out.liveNear.fill(0.01);
157
157
  out.liveSourceRadius.fill(0);
158
+ out.liveProjections.fill(0);
159
+ for (let k = 0; k < out.liveLayers.length; k++)
160
+ out.liveProjections[k * 4 + 3] = 1;
158
161
  /*
159
162
  * Zero, then written under the owning light below. The weights used to be a straight copy of
160
163
  * this set's own two, because the arrays were addressed by live slot; they are addressed by
@@ -188,6 +191,11 @@ export class LivePointShadowSet {
188
191
  out.liveFar[shaderSlot] = map.far;
189
192
  out.liveNear[shaderSlot] = map.near;
190
193
  out.liveSourceRadius[shaderSlot] = map.sourceRadius;
194
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
195
+ out.liveProjections[shaderSlot * 4] = map.originX;
196
+ out.liveProjections[shaderSlot * 4 + 1] = map.originY;
197
+ out.liveProjections[shaderSlot * 4 + 2] = map.originZ;
198
+ out.liveProjections[shaderSlot * 4 + 3] = map.far;
191
199
  /*
192
200
  * The handover weight *and* the image's own arrival, multiplied.
193
201
  *
@@ -167,13 +167,62 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
167
167
  scratch.staleOwner[slot] = lightIndex;
168
168
  scratch.staleRun[slot] = 0;
169
169
  }
170
- const run = stale ? Math.min((scratch.staleRun[slot] ?? 0) + 1, CHRONIC_STALE_FRAMES) : 0;
170
+ /*
171
+ * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
172
+ *
173
+ * A light that wanders on a curve is not stale on *every* frame — near the turning points of
174
+ * its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
175
+ * lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
176
+ * then travelled smoothly through the fast part of the wander and stepped through the slow
177
+ * part, which is not motion and not stillness: it reads as a vibration. Reported that way,
178
+ * against a brazier, once the tracking allowance was in.
179
+ *
180
+ * So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
181
+ * earned the allowance three quiet frames before it loses it, and decays one frame at a time
182
+ * so a light that genuinely settles stops re-baking rather than holding a budget for ever.
183
+ */
184
+ const previous = scratch.staleRun[slot] ?? 0;
185
+ const run = stale
186
+ ? Math.min(previous + 1, CHRONIC_STALE_FRAMES * 2)
187
+ : Math.max(previous - 1, 0);
171
188
  scratch.staleRun[slot] = run;
172
189
  scratch.chronic[slot] = run >= CHRONIC_STALE_FRAMES ? 1 : 0;
173
190
  }
174
191
  const planned = planPointShadowBakes(scratch.stale, scratch.ready, count, scratch.order, scratch.chronic);
175
192
  let faces = facesPerFrame;
176
193
  let urgentSpent = false;
194
+ /*
195
+ * **One wandering light finishes its cube in the frame it started it**, the same allowance a
196
+ * light with no image at all gets, and for a reason that turns out to be the same one: a map
197
+ * dribbled out at two faces a frame is a map nobody can sample correctly until it lands.
198
+ *
199
+ * A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
200
+ * cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
201
+ * way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
202
+ * three frames means the published origin steps once every third frame. The shader shoots from
203
+ * that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
204
+ * "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
205
+ * wanders about once a second.
206
+ *
207
+ * Given the cube in one frame the origin moves every frame and the shadow travels instead.
208
+ *
209
+ * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
210
+ * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
211
+ * that most recently went stale, which is the one somebody is standing next to; the rest keep
212
+ * dribbling under the ordinary budget, and their shadows step rather than travel. That is the
213
+ * right way round: a shadow you are looking at moves, and a shadow across the square does not
214
+ * cost the frame.
215
+ *
216
+ * It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
217
+ * chronic ones last — so a light arriving in range still gets its first image ahead of a flame
218
+ * refining one it already has.
219
+ *
220
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
221
+ * fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
222
+ * a handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
223
+ * raised or this given up, and `gpuTiming` is how to tell which.
224
+ */
225
+ let trackingSpent = false;
177
226
  for (let i = 0; i < planned && faces > 0; i++) {
178
227
  const slot = scratch.order[i] ?? -1;
179
228
  /*
@@ -185,11 +234,16 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
185
234
  const map = lightIndex >= 0 ? pool.mapForLight(lightIndex) : undefined;
186
235
  if (light === undefined || map === undefined)
187
236
  continue;
188
- const cold = slot < pool.sampledCount && scratch.ready[slot] === 0;
189
- const budget = cold && !urgentSpent ? faceCount : faces;
190
- if (cold && !urgentSpent)
237
+ const sampled = slot < pool.sampledCount;
238
+ const cold = sampled && scratch.ready[slot] === 0;
239
+ const urgent = cold && !urgentSpent;
240
+ /* A light being shaded whose map will be stale again next frame however it is served. */
241
+ const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
242
+ if (urgent)
191
243
  urgentSpent = true;
192
- faces -= bake(map, light, staticCasters, budget);
244
+ if (tracking)
245
+ trackingSpent = true;
246
+ faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
193
247
  }
194
248
  /*
195
249
  * Live maps contain movers only and are composed with the static maps in the shader. A second