@driftengine/core 4.6.0 → 4.7.0

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/scripts/png.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  * Enough of a PNG reader to compare two frames, with no dependency.
3
3
  *
4
4
  * A screenshot arrives as 8-bit RGBA, filter method 0, not interlaced, which is what every
5
- * browser produces. A bought model's maps also arrive as 8-bit grey, grey with alpha and RGB, and
6
- * the baker and the native host read those through here too. Anything else — a palette, sixteen
7
- * bits, interlacing — is refused by name rather than guessed at, because a reader that quietly
8
- * mis-decodes produces pixel differences that look like a rendering change.
5
+ * browser produces, and is read on a path that copies nothing. A bought model's maps arrive as
6
+ * every other non-interlaced kind — grey, grey with alpha, RGB and RGBA at eight or sixteen bits,
7
+ * palettes and greys at one to eight — and the baker and the native host read those through here
8
+ * too, widened to 8-bit samples. Interlacing is refused by name rather than guessed at, because a
9
+ * reader that quietly mis-decodes produces pixel differences that look like a rendering change.
9
10
  *
10
11
  * `zlib` is Node's own, so this costs nothing to have.
11
12
  */
@@ -14,14 +15,28 @@ import { readFileSync } from 'node:fs';
14
15
 
15
16
  const SIGNATURE = 0x89504e47;
16
17
 
17
- /** `{ width, height, channels, pixels }`, pixels being row-major 8-bit samples. */
18
+ /** Samples a pixel, by colour type. */
19
+ const SAMPLES = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
20
+ /** The bit depths the specification allows for each colour type. */
21
+ const DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
22
+
23
+ /**
24
+ * `{ width, height, channels, pixels }`, pixels being row-major 8-bit samples.
25
+ *
26
+ * A palette widens to RGB, or to RGBA where it carries `tRNS`; a grey or RGB image with a `tRNS`
27
+ * key gains an alpha channel, nought at that one value; sixteen bits keep the high byte; a grey
28
+ * below eight bits is scaled to the full byte, so one bit is 0 or 255.
29
+ */
18
30
  export function decodePng(bytes) {
19
31
  if (bytes.length < 8 || bytes.readUInt32BE(0) !== SIGNATURE) throw new Error('not a PNG');
20
32
 
21
33
  let at = 8;
22
34
  let width = 0;
23
35
  let height = 0;
24
- let channels = 0;
36
+ let depth = 0;
37
+ let colorType = 0;
38
+ let palette = null;
39
+ let transparency = null;
25
40
  const parts = [];
26
41
 
27
42
  while (at + 8 <= bytes.length) {
@@ -31,20 +46,22 @@ export function decodePng(bytes) {
31
46
  if (type === 'IHDR') {
32
47
  width = body.readUInt32BE(0);
33
48
  height = body.readUInt32BE(4);
34
- const depth = body[8];
35
- const colorType = body[9];
49
+ depth = body[8];
50
+ colorType = body[9];
36
51
  const interlace = body[12];
37
- if (depth !== 8) throw new Error(`bit depth ${depth}, only 8 is handled`);
38
52
  if (interlace !== 0) throw new Error('interlaced PNG, not handled');
39
- /*
40
- * Grey, grey with alpha, RGB and RGBA — every 8-bit type but a palette. A bought model's
41
- * single-channel maps are greyscale, and refusing them left a baker's texture cap unable to
42
- * shrink a third of one scene's maps. A palette needs `PLTE` read, and nothing has asked.
43
- */
44
- channels = { 0: 1, 4: 2, 2: 3, 6: 4 }[colorType] ?? 0;
45
- if (channels === 0) {
46
- throw new Error(`colour type ${colorType}, only 0, 2, 4 and 6 are handled`);
53
+ if (SAMPLES[colorType] === undefined) {
54
+ throw new Error(`colour type ${colorType}, which is not a PNG colour type`);
47
55
  }
56
+ if (!DEPTHS[colorType].includes(depth)) {
57
+ throw new Error(
58
+ `bit depth ${depth} for colour type ${colorType}, which PNG does not allow`,
59
+ );
60
+ }
61
+ } else if (type === 'PLTE') {
62
+ palette = body;
63
+ } else if (type === 'tRNS') {
64
+ transparency = body;
48
65
  } else if (type === 'IDAT') {
49
66
  parts.push(body);
50
67
  } else if (type === 'IEND') {
@@ -54,15 +71,21 @@ export function decodePng(bytes) {
54
71
  at += 12 + length;
55
72
  }
56
73
  if (width === 0 || height === 0) throw new Error('no IHDR');
74
+ if (colorType === 3 && palette === null) throw new Error('a palette PNG with no PLTE');
57
75
 
76
+ const samples = SAMPLES[colorType];
77
+ const bits = samples * depth;
78
+ /* What a filter steps back by: a whole pixel, or one byte where a pixel is smaller than one. */
79
+ const step = Math.max(1, bits >> 3);
80
+ const stride = (width * bits + 7) >> 3;
58
81
  const raw = inflateSync(Buffer.concat(parts));
59
- const stride = width * channels;
60
- const pixels = Buffer.alloc(stride * height);
82
+ const scan = Buffer.alloc(stride * height);
61
83
 
62
84
  /*
63
85
  * Un-filtering, which is the whole of the format that is not zlib. Each row states one of five
64
- * predictors and the residual against it; `a` is the sample one pixel left, `b` the one above,
65
- * `c` the one above and left, and all three are zero outside the image rather than wrapped.
86
+ * predictors and the residual against it; `a` is the byte one pixel left, `b` the one above,
87
+ * `c` the one above and left, and all three are zero outside the image rather than wrapped. It
88
+ * runs on the packed bytes, before a sub-byte pixel is unpacked, which is what the format says.
66
89
  */
67
90
  for (let y = 0; y < height; y++) {
68
91
  const filter = raw[y * (stride + 1)];
@@ -70,9 +93,9 @@ export function decodePng(bytes) {
70
93
  const to = y * stride;
71
94
  for (let i = 0; i < stride; i++) {
72
95
  const value = raw[from + i];
73
- const a = i >= channels ? pixels[to + i - channels] : 0;
74
- const b = y > 0 ? pixels[to - stride + i] : 0;
75
- const c = i >= channels && y > 0 ? pixels[to - stride + i - channels] : 0;
96
+ const a = i >= step ? scan[to + i - step] : 0;
97
+ const b = y > 0 ? scan[to - stride + i] : 0;
98
+ const c = i >= step && y > 0 ? scan[to - stride + i - step] : 0;
76
99
  let restored;
77
100
  if (filter === 0) restored = value;
78
101
  else if (filter === 1) restored = value + a;
@@ -80,10 +103,61 @@ export function decodePng(bytes) {
80
103
  else if (filter === 3) restored = value + ((a + b) >> 1);
81
104
  else if (filter === 4) restored = value + paeth(a, b, c);
82
105
  else throw new Error(`row ${y} states filter ${filter}, which is not a PNG filter`);
83
- pixels[to + i] = restored & 0xff;
106
+ scan[to + i] = restored & 0xff;
84
107
  }
85
108
  }
86
109
 
110
+ /* Eight bits of grey, grey and alpha, RGB or RGBA with no key: the scan is the pixels. */
111
+ if (depth === 8 && colorType !== 3 && transparency === null) {
112
+ return { width, height, channels: samples, pixels: scan };
113
+ }
114
+ return widen(scan, width, height, depth, colorType, stride, palette, transparency);
115
+ }
116
+
117
+ /** A sample at its own precision: pixel `x`, sample `s` of row `y`. */
118
+ function sampleAt(scan, stride, depth, samples, y, x, s) {
119
+ const index = x * samples + s;
120
+ if (depth === 16) return scan.readUInt16BE(y * stride + index * 2);
121
+ if (depth === 8) return scan[y * stride + index];
122
+ const bit = index * depth;
123
+ return (scan[y * stride + (bit >> 3)] >> (8 - depth - (bit & 7))) & ((1 << depth) - 1);
124
+ }
125
+
126
+ /** Everything the 8-bit path does not take, widened to 8-bit samples. */
127
+ function widen(scan, width, height, depth, colorType, stride, palette, transparency) {
128
+ const samples = SAMPLES[colorType];
129
+ const paletted = colorType === 3;
130
+ /* A key in tRNS is one sample value a channel, at the image's own depth. */
131
+ const keyed = !paletted && transparency !== null && (colorType === 0 || colorType === 2);
132
+ const channels = paletted ? (transparency === null ? 3 : 4) : samples + (keyed ? 1 : 0);
133
+ const key = keyed
134
+ ? [0, 1, 2].slice(0, samples).map((s) => transparency.readUInt16BE(s * 2))
135
+ : null;
136
+ const to8 =
137
+ depth === 16 ? (v) => v >> 8 : depth === 8 ? (v) => v : (v) => (v * 255) / ((1 << depth) - 1);
138
+ const entries = paletted ? palette.length / 3 : 0;
139
+ const pixels = Buffer.alloc(width * height * channels);
140
+ let out = 0;
141
+ for (let y = 0; y < height; y++) {
142
+ for (let x = 0; x < width; x++) {
143
+ if (paletted) {
144
+ const index = sampleAt(scan, stride, depth, 1, y, x, 0);
145
+ if (index >= entries) throw new Error(`palette index ${index} past a PLTE of ${entries}`);
146
+ pixels[out++] = palette[index * 3];
147
+ pixels[out++] = palette[index * 3 + 1];
148
+ pixels[out++] = palette[index * 3 + 2];
149
+ if (channels === 4) pixels[out++] = index < transparency.length ? transparency[index] : 255;
150
+ continue;
151
+ }
152
+ let matches = key !== null;
153
+ for (let s = 0; s < samples; s++) {
154
+ const value = sampleAt(scan, stride, depth, samples, y, x, s);
155
+ if (key !== null && value !== key[s]) matches = false;
156
+ pixels[out++] = to8(value);
157
+ }
158
+ if (keyed) pixels[out++] = matches ? 0 : 255;
159
+ }
160
+ }
87
161
  return { width, height, channels, pixels };
88
162
  }
89
163
 
@@ -49,6 +49,12 @@ export interface BudgetCounter extends BudgetLine {
49
49
  ask(): void;
50
50
  /** One of them was refused, and the frame is marked. */
51
51
  drop(): void;
52
+ /**
53
+ * The ceiling, where it is a construction-time option and not known when the line is declared.
54
+ * Called at construction, once; a line keeps the place it was declared in, which is the order a
55
+ * report reads in.
56
+ */
57
+ limit(ceiling: number): void;
52
58
  }
53
59
 
54
60
  class Line implements BudgetCounter {
@@ -57,10 +63,14 @@ class Line implements BudgetCounter {
57
63
 
58
64
  constructor(
59
65
  readonly name: string,
60
- readonly ceiling: number | null,
66
+ public ceiling: number | null,
61
67
  private readonly budget: FrameBudget,
62
68
  ) {}
63
69
 
70
+ limit(ceiling: number): void {
71
+ this.ceiling = ceiling;
72
+ }
73
+
64
74
  /** One more of this thing was asked for, whether or not it will fit. */
65
75
  ask(): void {
66
76
  this.used += 1;
@@ -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;
@@ -586,7 +586,9 @@ import type { WorldLightFieldOptions } from '../../driftLight/worldLightField.ts
586
586
  import { createDriftLightUniforms, resolveDriftLight } from '../../driftLight/uniforms.ts';
587
587
 
588
588
  /**
589
- * How many draws one frame may make before the ring is full.
589
+ * How many draws one frame is prepared for before anything grows: `drawsPerFrame`'s default, and
590
+ * the starting size of the arena and the command pool, which reallocate on demand. The two rings
591
+ * that cannot grow take `drawsPerFrame` itself.
590
592
  *
591
593
  * **Four thousand and ninety-six, and the number is an allocation rather than a limit.**
592
594
  * `UniformRing.flush` uploads `used * slotSize`, so a frame that draws two hundred pays for two
@@ -644,37 +646,6 @@ const MAX_OVERLAYS = 64;
644
646
  */
645
647
  const MAX_SKY_DRAWS = 64;
646
648
 
647
- /**
648
- * Distinct materials a frame may set.
649
- *
650
- * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
651
- * material rather than how much it draws.
652
- *
653
- * **A thousand and twenty-four, and it is the expensive one of the two.** A slot is the fragment
654
- * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
655
- * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
656
- * Raising it from 256 costs 5.25 to 9.75 MB.
657
- *
658
- * It is worth paying for how it *fails* rather than how it performs. Past the ring
659
- * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
660
- * a stretch of the world simply not drawn, which has been reported from a consumer as
661
- * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
662
- * ceiling is a budget that ships this the first time somebody adds a model.
663
- *
664
- * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
665
- * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
666
- * halves in one diff. It is recorded because the wrong failure was the stated argument for
667
- * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
668
- * looking in completely different places — one at materials, the other at culling, which is
669
- * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
670
- * consumer reads a number instead of a sentence.
671
- *
672
- * **What would make it wrong** is a scene switching material every draw: a thousand material
673
- * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
674
- * ring size answers — many copies of one mesh want one material and one instanced draw.
675
- */
676
- const MAX_MATERIALS_PER_FRAME = 1024;
677
-
678
649
  /**
679
650
  * Whether the environment probe's permutation fits this device's per-stage binding limits.
680
651
  *
@@ -1584,8 +1555,37 @@ export class WebGPURenderer implements RendererApi {
1584
1555
  * geometry come first.
1585
1556
  */
1586
1557
  private readonly budget = new FrameBudget();
1587
- private readonly drawBudget = this.budget.line('draws', MAX_DRAWS_PER_FRAME);
1588
- private readonly materialBudget = this.budget.line('materials', MAX_MATERIALS_PER_FRAME);
1558
+ private readonly drawBudget = this.budget.line('draws', null);
1559
+ /**
1560
+ * Distinct materials a frame may set: `materialChangesPerFrame`, 1,024 unless a consumer asks.
1561
+ *
1562
+ * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
1563
+ * material rather than how much it draws.
1564
+ *
1565
+ * **A thousand and twenty-four by default, and it is the expensive one of the two.** A slot is the fragment
1566
+ * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
1567
+ * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
1568
+ * Raising it from 256 costs 5.25 to 9.75 MB.
1569
+ *
1570
+ * It is worth paying for how it *fails* rather than how it performs. Past the ring
1571
+ * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
1572
+ * a stretch of the world simply not drawn, which has been reported from a consumer as
1573
+ * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
1574
+ * ceiling is a budget that ships this the first time somebody adds a model.
1575
+ *
1576
+ * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
1577
+ * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
1578
+ * halves in one diff. It is recorded because the wrong failure was the stated argument for
1579
+ * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
1580
+ * looking in completely different places — one at materials, the other at culling, which is
1581
+ * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
1582
+ * consumer reads a number instead of a sentence.
1583
+ *
1584
+ * **What would make it wrong** is a scene switching material every draw: a thousand material
1585
+ * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
1586
+ * ring size answers — many copies of one mesh want one material and one instanced draw.
1587
+ */
1588
+ private readonly materialBudget = this.budget.line('materials', null);
1589
1589
  /**
1590
1590
  * Native bind groups built this frame, which is the one cost a consumer could not see.
1591
1591
  *
@@ -1596,7 +1596,7 @@ export class WebGPURenderer implements RendererApi {
1596
1596
  * in draw order, precisely because the frame reported draws and material changes and never this.
1597
1597
  */
1598
1598
  private readonly bindGroupBudget = this.budget.line('bind groups', null);
1599
- private readonly shadowDrawBudget = this.budget.line('shadow draws', MAX_DRAWS_PER_FRAME);
1599
+ private readonly shadowDrawBudget = this.budget.line('shadow draws', null);
1600
1600
  private readonly scatterDepthBudget = this.budget.line(
1601
1601
  'scatter shadow draws',
1602
1602
  MAX_SCATTER_DEPTH_DRAWS,
@@ -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;
@@ -5908,6 +5909,9 @@ export class WebGPURenderer implements RendererApi {
5908
5909
  }
5909
5910
  : quality;
5910
5911
  quality = this.quality;
5912
+ this.materialBudget.limit(quality.materialChangesPerFrame);
5913
+ this.drawBudget.limit(quality.drawsPerFrame);
5914
+ this.shadowDrawBudget.limit(quality.drawsPerFrame);
5911
5915
  this.maxDrawingBufferPixels = quality.maxDrawingBufferPixels;
5912
5916
  this.exposure = quality.outputExposure;
5913
5917
  this.flushSchedule =
@@ -6013,7 +6017,7 @@ export class WebGPURenderer implements RendererApi {
6013
6017
  this.perDraw = new UniformRing(
6014
6018
  device,
6015
6019
  FLAT_VERT_SIZE,
6016
- MAX_DRAWS_PER_FRAME,
6020
+ quality.drawsPerFrame,
6017
6021
  USAGE_UNIFORM_DST,
6018
6022
  'flat.vertRing',
6019
6023
  this.drawBudget,
@@ -6037,7 +6041,7 @@ export class WebGPURenderer implements RendererApi {
6037
6041
  this.perFrame = new UniformRing(
6038
6042
  device,
6039
6043
  this.fragment.uniformSize,
6040
- MAX_MATERIALS_PER_FRAME,
6044
+ this.quality.materialChangesPerFrame,
6041
6045
  USAGE_UNIFORM_DST,
6042
6046
  'flat.fragRing',
6043
6047
  this.materialBudget,
@@ -6772,7 +6776,7 @@ export class WebGPURenderer implements RendererApi {
6772
6776
  device,
6773
6777
  /* The cutout block is the plain one and two fields more, and a slot must hold either. */
6774
6778
  Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE),
6775
- MAX_DRAWS_PER_FRAME,
6779
+ quality.drawsPerFrame,
6776
6780
  USAGE_UNIFORM_DST,
6777
6781
  'shadow.drawRing',
6778
6782
  this.shadowDrawBudget,
@@ -8207,7 +8211,7 @@ export class WebGPURenderer implements RendererApi {
8207
8211
  if (!this.warnedMaterialsFull) {
8208
8212
  this.warnedMaterialsFull = true;
8209
8213
  console.warn(
8210
- `WebGPU: more than ${MAX_MATERIALS_PER_FRAME} material changes in a frame; the draws asking for the rest are skipped. renderer.frameBudget names the line and the count.`,
8214
+ `WebGPU: more than ${this.quality.materialChangesPerFrame} material changes in a frame; the draws asking for the rest are skipped. renderer.frameBudget names the line and the count.`,
8211
8215
  );
8212
8216
  }
8213
8217
  return null;
@@ -8422,15 +8426,16 @@ export class WebGPURenderer implements RendererApi {
8422
8426
  options: TranslucentMeshOptions = {},
8423
8427
  ): void {
8424
8428
  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;
8429
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `GpuMesh.complete`
8430
+ and `GpuMesh.indexCount`. */
8431
+ if (!mesh.complete || mesh.indexCount === 0) return;
8427
8432
 
8428
8433
  const slot = this.perDraw.allocate();
8429
8434
  if (slot === null) {
8430
8435
  if (!this.warnedFull) {
8431
8436
  this.warnedFull = true;
8432
8437
  console.warn(
8433
- `WebGPU: more than ${MAX_DRAWS_PER_FRAME} draws in a frame; the rest are skipped`,
8438
+ `WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`,
8434
8439
  );
8435
8440
  }
8436
8441
  return;
@@ -8596,7 +8601,7 @@ export class WebGPURenderer implements RendererApi {
8596
8601
  /*
8597
8602
  * Guards the draw call only, not the restore below.
8598
8603
  *
8599
- * `materialSlotForDraw` returns null once `MAX_MATERIALS_PER_FRAME` is exhausted, and an
8604
+ * `materialSlotForDraw` returns null once `materialChangesPerFrame` is exhausted, and an
8600
8605
  * early `return` here used to skip the restore too — which left `uOpacity`,
8601
8606
  * `uLightingEnabled` and `uFogEnabled` dirtied in `perFrameFloats`/`perFrameInts` for the
8602
8607
  * rest of the frame, since that scratch block is cumulative state shared by every draw
@@ -8868,7 +8873,8 @@ export class WebGPURenderer implements RendererApi {
8868
8873
  ): void {
8869
8874
  if (!this.canDraw() || this.viewProj === null) return;
8870
8875
  const mesh = batch.mesh;
8871
- if (!mesh.complete) return;
8876
+ /* Nor a batch of a mesh with nothing in it: see `GpuMesh.indexCount`. */
8877
+ if (!mesh.complete || mesh.indexCount === 0) return;
8872
8878
  const count = Math.min(data.count, batch.capacity);
8873
8879
  if (count === 0) return;
8874
8880
  /* A culling batch's camera draw: the whole batch against the view and the occluders on the CPU,
@@ -8885,7 +8891,7 @@ export class WebGPURenderer implements RendererApi {
8885
8891
  if (!this.warnedFull) {
8886
8892
  this.warnedFull = true;
8887
8893
  console.warn(
8888
- `WebGPU: more than ${MAX_DRAWS_PER_FRAME} draws in a frame; the rest are skipped`,
8894
+ `WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`,
8889
8895
  );
8890
8896
  }
8891
8897
  return;
@@ -9269,7 +9275,7 @@ export class WebGPURenderer implements RendererApi {
9269
9275
  * *unchanged*, so a run of entries sharing one material has no shorter spelling and the sink
9270
9276
  * bound once per entry. On this backend that is a cliff rather than a slope: `setMaterial`
9271
9277
  * dirties `materialSlot`, so a bind per entry is a **slot per draw** out of
9272
- * `MAX_MATERIALS_PER_FRAME` — and past the ring `materialSlotForDraw` returns null and the draw
9278
+ * `materialChangesPerFrame` — and past the ring `materialSlotForDraw` returns null and the draw
9273
9279
  * is skipped, which is the world that appears and disappears that constant's own comment
9274
9280
  * describes. A consumer measured 82 materials a model over two to four draws each, and a
9275
9281
  * reflection replaying the frame shares the ring with the pass that recorded it.
@@ -9522,7 +9528,7 @@ export class WebGPURenderer implements RendererApi {
9522
9528
  const tint = this.glassTint;
9523
9529
  if (pass === null || tint === null) return;
9524
9530
  const geometry = mesh as GpuMesh & { key?: string };
9525
- if (geometry.vertexBuffers === undefined) return;
9531
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
9526
9532
  const cutout = cutoutOf(material);
9527
9533
  const albedo = cutout === null ? null : (cutout.albedo as unknown as GpuSurfaceTexture);
9528
9534
  const slot = this.shadowDraws.allocate();
@@ -9565,7 +9571,7 @@ export class WebGPURenderer implements RendererApi {
9565
9571
  if (pass === null || tint === null) return;
9566
9572
  const gpuBatch = batch as GpuInstancedBatch;
9567
9573
  const geometry = gpuBatch.mesh as GpuMesh & { key?: string };
9568
- if (geometry.vertexBuffers === undefined) return;
9574
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
9569
9575
  const count = Math.min(data.count, gpuBatch.capacity);
9570
9576
  if (count === 0) return;
9571
9577
  const cutout = cutoutOf(material);
@@ -9609,7 +9615,7 @@ export class WebGPURenderer implements RendererApi {
9609
9615
  const tint = this.glassTint;
9610
9616
  if (pass === null || tint === null) return;
9611
9617
  const geometry = mesh as GpuMesh & { key?: string };
9612
- if (geometry.vertexBuffers === undefined) return;
9618
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
9613
9619
  const paletteSlot = this.skinPalettes.take(this.surface.device, palette);
9614
9620
  const view = paletteSlot === null ? null : this.skinPalettes.view(paletteSlot);
9615
9621
  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