@driftengine/core 4.6.1 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -586,7 +586,9 @@ import type { WorldLightFieldOptions } from '../../driftLight/worldLightField.ts
586
586
  import { createDriftLightUniforms, resolveDriftLight } from '../../driftLight/uniforms.ts';
587
587
 
588
588
  /**
589
- * How many draws one frame may make before the ring is full.
589
+ * How many draws one frame is prepared for before anything grows: `drawsPerFrame`'s default, and
590
+ * the starting size of the arena and the command pool, which reallocate on demand. The two rings
591
+ * that cannot grow take `drawsPerFrame` itself.
590
592
  *
591
593
  * **Four thousand and ninety-six, and the number is an allocation rather than a limit.**
592
594
  * `UniformRing.flush` uploads `used * slotSize`, so a frame that draws two hundred pays for two
@@ -644,37 +646,6 @@ const MAX_OVERLAYS = 64;
644
646
  */
645
647
  const MAX_SKY_DRAWS = 64;
646
648
 
647
- /**
648
- * Distinct materials a frame may set.
649
- *
650
- * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
651
- * material rather than how much it draws.
652
- *
653
- * **A thousand and twenty-four, and it is the expensive one of the two.** A slot is the fragment
654
- * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
655
- * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
656
- * Raising it from 256 costs 5.25 to 9.75 MB.
657
- *
658
- * It is worth paying for how it *fails* rather than how it performs. Past the ring
659
- * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
660
- * a stretch of the world simply not drawn, which has been reported from a consumer as
661
- * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
662
- * ceiling is a budget that ships this the first time somebody adds a model.
663
- *
664
- * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
665
- * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
666
- * halves in one diff. It is recorded because the wrong failure was the stated argument for
667
- * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
668
- * looking in completely different places — one at materials, the other at culling, which is
669
- * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
670
- * consumer reads a number instead of a sentence.
671
- *
672
- * **What would make it wrong** is a scene switching material every draw: a thousand material
673
- * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
674
- * ring size answers — many copies of one mesh want one material and one instanced draw.
675
- */
676
- const MAX_MATERIALS_PER_FRAME = 1024;
677
-
678
649
  /**
679
650
  * Whether the environment probe's permutation fits this device's per-stage binding limits.
680
651
  *
@@ -1584,8 +1555,37 @@ export class WebGPURenderer implements RendererApi {
1584
1555
  * geometry come first.
1585
1556
  */
1586
1557
  private readonly budget = new FrameBudget();
1587
- private readonly drawBudget = this.budget.line('draws', MAX_DRAWS_PER_FRAME);
1588
- private readonly materialBudget = this.budget.line('materials', MAX_MATERIALS_PER_FRAME);
1558
+ private readonly drawBudget = this.budget.line('draws', null);
1559
+ /**
1560
+ * Distinct materials a frame may set: `materialChangesPerFrame`, 1,024 unless a consumer asks.
1561
+ *
1562
+ * A slot is spent per *change*, not per draw, so this counts how many times a scene switches
1563
+ * material rather than how much it draws.
1564
+ *
1565
+ * **A thousand and twenty-four by default, and it is the expensive one of the two.** A slot is the fragment
1566
+ * block — 3,376 to 6,656 bytes depending on which of the sixteen permutations is built — paid
1567
+ * twice for staging and buffer, so **7,168 to 13,312 bytes a slot** against 1,540 for a draw.
1568
+ * Raising it from 256 costs 5.25 to 9.75 MB.
1569
+ *
1570
+ * It is worth paying for how it *fails* rather than how it performs. Past the ring
1571
+ * `materialSlotForDraw` returns null and **every draw that needed a new material is skipped** —
1572
+ * a stretch of the world simply not drawn, which has been reported from a consumer as
1573
+ * ground that appears and disappears as the camera moves. A budget that hopes to stay under a
1574
+ * ceiling is a budget that ships this the first time somebody adds a model.
1575
+ *
1576
+ * **This comment said "silently reuses the last material" until 2026-09-02, and so did the
1577
+ * warning, and neither was ever true.** The skip has been there since `9d90bf7` wrote both
1578
+ * halves in one diff. It is recorded because the wrong failure was the stated argument for
1579
+ * raising this number from 256, and because "wrong texture" and "no geometry" send a consumer
1580
+ * looking in completely different places — one at materials, the other at culling, which is
1581
+ * where the reporting game spent its weeks. `frameBudget` reports the count now, so the next
1582
+ * consumer reads a number instead of a sentence.
1583
+ *
1584
+ * **What would make it wrong** is a scene switching material every draw: a thousand material
1585
+ * changes is a thousand uniform copies, and a consumer reaching this has a batching problem no
1586
+ * ring size answers — many copies of one mesh want one material and one instanced draw.
1587
+ */
1588
+ private readonly materialBudget = this.budget.line('materials', null);
1589
1589
  /**
1590
1590
  * Native bind groups built this frame, which is the one cost a consumer could not see.
1591
1591
  *
@@ -1596,7 +1596,7 @@ export class WebGPURenderer implements RendererApi {
1596
1596
  * in draw order, precisely because the frame reported draws and material changes and never this.
1597
1597
  */
1598
1598
  private readonly bindGroupBudget = this.budget.line('bind groups', null);
1599
- private readonly shadowDrawBudget = this.budget.line('shadow draws', MAX_DRAWS_PER_FRAME);
1599
+ private readonly shadowDrawBudget = this.budget.line('shadow draws', null);
1600
1600
  private readonly scatterDepthBudget = this.budget.line(
1601
1601
  'scatter shadow draws',
1602
1602
  MAX_SCATTER_DEPTH_DRAWS,
@@ -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
- MAX_DRAWS_PER_FRAME,
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
- MAX_MATERIALS_PER_FRAME,
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
- MAX_DRAWS_PER_FRAME,
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 ${MAX_MATERIALS_PER_FRAME} material changes in a frame; the draws asking for the rest are skipped. renderer.frameBudget names the line and the count.`,
8214
+ `WebGPU: more than ${this.quality.materialChangesPerFrame} material changes in a frame; the draws asking for the rest are skipped. renderer.frameBudget names the line and the count.`,
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 ${MAX_DRAWS_PER_FRAME} draws in a frame; the rest are skipped`,
8438
+ `WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`,
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 `MAX_MATERIALS_PER_FRAME` is exhausted, and an
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 ${MAX_DRAWS_PER_FRAME} draws in a frame; the rest are skipped`,
8894
+ `WebGPU: more than ${this.quality.drawsPerFrame} draws in a frame; the rest are skipped`,
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
- * `MAX_MATERIALS_PER_FRAME` — and past the ring `materialSlotForDraw` returns null and the draw
9278
+ * `materialChangesPerFrame` — and past the ring `materialSlotForDraw` returns null and the draw
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.
@@ -37,7 +37,8 @@ import type { FrameResource } from './frame/index.ts';
37
37
  */
38
38
  export type PassDevice = {
39
39
  /**
40
- * Pre-multiply a projection by this **if your WGSL was generated from GLSL**. Identity on WebGL2.
40
+ * Pre-multiply a projection by this **if your WGSL was generated from GLSL**. On WebGL2 it is
41
+ * the depth remap while that context draws reversed depth, and the identity where it cannot.
41
42
  *
42
43
  * A contributed pass takes its camera from its caller, so it never sees the corrected matrix the
43
44
  * renderer built for its own verbs. Without this it would have to copy four numbers — and a
@@ -58,7 +59,8 @@ export type PassDevice = {
58
59
  */
59
60
  readonly clipCorrection: Float32Array;
60
61
  /**
61
- * Pre-multiply a projection by this **if you wrote your WGSL by hand**. Identity on WebGL2.
62
+ * Pre-multiply a projection by this **if you wrote your WGSL by hand**. On WebGL2 it is the same
63
+ * matrix as `clipCorrection`.
62
64
  *
63
65
  * The same matrix without the Y negation, because there is no generated negation to cancel.
64
66
  * Taking `clipCorrection` in a hand-written shader mirrors the picture vertically — and mirrors
@@ -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
  /**
@@ -573,10 +604,10 @@ export interface RenderQuality {
573
604
  * the output size with no upscaling, which is a temporal antialiaser with a better neighbourhood
574
605
  * rule than `temporalAa`'s. A sentinel that collided with it would make that unaskable.
575
606
  *
576
- * The range is 1.3 to 2 and a value inside it is clamped to the end it is nearest. Below 1.3 the
577
- * render saves less than a third of the fragment work and the resolve's own cost eats it; above 2
578
- * the render is a quarter of the output and no reconstruction holds an edge through that. A
579
- * negative number is off, not a clamp to the bottom: it cannot mean "a little".
607
+ * The range is 1.3 to 2 and a positive value outside it is clamped to the end it is nearest.
608
+ * Below 1.3 the render saves less than a third of the fragment work and the resolve's own cost
609
+ * eats it; above 2 the render is a quarter of the output and no reconstruction holds an edge
610
+ * through that. A negative number is off, not a clamp to the bottom: it cannot mean "a little".
580
611
  *
581
612
  * **What it needs from you.** A `createMover()` per moving object, passed with that object's
582
613
  * draws where `drawMesh` takes last frame's matrix — the renderer keeps last frame's model and,
@@ -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);
@@ -160,7 +160,14 @@ export function recordingGl(
160
160
  and a small answer is read as a device that cannot do the thing. */
161
161
  return 4096;
162
162
  },
163
- getExtension: (name: unknown) => (extensions.has(String(name)) ? {} : null),
163
+ getExtension: (name: unknown) => {
164
+ if (!extensions.has(String(name))) return null;
165
+ /* Clip control is called as well as asked for, so its object has to carry the call. */
166
+ if (name === 'EXT_clip_control') {
167
+ return { LOWER_LEFT_EXT: 0x8ca1, ZERO_TO_ONE_EXT: 0x935f, clipControlEXT: () => undefined };
168
+ }
169
+ return {};
170
+ },
164
171
  getShaderParameter: () => true,
165
172
  /*
166
173
  * A shader remembers its own source, and a program remembers its fragment one, so a link can
@@ -24,7 +24,7 @@
24
24
  * What freezing needs of a world.
25
25
  *
26
26
  * Taken as a parameter rather than imported, on `AGENTS.md`'s rule: core has no entity store and a
27
- * consumer may be holding one of several. Six methods, all of which any store can answer.
27
+ * consumer may be holding one of several. Five methods, all of which any store can answer.
28
28
  */
29
29
  export interface FreezableWorld {
30
30
  /** Every entity whose home is this cell, live or not. */