@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 +37 -0
- package/README.md +1 -1
- package/dist/render/backend/budget.d.ts +6 -0
- package/dist/render/backend/budget.js +3 -0
- package/dist/render/backend/webgl2/renderer.js +11 -7
- package/dist/render/backend/webgpu/buffers.d.ts +7 -0
- package/dist/render/backend/webgpu/motionPass.js +2 -1
- package/dist/render/backend/webgpu/renderer.d.ts +30 -1
- package/dist/render/backend/webgpu/renderer.js +62 -55
- package/dist/render/mesh.d.ts +6 -1
- package/dist/render/mesh.js +6 -1
- package/dist/render/renderQuality.d.ts +35 -0
- package/dist/render/renderQuality.js +19 -0
- package/package.json +6 -6
- package/scripts/png.d.mts +1 -1
- package/scripts/png.mjs +99 -25
- package/src/render/backend/budget.ts +11 -1
- package/src/render/backend/webgl2/renderer.ts +11 -7
- package/src/render/backend/webgpu/buffers.ts +7 -0
- package/src/render/backend/webgpu/motionPass.ts +2 -1
- package/src/render/backend/webgpu/renderer.ts +62 -56
- package/src/render/mesh.ts +6 -1
- package/src/render/renderQuality.ts +55 -0
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 >=
|
|
74
|
-
const b = y > 0 ?
|
|
75
|
-
const c = i >=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
1588
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 ${
|
|
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 `
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
* `
|
|
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;
|
package/src/render/mesh.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|