@driftengine/core 4.6.0 → 4.6.1

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,15 @@
1
1
  [
2
+ {
3
+ "version": "4.6.1",
4
+ "date": "2026-10-01",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "short": "A mesh with no triangles is skipped by every pass instead of drawn as nothing, so a game's console no longer fills with WebGPU warnings about empty draws.",
9
+ "text": "**A mesh with no triangles draws nothing, in every pass, on both backends.** A game's world can hold an empty mesh, for a level with none of that kind of geometry, and every pass it reached issued a draw of zero indices: WebGPU answers each with a warning, five on one game's first screen (the frame, its reflection, the sun's static and peeled maps and a lamp's shadow faces), and WebGL2 counted draws that drew nothing. `GpuMesh.indexCount` and `Mesh.indexCount` carry the rule, and every place a mesh enters a pass skips an empty one beside its other checks: meshes, translucent meshes, instanced batches, light volumes, shadow casters, glass depth and the motion pass. A game that already skipped its empty meshes itself can keep doing so."
10
+ }
11
+ ]
12
+ },
2
13
  {
3
14
  "version": "4.6.0",
4
15
  "date": "2026-10-01",
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.9 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
5
+ **Cost: 699.0 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)
@@ -2844,7 +2844,8 @@ export class WebGL2Renderer {
2844
2844
  if (this.contextLost)
2845
2845
  return;
2846
2846
  const mesh = batch.mesh;
2847
- if (!mesh.complete)
2847
+ /* Nor a batch of a mesh with nothing in it: see `Mesh.indexCount`. */
2848
+ if (!mesh.complete || mesh.indexCount === 0)
2848
2849
  return;
2849
2850
  const count = Math.min(data.count, batch.capacity);
2850
2851
  if (count === 0)
@@ -6215,8 +6216,9 @@ export class WebGL2Renderer {
6215
6216
  _previousModel = null) {
6216
6217
  if (this.contextLost)
6217
6218
  return;
6218
- /* Geometry that has not all arrived is not drawn. See `Mesh.complete`. */
6219
- if (!mesh.complete)
6219
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
6220
+ and `Mesh.indexCount`. */
6221
+ if (!mesh.complete || mesh.indexCount === 0)
6220
6222
  return;
6221
6223
  /*
6222
6224
  * Skipped where the flag asks and the bounds say so.
@@ -6406,8 +6408,9 @@ export class WebGL2Renderer {
6406
6408
  drawTranslucentMesh(mesh, model, opacity, options = {}) {
6407
6409
  if (this.contextLost)
6408
6410
  return;
6409
- /* Geometry that has not all arrived is not drawn. See `Mesh.complete`. */
6410
- if (!mesh.complete)
6411
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
6412
+ and `Mesh.indexCount`. */
6413
+ if (!mesh.complete || mesh.indexCount === 0)
6411
6414
  return;
6412
6415
  if (opacity <= 0)
6413
6416
  return;
@@ -6803,8 +6806,9 @@ export class WebGL2Renderer {
6803
6806
  drawLightVolume(mesh, model, camera, strength, length, spread, options = {}) {
6804
6807
  if (this.contextLost)
6805
6808
  return;
6806
- /* Geometry that has not all arrived is not drawn. See `Mesh.complete`. */
6807
- if (!mesh.complete)
6809
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
6810
+ and `Mesh.indexCount`. */
6811
+ if (!mesh.complete || mesh.indexCount === 0)
6808
6812
  return;
6809
6813
  const shown = options.medium === undefined
6810
6814
  ? strength
@@ -68,6 +68,13 @@ export interface GpuMesh {
68
68
  readonly weights: number;
69
69
  } | null;
70
70
  readonly indexBuffer: GPUBuffer;
71
+ /**
72
+ * How many indices the mesh draws. **Zero is a mesh with nothing to draw, and no pass draws it**:
73
+ * a level can hold none of some kind of geometry, and a draw of zero indices does nothing but
74
+ * earn a warning from the device in every pass that issues it. Every place a mesh enters a pass
75
+ * checks this beside `complete` or its vertex buffers, and WebGL2 skips the same meshes, so the
76
+ * two backends count the same draws.
77
+ */
71
78
  readonly indexCount: number;
72
79
  /**
73
80
  * How big this mesh is, in its own space.
@@ -465,7 +465,8 @@ export class MotionPass {
465
465
  else {
466
466
  pipeline = this.pipelineFor(mesh.vertexStride);
467
467
  }
468
- if (pipeline === null)
468
+ /* A mesh with nothing in it draws nothing: see `GpuMesh.indexCount`. */
469
+ if (pipeline === null || mesh.indexCount === 0)
469
470
  continue;
470
471
  if (pipeline !== bound) {
471
472
  pass.setPipeline(pipeline);
@@ -1982,7 +1982,7 @@ export class WebGPURenderer {
1982
1982
  if (!this.canDraw())
1983
1983
  return;
1984
1984
  const geometry = mesh;
1985
- if (geometry.vertexBuffers === undefined)
1985
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
1986
1986
  return;
1987
1987
  const vertexSlot = this.filmVerts.allocate();
1988
1988
  const fragmentSlot = this.filmFrags.allocate();
@@ -3465,8 +3465,9 @@ export class WebGPURenderer {
3465
3465
  return;
3466
3466
  /* Geometry that has not all arrived is not drawn, which is the surface's contract and what
3467
3467
  every other mesh verb here and the other backend's volume already kept. This one drew a
3468
- beam from a buffer still filling, and counted it, until 2026-09-19. See `Mesh.complete`. */
3469
- if (!mesh.complete)
3468
+ beam from a buffer still filling, and counted it, until 2026-09-19. See `Mesh.complete`. A
3469
+ hull with no triangles draws nothing either: see `GpuMesh.indexCount`. */
3470
+ if (!mesh.complete || mesh.indexCount === 0)
3470
3471
  return;
3471
3472
  /* At zero it draws nothing rather than adding black, so a caller may keep the call in. That
3472
3473
  now covers a clear night too: a beam with no medium to light is not drawn at all. */
@@ -4426,7 +4427,7 @@ export class WebGPURenderer {
4426
4427
  if (cutout !== null && cutoutGroup !== null)
4427
4428
  this.writeCutout(slot, cutout, DEPTH_CUTOUT_VERT_FIELDS);
4428
4429
  const geometry = mesh;
4429
- if (geometry.vertexBuffers === undefined)
4430
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4430
4431
  return;
4431
4432
  const cuts = cutoutGroup !== null;
4432
4433
  /* A two-sided caster casts from whichever face the light sees: nothing culled. */
@@ -4455,7 +4456,7 @@ export class WebGPURenderer {
4455
4456
  return;
4456
4457
  const gpuBatch = batch;
4457
4458
  const geometry = gpuBatch.mesh;
4458
- if (geometry.vertexBuffers === undefined)
4459
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4459
4460
  return;
4460
4461
  const count = Math.min(data.count, gpuBatch.capacity);
4461
4462
  if (count === 0)
@@ -4510,7 +4511,7 @@ export class WebGPURenderer {
4510
4511
  this.shadowDraws.writeFloats(slot, DEPTH_VERT_FIELDS.uModel.offset, model);
4511
4512
  this.writeShadowWind(slot);
4512
4513
  const geometry = mesh;
4513
- if (geometry.vertexBuffers === undefined)
4514
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4514
4515
  return;
4515
4516
  pass.setPipeline(depthPipeline(this.pipelines, this.surface.device, this.shadowSkinnedLayout, `depth-skinned|${this.depthCullMode}|${geometry.key ?? ''}`, this.presentOf(geometry), this.depthCullMode, true));
4516
4517
  pass.setBindGroup(0, this.shadowSkinnedGroup(view, paletteSlot), [slot]);
@@ -4532,7 +4533,7 @@ export class WebGPURenderer {
4532
4533
  if (pass === null || data.count === 0)
4533
4534
  return;
4534
4535
  const geometry = scatter;
4535
- if (geometry.vertexBuffers === undefined)
4536
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4536
4537
  return;
4537
4538
  const v = this.scatterDepthFloats;
4538
4539
  const at = (name) => (SCATTER_DEPTH_FIELDS[name]?.offset ?? -4) / 4;
@@ -6823,8 +6824,9 @@ export class WebGPURenderer {
6823
6824
  submitMesh(mesh, model, tint, opacity, blend, options = {}) {
6824
6825
  if (!this.canDraw() || this.viewProj === null)
6825
6826
  return;
6826
- /* Geometry that has not all arrived is not drawn. See `GpuMesh.complete`. */
6827
- if (!mesh.complete)
6827
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `GpuMesh.complete`
6828
+ and `GpuMesh.indexCount`. */
6829
+ if (!mesh.complete || mesh.indexCount === 0)
6828
6830
  return;
6829
6831
  const slot = this.perDraw.allocate();
6830
6832
  if (slot === null) {
@@ -7212,7 +7214,8 @@ export class WebGPURenderer {
7212
7214
  if (!this.canDraw() || this.viewProj === null)
7213
7215
  return;
7214
7216
  const mesh = batch.mesh;
7215
- if (!mesh.complete)
7217
+ /* Nor a batch of a mesh with nothing in it: see `GpuMesh.indexCount`. */
7218
+ if (!mesh.complete || mesh.indexCount === 0)
7216
7219
  return;
7217
7220
  const count = Math.min(data.count, batch.capacity);
7218
7221
  if (count === 0)
@@ -7803,7 +7806,7 @@ export class WebGPURenderer {
7803
7806
  if (pass === null || tint === null)
7804
7807
  return;
7805
7808
  const geometry = mesh;
7806
- if (geometry.vertexBuffers === undefined)
7809
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
7807
7810
  return;
7808
7811
  const cutout = cutoutOf(material);
7809
7812
  const albedo = cutout === null ? null : cutout.albedo;
@@ -7834,7 +7837,7 @@ export class WebGPURenderer {
7834
7837
  return;
7835
7838
  const gpuBatch = batch;
7836
7839
  const geometry = gpuBatch.mesh;
7837
- if (geometry.vertexBuffers === undefined)
7840
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
7838
7841
  return;
7839
7842
  const count = Math.min(data.count, gpuBatch.capacity);
7840
7843
  if (count === 0)
@@ -7867,7 +7870,7 @@ export class WebGPURenderer {
7867
7870
  if (pass === null || tint === null)
7868
7871
  return;
7869
7872
  const geometry = mesh;
7870
- if (geometry.vertexBuffers === undefined)
7873
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
7871
7874
  return;
7872
7875
  const paletteSlot = this.skinPalettes.take(this.surface.device, palette);
7873
7876
  const view = paletteSlot === null ? null : this.skinPalettes.view(paletteSlot);
@@ -19,7 +19,12 @@ export declare class Mesh {
19
19
  private readonly deformable;
20
20
  /** How many floats the position buffer holds, so an update of the wrong size is refused. */
21
21
  private readonly positionFloats;
22
- /** Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). */
22
+ /**
23
+ * Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). **At zero the
24
+ * mesh has nothing to draw and no verb draws it**, as on WebGPU (`GpuMesh.indexCount`): a draw of
25
+ * nothing is silent here but is still a call and a counted draw, and the two backends count the
26
+ * same frame.
27
+ */
23
28
  readonly indexCount: number;
24
29
  /**
25
30
  * The constant values this mesh's *absent* attributes are shaded with, re-applied on
@@ -88,7 +88,12 @@ export class Mesh {
88
88
  deformable = null;
89
89
  /** How many floats the position buffer holds, so an update of the wrong size is refused. */
90
90
  positionFloats;
91
- /** Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). */
91
+ /**
92
+ * Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). **At zero the
93
+ * mesh has nothing to draw and no verb draws it**, as on WebGPU (`GpuMesh.indexCount`): a draw of
94
+ * nothing is silent here but is still a call and a counted draw, and the two backends count the
95
+ * same frame.
96
+ */
92
97
  indexCount;
93
98
  /**
94
99
  * The constant values this mesh's *absent* attributes are shaded with, re-applied on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.6.0",
3
+ "version": "4.6.1",
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.6.0",
33
- "@driftengine/physics": "4.6.0",
32
+ "@driftengine/drft": "4.6.1",
33
+ "@driftengine/physics": "4.6.1",
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.6.0",
67
- "@driftengine/network": "4.6.0",
68
- "@driftengine/texture": "4.6.0"
66
+ "@driftengine/nav": "4.6.1",
67
+ "@driftengine/network": "4.6.1",
68
+ "@driftengine/texture": "4.6.1"
69
69
  }
70
70
  }
@@ -3794,7 +3794,8 @@ export class WebGL2Renderer implements RendererApi {
3794
3794
  ): void {
3795
3795
  if (this.contextLost) return;
3796
3796
  const mesh = batch.mesh;
3797
- if (!mesh.complete) return;
3797
+ /* Nor a batch of a mesh with nothing in it: see `Mesh.indexCount`. */
3798
+ if (!mesh.complete || mesh.indexCount === 0) return;
3798
3799
  const count = Math.min(data.count, batch.capacity);
3799
3800
  if (count === 0) return;
3800
3801
  /* A culling batch's camera draw: the whole batch against the view and the occluders first,
@@ -7698,8 +7699,9 @@ export class WebGL2Renderer implements RendererApi {
7698
7699
  _previousModel: ReadonlyMat4 | Mover | null = null,
7699
7700
  ): void {
7700
7701
  if (this.contextLost) return;
7701
- /* Geometry that has not all arrived is not drawn. See `Mesh.complete`. */
7702
- if (!mesh.complete) return;
7702
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
7703
+ and `Mesh.indexCount`. */
7704
+ if (!mesh.complete || mesh.indexCount === 0) return;
7703
7705
 
7704
7706
  /*
7705
7707
  * Skipped where the flag asks and the bounds say so.
@@ -7885,8 +7887,9 @@ export class WebGL2Renderer implements RendererApi {
7885
7887
  options: TranslucentMeshOptions = {},
7886
7888
  ): void {
7887
7889
  if (this.contextLost) return;
7888
- /* Geometry that has not all arrived is not drawn. See `Mesh.complete`. */
7889
- if (!mesh.complete) return;
7890
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
7891
+ and `Mesh.indexCount`. */
7892
+ if (!mesh.complete || mesh.indexCount === 0) return;
7890
7893
 
7891
7894
  if (opacity <= 0) return;
7892
7895
 
@@ -8312,8 +8315,9 @@ export class WebGL2Renderer implements RendererApi {
8312
8315
  options: LightVolumeDrawOptions = {},
8313
8316
  ): void {
8314
8317
  if (this.contextLost) return;
8315
- /* Geometry that has not all arrived is not drawn. See `Mesh.complete`. */
8316
- if (!mesh.complete) return;
8318
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
8319
+ and `Mesh.indexCount`. */
8320
+ if (!mesh.complete || mesh.indexCount === 0) return;
8317
8321
  const shown =
8318
8322
  options.medium === undefined
8319
8323
  ? strength
@@ -154,6 +154,13 @@ export interface GpuMesh {
154
154
  */
155
155
  readonly skinOffsets: { readonly joints: number; readonly weights: number } | null;
156
156
  readonly indexBuffer: GPUBuffer;
157
+ /**
158
+ * How many indices the mesh draws. **Zero is a mesh with nothing to draw, and no pass draws it**:
159
+ * a level can hold none of some kind of geometry, and a draw of zero indices does nothing but
160
+ * earn a warning from the device in every pass that issues it. Every place a mesh enters a pass
161
+ * checks this beside `complete` or its vertex buffers, and WebGL2 skips the same meshes, so the
162
+ * two backends count the same draws.
163
+ */
157
164
  readonly indexCount: number;
158
165
  /**
159
166
  * How big this mesh is, in its own space.
@@ -509,7 +509,8 @@ export class MotionPass {
509
509
  } else {
510
510
  pipeline = this.pipelineFor(mesh.vertexStride);
511
511
  }
512
- if (pipeline === null) continue;
512
+ /* A mesh with nothing in it draws nothing: see `GpuMesh.indexCount`. */
513
+ if (pipeline === null || mesh.indexCount === 0) continue;
513
514
  if (pipeline !== bound) {
514
515
  pass.setPipeline(pipeline);
515
516
  bound = pipeline;
@@ -2624,7 +2624,7 @@ export class WebGPURenderer implements RendererApi {
2624
2624
  ): void {
2625
2625
  if (!this.canDraw()) return;
2626
2626
  const geometry = mesh as GpuMesh & { key?: string };
2627
- if (geometry.vertexBuffers === undefined) return;
2627
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
2628
2628
  const vertexSlot = this.filmVerts.allocate();
2629
2629
  const fragmentSlot = this.filmFrags.allocate();
2630
2630
  if (vertexSlot === null || fragmentSlot === null) return;
@@ -4329,8 +4329,9 @@ export class WebGPURenderer implements RendererApi {
4329
4329
  if (!this.canDraw()) return;
4330
4330
  /* Geometry that has not all arrived is not drawn, which is the surface's contract and what
4331
4331
  every other mesh verb here and the other backend's volume already kept. This one drew a
4332
- beam from a buffer still filling, and counted it, until 2026-09-19. See `Mesh.complete`. */
4333
- if (!mesh.complete) return;
4332
+ beam from a buffer still filling, and counted it, until 2026-09-19. See `Mesh.complete`. A
4333
+ hull with no triangles draws nothing either: see `GpuMesh.indexCount`. */
4334
+ if (!mesh.complete || mesh.indexCount === 0) return;
4334
4335
  /* At zero it draws nothing rather than adding black, so a caller may keep the call in. That
4335
4336
  now covers a clear night too: a beam with no medium to light is not drawn at all. */
4336
4337
  const shown =
@@ -5478,7 +5479,7 @@ export class WebGPURenderer implements RendererApi {
5478
5479
  this.writeCutout(slot, cutout, DEPTH_CUTOUT_VERT_FIELDS);
5479
5480
 
5480
5481
  const geometry = mesh as GpuMesh & { key?: string };
5481
- if (geometry.vertexBuffers === undefined) return;
5482
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
5482
5483
  const cuts = cutoutGroup !== null;
5483
5484
  /* A two-sided caster casts from whichever face the light sees: nothing culled. */
5484
5485
  const cull = material?.doubleSided === true ? 'none' : this.depthCullMode;
@@ -5517,7 +5518,7 @@ export class WebGPURenderer implements RendererApi {
5517
5518
  if (pass === null) return;
5518
5519
  const gpuBatch = batch as GpuInstancedBatch;
5519
5520
  const geometry = gpuBatch.mesh as GpuMesh & { key?: string };
5520
- if (geometry.vertexBuffers === undefined) return;
5521
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
5521
5522
  const count = Math.min(data.count, gpuBatch.capacity);
5522
5523
  if (count === 0) return;
5523
5524
  const cutout = cutoutOf(material);
@@ -5591,7 +5592,7 @@ export class WebGPURenderer implements RendererApi {
5591
5592
  this.writeShadowWind(slot);
5592
5593
 
5593
5594
  const geometry = mesh as GpuMesh & { key?: string };
5594
- if (geometry.vertexBuffers === undefined) return;
5595
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
5595
5596
  pass.setPipeline(
5596
5597
  depthPipeline(
5597
5598
  this.pipelines,
@@ -5621,7 +5622,7 @@ export class WebGPURenderer implements RendererApi {
5621
5622
  const pass = this.shadowPass;
5622
5623
  if (pass === null || data.count === 0) return;
5623
5624
  const geometry = scatter as GpuScatter;
5624
- if (geometry.vertexBuffers === undefined) return;
5625
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
5625
5626
 
5626
5627
  const v = this.scatterDepthFloats;
5627
5628
  const at = (name: string): number => (SCATTER_DEPTH_FIELDS[name]?.offset ?? -4) / 4;
@@ -8422,8 +8423,9 @@ export class WebGPURenderer implements RendererApi {
8422
8423
  options: TranslucentMeshOptions = {},
8423
8424
  ): void {
8424
8425
  if (!this.canDraw() || this.viewProj === null) return;
8425
- /* Geometry that has not all arrived is not drawn. See `GpuMesh.complete`. */
8426
- if (!mesh.complete) return;
8426
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `GpuMesh.complete`
8427
+ and `GpuMesh.indexCount`. */
8428
+ if (!mesh.complete || mesh.indexCount === 0) return;
8427
8429
 
8428
8430
  const slot = this.perDraw.allocate();
8429
8431
  if (slot === null) {
@@ -8868,7 +8870,8 @@ export class WebGPURenderer implements RendererApi {
8868
8870
  ): void {
8869
8871
  if (!this.canDraw() || this.viewProj === null) return;
8870
8872
  const mesh = batch.mesh;
8871
- if (!mesh.complete) return;
8873
+ /* Nor a batch of a mesh with nothing in it: see `GpuMesh.indexCount`. */
8874
+ if (!mesh.complete || mesh.indexCount === 0) return;
8872
8875
  const count = Math.min(data.count, batch.capacity);
8873
8876
  if (count === 0) return;
8874
8877
  /* A culling batch's camera draw: the whole batch against the view and the occluders on the CPU,
@@ -9522,7 +9525,7 @@ export class WebGPURenderer implements RendererApi {
9522
9525
  const tint = this.glassTint;
9523
9526
  if (pass === null || tint === null) return;
9524
9527
  const geometry = mesh as GpuMesh & { key?: string };
9525
- if (geometry.vertexBuffers === undefined) return;
9528
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
9526
9529
  const cutout = cutoutOf(material);
9527
9530
  const albedo = cutout === null ? null : (cutout.albedo as unknown as GpuSurfaceTexture);
9528
9531
  const slot = this.shadowDraws.allocate();
@@ -9565,7 +9568,7 @@ export class WebGPURenderer implements RendererApi {
9565
9568
  if (pass === null || tint === null) return;
9566
9569
  const gpuBatch = batch as GpuInstancedBatch;
9567
9570
  const geometry = gpuBatch.mesh as GpuMesh & { key?: string };
9568
- if (geometry.vertexBuffers === undefined) return;
9571
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
9569
9572
  const count = Math.min(data.count, gpuBatch.capacity);
9570
9573
  if (count === 0) return;
9571
9574
  const cutout = cutoutOf(material);
@@ -9609,7 +9612,7 @@ export class WebGPURenderer implements RendererApi {
9609
9612
  const tint = this.glassTint;
9610
9613
  if (pass === null || tint === null) return;
9611
9614
  const geometry = mesh as GpuMesh & { key?: string };
9612
- if (geometry.vertexBuffers === undefined) return;
9615
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
9613
9616
  const paletteSlot = this.skinPalettes.take(this.surface.device, palette);
9614
9617
  const view = paletteSlot === null ? null : this.skinPalettes.view(paletteSlot);
9615
9618
  if (view === null) return;
@@ -99,7 +99,12 @@ export class Mesh {
99
99
  private readonly deformable: { positions: WebGLBuffer; normals: WebGLBuffer } | null = null;
100
100
  /** How many floats the position buffer holds, so an update of the wrong size is refused. */
101
101
  private readonly positionFloats: number;
102
- /** Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). */
102
+ /**
103
+ * Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). **At zero the
104
+ * mesh has nothing to draw and no verb draws it**, as on WebGPU (`GpuMesh.indexCount`): a draw of
105
+ * nothing is silent here but is still a call and a counted draw, and the two backends count the
106
+ * same frame.
107
+ */
103
108
  readonly indexCount: number;
104
109
  /**
105
110
  * The constant values this mesh's *absent* attributes are shaded with, re-applied on