@driftengine/core 4.1.0 → 4.1.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.
package/CHANGELOG.json CHANGED
@@ -1,4 +1,31 @@
1
1
  [
2
+ {
3
+ "version": "4.1.2",
4
+ "date": "2026-09-20",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "short": "A device that refuses the transparent pipeline loses the glass, not the frame. It was taking the whole command buffer with it, so a phone drew nothing where it could have drawn everything else.",
9
+ "text": "**Reported from a Galaxy S23 Ultra, on the published site.** `CreateGraphicsPipelines failed with VK_ERROR_UNKNOWN` for `gpu-driven blend`, and then four more errors that were all the same one: an invalid pipeline makes an invalid bind group, which makes an invalid encoder, which makes an invalid command buffer. **The city drew nothing, on a device that could have drawn all of it but the glass.**\n\n**`createRenderPipeline` does not throw where a driver refuses it.** It hands back an *invalid* handle and reports asynchronously, so there is nothing to catch at the call and the code after it carried on: a bind group built from the invalid layout, a pass set to the invalid pipeline, a submit of the invalid buffer. Two `as GPURenderPipeline` casts are where that assumption lived.\n\n**So the pipeline is built inside error scopes and disowned where one comes back.** Both scopes, validation and internal, because a shader a driver cannot compile is reported as one on some backends and the other elsewhere, and this is exactly the case where guessing costs the whole frame. The transparent pass then skips itself and everything opaque still draws. The warning is said once, in the words the device used.\n\n**The check that went with it had to be rewritten before it meant anything.** The first version asserted that no transparent draw was encoded, and it could not have failed: the fixture carries nothing transparent, so that draw is absent either way and it passed over an unfixed pass. What is observable is the warning and the frame, so that is what it asserts, and perturbing the disown turns it red.\n\n**What is not fixed is why that driver refuses the pipeline.** Its targets are `rgba16float` and `r8unorm` under `layout: auto`, and diagnosing `VK_ERROR_UNKNOWN` needs the part that produces it. This is the containment: a refusal costs what it is about."
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "version": "4.1.1",
15
+ "date": "2026-09-20",
16
+ "entries": [
17
+ {
18
+ "kind": "added",
19
+ "short": "Two adapters, so the inspector and the network panel have something to read. An entity world becomes inspectable and a lockstep session becomes a readout, both typed by shape rather than by import.",
20
+ "text": "**A panel needs its world in the shape the panel reads, and two of those shapes are the same in every game that has the thing behind them.** `createToolsOverlay` put the panels on screen in 4.1.0 and left every consumer to write the same two bridges, which is the drift this repository opens by describing, one layer in.\n\n**`entitiesInspectable(world, types)` turns an entity world into an `InspectableWorld`.** The inspector addresses a component by name, because a name is what a row carries; a world addresses it by the type object, because that is what indexes its stores. Bridging them is seven forwarding methods and a lookup. **A name with no type behind it does nothing rather than guessing**, so a row built against a stale schema cannot write a field into a component the world does not have and report success, which is a silent loss of an edit somebody just made and watched apply.\n\n**`createSessionRecorder`, `observeSession` and `sessionReadout` turn a lockstep session into a `NetworkReadout`.** The watching is the half worth writing once: `session.desync` is **latched rather than an event**, so it keeps answering with the same disagreement on every frame until the next one arrives, and a recorder that appended what it read would turn one divergence into sixty a second and bury the tick somebody is looking for under copies of itself. `snapshotBytes` is the caller's, because `RewindLoop` is generic over the state it snapshots and cannot know the size of one; a caller passing zero is saying it has not measured.\n\n**Both are typed structurally rather than imported**, the arrangement `@driftengine/drft` already has with the DTEX types `@driftengine/texture` satisfies. So this package gains no dependency, the release is the same ninety five places it was, and a consumer whose state is not an entity world at all can still satisfy the shape.\n\n**5,391 bytes gzipped, from 5,105, and 286 of that is these two.** Named in the size fixture in the same commit, which is the whole point of the paragraph the last release added to that file."
21
+ },
22
+ {
23
+ "kind": "fixed",
24
+ "short": "The city scene fits the device it is on. A phone binds 128 MiB and the published reach asked for 145, so every handheld got a blank frame and a refusal in a console nobody had open.",
25
+ "text": "**Reported from a Galaxy S23 Ultra, on the published site: \"A city at dusk\" was a blank frame.** The scene asked for **152,314,560 bytes** as one storage binding and the device binds **134,217,728**. That is 128 MiB, the WebGPU default, and it is what most handhelds offer against the several gigabytes a desktop adapter does.\n\n**Nothing was misconfigured.** `select.ts` already asks the adapter for its ceiling on `maxStorageBufferBindingSize` rather than taking the default, so there was nothing left to raise: the city was simply larger than the part could hold. The refusal was correct, it named the number, and it reached a console rather than a person.\n\n**The scene sizes itself to the device now.** `reachForCeiling` steps down a ladder of reaches until the vertex buffer fits inside four fifths of what the adapter reports, leaving room for the pipeline's other storage bindings. An S23 Ultra gets a reach of 450 metres at 71% of its limit; a desktop adapter keeps the published 900. A smaller reach asked for with `?reach=` is a decision and is never raised to fill the room available.\n\n**The budget could not have fixed this.** The scene ignored the one it was handed, and honouring it would not have helped: a demos page passes `full` to every device and only a `?budget=` in the address says otherwise, so a phone never asked for less. Sizing against the limit the device reports needs no coordination with whoever mounts the scene.\n\n**`VERTEX_FLOATS` is exported for it.** A `StreamCapacity` is counts and what a device refuses is bytes, so whether a capacity fits is `vertices * VERTEX_FLOATS * 4` against the binding ceiling. Without it a consumer either hard-codes the stride or finds out by being refused, which is what this scene did on every phone."
26
+ }
27
+ ]
28
+ },
2
29
  {
3
30
  "version": "4.1.0",
4
31
  "date": "2026-09-20",
package/dist/index.d.ts CHANGED
@@ -73,7 +73,7 @@ export { OcclusionBuffer } from './render/occlusion.ts';
73
73
  export type { ComputeContext, ComputeDefinition, ComputeDevice, ComputeHandle, } from './render/compute.ts';
74
74
  export { createRenderer } from './render/backend/createRenderer.ts';
75
75
  export type { CreatedRenderer, CreateRendererOptions, RenderPipeline, } from './render/backend/createRenderer.ts';
76
- export { GPU_DRIVEN_LISTS, GpuDrivenPass, MATERIAL_FLOATS, } from './render/backend/webgpu/gpuDrivenPass.ts';
76
+ export { GPU_DRIVEN_LISTS, GpuDrivenPass, MATERIAL_FLOATS, VERTEX_FLOATS, } from './render/backend/webgpu/gpuDrivenPass.ts';
77
77
  export type { GpuDrivenList, GpuDrivenMaterial, GpuDrivenShadowOptions, GpuDrivenView, } from './render/backend/webgpu/gpuDrivenPass.ts';
78
78
  export { atmosphereFog, createFogTarget } from './render/fog.ts';
79
79
  export type { FogOptions, FogTarget } from './render/fog.ts';
package/dist/index.js CHANGED
@@ -105,7 +105,15 @@ export { createRenderer } from './render/backend/createRenderer.js';
105
105
  * It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
106
106
  * the standard material. `DRAFT_SCENES`, never `SCENES`, until it draws the six published ones.
107
107
  */
108
- export { GPU_DRIVEN_LISTS, GpuDrivenPass, MATERIAL_FLOATS, } from './render/backend/webgpu/gpuDrivenPass.js';
108
+ export { GPU_DRIVEN_LISTS, GpuDrivenPass, MATERIAL_FLOATS,
109
+ /*
110
+ * **The vertex stride, because a consumer sizing a streaming scene has to know it.** A
111
+ * `StreamCapacity` is counts, and what a device refuses is bytes: the pipeline binds the whole
112
+ * vertex buffer as one storage binding, so whether a capacity fits is `vertices * VERTEX_FLOATS
113
+ * * 4` against `maxStorageBufferBindingSize`. Without this a consumer either hard-codes 48 or
114
+ * finds out by being refused, and the published city scene did the second on every phone.
115
+ */
116
+ VERTEX_FLOATS, } from './render/backend/webgpu/gpuDrivenPass.js';
109
117
  /*
110
118
  * **One environment, one haze, on both pipelines.** `GpuDrivenView.fog` is the medium the forward
111
119
  * path binds, and a consumer drawing both in one frame fills it from `atmosphereFog` rather than
@@ -613,6 +613,18 @@ export declare class GpuDrivenPass implements PassDefinition {
613
613
  * origin so nothing ever said so. `StreamingScene` computes them when a mesh is placed.
614
614
  */
615
615
  private buildBuffers;
616
+ /**
617
+ * Close the two scopes the blend pipeline was built in, and disown it where the device refused.
618
+ *
619
+ * **Asynchronous because a pipeline failure is.** Nothing can be known at the call that creates
620
+ * it, so the first frames draw no transparency and then either it arrives or it never does. That
621
+ * is the honest order: a pane missing for two frames is invisible, and a frame missing entirely
622
+ * is what this exists to prevent.
623
+ *
624
+ * The warning is once, in the words the device used, because the whole difficulty of the report
625
+ * this came from was five errors that were all the same one.
626
+ */
627
+ private watchBlendPipeline;
616
628
  private buildPipelines;
617
629
  /**
618
630
  * The groups that name `history` and `drawn`, rebuilt when the two swap.
@@ -941,6 +941,29 @@ export class GpuDrivenPass {
941
941
  this.zeroHistory = this.empty(device, 'history zero', count * 4);
942
942
  this.historySink = this.empty(device, 'history sink', count * 4);
943
943
  }
944
+ /**
945
+ * Close the two scopes the blend pipeline was built in, and disown it where the device refused.
946
+ *
947
+ * **Asynchronous because a pipeline failure is.** Nothing can be known at the call that creates
948
+ * it, so the first frames draw no transparency and then either it arrives or it never does. That
949
+ * is the honest order: a pane missing for two frames is invisible, and a frame missing entirely
950
+ * is what this exists to prevent.
951
+ *
952
+ * The warning is once, in the words the device used, because the whole difficulty of the report
953
+ * this came from was five errors that were all the same one.
954
+ */
955
+ watchBlendPipeline(device) {
956
+ const refused = (error, scope) => {
957
+ if (error === null || this.blendPipeline === null)
958
+ return;
959
+ this.blendPipeline = null;
960
+ this.blendRasterGroup = null;
961
+ console.warn(`[driftengine] this device refused the gpu-driven blend pipeline (${scope}), so the ` +
962
+ `transparent pass is off and everything opaque still draws: ${error.message}`);
963
+ };
964
+ void device.popErrorScope().then((error) => refused(error, 'internal'));
965
+ void device.popErrorScope().then((error) => refused(error, 'validation'));
966
+ }
944
967
  buildPipelines(device, format, samples) {
945
968
  const compute = (label, code) => device.createComputePipeline({
946
969
  label: `gpu-driven ${label}`,
@@ -1045,6 +1068,23 @@ export class GpuDrivenPass {
1045
1068
  * hide. The opaque raster culls back faces, where a closed hull's far side is a wasted draw.
1046
1069
  */
1047
1070
  const blend = device.createShaderModule({ label: 'blend raster', code: BLEND_RASTER_WGSL });
1071
+ /*
1072
+ * **Built inside error scopes, because a driver that refuses this must not take the frame.**
1073
+ *
1074
+ * Reported from a Galaxy S23 Ultra: `CreateGraphicsPipelines failed with VK_ERROR_UNKNOWN`
1075
+ * for this pipeline, and then four more errors that were all the same one. `createRenderPipeline`
1076
+ * does not throw where a driver refuses — it hands back an *invalid* pipeline and reports
1077
+ * asynchronously — so the code that followed built a bind group from its layout, set it on a
1078
+ * pass, and submitted a command buffer, each of which is invalid because the one before it was.
1079
+ * The city drew **nothing**, on a device that could have drawn all of it but the glass.
1080
+ *
1081
+ * So the handle is disowned where the device refused it and the transparent pass skips itself.
1082
+ * Both scopes, because a shader a driver cannot compile is reported as internal on some
1083
+ * backends and as validation on others, and this is exactly the case where guessing which
1084
+ * costs the whole frame.
1085
+ */
1086
+ device.pushErrorScope('validation');
1087
+ device.pushErrorScope('internal');
1048
1088
  this.blendPipeline = device.createRenderPipeline({
1049
1089
  label: 'gpu-driven blend',
1050
1090
  layout: 'auto',
@@ -1077,6 +1117,7 @@ export class GpuDrivenPass {
1077
1117
  depthCompare: DEPTH_COMPARE,
1078
1118
  },
1079
1119
  });
1120
+ this.watchBlendPipeline(device);
1080
1121
  /*
1081
1122
  * **Premultiplied over, where `renderer.ts`'s composite is a lerp**, and `blendRaster.wgsl.ts`
1082
1123
  * carries why: this pipeline's colour target has a coverage mask in its alpha, which the blit
@@ -1585,30 +1626,42 @@ export class GpuDrivenPass {
1585
1626
  ],
1586
1627
  });
1587
1628
  /* Everything the transparent raster reads: the same scene, the same frame, its own list. */
1588
- this.blendRasterGroup = device.createBindGroup({
1589
- layout: this.blendPipeline.getBindGroupLayout(0),
1590
- entries: [
1591
- { binding: 0, resource: { buffer: this.viewProjBuffer } },
1592
- { binding: 1, resource: { buffer: this.blendList } },
1593
- { binding: 2, resource: { buffer: this.clusterMeta } },
1594
- { binding: 3, resource: { buffer: this.indices } },
1595
- { binding: 4, resource: { buffer: this.vertices } },
1596
- { binding: 5, resource: { buffer: this.transformBuffer } },
1597
- { binding: 6, resource: { buffer: this.materialOf } },
1598
- { binding: 7, resource: { buffer: this.materialTable } },
1599
- { binding: 8, resource: { buffer: this.frameBuffer } },
1600
- {
1601
- binding: 9,
1602
- resource: this.environmentView ?? this.emptyEnvironmentView,
1603
- },
1604
- { binding: 10, resource: this.environmentSampler ?? this.emptySampler },
1605
- { binding: 11, resource: this.latentsView },
1606
- { binding: 12, resource: this.latentClamp },
1607
- { binding: 13, resource: this.latentRepeat },
1608
- { binding: 14, resource: { buffer: this.decodeNodes } },
1609
- { binding: 15, resource: { buffer: this.decodeWeights } },
1610
- ],
1611
- });
1629
+ /*
1630
+ * Nothing to bind against where the device refused the pipeline, and the resolve below still
1631
+ * wants building — it is a different pipeline and this one's absence is not its problem. An
1632
+ * early return here would have taken it with us, which is the same shape of mistake as the
1633
+ * cascade this guard exists to stop.
1634
+ */
1635
+ this.blendRasterGroup =
1636
+ this.blendPipeline === null
1637
+ ? null
1638
+ : device.createBindGroup({
1639
+ layout: this.blendPipeline.getBindGroupLayout(0),
1640
+ entries: [
1641
+ { binding: 0, resource: { buffer: this.viewProjBuffer } },
1642
+ { binding: 1, resource: { buffer: this.blendList } },
1643
+ { binding: 2, resource: { buffer: this.clusterMeta } },
1644
+ { binding: 3, resource: { buffer: this.indices } },
1645
+ { binding: 4, resource: { buffer: this.vertices } },
1646
+ { binding: 5, resource: { buffer: this.transformBuffer } },
1647
+ { binding: 6, resource: { buffer: this.materialOf } },
1648
+ { binding: 7, resource: { buffer: this.materialTable } },
1649
+ { binding: 8, resource: { buffer: this.frameBuffer } },
1650
+ {
1651
+ binding: 9,
1652
+ resource: this.environmentView ?? this.emptyEnvironmentView,
1653
+ },
1654
+ {
1655
+ binding: 10,
1656
+ resource: this.environmentSampler ?? this.emptySampler,
1657
+ },
1658
+ { binding: 11, resource: this.latentsView },
1659
+ { binding: 12, resource: this.latentClamp },
1660
+ { binding: 13, resource: this.latentRepeat },
1661
+ { binding: 14, resource: { buffer: this.decodeNodes } },
1662
+ { binding: 15, resource: { buffer: this.decodeWeights } },
1663
+ ],
1664
+ });
1612
1665
  this.blendResolveGroup = device.createBindGroup({
1613
1666
  layout: this.blendResolvePipeline.getBindGroupLayout(0),
1614
1667
  entries: [
@@ -2028,6 +2081,13 @@ export class GpuDrivenPass {
2028
2081
  return;
2029
2082
  }
2030
2083
  case 'blendDraw': {
2084
+ /*
2085
+ * **Skipped where the device refused the pipeline, rather than drawn with an invalid one.**
2086
+ * Everything opaque has already been drawn by the time this runs, so what is lost is the
2087
+ * glass and not the city. See `watchBlendPipeline`.
2088
+ */
2089
+ if (this.blendPipeline === null || this.blendRasterGroup === null)
2090
+ return;
2031
2091
  const pass = encoder.beginRenderPass({
2032
2092
  label: 'gpu-driven blend draw',
2033
2093
  colorAttachments: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
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.0",
33
- "@driftengine/physics": "4.1.0",
32
+ "@driftengine/drft": "4.1.2",
33
+ "@driftengine/physics": "4.1.2",
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.0",
67
- "@driftengine/network": "4.1.0",
68
- "@driftengine/texture": "4.1.0"
66
+ "@driftengine/nav": "4.1.2",
67
+ "@driftengine/network": "4.1.2",
68
+ "@driftengine/texture": "4.1.2"
69
69
  }
70
70
  }
package/src/index.ts CHANGED
@@ -238,6 +238,14 @@ export {
238
238
  GPU_DRIVEN_LISTS,
239
239
  GpuDrivenPass,
240
240
  MATERIAL_FLOATS,
241
+ /*
242
+ * **The vertex stride, because a consumer sizing a streaming scene has to know it.** A
243
+ * `StreamCapacity` is counts, and what a device refuses is bytes: the pipeline binds the whole
244
+ * vertex buffer as one storage binding, so whether a capacity fits is `vertices * VERTEX_FLOATS
245
+ * * 4` against `maxStorageBufferBindingSize`. Without this a consumer either hard-codes 48 or
246
+ * finds out by being refused, and the published city scene did the second on every phone.
247
+ */
248
+ VERTEX_FLOATS,
241
249
  } from './render/backend/webgpu/gpuDrivenPass.ts';
242
250
  export type {
243
251
  GpuDrivenList,
@@ -1299,6 +1299,31 @@ export class GpuDrivenPass implements PassDefinition {
1299
1299
  this.historySink = this.empty(device, 'history sink', count * 4);
1300
1300
  }
1301
1301
 
1302
+ /**
1303
+ * Close the two scopes the blend pipeline was built in, and disown it where the device refused.
1304
+ *
1305
+ * **Asynchronous because a pipeline failure is.** Nothing can be known at the call that creates
1306
+ * it, so the first frames draw no transparency and then either it arrives or it never does. That
1307
+ * is the honest order: a pane missing for two frames is invisible, and a frame missing entirely
1308
+ * is what this exists to prevent.
1309
+ *
1310
+ * The warning is once, in the words the device used, because the whole difficulty of the report
1311
+ * this came from was five errors that were all the same one.
1312
+ */
1313
+ 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
+ );
1322
+ };
1323
+ void device.popErrorScope().then((error) => refused(error, 'internal'));
1324
+ void device.popErrorScope().then((error) => refused(error, 'validation'));
1325
+ }
1326
+
1302
1327
  private buildPipelines(device: GPUDevice, format: GPUTextureFormat, samples: number): void {
1303
1328
  const compute = (label: string, code: string): GPUComputePipeline =>
1304
1329
  device.createComputePipeline({
@@ -1409,6 +1434,23 @@ export class GpuDrivenPass implements PassDefinition {
1409
1434
  * hide. The opaque raster culls back faces, where a closed hull's far side is a wasted draw.
1410
1435
  */
1411
1436
  const blend = device.createShaderModule({ label: 'blend raster', code: BLEND_RASTER_WGSL });
1437
+ /*
1438
+ * **Built inside error scopes, because a driver that refuses this must not take the frame.**
1439
+ *
1440
+ * Reported from a Galaxy S23 Ultra: `CreateGraphicsPipelines failed with VK_ERROR_UNKNOWN`
1441
+ * for this pipeline, and then four more errors that were all the same one. `createRenderPipeline`
1442
+ * does not throw where a driver refuses — it hands back an *invalid* pipeline and reports
1443
+ * asynchronously — so the code that followed built a bind group from its layout, set it on a
1444
+ * pass, and submitted a command buffer, each of which is invalid because the one before it was.
1445
+ * The city drew **nothing**, on a device that could have drawn all of it but the glass.
1446
+ *
1447
+ * So the handle is disowned where the device refused it and the transparent pass skips itself.
1448
+ * Both scopes, because a shader a driver cannot compile is reported as internal on some
1449
+ * backends and as validation on others, and this is exactly the case where guessing which
1450
+ * costs the whole frame.
1451
+ */
1452
+ device.pushErrorScope('validation');
1453
+ device.pushErrorScope('internal');
1412
1454
  this.blendPipeline = device.createRenderPipeline({
1413
1455
  label: 'gpu-driven blend',
1414
1456
  layout: 'auto',
@@ -1441,6 +1483,7 @@ export class GpuDrivenPass implements PassDefinition {
1441
1483
  depthCompare: DEPTH_COMPARE,
1442
1484
  },
1443
1485
  });
1486
+ this.watchBlendPipeline(device);
1444
1487
 
1445
1488
  /*
1446
1489
  * **Premultiplied over, where `renderer.ts`'s composite is a lerp**, and `blendRaster.wgsl.ts`
@@ -2015,30 +2058,42 @@ export class GpuDrivenPass implements PassDefinition {
2015
2058
  });
2016
2059
 
2017
2060
  /* Everything the transparent raster reads: the same scene, the same frame, its own list. */
2018
- this.blendRasterGroup = device.createBindGroup({
2019
- layout: (this.blendPipeline as GPURenderPipeline).getBindGroupLayout(0),
2020
- entries: [
2021
- { binding: 0, resource: { buffer: this.viewProjBuffer as GPUBuffer } },
2022
- { binding: 1, resource: { buffer: this.blendList as GPUBuffer } },
2023
- { binding: 2, resource: { buffer: this.clusterMeta as GPUBuffer } },
2024
- { binding: 3, resource: { buffer: this.indices as GPUBuffer } },
2025
- { binding: 4, resource: { buffer: this.vertices as GPUBuffer } },
2026
- { binding: 5, resource: { buffer: this.transformBuffer as GPUBuffer } },
2027
- { binding: 6, resource: { buffer: this.materialOf as GPUBuffer } },
2028
- { binding: 7, resource: { buffer: this.materialTable as GPUBuffer } },
2029
- { binding: 8, resource: { buffer: this.frameBuffer as GPUBuffer } },
2030
- {
2031
- binding: 9,
2032
- resource: this.environmentView ?? (this.emptyEnvironmentView as GPUTextureView),
2033
- },
2034
- { binding: 10, resource: this.environmentSampler ?? (this.emptySampler as GPUSampler) },
2035
- { binding: 11, resource: this.latentsView as GPUTextureView },
2036
- { binding: 12, resource: this.latentClamp as GPUSampler },
2037
- { binding: 13, resource: this.latentRepeat as GPUSampler },
2038
- { binding: 14, resource: { buffer: this.decodeNodes as GPUBuffer } },
2039
- { binding: 15, resource: { buffer: this.decodeWeights as GPUBuffer } },
2040
- ],
2041
- });
2061
+ /*
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.
2066
+ */
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
+ });
2042
2097
  this.blendResolveGroup = device.createBindGroup({
2043
2098
  layout: (this.blendResolvePipeline as GPURenderPipeline).getBindGroupLayout(0),
2044
2099
  entries: [
@@ -2519,6 +2574,12 @@ export class GpuDrivenPass implements PassDefinition {
2519
2574
  return;
2520
2575
  }
2521
2576
  case 'blendDraw': {
2577
+ /*
2578
+ * **Skipped where the device refused the pipeline, rather than drawn with an invalid one.**
2579
+ * Everything opaque has already been drawn by the time this runs, so what is lost is the
2580
+ * glass and not the city. See `watchBlendPipeline`.
2581
+ */
2582
+ if (this.blendPipeline === null || this.blendRasterGroup === null) return;
2522
2583
  const pass = encoder.beginRenderPass({
2523
2584
  label: 'gpu-driven blend draw',
2524
2585
  colorAttachments: [
@@ -2546,8 +2607,8 @@ export class GpuDrivenPass implements PassDefinition {
2546
2607
  },
2547
2608
  timestampWrites: this.writesFor('blendDraw'),
2548
2609
  });
2549
- pass.setPipeline(this.blendPipeline as GPURenderPipeline);
2550
- pass.setBindGroup(0, this.blendRasterGroup as GPUBindGroup);
2610
+ pass.setPipeline(this.blendPipeline);
2611
+ pass.setBindGroup(0, this.blendRasterGroup);
2551
2612
  pass.setViewport(0, 0, this.width, this.height, 0, 1);
2552
2613
  pass.drawIndirect(this.blendArgs as GPUBuffer, 0);
2553
2614
  pass.end();