@driftengine/core 4.6.1 → 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 +26 -0
- package/dist/render/backend/budget.d.ts +6 -0
- package/dist/render/backend/budget.js +3 -0
- package/dist/render/backend/webgpu/renderer.d.ts +30 -1
- package/dist/render/backend/webgpu/renderer.js +46 -42
- 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/webgpu/renderer.ts +46 -43
- package/src/render/renderQuality.ts +55 -0
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
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
|
+
},
|
|
2
28
|
{
|
|
3
29
|
"version": "4.6.1",
|
|
4
30
|
"date": "2026-10-01",
|
|
@@ -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.
|
|
@@ -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
|
-
* `
|
|
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
|
|
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',
|
|
996
|
-
|
|
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',
|
|
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);
|
|
@@ -4782,6 +4783,9 @@ export class WebGPURenderer {
|
|
|
4782
4783
|
}
|
|
4783
4784
|
: quality;
|
|
4784
4785
|
quality = this.quality;
|
|
4786
|
+
this.materialBudget.limit(quality.materialChangesPerFrame);
|
|
4787
|
+
this.drawBudget.limit(quality.drawsPerFrame);
|
|
4788
|
+
this.shadowDrawBudget.limit(quality.drawsPerFrame);
|
|
4785
4789
|
this.maxDrawingBufferPixels = quality.maxDrawingBufferPixels;
|
|
4786
4790
|
this.exposure = quality.outputExposure;
|
|
4787
4791
|
this.flushSchedule =
|
|
@@ -4879,7 +4883,7 @@ export class WebGPURenderer {
|
|
|
4879
4883
|
this.skinnedBindGroupLayout = createFlatBindGroupLayout(device, this.variant, true);
|
|
4880
4884
|
this.morphedBindGroupLayout = createFlatBindGroupLayout(device, this.variant, false, true);
|
|
4881
4885
|
this.bothBindGroupLayout = createFlatBindGroupLayout(device, this.variant, true, true);
|
|
4882
|
-
this.perDraw = new UniformRing(device, FLAT_VERT_SIZE,
|
|
4886
|
+
this.perDraw = new UniformRing(device, FLAT_VERT_SIZE, quality.drawsPerFrame, USAGE_UNIFORM_DST, 'flat.vertRing', this.drawBudget);
|
|
4883
4887
|
this.perFrameStaging = new ArrayBuffer(this.fragment.uniformSize);
|
|
4884
4888
|
this.perFrameFloats = new Float32Array(this.perFrameStaging);
|
|
4885
4889
|
this.perFrameInts = new Int32Array(this.perFrameStaging);
|
|
@@ -4896,7 +4900,7 @@ export class WebGPURenderer {
|
|
|
4896
4900
|
* A slot per *material change* rather than per draw: `drawMesh` reuses the open slot until
|
|
4897
4901
|
* something dirties it, so a scene drawing a hundred meshes of one material spends one.
|
|
4898
4902
|
*/
|
|
4899
|
-
this.perFrame = new UniformRing(device, this.fragment.uniformSize,
|
|
4903
|
+
this.perFrame = new UniformRing(device, this.fragment.uniformSize, this.quality.materialChangesPerFrame, USAGE_UNIFORM_DST, 'flat.fragRing', this.materialBudget);
|
|
4900
4904
|
/*
|
|
4901
4905
|
* A one-pixel stand-in for the albedo binding. `uAlbedoEnabled` is zero for untextured
|
|
4902
4906
|
* geometry, which is nearly all of it, so the shader never samples this — but WebGPU
|
|
@@ -5488,7 +5492,7 @@ export class WebGPURenderer {
|
|
|
5488
5492
|
this.shadowCutoutLayout = createDepthBindGroupLayout(device, false, true);
|
|
5489
5493
|
this.shadowDraws = new UniformRing(device,
|
|
5490
5494
|
/* The cutout block is the plain one and two fields more, and a slot must hold either. */
|
|
5491
|
-
Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE),
|
|
5495
|
+
Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE), quality.drawsPerFrame, USAGE_UNIFORM_DST, 'shadow.drawRing', this.shadowDrawBudget);
|
|
5492
5496
|
const depthPassUniforms = device.createBuffer({
|
|
5493
5497
|
label: 'depth.passUniforms',
|
|
5494
5498
|
size: DEPTH_FRAG_SIZE,
|
|
@@ -6639,7 +6643,7 @@ export class WebGPURenderer {
|
|
|
6639
6643
|
if (slot === null) {
|
|
6640
6644
|
if (!this.warnedMaterialsFull) {
|
|
6641
6645
|
this.warnedMaterialsFull = true;
|
|
6642
|
-
console.warn(`WebGPU: more than ${
|
|
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.`);
|
|
6643
6647
|
}
|
|
6644
6648
|
return null;
|
|
6645
6649
|
}
|
|
@@ -6832,7 +6836,7 @@ export class WebGPURenderer {
|
|
|
6832
6836
|
if (slot === null) {
|
|
6833
6837
|
if (!this.warnedFull) {
|
|
6834
6838
|
this.warnedFull = true;
|
|
6835
|
-
console.warn(`WebGPU: more than ${
|
|
6839
|
+
console.warn(`WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`);
|
|
6836
6840
|
}
|
|
6837
6841
|
return;
|
|
6838
6842
|
}
|
|
@@ -6975,7 +6979,7 @@ export class WebGPURenderer {
|
|
|
6975
6979
|
/*
|
|
6976
6980
|
* Guards the draw call only, not the restore below.
|
|
6977
6981
|
*
|
|
6978
|
-
* `materialSlotForDraw` returns null once `
|
|
6982
|
+
* `materialSlotForDraw` returns null once `materialChangesPerFrame` is exhausted, and an
|
|
6979
6983
|
* early `return` here used to skip the restore too — which left `uOpacity`,
|
|
6980
6984
|
* `uLightingEnabled` and `uFogEnabled` dirtied in `perFrameFloats`/`perFrameInts` for the
|
|
6981
6985
|
* rest of the frame, since that scratch block is cumulative state shared by every draw
|
|
@@ -7232,7 +7236,7 @@ export class WebGPURenderer {
|
|
|
7232
7236
|
if (slot === null) {
|
|
7233
7237
|
if (!this.warnedFull) {
|
|
7234
7238
|
this.warnedFull = true;
|
|
7235
|
-
console.warn(`WebGPU: more than ${
|
|
7239
|
+
console.warn(`WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`);
|
|
7236
7240
|
}
|
|
7237
7241
|
return;
|
|
7238
7242
|
}
|
|
@@ -7576,7 +7580,7 @@ export class WebGPURenderer {
|
|
|
7576
7580
|
* *unchanged*, so a run of entries sharing one material has no shorter spelling and the sink
|
|
7577
7581
|
* bound once per entry. On this backend that is a cliff rather than a slope: `setMaterial`
|
|
7578
7582
|
* dirties `materialSlot`, so a bind per entry is a **slot per draw** out of
|
|
7579
|
-
* `
|
|
7583
|
+
* `materialChangesPerFrame` — and past the ring `materialSlotForDraw` returns null and the draw
|
|
7580
7584
|
* is skipped, which is the world that appears and disappears that constant's own comment
|
|
7581
7585
|
* describes. A consumer measured 82 materials a model over two to four draws each, and a
|
|
7582
7586
|
* reflection replaying the frame shares the ring with the pass that recorded it.
|
|
@@ -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.
|
|
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.
|
|
33
|
-
"@driftengine/physics": "4.
|
|
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.
|
|
67
|
-
"@driftengine/network": "4.
|
|
68
|
-
"@driftengine/texture": "4.
|
|
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
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;
|
|
@@ -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,
|
|
@@ -5909,6 +5909,9 @@ export class WebGPURenderer implements RendererApi {
|
|
|
5909
5909
|
}
|
|
5910
5910
|
: quality;
|
|
5911
5911
|
quality = this.quality;
|
|
5912
|
+
this.materialBudget.limit(quality.materialChangesPerFrame);
|
|
5913
|
+
this.drawBudget.limit(quality.drawsPerFrame);
|
|
5914
|
+
this.shadowDrawBudget.limit(quality.drawsPerFrame);
|
|
5912
5915
|
this.maxDrawingBufferPixels = quality.maxDrawingBufferPixels;
|
|
5913
5916
|
this.exposure = quality.outputExposure;
|
|
5914
5917
|
this.flushSchedule =
|
|
@@ -6014,7 +6017,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
6014
6017
|
this.perDraw = new UniformRing(
|
|
6015
6018
|
device,
|
|
6016
6019
|
FLAT_VERT_SIZE,
|
|
6017
|
-
|
|
6020
|
+
quality.drawsPerFrame,
|
|
6018
6021
|
USAGE_UNIFORM_DST,
|
|
6019
6022
|
'flat.vertRing',
|
|
6020
6023
|
this.drawBudget,
|
|
@@ -6038,7 +6041,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
6038
6041
|
this.perFrame = new UniformRing(
|
|
6039
6042
|
device,
|
|
6040
6043
|
this.fragment.uniformSize,
|
|
6041
|
-
|
|
6044
|
+
this.quality.materialChangesPerFrame,
|
|
6042
6045
|
USAGE_UNIFORM_DST,
|
|
6043
6046
|
'flat.fragRing',
|
|
6044
6047
|
this.materialBudget,
|
|
@@ -6773,7 +6776,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
6773
6776
|
device,
|
|
6774
6777
|
/* The cutout block is the plain one and two fields more, and a slot must hold either. */
|
|
6775
6778
|
Math.max(DEPTH_VERT_SIZE, DEPTH_CUTOUT_VERT_SIZE),
|
|
6776
|
-
|
|
6779
|
+
quality.drawsPerFrame,
|
|
6777
6780
|
USAGE_UNIFORM_DST,
|
|
6778
6781
|
'shadow.drawRing',
|
|
6779
6782
|
this.shadowDrawBudget,
|
|
@@ -8208,7 +8211,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
8208
8211
|
if (!this.warnedMaterialsFull) {
|
|
8209
8212
|
this.warnedMaterialsFull = true;
|
|
8210
8213
|
console.warn(
|
|
8211
|
-
`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.`,
|
|
8212
8215
|
);
|
|
8213
8216
|
}
|
|
8214
8217
|
return null;
|
|
@@ -8432,7 +8435,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
8432
8435
|
if (!this.warnedFull) {
|
|
8433
8436
|
this.warnedFull = true;
|
|
8434
8437
|
console.warn(
|
|
8435
|
-
`WebGPU: more than ${
|
|
8438
|
+
`WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`,
|
|
8436
8439
|
);
|
|
8437
8440
|
}
|
|
8438
8441
|
return;
|
|
@@ -8598,7 +8601,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
8598
8601
|
/*
|
|
8599
8602
|
* Guards the draw call only, not the restore below.
|
|
8600
8603
|
*
|
|
8601
|
-
* `materialSlotForDraw` returns null once `
|
|
8604
|
+
* `materialSlotForDraw` returns null once `materialChangesPerFrame` is exhausted, and an
|
|
8602
8605
|
* early `return` here used to skip the restore too — which left `uOpacity`,
|
|
8603
8606
|
* `uLightingEnabled` and `uFogEnabled` dirtied in `perFrameFloats`/`perFrameInts` for the
|
|
8604
8607
|
* rest of the frame, since that scratch block is cumulative state shared by every draw
|
|
@@ -8888,7 +8891,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
8888
8891
|
if (!this.warnedFull) {
|
|
8889
8892
|
this.warnedFull = true;
|
|
8890
8893
|
console.warn(
|
|
8891
|
-
`WebGPU: more than ${
|
|
8894
|
+
`WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`,
|
|
8892
8895
|
);
|
|
8893
8896
|
}
|
|
8894
8897
|
return;
|
|
@@ -9272,7 +9275,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
9272
9275
|
* *unchanged*, so a run of entries sharing one material has no shorter spelling and the sink
|
|
9273
9276
|
* bound once per entry. On this backend that is a cliff rather than a slope: `setMaterial`
|
|
9274
9277
|
* dirties `materialSlot`, so a bind per entry is a **slot per draw** out of
|
|
9275
|
-
* `
|
|
9278
|
+
* `materialChangesPerFrame` — and past the ring `materialSlotForDraw` returns null and the draw
|
|
9276
9279
|
* is skipped, which is the world that appears and disappears that constant's own comment
|
|
9277
9280
|
* describes. A consumer measured 82 materials a model over two to four draws each, and a
|
|
9278
9281
|
* reflection replaying the frame shares the ring with the pass that recorded it.
|
|
@@ -97,6 +97,37 @@ export interface RenderQuality {
|
|
|
97
97
|
* halved frame rate rather than a slightly slower one.
|
|
98
98
|
*/
|
|
99
99
|
readonly maxDrawingBufferPixels: number;
|
|
100
|
+
/**
|
|
101
|
+
* How many times a frame may change material, on a backend that keeps a slot for each.
|
|
102
|
+
*
|
|
103
|
+
* **WebGPU spends a slot of a uniform ring per material change and skips every draw past it**,
|
|
104
|
+
* because a buffer rewritten between two draws would hand both of them its last contents. So
|
|
105
|
+
* this is a ceiling on what a frame can show, not a performance hint: a scene that needs more
|
|
106
|
+
* loses whatever it asks for last — measured on a bought city of 1,139 materials, whose street
|
|
107
|
+
* spends some nine hundred and whose river's planar mirror spends the rest twice over.
|
|
108
|
+
*
|
|
109
|
+
* **What it costs is memory, paid at construction**: a slot is the fragment block twice over,
|
|
110
|
+
* staging and buffer, so 7 to 13 KB depending on the permutation built — 1,024 is 7 to 13 MB
|
|
111
|
+
* and 2,048 twice that. Hence a default that suits most scenes and an option for the one that
|
|
112
|
+
* measures past it, rather than a number every consumer pays for. WebGL2 writes each uniform
|
|
113
|
+
* into the command stream and keeps no ring, so there it is read and does nothing, and its
|
|
114
|
+
* budget line reports no ceiling. What would make it wrong is a scene that switches material
|
|
115
|
+
* every draw, which no ring answers: many copies of one mesh want one instanced draw.
|
|
116
|
+
*/
|
|
117
|
+
readonly materialChangesPerFrame: number;
|
|
118
|
+
/**
|
|
119
|
+
* How many draws a frame may make, on a backend that keeps a uniform slot for each — the main
|
|
120
|
+
* pass and the shadow passes each hold this many.
|
|
121
|
+
*
|
|
122
|
+
* **The same shape as `materialChangesPerFrame` and the same failure**: WebGPU skips every draw
|
|
123
|
+
* past the ring. A frame that draws two hundred pays for two hundred whatever this says, so
|
|
124
|
+
* raising it costs no bandwidth; it costs memory at construction, 1,540 bytes a slot — 6 MB at
|
|
125
|
+
* the default 4,096. A city whose street spends three and a half thousand and whose river
|
|
126
|
+
* mirrors that street spends more than the default, and a scene in the ordinary band of two to
|
|
127
|
+
* ten thousand draws can meet it. WebGL2 keeps no ring and reads this without effect. What would
|
|
128
|
+
* make it wrong is a frame that approaches it on the CPU first, which four thousand draws can.
|
|
129
|
+
*/
|
|
130
|
+
readonly drawsPerFrame: number;
|
|
100
131
|
readonly directionalShadows: boolean;
|
|
101
132
|
readonly pointShadows: boolean;
|
|
102
133
|
/**
|
|
@@ -821,6 +852,10 @@ export interface RenderQuality {
|
|
|
821
852
|
export type RenderQualityOptions = Partial<RenderQuality>;
|
|
822
853
|
|
|
823
854
|
export const MAX_SHADOW_FILTER_TAPS = 12;
|
|
855
|
+
/** The most material changes a frame may be built to hold. See `materialChangesPerFrame`. */
|
|
856
|
+
export const MAX_MATERIAL_CHANGES_PER_FRAME = 8192;
|
|
857
|
+
/** The most draws a frame may be built to hold. See `drawsPerFrame`. */
|
|
858
|
+
export const MAX_DRAWS_PER_FRAME = 65536;
|
|
824
859
|
export const MAX_WATER_REFLECTION_FILTER_TAPS = 9;
|
|
825
860
|
export const POINT_SHADOW_FADE_START = 0.35;
|
|
826
861
|
export const DIRECTIONAL_SHADOW_FADE_START = 0.65;
|
|
@@ -864,6 +899,8 @@ export const DEFAULT_RENDER_QUALITY: Readonly<RenderQuality> = Object.freeze({
|
|
|
864
899
|
* if it is above where people actually are. Consumers wanting the pixels set it to 0.
|
|
865
900
|
*/
|
|
866
901
|
maxDrawingBufferPixels: 12_000_000,
|
|
902
|
+
materialChangesPerFrame: 1024,
|
|
903
|
+
drawsPerFrame: 4096,
|
|
867
904
|
directionalShadows: true,
|
|
868
905
|
pointShadows: true,
|
|
869
906
|
/* The engine's own budgets, which the renderer lowers per device when it has to. */
|
|
@@ -1015,6 +1052,9 @@ export function resolveRenderQuality(options: RenderQualityOptions = {}): Readon
|
|
|
1015
1052
|
maxDevicePixelRatio: options.maxDevicePixelRatio ?? DEFAULT_RENDER_QUALITY.maxDevicePixelRatio,
|
|
1016
1053
|
maxDrawingBufferPixels:
|
|
1017
1054
|
options.maxDrawingBufferPixels ?? DEFAULT_RENDER_QUALITY.maxDrawingBufferPixels,
|
|
1055
|
+
materialChangesPerFrame:
|
|
1056
|
+
options.materialChangesPerFrame ?? DEFAULT_RENDER_QUALITY.materialChangesPerFrame,
|
|
1057
|
+
drawsPerFrame: options.drawsPerFrame ?? DEFAULT_RENDER_QUALITY.drawsPerFrame,
|
|
1018
1058
|
directionalShadows: options.directionalShadows ?? DEFAULT_RENDER_QUALITY.directionalShadows,
|
|
1019
1059
|
pointShadows: options.pointShadows ?? DEFAULT_RENDER_QUALITY.pointShadows,
|
|
1020
1060
|
/* Whole slots, at least one, never above what the shader is written for: these two are
|
|
@@ -1132,6 +1172,21 @@ export function resolveRenderQuality(options: RenderQualityOptions = {}): Readon
|
|
|
1132
1172
|
// Zero is legal and means uncapped, so this is the non-negative check rather than
|
|
1133
1173
|
// the positive one.
|
|
1134
1174
|
nonNegative('maxDrawingBufferPixels', quality.maxDrawingBufferPixels);
|
|
1175
|
+
positiveInteger('materialChangesPerFrame', quality.materialChangesPerFrame);
|
|
1176
|
+
if (quality.materialChangesPerFrame > MAX_MATERIAL_CHANGES_PER_FRAME) {
|
|
1177
|
+
throw new Error(
|
|
1178
|
+
`materialChangesPerFrame is ${quality.materialChangesPerFrame}, and at most ` +
|
|
1179
|
+
`${MAX_MATERIAL_CHANGES_PER_FRAME} are kept: past that the ring is a hundred megabytes, ` +
|
|
1180
|
+
'and a frame changing material that often wants its draws batched instead.',
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
positiveInteger('drawsPerFrame', quality.drawsPerFrame);
|
|
1184
|
+
if (quality.drawsPerFrame > MAX_DRAWS_PER_FRAME) {
|
|
1185
|
+
throw new Error(
|
|
1186
|
+
`drawsPerFrame is ${quality.drawsPerFrame}, and at most ${MAX_DRAWS_PER_FRAME} are kept: ` +
|
|
1187
|
+
'past that the rings are a hundred megabytes, and a frame drawing that often wants instancing.',
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1135
1190
|
positiveInteger('directionalShadowMapSize', quality.directionalShadowMapSize);
|
|
1136
1191
|
positiveInteger('pointShadowFaceSize', quality.pointShadowFaceSize);
|
|
1137
1192
|
positiveInteger('shadowFilterTaps', quality.shadowFilterTaps);
|