@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/CHANGELOG.json CHANGED
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "4.7.0",
4
+ "date": "2026-10-01",
5
+ "entries": [
6
+ {
7
+ "kind": "added",
8
+ "short": "Blender files bake directly: .blend from 2.79 to 5.x, compressed or not, with meshes, normals, materials, lights, cameras, shape keys and animation, and through your Blender where needed.",
9
+ "text": "**A `.blend` file is read directly, with nothing installed.** `readModel` and `npm run bake` take Blender's own format from 2.79 to 5.x, plain, gzip or zstd, because the reader learns every struct from the file's `DNA1` catalogue rather than from a list of Blender releases. It reads meshes in all three layouts Blender has stored them in (the legacy structs, 3.5's named attributes and 5.0's attribute storage), their normals by Blender's own rule — sharp edges and faces, auto smooth, custom split normals — with UVs, colour attributes and shape keys; Principled materials wired the way the glTF importer wires them, with their packed pictures; point, spot and sun lights, blackbody colour included; cameras; parents, collection instances and render visibility; and keyed object animation, layered actions and NLA strips included. It builds the same glTF the rest of the pipeline reads, so a `.blend` and a `.glb` reach the baker as the same thing, and the fixtures in `scripts/fixtures/blend/` hold it to what Blender's own exporter writes for the same file. **What only Blender can evaluate is refused by name** — a rig, a constraint, a modifier past the smoothing ones — as `BlendNeedsBlender`, never as half a scene; the baker then drives the user's own Blender through its bundled glTF exporter, `--via-blender` asks for that outright and `--direct` refuses it, and every import says which route it took."
10
+ },
11
+ {
12
+ "kind": "added",
13
+ "short": "materialChangesPerFrame: a scene that changes material more than 1,024 times a frame on WebGPU can now ask for a larger ring instead of losing the draws past it.",
14
+ "text": "**How many material changes a WebGPU frame holds is a quality option, `materialChangesPerFrame`.** A frame spends a slot of a uniform ring each time it changes material and skips every draw past the last slot, which is geometry missing rather than geometry drawn wrong — and 1,024 was a constant. A bought city of eleven hundred materials spends about nine hundred on a street, and a planar mirror of that street spends as many again, so its reflection came and went as the camera turned. The default stays 1,024; a scene that measures past it asks for more at construction and pays seven to thirteen kilobytes a slot, and `frameBudget`'s materials line reports the ceiling it got. WebGL2 keeps no ring and reads the option without effect."
15
+ },
16
+ {
17
+ "kind": "fixed",
18
+ "short": "The PNG reader in core's scripts reads palettes, one- to four-bit greys, sixteen-bit images and tRNS transparency, so a bake no longer drops those pictures and draws their surfaces white.",
19
+ "text": "**`@driftengine/core/scripts/png.mjs` reads every non-interlaced PNG.** It refused palettes (colour type 3), greys below eight bits and sixteen-bit images, and the baker and the native host read model pictures through it: measured on one bought city, 219 of its 2,447 pictures, every material wearing one baked with no colour map and drew white. Rows are now un-filtered as packed bytes and then widened to eight-bit samples — a palette to RGB, or RGBA where it carries `tRNS`; a grey or RGB `tRNS` key to an alpha of nought at that value; sixteen bits to the high byte; a grey below eight bits scaled to the full byte. An eight-bit image with no key still takes the path that copies nothing, so a screenshot decodes as it did. Interlacing is still refused by name."
20
+ },
21
+ {
22
+ "kind": "fixed",
23
+ "short": "A glTF material with no metallic texture and no metallicFactor is a full metal, as the specification says, and now reflects like one instead of reading as matte.",
24
+ "text": "**An untextured glTF metal that states no `metallicFactor` mirrors.** The specification's default for the factor is 1, and the reader took the material's reflectivity from the factor alone with a default of 0, so every such material — an exporter writes none when the value is the default — drew as a matte surface. The reflectivity of a material with no base colour texture now follows its metallic value, defaulted as the specification defaults it; a textured or vertex-coloured one is unchanged."
25
+ }
26
+ ]
27
+ },
28
+ {
29
+ "version": "4.6.1",
30
+ "date": "2026-10-01",
31
+ "entries": [
32
+ {
33
+ "kind": "fixed",
34
+ "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.",
35
+ "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."
36
+ }
37
+ ]
38
+ },
2
39
  {
3
40
  "version": "4.6.0",
4
41
  "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)
@@ -47,6 +47,12 @@ export interface BudgetCounter extends BudgetLine {
47
47
  ask(): void;
48
48
  /** One of them was refused, and the frame is marked. */
49
49
  drop(): void;
50
+ /**
51
+ * The ceiling, where it is a construction-time option and not known when the line is declared.
52
+ * Called at construction, once; a line keeps the place it was declared in, which is the order a
53
+ * report reads in.
54
+ */
55
+ limit(ceiling: number): void;
50
56
  }
51
57
  /**
52
58
  * Every ceiling a backend imposes, counted for the frame just drawn.
@@ -30,6 +30,9 @@ class Line {
30
30
  this.ceiling = ceiling;
31
31
  this.budget = budget;
32
32
  }
33
+ limit(ceiling) {
34
+ this.ceiling = ceiling;
35
+ }
33
36
  /** One more of this thing was asked for, whether or not it will fit. */
34
37
  ask() {
35
38
  this.used += 1;
@@ -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);
@@ -242,6 +242,35 @@ export declare class WebGPURenderer implements RendererApi {
242
242
  */
243
243
  private readonly budget;
244
244
  private readonly drawBudget;
245
+ /**
246
+ * Distinct materials a frame may set: `materialChangesPerFrame`, 1,024 unless a consumer asks.
247
+ *
248
+ * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
249
+ * material rather than how much it draws.
250
+ *
251
+ * **A thousand and twenty-four by default, and it is the expensive one of the two.** A slot is the fragment
252
+ * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
253
+ * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
254
+ * Raising it from 256 costs 5.25 to 9.75 MB.
255
+ *
256
+ * It is worth paying for how it *fails* rather than how it performs. Past the ring
257
+ * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
258
+ * a stretch of the world simply not drawn, which has been reported from a consumer as
259
+ * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
260
+ * ceiling is a budget that ships this the first time somebody adds a model.
261
+ *
262
+ * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
263
+ * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
264
+ * halves in one diff. It is recorded because the wrong failure was the stated argument for
265
+ * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
266
+ * looking in completely different places — one at materials, the other at culling, which is
267
+ * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
268
+ * consumer reads a number instead of a sentence.
269
+ *
270
+ * **What would make it wrong** is a scene switching material every draw: a thousand material
271
+ * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
272
+ * ring size answers — many copies of one mesh want one material and one instanced draw.
273
+ */
245
274
  private readonly materialBudget;
246
275
  /**
247
276
  * Native bind groups built this frame, which is the one cost a consumer could not see.
@@ -2277,7 +2306,7 @@ export declare class WebGPURenderer implements RendererApi {
2277
2306
  * *unchanged*, so a run of entries sharing one material has no shorter spelling and the sink
2278
2307
  * bound once per entry. On this backend that is a cliff rather than a slope: `setMaterial`
2279
2308
  * dirties `materialSlot`, so a bind per entry is a **slot per draw** out of
2280
- * `MAX_MATERIALS_PER_FRAME` — and past the ring `materialSlotForDraw` returns null and the draw
2309
+ * `materialChangesPerFrame` — and past the ring `materialSlotForDraw` returns null and the draw
2281
2310
  * is skipped, which is the world that appears and disappears that constant's own comment
2282
2311
  * describes. A consumer measured 82 materials a model over two to four draws each, and a
2283
2312
  * reflection replaying the frame shares the ring with the pass that recorded it.
@@ -139,7 +139,9 @@ import { LightField } from '../../driftLight/lightField.js';
139
139
  import { WorldLightField } from '../../driftLight/worldLightField.js';
140
140
  import { createDriftLightUniforms, resolveDriftLight } from '../../driftLight/uniforms.js';
141
141
  /**
142
- * How many draws one frame may make before the ring is full.
142
+ * How many draws one frame is prepared for before anything grows: `drawsPerFrame`'s default, and
143
+ * the starting size of the arena and the command pool, which reallocate on demand. The two rings
144
+ * that cannot grow take `drawsPerFrame` itself.
143
145
  *
144
146
  * **Four thousand and ninety-six, and the number is an allocation rather than a limit.**
145
147
  * `UniformRing.flush` uploads `used * slotSize`, so a frame that draws two hundred pays for two
@@ -194,36 +196,6 @@ const MAX_OVERLAYS = 64;
194
196
  * and passes nobody has written yet; the cost is sixteen kilobytes.
195
197
  */
196
198
  const MAX_SKY_DRAWS = 64;
197
- /**
198
- * Distinct materials a frame may set.
199
- *
200
- * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
201
- * material rather than how much it draws.
202
- *
203
- * **A thousand and twenty-four, and it is the expensive one of the two.** A slot is the fragment
204
- * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
205
- * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
206
- * Raising it from 256 costs 5.25 to 9.75 MB.
207
- *
208
- * It is worth paying for how it *fails* rather than how it performs. Past the ring
209
- * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
210
- * a stretch of the world simply not drawn, which has been reported from a consumer as
211
- * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
212
- * ceiling is a budget that ships this the first time somebody adds a model.
213
- *
214
- * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
215
- * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
216
- * halves in one diff. It is recorded because the wrong failure was the stated argument for
217
- * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
218
- * looking in completely different places — one at materials, the other at culling, which is
219
- * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
220
- * consumer reads a number instead of a sentence.
221
- *
222
- * **What would make it wrong** is a scene switching material every draw: a thousand material
223
- * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
224
- * ring size answers — many copies of one mesh want one material and one instanced draw.
225
- */
226
- const MAX_MATERIALS_PER_FRAME = 1024;
227
199
  /**
228
200
  * Whether the environment probe's permutation fits this device's per-stage binding limits.
229
201
  *
@@ -992,8 +964,37 @@ export class WebGPURenderer {
992
964
  * geometry come first.
993
965
  */
994
966
  budget = new FrameBudget();
995
- drawBudget = this.budget.line('draws', MAX_DRAWS_PER_FRAME);
996
- materialBudget = this.budget.line('materials', MAX_MATERIALS_PER_FRAME);
967
+ drawBudget = this.budget.line('draws', null);
968
+ /**
969
+ * Distinct materials a frame may set: `materialChangesPerFrame`, 1,024 unless a consumer asks.
970
+ *
971
+ * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
972
+ * material rather than how much it draws.
973
+ *
974
+ * **A thousand and twenty-four by default, and it is the expensive one of the two.** A slot is the fragment
975
+ * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
976
+ * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
977
+ * Raising it from 256 costs 5.25 to 9.75 MB.
978
+ *
979
+ * It is worth paying for how it *fails* rather than how it performs. Past the ring
980
+ * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
981
+ * a stretch of the world simply not drawn, which has been reported from a consumer as
982
+ * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
983
+ * ceiling is a budget that ships this the first time somebody adds a model.
984
+ *
985
+ * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
986
+ * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
987
+ * halves in one diff. It is recorded because the wrong failure was the stated argument for
988
+ * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
989
+ * looking in completely different places — one at materials, the other at culling, which is
990
+ * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
991
+ * consumer reads a number instead of a sentence.
992
+ *
993
+ * **What would make it wrong** is a scene switching material every draw: a thousand material
994
+ * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
995
+ * ring size answers — many copies of one mesh want one material and one instanced draw.
996
+ */
997
+ materialBudget = this.budget.line('materials', null);
997
998
  /**
998
999
  * Native bind groups built this frame, which is the one cost a consumer could not see.
999
1000
  *
@@ -1004,7 +1005,7 @@ export class WebGPURenderer {
1004
1005
  * in draw order, precisely because the frame reported draws and material changes and never this.
1005
1006
  */
1006
1007
  bindGroupBudget = this.budget.line('bind groups', null);
1007
- shadowDrawBudget = this.budget.line('shadow draws', MAX_DRAWS_PER_FRAME);
1008
+ shadowDrawBudget = this.budget.line('shadow draws', null);
1008
1009
  scatterDepthBudget = this.budget.line('scatter shadow draws', MAX_SCATTER_DEPTH_DRAWS);
1009
1010
  waterBudget = this.budget.line('water bodies', MAX_WATER_BODIES_PER_FRAME);
1010
1011
  lightVolumeBudget = this.budget.line('light volumes', MAX_LIGHT_VOLUMES_PER_FRAME);
@@ -1982,7 +1983,7 @@ export class WebGPURenderer {
1982
1983
  if (!this.canDraw())
1983
1984
  return;
1984
1985
  const geometry = mesh;
1985
- if (geometry.vertexBuffers === undefined)
1986
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
1986
1987
  return;
1987
1988
  const vertexSlot = this.filmVerts.allocate();
1988
1989
  const fragmentSlot = this.filmFrags.allocate();
@@ -3465,8 +3466,9 @@ export class WebGPURenderer {
3465
3466
  return;
3466
3467
  /* Geometry that has not all arrived is not drawn, which is the surface's contract and what
3467
3468
  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)
3469
+ beam from a buffer still filling, and counted it, until 2026-09-19. See `Mesh.complete`. A
3470
+ hull with no triangles draws nothing either: see `GpuMesh.indexCount`. */
3471
+ if (!mesh.complete || mesh.indexCount === 0)
3470
3472
  return;
3471
3473
  /* At zero it draws nothing rather than adding black, so a caller may keep the call in. That
3472
3474
  now covers a clear night too: a beam with no medium to light is not drawn at all. */
@@ -4426,7 +4428,7 @@ export class WebGPURenderer {
4426
4428
  if (cutout !== null && cutoutGroup !== null)
4427
4429
  this.writeCutout(slot, cutout, DEPTH_CUTOUT_VERT_FIELDS);
4428
4430
  const geometry = mesh;
4429
- if (geometry.vertexBuffers === undefined)
4431
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4430
4432
  return;
4431
4433
  const cuts = cutoutGroup !== null;
4432
4434
  /* A two-sided caster casts from whichever face the light sees: nothing culled. */
@@ -4455,7 +4457,7 @@ export class WebGPURenderer {
4455
4457
  return;
4456
4458
  const gpuBatch = batch;
4457
4459
  const geometry = gpuBatch.mesh;
4458
- if (geometry.vertexBuffers === undefined)
4460
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4459
4461
  return;
4460
4462
  const count = Math.min(data.count, gpuBatch.capacity);
4461
4463
  if (count === 0)
@@ -4510,7 +4512,7 @@ export class WebGPURenderer {
4510
4512
  this.shadowDraws.writeFloats(slot, DEPTH_VERT_FIELDS.uModel.offset, model);
4511
4513
  this.writeShadowWind(slot);
4512
4514
  const geometry = mesh;
4513
- if (geometry.vertexBuffers === undefined)
4515
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4514
4516
  return;
4515
4517
  pass.setPipeline(depthPipeline(this.pipelines, this.surface.device, this.shadowSkinnedLayout, `depth-skinned|${this.depthCullMode}|${geometry.key ?? ''}`, this.presentOf(geometry), this.depthCullMode, true));
4516
4518
  pass.setBindGroup(0, this.shadowSkinnedGroup(view, paletteSlot), [slot]);
@@ -4532,7 +4534,7 @@ export class WebGPURenderer {
4532
4534
  if (pass === null || data.count === 0)
4533
4535
  return;
4534
4536
  const geometry = scatter;
4535
- if (geometry.vertexBuffers === undefined)
4537
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
4536
4538
  return;
4537
4539
  const v = this.scatterDepthFloats;
4538
4540
  const at = (name) => (SCATTER_DEPTH_FIELDS[name]?.offset ?? -4) / 4;
@@ -4781,6 +4783,9 @@ export class WebGPURenderer {
4781
4783
  }
4782
4784
  : quality;
4783
4785
  quality = this.quality;
4786
+ this.materialBudget.limit(quality.materialChangesPerFrame);
4787
+ this.drawBudget.limit(quality.drawsPerFrame);
4788
+ this.shadowDrawBudget.limit(quality.drawsPerFrame);
4784
4789
  this.maxDrawingBufferPixels = quality.maxDrawingBufferPixels;
4785
4790
  this.exposure = quality.outputExposure;
4786
4791
  this.flushSchedule =
@@ -4878,7 +4883,7 @@ export class WebGPURenderer {
4878
4883
  this.skinnedBindGroupLayout = createFlatBindGroupLayout(device, this.variant, true);
4879
4884
  this.morphedBindGroupLayout = createFlatBindGroupLayout(device, this.variant, false, true);
4880
4885
  this.bothBindGroupLayout = createFlatBindGroupLayout(device, this.variant, true, true);
4881
- this.perDraw = new UniformRing(device, FLAT_VERT_SIZE, MAX_DRAWS_PER_FRAME, USAGE_UNIFORM_DST, 'flat.vertRing', this.drawBudget);
4886
+ this.perDraw = new UniformRing(device, FLAT_VERT_SIZE, quality.drawsPerFrame, USAGE_UNIFORM_DST, 'flat.vertRing', this.drawBudget);
4882
4887
  this.perFrameStaging = new ArrayBuffer(this.fragment.uniformSize);
4883
4888
  this.perFrameFloats = new Float32Array(this.perFrameStaging);
4884
4889
  this.perFrameInts = new Int32Array(this.perFrameStaging);
@@ -4895,7 +4900,7 @@ export class WebGPURenderer {
4895
4900
  * A slot per *material change* rather than per draw: `drawMesh` reuses the open slot until
4896
4901
  * something dirties it, so a scene drawing a hundred meshes of one material spends one.
4897
4902
  */
4898
- this.perFrame = new UniformRing(device, this.fragment.uniformSize, MAX_MATERIALS_PER_FRAME, USAGE_UNIFORM_DST, 'flat.fragRing', this.materialBudget);
4903
+ this.perFrame = new UniformRing(device, this.fragment.uniformSize, this.quality.materialChangesPerFrame, USAGE_UNIFORM_DST, 'flat.fragRing', this.materialBudget);
4899
4904
  /*
4900
4905
  * A one-pixel stand-in for the albedo binding. `uAlbedoEnabled` is zero for untextured
4901
4906
  * geometry, which is nearly all of it, so the shader never samples this — but WebGPU
@@ -5487,7 +5492,7 @@ export class WebGPURenderer {
5487
5492
  this.shadowCutoutLayout = createDepthBindGroupLayout(device, false, true);
5488
5493
  this.shadowDraws = new UniformRing(device,
5489
5494
  /* The cutout block is the plain one and two fields more, and a slot must hold either. */
5490
- Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE), MAX_DRAWS_PER_FRAME, USAGE_UNIFORM_DST, 'shadow.drawRing', this.shadowDrawBudget);
5495
+ Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE), quality.drawsPerFrame, USAGE_UNIFORM_DST, 'shadow.drawRing', this.shadowDrawBudget);
5491
5496
  const depthPassUniforms = device.createBuffer({
5492
5497
  label: 'depth.passUniforms',
5493
5498
  size: DEPTH_FRAG_SIZE,
@@ -6638,7 +6643,7 @@ export class WebGPURenderer {
6638
6643
  if (slot === null) {
6639
6644
  if (!this.warnedMaterialsFull) {
6640
6645
  this.warnedMaterialsFull = true;
6641
- console.warn(`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.`);
6646
+ console.warn(`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.`);
6642
6647
  }
6643
6648
  return null;
6644
6649
  }
@@ -6823,14 +6828,15 @@ export class WebGPURenderer {
6823
6828
  submitMesh(mesh, model, tint, opacity, blend, options = {}) {
6824
6829
  if (!this.canDraw() || this.viewProj === null)
6825
6830
  return;
6826
- /* Geometry that has not all arrived is not drawn. See `GpuMesh.complete`. */
6827
- if (!mesh.complete)
6831
+ /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `GpuMesh.complete`
6832
+ and `GpuMesh.indexCount`. */
6833
+ if (!mesh.complete || mesh.indexCount === 0)
6828
6834
  return;
6829
6835
  const slot = this.perDraw.allocate();
6830
6836
  if (slot === null) {
6831
6837
  if (!this.warnedFull) {
6832
6838
  this.warnedFull = true;
6833
- console.warn(`WebGPU: more than ${MAX_DRAWS_PER_FRAME} draws in a frame; the rest are skipped`);
6839
+ console.warn(`WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`);
6834
6840
  }
6835
6841
  return;
6836
6842
  }
@@ -6973,7 +6979,7 @@ export class WebGPURenderer {
6973
6979
  /*
6974
6980
  * Guards the draw call only, not the restore below.
6975
6981
  *
6976
- * `materialSlotForDraw` returns null once `MAX_MATERIALS_PER_FRAME` is exhausted, and an
6982
+ * `materialSlotForDraw` returns null once `materialChangesPerFrame` is exhausted, and an
6977
6983
  * early `return` here used to skip the restore too — which left `uOpacity`,
6978
6984
  * `uLightingEnabled` and `uFogEnabled` dirtied in `perFrameFloats`/`perFrameInts` for the
6979
6985
  * rest of the frame, since that scratch block is cumulative state shared by every draw
@@ -7212,7 +7218,8 @@ export class WebGPURenderer {
7212
7218
  if (!this.canDraw() || this.viewProj === null)
7213
7219
  return;
7214
7220
  const mesh = batch.mesh;
7215
- if (!mesh.complete)
7221
+ /* Nor a batch of a mesh with nothing in it: see `GpuMesh.indexCount`. */
7222
+ if (!mesh.complete || mesh.indexCount === 0)
7216
7223
  return;
7217
7224
  const count = Math.min(data.count, batch.capacity);
7218
7225
  if (count === 0)
@@ -7229,7 +7236,7 @@ export class WebGPURenderer {
7229
7236
  if (slot === null) {
7230
7237
  if (!this.warnedFull) {
7231
7238
  this.warnedFull = true;
7232
- console.warn(`WebGPU: more than ${MAX_DRAWS_PER_FRAME} draws in a frame; the rest are skipped`);
7239
+ console.warn(`WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`);
7233
7240
  }
7234
7241
  return;
7235
7242
  }
@@ -7573,7 +7580,7 @@ export class WebGPURenderer {
7573
7580
  * *unchanged*, so a run of entries sharing one material has no shorter spelling and the sink
7574
7581
  * bound once per entry. On this backend that is a cliff rather than a slope: `setMaterial`
7575
7582
  * dirties `materialSlot`, so a bind per entry is a **slot per draw** out of
7576
- * `MAX_MATERIALS_PER_FRAME` — and past the ring `materialSlotForDraw` returns null and the draw
7583
+ * `materialChangesPerFrame` — and past the ring `materialSlotForDraw` returns null and the draw
7577
7584
  * is skipped, which is the world that appears and disappears that constant's own comment
7578
7585
  * describes. A consumer measured 82 materials a model over two to four draws each, and a
7579
7586
  * reflection replaying the frame shares the ring with the pass that recorded it.
@@ -7803,7 +7810,7 @@ export class WebGPURenderer {
7803
7810
  if (pass === null || tint === null)
7804
7811
  return;
7805
7812
  const geometry = mesh;
7806
- if (geometry.vertexBuffers === undefined)
7813
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
7807
7814
  return;
7808
7815
  const cutout = cutoutOf(material);
7809
7816
  const albedo = cutout === null ? null : cutout.albedo;
@@ -7834,7 +7841,7 @@ export class WebGPURenderer {
7834
7841
  return;
7835
7842
  const gpuBatch = batch;
7836
7843
  const geometry = gpuBatch.mesh;
7837
- if (geometry.vertexBuffers === undefined)
7844
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
7838
7845
  return;
7839
7846
  const count = Math.min(data.count, gpuBatch.capacity);
7840
7847
  if (count === 0)
@@ -7867,7 +7874,7 @@ export class WebGPURenderer {
7867
7874
  if (pass === null || tint === null)
7868
7875
  return;
7869
7876
  const geometry = mesh;
7870
- if (geometry.vertexBuffers === undefined)
7877
+ if (geometry.vertexBuffers === undefined || geometry.indexCount === 0)
7871
7878
  return;
7872
7879
  const paletteSlot = this.skinPalettes.take(this.surface.device, palette);
7873
7880
  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
@@ -88,6 +88,37 @@ export interface RenderQuality {
88
88
  * halved frame rate rather than a slightly slower one.
89
89
  */
90
90
  readonly maxDrawingBufferPixels: number;
91
+ /**
92
+ * How many times a frame may change material, on a backend that keeps a slot for each.
93
+ *
94
+ * **WebGPU spends a slot of a uniform ring per material change and skips every draw past it**,
95
+ * because a buffer rewritten between two draws would hand both of them its last contents. So
96
+ * this is a ceiling on what a frame can show, not a performance hint: a scene that needs more
97
+ * loses whatever it asks for last — measured on a bought city of 1,139 materials, whose street
98
+ * spends some nine hundred and whose river's planar mirror spends the rest twice over.
99
+ *
100
+ * **What it costs is memory, paid at construction**: a slot is the fragment block twice over,
101
+ * staging and buffer, so 7 to 13 KB depending on the permutation built — 1,024 is 7 to 13 MB
102
+ * and 2,048 twice that. Hence a default that suits most scenes and an option for the one that
103
+ * measures past it, rather than a number every consumer pays for. WebGL2 writes each uniform
104
+ * into the command stream and keeps no ring, so there it is read and does nothing, and its
105
+ * budget line reports no ceiling. What would make it wrong is a scene that switches material
106
+ * every draw, which no ring answers: many copies of one mesh want one instanced draw.
107
+ */
108
+ readonly materialChangesPerFrame: number;
109
+ /**
110
+ * How many draws a frame may make, on a backend that keeps a uniform slot for each — the main
111
+ * pass and the shadow passes each hold this many.
112
+ *
113
+ * **The same shape as `materialChangesPerFrame` and the same failure**: WebGPU skips every draw
114
+ * past the ring. A frame that draws two hundred pays for two hundred whatever this says, so
115
+ * raising it costs no bandwidth; it costs memory at construction, 1,540 bytes a slot — 6 MB at
116
+ * the default 4,096. A city whose street spends three and a half thousand and whose river
117
+ * mirrors that street spends more than the default, and a scene in the ordinary band of two to
118
+ * ten thousand draws can meet it. WebGL2 keeps no ring and reads this without effect. What would
119
+ * make it wrong is a frame that approaches it on the CPU first, which four thousand draws can.
120
+ */
121
+ readonly drawsPerFrame: number;
91
122
  readonly directionalShadows: boolean;
92
123
  readonly pointShadows: boolean;
93
124
  /**
@@ -810,6 +841,10 @@ export interface RenderQuality {
810
841
  /** Callers override only the levers selected by a future settings profile. */
811
842
  export type RenderQualityOptions = Partial<RenderQuality>;
812
843
  export declare const MAX_SHADOW_FILTER_TAPS = 12;
844
+ /** The most material changes a frame may be built to hold. See `materialChangesPerFrame`. */
845
+ export declare const MAX_MATERIAL_CHANGES_PER_FRAME = 8192;
846
+ /** The most draws a frame may be built to hold. See `drawsPerFrame`. */
847
+ export declare const MAX_DRAWS_PER_FRAME = 65536;
813
848
  export declare const MAX_WATER_REFLECTION_FILTER_TAPS = 9;
814
849
  export declare const POINT_SHADOW_FADE_START = 0.35;
815
850
  export declare const DIRECTIONAL_SHADOW_FADE_START = 0.65;
@@ -4,6 +4,10 @@ import { MAX_GLOBAL_MEDIUM_STEPS } from './globalMedium.js';
4
4
  import { MAX_POINT_LIGHTS } from './lightBudget.js';
5
5
  import { PREFILTER_SAMPLE_COUNTS } from './prefilterEnvMap.js';
6
6
  export const MAX_SHADOW_FILTER_TAPS = 12;
7
+ /** The most material changes a frame may be built to hold. See `materialChangesPerFrame`. */
8
+ export const MAX_MATERIAL_CHANGES_PER_FRAME = 8192;
9
+ /** The most draws a frame may be built to hold. See `drawsPerFrame`. */
10
+ export const MAX_DRAWS_PER_FRAME = 65536;
7
11
  export const MAX_WATER_REFLECTION_FILTER_TAPS = 9;
8
12
  export const POINT_SHADOW_FADE_START = 0.35;
9
13
  export const DIRECTIONAL_SHADOW_FADE_START = 0.65;
@@ -46,6 +50,8 @@ export const DEFAULT_RENDER_QUALITY = Object.freeze({
46
50
  * if it is above where people actually are. Consumers wanting the pixels set it to 0.
47
51
  */
48
52
  maxDrawingBufferPixels: 12_000_000,
53
+ materialChangesPerFrame: 1024,
54
+ drawsPerFrame: 4096,
49
55
  directionalShadows: true,
50
56
  pointShadows: true,
51
57
  /* The engine's own budgets, which the renderer lowers per device when it has to. */
@@ -194,6 +200,8 @@ export function resolveRenderQuality(options = {}) {
194
200
  pointLightFalloff: options.pointLightFalloff ?? DEFAULT_RENDER_QUALITY.pointLightFalloff,
195
201
  maxDevicePixelRatio: options.maxDevicePixelRatio ?? DEFAULT_RENDER_QUALITY.maxDevicePixelRatio,
196
202
  maxDrawingBufferPixels: options.maxDrawingBufferPixels ?? DEFAULT_RENDER_QUALITY.maxDrawingBufferPixels,
203
+ materialChangesPerFrame: options.materialChangesPerFrame ?? DEFAULT_RENDER_QUALITY.materialChangesPerFrame,
204
+ drawsPerFrame: options.drawsPerFrame ?? DEFAULT_RENDER_QUALITY.drawsPerFrame,
197
205
  directionalShadows: options.directionalShadows ?? DEFAULT_RENDER_QUALITY.directionalShadows,
198
206
  pointShadows: options.pointShadows ?? DEFAULT_RENDER_QUALITY.pointShadows,
199
207
  /* Whole slots, at least one, never above what the shader is written for: these two are
@@ -260,6 +268,17 @@ export function resolveRenderQuality(options = {}) {
260
268
  // Zero is legal and means uncapped, so this is the non-negative check rather than
261
269
  // the positive one.
262
270
  nonNegative('maxDrawingBufferPixels', quality.maxDrawingBufferPixels);
271
+ positiveInteger('materialChangesPerFrame', quality.materialChangesPerFrame);
272
+ if (quality.materialChangesPerFrame > MAX_MATERIAL_CHANGES_PER_FRAME) {
273
+ throw new Error(`materialChangesPerFrame is ${quality.materialChangesPerFrame}, and at most ` +
274
+ `${MAX_MATERIAL_CHANGES_PER_FRAME} are kept: past that the ring is a hundred megabytes, ` +
275
+ 'and a frame changing material that often wants its draws batched instead.');
276
+ }
277
+ positiveInteger('drawsPerFrame', quality.drawsPerFrame);
278
+ if (quality.drawsPerFrame > MAX_DRAWS_PER_FRAME) {
279
+ throw new Error(`drawsPerFrame is ${quality.drawsPerFrame}, and at most ${MAX_DRAWS_PER_FRAME} are kept: ` +
280
+ 'past that the rings are a hundred megabytes, and a frame drawing that often wants instancing.');
281
+ }
263
282
  positiveInteger('directionalShadowMapSize', quality.directionalShadowMapSize);
264
283
  positiveInteger('pointShadowFaceSize', quality.pointShadowFaceSize);
265
284
  positiveInteger('shadowFilterTaps', quality.shadowFilterTaps);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
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.7.0",
33
+ "@driftengine/physics": "4.7.0",
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.7.0",
67
+ "@driftengine/network": "4.7.0",
68
+ "@driftengine/texture": "4.7.0"
69
69
  }
70
70
  }
package/scripts/png.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- /** What a decoded PNG is: row-major 8-bit samples, three or four to a pixel. */
1
+ /** What a decoded PNG is: row-major 8-bit samples, one to four to a pixel. */
2
2
  export interface DecodedPng {
3
3
  readonly width: number;
4
4
  readonly height: number;