@driftengine/core 4.1.3 → 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.
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
  /*
@@ -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) {
@@ -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);