@driftengine/core 4.1.2 → 4.1.4

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.
@@ -48,6 +48,64 @@ export declare const DEFAULT_TEXT_STYLE: TextStyle;
48
48
  /** Width of a string in pixels at a given cell size, without laying it out. */
49
49
  export declare function textWidthPx(text: string, cellSize: number): number;
50
50
  export declare function textHeightPx(cellSize: number): number;
51
+ /**
52
+ * The cell size to draw at, so every cell of a bitmap glyph covers whole device pixels.
53
+ *
54
+ * **A 5x7 face draws every stroke exactly one cell wide, so a fractional cell is not a slightly
55
+ * softer glyph — it is a glyph whose strokes are different widths.** The rasteriser gives one
56
+ * column of cells four pixels and the next three, and a face carrying its legibility entirely in
57
+ * uniform strokes comes apart. Reported three times as text "eaten away" in a checkerboard while
58
+ * larger text in the same frame stayed solid, which is the size dependence this explains: a whole
59
+ * cell is uniform and a fractional one is not, and nothing about it is a dropped pixel.
60
+ *
61
+ * Two doors reach a fractional cell, which is why it was reported as unrelated faults. A caller
62
+ * asks for one outright, sizing a second line at a fraction of its headline; or asks for a whole
63
+ * one that the device ratio makes fractional, a 3-pixel cell at a 1.25 ratio being 3.75 on the
64
+ * grid. `viewportWidth` is whatever units the caller lays out in — CSS pixels for an overlay
65
+ * sized from `cssWidth`, device pixels for one sized from the canvas — and `bufferWidth` is the
66
+ * drawing buffer, so the ratio between them is exactly what turns one into the other.
67
+ *
68
+ * **What it gives up is the asked size.** The cell drawn is the largest whole number of device
69
+ * pixels that is not larger than the one asked for, so a string is drawn up to one device pixel
70
+ * per cell narrower than `textWidthPx` measures it. Down rather than to nearest, because a caller
71
+ * that fitted a line to a box measured it first: rounding up would draw a line wider than the box
72
+ * it was fitted to, and rounding down can only leave a gap.
73
+ *
74
+ * **`textWidthPx` and `RendererApi.textWidth` are deliberately left unsnapped**, and that is the
75
+ * cost rather than an oversight. Neither is handed a viewport, so neither can know whether the
76
+ * caller lays out in CSS pixels or device ones — and this ratio is exactly that question. A width
77
+ * is therefore an upper bound on what is drawn: a centred line sits up to half the shortfall off
78
+ * centre, at most half a device pixel per cell. Handing the measurement a viewport would fix it
79
+ * and is a change to the public surface rather than to a patch.
80
+ *
81
+ * **What would make it wrong** is a face whose strokes are not cell-aligned — an SDF atlas, where
82
+ * the glyph is a distance field and a fractional size is exactly what it is built to serve.
83
+ * `sdfTextLayout.ts` is that path and does not come through here.
84
+ */
85
+ export declare function deviceSnappedCellSize(cellSize: number, viewportWidth: number, bufferWidth: number): number;
86
+ /**
87
+ * The same grid's **phase**, which is the other half of the same defect.
88
+ *
89
+ * **A whole pitch on a fractional origin still draws strokes of two widths.** Snapping the cell to
90
+ * four device pixels puts the boundaries four apart, and starting them at 10.4 puts every boundary
91
+ * at `.4` — so each stroke covers three whole pixels and two halves, and the rasteriser resolves
92
+ * that the same ragged way it resolved a fractional cell. The pitch decides how far apart the
93
+ * boundaries are; this decides where the first one is, and a face of uniform strokes needs both.
94
+ *
95
+ * It was found the way the first half was: the cell snap shipped, and the reporter said the text was
96
+ * still wrong.
97
+ *
98
+ * **To nearest rather than down**, which is the opposite of the cell above and for the opposite
99
+ * reason: this is a position, not a size. Moving a label up to half a device pixel is the smallest
100
+ * change that puts its grid on the display's, while flooring would shift every label the same way
101
+ * and bias a centred one off centre.
102
+ *
103
+ * **What would make it wrong** is a caller animating an origin sub-pixel on purpose — a label that
104
+ * slides smoothly now steps by a device pixel. For a face whose strokes are one pixel wide that is
105
+ * the trade this whole function exists to make, and `sdfTextLayout.ts`, which does not come through
106
+ * here, is the path for anything that wants the other answer.
107
+ */
108
+ export declare function deviceSnappedOrigin(value: number, viewportWidth: number, bufferWidth: number): number;
51
109
  export declare class TextLayout {
52
110
  /** Reused every upload; text changes far too often to allocate per change. */
53
111
  readonly cells: Float32Array<ArrayBuffer>;
@@ -38,6 +38,89 @@ export function textWidthPx(text, cellSize) {
38
38
  export function textHeightPx(cellSize) {
39
39
  return GLYPH_HEIGHT * cellSize;
40
40
  }
41
+ /**
42
+ * The cell size to draw at, so every cell of a bitmap glyph covers whole device pixels.
43
+ *
44
+ * **A 5x7 face draws every stroke exactly one cell wide, so a fractional cell is not a slightly
45
+ * softer glyph — it is a glyph whose strokes are different widths.** The rasteriser gives one
46
+ * column of cells four pixels and the next three, and a face carrying its legibility entirely in
47
+ * uniform strokes comes apart. Reported three times as text "eaten away" in a checkerboard while
48
+ * larger text in the same frame stayed solid, which is the size dependence this explains: a whole
49
+ * cell is uniform and a fractional one is not, and nothing about it is a dropped pixel.
50
+ *
51
+ * Two doors reach a fractional cell, which is why it was reported as unrelated faults. A caller
52
+ * asks for one outright, sizing a second line at a fraction of its headline; or asks for a whole
53
+ * one that the device ratio makes fractional, a 3-pixel cell at a 1.25 ratio being 3.75 on the
54
+ * grid. `viewportWidth` is whatever units the caller lays out in — CSS pixels for an overlay
55
+ * sized from `cssWidth`, device pixels for one sized from the canvas — and `bufferWidth` is the
56
+ * drawing buffer, so the ratio between them is exactly what turns one into the other.
57
+ *
58
+ * **What it gives up is the asked size.** The cell drawn is the largest whole number of device
59
+ * pixels that is not larger than the one asked for, so a string is drawn up to one device pixel
60
+ * per cell narrower than `textWidthPx` measures it. Down rather than to nearest, because a caller
61
+ * that fitted a line to a box measured it first: rounding up would draw a line wider than the box
62
+ * it was fitted to, and rounding down can only leave a gap.
63
+ *
64
+ * **`textWidthPx` and `RendererApi.textWidth` are deliberately left unsnapped**, and that is the
65
+ * cost rather than an oversight. Neither is handed a viewport, so neither can know whether the
66
+ * caller lays out in CSS pixels or device ones — and this ratio is exactly that question. A width
67
+ * is therefore an upper bound on what is drawn: a centred line sits up to half the shortfall off
68
+ * centre, at most half a device pixel per cell. Handing the measurement a viewport would fix it
69
+ * and is a change to the public surface rather than to a patch.
70
+ *
71
+ * **What would make it wrong** is a face whose strokes are not cell-aligned — an SDF atlas, where
72
+ * the glyph is a distance field and a fractional size is exactly what it is built to serve.
73
+ * `sdfTextLayout.ts` is that path and does not come through here.
74
+ */
75
+ export function deviceSnappedCellSize(cellSize, viewportWidth, bufferWidth) {
76
+ const ratio = bufferWidth / viewportWidth;
77
+ if (!Number.isFinite(ratio) || ratio <= 0)
78
+ return cellSize;
79
+ const device = cellSize * ratio;
80
+ if (!Number.isFinite(device))
81
+ return cellSize;
82
+ /*
83
+ * The tolerance is against float error rather than a fudge: a ratio of 1 turns an asked 4 into
84
+ * 3.9999999999999996 often enough, and flooring that would shrink every whole cell on every
85
+ * ordinary display by a pixel — the change doing most harm where there was nothing to fix.
86
+ */
87
+ const whole = Math.floor(device + 1e-6);
88
+ /* Below one device pixel there is no whole size to snap to, and flooring would draw nothing. */
89
+ if (whole < 1)
90
+ return cellSize;
91
+ return whole / ratio;
92
+ }
93
+ /**
94
+ * The same grid's **phase**, which is the other half of the same defect.
95
+ *
96
+ * **A whole pitch on a fractional origin still draws strokes of two widths.** Snapping the cell to
97
+ * four device pixels puts the boundaries four apart, and starting them at 10.4 puts every boundary
98
+ * at `.4` — so each stroke covers three whole pixels and two halves, and the rasteriser resolves
99
+ * that the same ragged way it resolved a fractional cell. The pitch decides how far apart the
100
+ * boundaries are; this decides where the first one is, and a face of uniform strokes needs both.
101
+ *
102
+ * It was found the way the first half was: the cell snap shipped, and the reporter said the text was
103
+ * still wrong.
104
+ *
105
+ * **To nearest rather than down**, which is the opposite of the cell above and for the opposite
106
+ * reason: this is a position, not a size. Moving a label up to half a device pixel is the smallest
107
+ * change that puts its grid on the display's, while flooring would shift every label the same way
108
+ * and bias a centred one off centre.
109
+ *
110
+ * **What would make it wrong** is a caller animating an origin sub-pixel on purpose — a label that
111
+ * slides smoothly now steps by a device pixel. For a face whose strokes are one pixel wide that is
112
+ * the trade this whole function exists to make, and `sdfTextLayout.ts`, which does not come through
113
+ * here, is the path for anything that wants the other answer.
114
+ */
115
+ export function deviceSnappedOrigin(value, viewportWidth, bufferWidth) {
116
+ const ratio = bufferWidth / viewportWidth;
117
+ if (!Number.isFinite(ratio) || ratio <= 0)
118
+ return value;
119
+ const device = value * ratio;
120
+ if (!Number.isFinite(device))
121
+ return value;
122
+ return Math.round(device) / ratio;
123
+ }
41
124
  export class TextLayout {
42
125
  /** Reused every upload; text changes far too often to allocate per change. */
43
126
  cells = new Float32Array(MAX_CELLS * 2);
@@ -1,4 +1,4 @@
1
- import { MAX_CELLS, TEXT_CUBE, TextLayout } from './textLayout.js';
1
+ import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedCellSize, deviceSnappedOrigin, } from './textLayout.js';
2
2
  import { compileProgram, uniformLocations } from './shader.js';
3
3
  import { TEXT_FRAG, TEXT_VERT } from './shaders/text.js';
4
4
  /**
@@ -100,8 +100,12 @@ export class TextRenderer {
100
100
  gl.enable(gl.BLEND);
101
101
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
102
102
  setVec2(gl, this.uniforms['uViewport'], viewportWidth, viewportHeight);
103
- setVec2(gl, this.uniforms['uOrigin'], originX, originY);
104
- setFloat(gl, this.uniforms['uCellSize'], style.cellSize);
103
+ /* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
104
+ See `deviceSnappedOrigin`. */
105
+ setVec2(gl, this.uniforms['uOrigin'], deviceSnappedOrigin(originX, viewportWidth, gl.drawingBufferWidth), deviceSnappedOrigin(originY, viewportHeight, gl.drawingBufferHeight));
106
+ /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
107
+ decision is shared with WebGPU; only this binding is per-backend. See `textLayout.ts`. */
108
+ setFloat(gl, this.uniforms['uCellSize'], deviceSnappedCellSize(style.cellSize, viewportWidth, gl.drawingBufferWidth));
105
109
  // Far enough that a whole line barely converges, near enough that a rotating character
106
110
  // reads as turning rather than shearing.
107
111
  setFloat(gl, this.uniforms['uDepth'], Math.max(viewportWidth, 600) * 1.4);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.1.2",
3
+ "version": "4.1.4",
4
4
  "description": "DriftEngine reusable browser 3D runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.1.2",
33
- "@driftengine/physics": "4.1.2",
32
+ "@driftengine/drft": "4.1.4",
33
+ "@driftengine/physics": "4.1.4",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -63,8 +63,8 @@
63
63
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
64
64
  },
65
65
  "devDependencies": {
66
- "@driftengine/nav": "4.1.2",
67
- "@driftengine/network": "4.1.2",
68
- "@driftengine/texture": "4.1.2"
66
+ "@driftengine/nav": "4.1.4",
67
+ "@driftengine/network": "4.1.4",
68
+ "@driftengine/texture": "4.1.4"
69
69
  }
70
70
  }
package/src/index.ts CHANGED
@@ -231,8 +231,18 @@ export type {
231
231
  * A consumer who names neither pays nothing: `core-only` is measured with these exported and does
232
232
  * not move, because nothing in the forward path reaches them.
233
233
  *
234
- * It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
235
- * the standard material. `DRAFT_SCENES`, never `SCENES`, until it draws the six published ones.
234
+ * **It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
235
+ * the standard material**, and that is the sentence to read before reaching for it. There is no
236
+ * flag that routes `drawMesh` through this: a consumer packs a scene and contributes the pass, and
237
+ * `setMaterial`, `setSurfaceGrain`, `setSurfaceRelief` and a skin palette reach none of it. What
238
+ * would change that is a second copy of the standard material, which `gpuDrivenPass.ts` refuses in
239
+ * its own header.
240
+ *
241
+ * **The line that used to be here said `DRAFT_SCENES`, never `SCENES`, and it went stale the day
242
+ * the city was published.** `city` is in `SCENES` and says `pipelines: ['gpu-driven']`, so a host
243
+ * that cannot run the second pipeline reads that from the list rather than from a mount that
244
+ * refuses. The rule it was standing in for is the one above: what gates a scene is whether this
245
+ * pass can draw it, not which array it is in.
236
246
  */
237
247
  export {
238
248
  GPU_DRIVEN_LISTS,
@@ -723,6 +723,46 @@ export class GpuDrivenPass implements PassDefinition {
723
723
  private oitAccumView: GPUTextureView | null = null;
724
724
  private oitRevealView: GPUTextureView | null = null;
725
725
  private blendPipeline: GPURenderPipeline | null = null;
726
+ /**
727
+ * Whether the device has actually accepted the blend pipeline.
728
+ *
729
+ * **False until it says so, and 4.1.2 had this the wrong way round.** That version built the
730
+ * pipeline, bound a group against its layout, encoded the pass, and disowned the handle when the
731
+ * error scope came back — but a scope comes back on a microtask, after all three. The reporter's
732
+ * next run carried the new warning *and* every error it was meant to prevent: `GetBindGroupLayout`
733
+ * on an invalid pipeline, a bind group against an invalid layout, a `SetPipeline` with it, and a
734
+ * refused submit.
735
+ *
736
+ * Nothing touches the handle until this is true. The cost is no transparency for the frames
737
+ * before the device answers, which nobody sees; the alternative is a frame that draws nothing.
738
+ */
739
+ private blendUsable = false;
740
+ /**
741
+ * The same question about *every other* pipeline in this pass, and the reason it exists is that
742
+ * 4.1.3 asked it about one.
743
+ *
744
+ * **The guard above was written from a log that named `gpu-driven blend`, and the four render
745
+ * pipelines and eight compute pipelines beside it were left with the shape that caused the
746
+ * report.** Reported again from an Android handset once that fix shipped: the transparent pass
747
+ * now skips itself correctly, the overlay still draws and the frame counter still counts — and
748
+ * the world is black, which is what an invalid *opaque* pipeline looks like once the blended one
749
+ * has stopped taking the command buffer with it.
750
+ *
751
+ * So the whole of `buildPipelines` is built inside one pair of scopes and nothing this pass
752
+ * encodes runs until they come back clean. The blend pipeline pushes its own pair *inside* that
753
+ * region, so the two verdicts stay separate: a device that refuses only the transparent pipeline
754
+ * still draws everything opaque, and a device that refuses anything else turns this pass off
755
+ * rather than encoding against a handle the driver has already rejected.
756
+ *
757
+ * **What it gives up** is the first frames, which draw nothing while the device is still
758
+ * answering. **What would make it wrong** is a device that never resolves an error scope; the
759
+ * `popErrorScope` check below is the same concession the blend guard makes, for the same reason.
760
+ */
761
+ private usable = false;
762
+ /** Why this pass refused itself, in the words the device used, or null while it has not. */
763
+ private refusal: string | null = null;
764
+ /** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
765
+ private makeBlendGroup: (() => GPUBindGroup) | null = null;
726
766
  private blendResolvePipeline: GPURenderPipeline | null = null;
727
767
  private blendLodGroup: GPUBindGroup | null = null;
728
768
  private blendCompactGroup: GPUBindGroup | null = null;
@@ -981,7 +1021,18 @@ export class GpuDrivenPass implements PassDefinition {
981
1021
  });
982
1022
  }
983
1023
  this.buildBuffers(device.device);
1024
+ /*
1025
+ * **Every pipeline this pass owns, inside one pair of scopes.** See `usable`: the blend
1026
+ * pipeline pushes its own pair within this region, so its refusal stays its own and any other
1027
+ * refusal is caught here and turns the pass off instead of reaching an encoder.
1028
+ */
1029
+ const scoped = typeof device.device.pushErrorScope === 'function';
1030
+ if (scoped) {
1031
+ device.device.pushErrorScope('validation');
1032
+ device.device.pushErrorScope('internal');
1033
+ }
984
1034
  this.buildPipelines(device.device, device.format, device.samples);
1035
+ this.watchPipelines(device.device, scoped);
985
1036
  if (this.width > 0) this.buildTargets(device.device);
986
1037
  }
987
1038
 
@@ -1311,17 +1362,83 @@ export class GpuDrivenPass implements PassDefinition {
1311
1362
  * this came from was five errors that were all the same one.
1312
1363
  */
1313
1364
  private watchBlendPipeline(device: GPUDevice): void {
1314
- const refused = (error: GPUError | null, scope: string): void => {
1315
- if (error === null || this.blendPipeline === null) return;
1316
- this.blendPipeline = null;
1317
- this.blendRasterGroup = null;
1318
- console.warn(
1319
- `[driftengine] this device refused the gpu-driven blend pipeline (${scope}), so the ` +
1320
- `transparent pass is off and everything opaque still draws: ${error.message}`,
1321
- );
1365
+ /*
1366
+ * **A device with no error scopes is taken at its word.** Nothing can be asked of it, and
1367
+ * refusing transparency forever on a device that never said no would be a worse failure than
1368
+ * the one this guards: a frame that quietly loses its glass on hardware that could draw it.
1369
+ */
1370
+ if (typeof device.popErrorScope !== 'function') {
1371
+ this.blendUsable = true;
1372
+ if (this.makeBlendGroup !== null) this.blendRasterGroup = this.makeBlendGroup();
1373
+ return;
1374
+ }
1375
+
1376
+ let outstanding = 2;
1377
+ const settle = (error: GPUError | null, scope: string): void => {
1378
+ outstanding -= 1;
1379
+ if (error !== null && this.blendPipeline !== null) {
1380
+ this.blendPipeline = null;
1381
+ this.blendRasterGroup = null;
1382
+ this.makeBlendGroup = null;
1383
+ console.warn(
1384
+ `[driftengine] this device refused the gpu-driven blend pipeline (${scope}), so the ` +
1385
+ `transparent pass is off and everything opaque still draws: ${error.message}`,
1386
+ );
1387
+ }
1388
+ /* Usable only once both scopes have come back clean, and only then is anything bound. */
1389
+ if (outstanding > 0 || this.blendPipeline === null) return;
1390
+ this.blendUsable = true;
1391
+ if (this.makeBlendGroup !== null) this.blendRasterGroup = this.makeBlendGroup();
1392
+ };
1393
+ void device.popErrorScope().then((error) => settle(error, 'internal'));
1394
+ void device.popErrorScope().then((error) => settle(error, 'validation'));
1395
+ }
1396
+
1397
+ /**
1398
+ * Close the scopes every other pipeline was built in, and refuse the pass where one came back.
1399
+ *
1400
+ * **This is `watchBlendPipeline` widened to the rest of the pass**, and the reason it is separate
1401
+ * is that the two refusals mean different things: a blend pipeline nobody can compile costs the
1402
+ * glass, and a raster, shadow, blit or compute pipeline nobody can compile costs the world. The
1403
+ * first degrades; the second has nothing to degrade to, so it says so and draws nothing rather
1404
+ * than encoding a command buffer the driver will reject whole.
1405
+ *
1406
+ * A device with no scopes is taken at its word, exactly as the blend guard takes it: refusing to
1407
+ * draw on hardware that never said no is the worse of the two failures.
1408
+ */
1409
+ private watchPipelines(device: GPUDevice, scoped: boolean): void {
1410
+ if (!scoped || typeof device.popErrorScope !== 'function') {
1411
+ this.usable = true;
1412
+ return;
1413
+ }
1414
+ let outstanding = 2;
1415
+ const settle = (error: GPUError | null, scope: string): void => {
1416
+ outstanding -= 1;
1417
+ if (error !== null && this.refusal === null) {
1418
+ this.refusal = error.message;
1419
+ console.warn(
1420
+ `[driftengine] this device refused a gpu-driven pipeline (${scope}), so this pass draws ` +
1421
+ `nothing and the rest of the frame is unaffected: ${error.message}`,
1422
+ );
1423
+ }
1424
+ /* Usable only once both have come back clean, which is what keeps an invalid handle out. */
1425
+ if (outstanding > 0 || this.refusal !== null) return;
1426
+ this.usable = true;
1322
1427
  };
1323
- void device.popErrorScope().then((error) => refused(error, 'internal'));
1324
- void device.popErrorScope().then((error) => refused(error, 'validation'));
1428
+ void device.popErrorScope().then((error) => settle(error, 'internal'));
1429
+ void device.popErrorScope().then((error) => settle(error, 'validation'));
1430
+ }
1431
+
1432
+ /**
1433
+ * Why this pass is drawing nothing, in the words the device used, or null where it is not.
1434
+ *
1435
+ * **Exposed because a black frame that says nothing is the bug this was reported as, twice.** A
1436
+ * pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend
1437
+ * can run this at all, so there is no earlier place to throw — and a scene that can read this can
1438
+ * tell somebody what happened instead of presenting an empty picture.
1439
+ */
1440
+ refusedBecause(): string | null {
1441
+ return this.refusal;
1325
1442
  }
1326
1443
 
1327
1444
  private buildPipelines(device: GPUDevice, format: GPUTextureFormat, samples: number): void {
@@ -1449,8 +1566,11 @@ export class GpuDrivenPass implements PassDefinition {
1449
1566
  * backends and as validation on others, and this is exactly the case where guessing which
1450
1567
  * costs the whole frame.
1451
1568
  */
1452
- device.pushErrorScope('validation');
1453
- device.pushErrorScope('internal');
1569
+ /* Only where the device offers them: see `watchBlendPipeline` for the case where it does not. */
1570
+ if (typeof device.pushErrorScope === 'function') {
1571
+ device.pushErrorScope('validation');
1572
+ device.pushErrorScope('internal');
1573
+ }
1454
1574
  this.blendPipeline = device.createRenderPipeline({
1455
1575
  label: 'gpu-driven blend',
1456
1576
  layout: 'auto',
@@ -2059,41 +2179,45 @@ export class GpuDrivenPass implements PassDefinition {
2059
2179
 
2060
2180
  /* Everything the transparent raster reads: the same scene, the same frame, its own list. */
2061
2181
  /*
2062
- * Nothing to bind against where the device refused the pipeline, and the resolve below still
2063
- * wants building — it is a different pipeline and this one's absence is not its problem. An
2064
- * early return here would have taken it with us, which is the same shape of mistake as the
2065
- * cascade this guard exists to stop.
2182
+ * Held rather than built, because whether the pipeline is usable is not known yet: the error
2183
+ * scopes settle on a microtask and this runs synchronously. `watchBlendPipeline` runs it once
2184
+ * the device has answered, and never if the answer was no.
2066
2185
  */
2067
- this.blendRasterGroup =
2068
- this.blendPipeline === null
2069
- ? null
2070
- : device.createBindGroup({
2071
- layout: this.blendPipeline.getBindGroupLayout(0),
2072
- entries: [
2073
- { binding: 0, resource: { buffer: this.viewProjBuffer as GPUBuffer } },
2074
- { binding: 1, resource: { buffer: this.blendList as GPUBuffer } },
2075
- { binding: 2, resource: { buffer: this.clusterMeta as GPUBuffer } },
2076
- { binding: 3, resource: { buffer: this.indices as GPUBuffer } },
2077
- { binding: 4, resource: { buffer: this.vertices as GPUBuffer } },
2078
- { binding: 5, resource: { buffer: this.transformBuffer as GPUBuffer } },
2079
- { binding: 6, resource: { buffer: this.materialOf as GPUBuffer } },
2080
- { binding: 7, resource: { buffer: this.materialTable as GPUBuffer } },
2081
- { binding: 8, resource: { buffer: this.frameBuffer as GPUBuffer } },
2082
- {
2083
- binding: 9,
2084
- resource: this.environmentView ?? (this.emptyEnvironmentView as GPUTextureView),
2085
- },
2086
- {
2087
- binding: 10,
2088
- resource: this.environmentSampler ?? (this.emptySampler as GPUSampler),
2089
- },
2090
- { binding: 11, resource: this.latentsView as GPUTextureView },
2091
- { binding: 12, resource: this.latentClamp as GPUSampler },
2092
- { binding: 13, resource: this.latentRepeat as GPUSampler },
2093
- { binding: 14, resource: { buffer: this.decodeNodes as GPUBuffer } },
2094
- { binding: 15, resource: { buffer: this.decodeWeights as GPUBuffer } },
2095
- ],
2096
- });
2186
+ this.makeBlendGroup = (): GPUBindGroup =>
2187
+ device.createBindGroup({
2188
+ layout: (this.blendPipeline as GPURenderPipeline).getBindGroupLayout(0),
2189
+ entries: [
2190
+ { binding: 0, resource: { buffer: this.viewProjBuffer as GPUBuffer } },
2191
+ { binding: 1, resource: { buffer: this.blendList as GPUBuffer } },
2192
+ { binding: 2, resource: { buffer: this.clusterMeta as GPUBuffer } },
2193
+ { binding: 3, resource: { buffer: this.indices as GPUBuffer } },
2194
+ { binding: 4, resource: { buffer: this.vertices as GPUBuffer } },
2195
+ { binding: 5, resource: { buffer: this.transformBuffer as GPUBuffer } },
2196
+ { binding: 6, resource: { buffer: this.materialOf as GPUBuffer } },
2197
+ { binding: 7, resource: { buffer: this.materialTable as GPUBuffer } },
2198
+ { binding: 8, resource: { buffer: this.frameBuffer as GPUBuffer } },
2199
+ {
2200
+ binding: 9,
2201
+ resource: this.environmentView ?? (this.emptyEnvironmentView as GPUTextureView),
2202
+ },
2203
+ {
2204
+ binding: 10,
2205
+ resource: this.environmentSampler ?? (this.emptySampler as GPUSampler),
2206
+ },
2207
+ { binding: 11, resource: this.latentsView as GPUTextureView },
2208
+ { binding: 12, resource: this.latentClamp as GPUSampler },
2209
+ { binding: 13, resource: this.latentRepeat as GPUSampler },
2210
+ { binding: 14, resource: { buffer: this.decodeNodes as GPUBuffer } },
2211
+ { binding: 15, resource: { buffer: this.decodeWeights as GPUBuffer } },
2212
+ ],
2213
+ });
2214
+
2215
+ /*
2216
+ * And build it now where the device has already answered, which is the ordinary case: the
2217
+ * verdict lands before this runs on a device with no error scopes, and after it on one that
2218
+ * has them. Whichever order, exactly one of the two places builds the group.
2219
+ */
2220
+ if (this.blendUsable) this.blendRasterGroup = this.makeBlendGroup();
2097
2221
  this.blendResolveGroup = device.createBindGroup({
2098
2222
  layout: (this.blendResolvePipeline as GPURenderPipeline).getBindGroupLayout(0),
2099
2223
  entries: [
@@ -2378,6 +2502,8 @@ export class GpuDrivenPass implements PassDefinition {
2378
2502
 
2379
2503
  prepare(ctx: PrepareContext): void {
2380
2504
  if (ctx.backend !== 'webgpu') return;
2505
+ /* Nothing is encoded until the device has answered for the pipelines. See `usable`. */
2506
+ if (!this.usable) return;
2381
2507
  const device = this.device;
2382
2508
  if (device === null || this.levels.length === 0) return;
2383
2509
  /* Before `writeFrame`, which reads what this took, and before the groups it may invalidate. */
@@ -2579,7 +2705,8 @@ export class GpuDrivenPass implements PassDefinition {
2579
2705
  * Everything opaque has already been drawn by the time this runs, so what is lost is the
2580
2706
  * glass and not the city. See `watchBlendPipeline`.
2581
2707
  */
2582
- if (this.blendPipeline === null || this.blendRasterGroup === null) return;
2708
+ if (!this.blendUsable || this.blendPipeline === null || this.blendRasterGroup === null)
2709
+ return;
2583
2710
  const pass = encoder.beginRenderPass({
2584
2711
  label: 'gpu-driven blend draw',
2585
2712
  colorAttachments: [
@@ -2615,6 +2742,25 @@ export class GpuDrivenPass implements PassDefinition {
2615
2742
  return;
2616
2743
  }
2617
2744
  case 'blendResolve': {
2745
+ /*
2746
+ * **Skipped on the same verdict as the draw, because the draw is what clears what this
2747
+ * reads.** This is the whole of the reported black frame, and the line above it predicted
2748
+ * it: the blend pass clears reveal to one, meaning nothing has covered this pixel yet, and
2749
+ * says in as many words that cleared to zero "the resolve would show no scene anywhere the
2750
+ * pass ran, which is a black frame that looks like the blend working".
2751
+ *
2752
+ * 4.1.2 skipped the draw and took its clear with it, and left this running against textures
2753
+ * nothing had written — a reveal of zero over every pixel, which is exactly "fully covered".
2754
+ * Reported from a Galaxy S23 Ultra and then an Adreno 740, both of which refuse only
2755
+ * `gpu-driven blend` and both of which drew the city into a buffer this then wiped: the
2756
+ * page's own readout said 43 fps and 1,087 clusters drawn while the screen was black.
2757
+ *
2758
+ * There is nothing to composite when nothing was rasterised, so the honest answer is not to
2759
+ * composite. **What would make it wrong** is a caller that expects this pass to have run for
2760
+ * some other reason; nothing does, and the opaque image is already in `colourView` before
2761
+ * this would load it.
2762
+ */
2763
+ if (!this.blendUsable) return;
2618
2764
  const pass = encoder.beginRenderPass({
2619
2765
  label: 'gpu-driven blend resolve',
2620
2766
  colorAttachments: [
@@ -2945,6 +3091,8 @@ export class GpuDrivenPass implements PassDefinition {
2945
3091
 
2946
3092
  draw(ctx: PassContext): void {
2947
3093
  if (ctx.backend !== 'webgpu') return;
3094
+ /* The same gate as `prepare`: an unanswered or refused pipeline set encodes nothing at all. */
3095
+ if (!this.usable) return;
2948
3096
  if (this.blitPipeline === null || this.blitGroup === null) return;
2949
3097
  if (ctx.outputTransform !== 0 && !this.gradeWarned) {
2950
3098
  this.gradeWarned = true;
@@ -150,6 +150,7 @@ import {
150
150
  type GpuTextCube,
151
151
  } from './textPass.ts';
152
152
  import type { TextStyle } from '../../textLayout.ts';
153
+ import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.ts';
153
154
  import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
154
155
  import {
155
156
  createSdfTextBindGroup,
@@ -3178,8 +3179,19 @@ export class WebGPURenderer implements RendererApi {
3178
3179
  const at = (name: string): number => TEXT_VERT_FIELDS[name]?.offset ?? 0;
3179
3180
  const v = this.textVerts;
3180
3181
  v.writeFloats(vertexSlot, at('uViewport'), [viewportWidth, viewportHeight]);
3181
- v.writeFloats(vertexSlot, at('uOrigin'), [originX, originY]);
3182
- v.writeFloat(vertexSlot, at('uCellSize'), style.cellSize);
3182
+ /* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
3183
+ See `deviceSnappedOrigin`. */
3184
+ v.writeFloats(vertexSlot, at('uOrigin'), [
3185
+ deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
3186
+ deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
3187
+ ]);
3188
+ /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
3189
+ decision is shared with WebGL2; only this binding is per-backend. See `textLayout.ts`. */
3190
+ v.writeFloat(
3191
+ vertexSlot,
3192
+ at('uCellSize'),
3193
+ deviceSnappedCellSize(style.cellSize, viewportWidth, this.surface.canvas.width),
3194
+ );
3183
3195
  /* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
3184
3196
  v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
3185
3197
  v.writeFloat(vertexSlot, at('uReveal'), style.reveal);