@driftengine/core 4.9.0 → 4.10.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.
Files changed (135) hide show
  1. package/CHANGELOG.json +62 -11
  2. package/README.md +43 -38
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +4 -3
  5. package/dist/render/ambientHarmonics.d.ts +1 -1
  6. package/dist/render/ambientHarmonics.js +1 -1
  7. package/dist/render/areaLights.d.ts +4 -4
  8. package/dist/render/areaLights.js +1 -1
  9. package/dist/render/backend/api.d.ts +10 -3
  10. package/dist/render/backend/select.js +11 -5
  11. package/dist/render/backend/webgl2/boneAnimations.d.ts +29 -0
  12. package/dist/render/backend/webgl2/boneAnimations.js +80 -0
  13. package/dist/render/backend/webgl2/instanced.d.ts +5 -1
  14. package/dist/render/backend/webgl2/instanced.js +13 -1
  15. package/dist/render/backend/webgl2/renderer.d.ts +90 -2
  16. package/dist/render/backend/webgl2/renderer.js +253 -24
  17. package/dist/render/backend/webgl2/skinPaletteTexture.d.ts +5 -3
  18. package/dist/render/backend/webgl2/skinPaletteTexture.js +6 -4
  19. package/dist/render/backend/webgpu/boneAnimations.d.ts +34 -0
  20. package/dist/render/backend/webgpu/boneAnimations.js +76 -0
  21. package/dist/render/backend/webgpu/buffers.d.ts +19 -4
  22. package/dist/render/backend/webgpu/buffers.js +18 -11
  23. package/dist/render/backend/webgpu/compressedUpload.d.ts +0 -1
  24. package/dist/render/backend/webgpu/compressedUpload.js +53 -8
  25. package/dist/render/backend/webgpu/depthPass.d.ts +59 -3
  26. package/dist/render/backend/webgpu/depthPass.js +49 -14
  27. package/dist/render/backend/webgpu/drawCommand.d.ts +18 -4
  28. package/dist/render/backend/webgpu/drawCommand.js +10 -3
  29. package/dist/render/backend/webgpu/flatPass.d.ts +85 -42
  30. package/dist/render/backend/webgpu/flatPass.js +136 -20
  31. package/dist/render/backend/webgpu/instanced.d.ts +14 -1
  32. package/dist/render/backend/webgpu/instanced.js +24 -1
  33. package/dist/render/backend/webgpu/materialStore.d.ts +101 -0
  34. package/dist/render/backend/webgpu/materialStore.js +265 -0
  35. package/dist/render/backend/webgpu/pipelineCache.d.ts +6 -0
  36. package/dist/render/backend/webgpu/pipelineCache.js +7 -0
  37. package/dist/render/backend/webgpu/renderer.d.ts +152 -35
  38. package/dist/render/backend/webgpu/renderer.js +787 -142
  39. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +7 -3
  40. package/dist/render/backend/webgpu/skinPaletteRing.js +13 -7
  41. package/dist/render/backend/webgpu/skinPaletteTexture.d.ts +6 -0
  42. package/dist/render/backend/webgpu/skinPaletteTexture.js +7 -1
  43. package/dist/render/backend/webgpu/staticBundles.d.ts +125 -0
  44. package/dist/render/backend/webgpu/staticBundles.js +241 -0
  45. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +10 -3
  46. package/dist/render/backend/webgpu/surfaceTexturePass.js +20 -2
  47. package/dist/render/backend/webgpu/uniformRing.d.ts +21 -0
  48. package/dist/render/backend/webgpu/uniformRing.js +9 -1
  49. package/dist/render/bloomChain.d.ts +3 -3
  50. package/dist/render/boneAnimation.d.ts +92 -0
  51. package/dist/render/boneAnimation.js +133 -0
  52. package/dist/render/compressedSource.d.ts +33 -13
  53. package/dist/render/compressedSource.js +70 -25
  54. package/dist/render/glCompressed.js +83 -0
  55. package/dist/render/instances.d.ts +26 -0
  56. package/dist/render/lightBudget.d.ts +10 -0
  57. package/dist/render/lightBudget.js +10 -0
  58. package/dist/render/lightmap.d.ts +1 -1
  59. package/dist/render/mesh.d.ts +5 -0
  60. package/dist/render/mesh.js +6 -0
  61. package/dist/render/pointLightSelection.d.ts +4 -4
  62. package/dist/render/screenSpaceReflection.d.ts +2 -1
  63. package/dist/render/shaders/boneAnimation.d.ts +19 -0
  64. package/dist/render/shaders/boneAnimation.js +63 -0
  65. package/dist/render/shaders/depth.d.ts +7 -0
  66. package/dist/render/shaders/depth.js +35 -10
  67. package/dist/render/shaders/flat/index.d.ts +6 -0
  68. package/dist/render/shaders/flat/index.js +27 -5
  69. package/dist/render/shaders/flat/main.d.ts +1 -1
  70. package/dist/render/shaders/flat/main.js +15 -5
  71. package/dist/render/shaders/flat/models.js +1 -1
  72. package/dist/render/shaders/flat/overlay.js +1 -1
  73. package/dist/render/shaders/flat/pointShadow.js +2 -2
  74. package/dist/render/shaders/flat/preamble.js +38 -24
  75. package/dist/render/shaders/generated/depth.wgsl.d.ts +93 -0
  76. package/dist/render/shaders/generated/depth.wgsl.js +93 -0
  77. package/dist/render/shaders/generated/flat.wgsl.d.ts +3077 -2903
  78. package/dist/render/shaders/generated/flat.wgsl.js +3100 -2925
  79. package/dist/render/shaders/outputTransform.d.ts +7 -0
  80. package/dist/render/shaders/outputTransform.js +28 -14
  81. package/dist/render/shaders/vertexChannel.d.ts +7 -0
  82. package/dist/render/shaders/vertexChannel.js +26 -0
  83. package/dist/render/staticDraws.d.ts +47 -0
  84. package/dist/render/staticDraws.js +43 -0
  85. package/dist/render/surfaceTexture.d.ts +38 -7
  86. package/dist/render/surfaceTexture.js +56 -17
  87. package/package.json +6 -6
  88. package/src/index.ts +8 -2
  89. package/src/render/ambientHarmonics.ts +1 -1
  90. package/src/render/areaLights.ts +4 -4
  91. package/src/render/backend/api.ts +13 -3
  92. package/src/render/backend/select.ts +10 -5
  93. package/src/render/backend/webgl2/boneAnimations.ts +105 -0
  94. package/src/render/backend/webgl2/instanced.ts +20 -1
  95. package/src/render/backend/webgl2/renderer.ts +315 -24
  96. package/src/render/backend/webgl2/skinPaletteTexture.ts +6 -4
  97. package/src/render/backend/webgpu/boneAnimations.ts +97 -0
  98. package/src/render/backend/webgpu/buffers.ts +42 -16
  99. package/src/render/backend/webgpu/compressedUpload.ts +59 -9
  100. package/src/render/backend/webgpu/depthPass.ts +51 -10
  101. package/src/render/backend/webgpu/drawCommand.ts +22 -4
  102. package/src/render/backend/webgpu/flatPass.ts +187 -14
  103. package/src/render/backend/webgpu/instanced.ts +32 -1
  104. package/src/render/backend/webgpu/materialStore.ts +276 -0
  105. package/src/render/backend/webgpu/pipelineCache.ts +7 -0
  106. package/src/render/backend/webgpu/renderer.ts +928 -155
  107. package/src/render/backend/webgpu/skinPaletteRing.ts +13 -7
  108. package/src/render/backend/webgpu/skinPaletteTexture.ts +7 -1
  109. package/src/render/backend/webgpu/staticBundles.ts +363 -0
  110. package/src/render/backend/webgpu/surfaceTexturePass.ts +29 -3
  111. package/src/render/backend/webgpu/uniformRing.ts +10 -1
  112. package/src/render/bloomChain.ts +3 -3
  113. package/src/render/boneAnimation.ts +223 -0
  114. package/src/render/compressedSource.ts +121 -29
  115. package/src/render/glCompressed.ts +83 -0
  116. package/src/render/instances.ts +27 -0
  117. package/src/render/lightBudget.ts +11 -0
  118. package/src/render/lightmap.ts +1 -1
  119. package/src/render/mesh.ts +6 -0
  120. package/src/render/pointLightSelection.ts +4 -4
  121. package/src/render/screenSpaceReflection.ts +2 -1
  122. package/src/render/shaders/boneAnimation.ts +63 -0
  123. package/src/render/shaders/depth.ts +45 -10
  124. package/src/render/shaders/flat/index.ts +33 -5
  125. package/src/render/shaders/flat/main.ts +15 -5
  126. package/src/render/shaders/flat/models.ts +1 -1
  127. package/src/render/shaders/flat/overlay.ts +1 -1
  128. package/src/render/shaders/flat/pointShadow.ts +2 -2
  129. package/src/render/shaders/flat/preamble.ts +38 -24
  130. package/src/render/shaders/generated/depth.wgsl.ts +95 -0
  131. package/src/render/shaders/generated/flat.wgsl.ts +3100 -2925
  132. package/src/render/shaders/outputTransform.ts +30 -14
  133. package/src/render/shaders/vertexChannel.ts +27 -0
  134. package/src/render/staticDraws.ts +95 -0
  135. package/src/render/surfaceTexture.ts +94 -27
@@ -199,10 +199,14 @@ import {
199
199
  resolveFrameReflections,
200
200
  type ResolvedFrameReflections,
201
201
  } from '../../frameReflections.ts';
202
- import { ReflectionSurfacePass } from './reflectionSurfacePass.ts';
202
+ import { REFLECTION_SURFACE_FORMAT, ReflectionSurfacePass } from './reflectionSurfacePass.ts';
203
203
  import { validateClothBinding } from '../../clothBindingData.ts';
204
204
  import type { ClothBindingData } from '../../clothBindingData.ts';
205
- import type { CompressedTextureFormat, SurfaceSource } from '../../compressedSource.ts';
205
+ import type {
206
+ CompressedTextureFormat,
207
+ CompressedTextureSource,
208
+ SurfaceSource,
209
+ } from '../../compressedSource.ts';
206
210
  import {
207
211
  createSdfTextBindGroup,
208
212
  createSdfTextBindGroupLayout,
@@ -412,16 +416,25 @@ import {
412
416
  SUN_STATIC_LAYER,
413
417
  } from '../../shadowMap.ts';
414
418
  import type { SceneCasterMaterial, ShadowCasterSink, ShadowCasters } from '../../shadowCasters.ts';
415
- import { VERTEX_LAYOUT, createGpuMesh, createGpuMeshIncremental, type GpuMesh } from './buffers.ts';
419
+ import {
420
+ VERTEX_LAYOUT,
421
+ createGpuMesh,
422
+ createGpuMeshIncremental,
423
+ type GpuMesh,
424
+ type StagedRewrite,
425
+ } from './buffers.ts';
416
426
  import type { GpuSurface } from './device.ts';
417
427
  import { SkinPaletteRing } from './skinPaletteRing.ts';
418
428
  import {
419
429
  DEPTH_FORMAT,
430
+ FLAT_ANIMATED_VERT_FIELDS,
420
431
  FLAT_VERT_FIELDS,
421
432
  FLAT_VERT_SIZE,
433
+ FLAT_VIEW_FIELDS,
434
+ FLAT_VIEW_SIZE,
422
435
  createFlatBindGroup,
423
436
  createFlatBindGroupLayout,
424
- flatFragmentBindings,
437
+ flatFragmentScratch,
425
438
  flatVariant,
426
439
  flatVertexBindings,
427
440
  type FlatVariant,
@@ -429,7 +442,15 @@ import {
429
442
  flatPipelineAsync,
430
443
  probeStageCeilings,
431
444
  } from './flatPass.ts';
432
- import type { OitTarget } from './flatPass.ts';
445
+ import type { AnimationViews, FlatFragmentScratch, OitTarget } from './flatPass.ts';
446
+ import { GpuBoneAnimation } from './boneAnimations.ts';
447
+ import { packBoneAnimation } from '../../boneAnimation.ts';
448
+ import type { BoneAnimationClip, BoneAnimationHandle } from '../../boneAnimation.ts';
449
+ import { MaterialStore, hashWords } from './materialStore.ts';
450
+ import { GpuStaticDraws, encodeBundle, takeRecorded } from './staticBundles.ts';
451
+ import type { StaticRecording } from './staticBundles.ts';
452
+ import { captureStaticDraws, replayStaticDraws } from '../../staticDraws.ts';
453
+ import type { StaticDrawsHandle } from '../../staticDraws.ts';
433
454
  import {
434
455
  DEPTH_FRAG_BINDING,
435
456
  DEPTH_FRAG_SIZE,
@@ -437,6 +458,8 @@ import {
437
458
  DEPTH_PREVIOUS_BINDING,
438
459
  DEPTH_CUTOUT_VERT_FIELDS,
439
460
  DEPTH_CUTOUT_VERT_SIZE,
461
+ DEPTH_INSTANCED_ANIMATED_CUTOUT_VERT_FIELDS,
462
+ DEPTH_INSTANCED_ANIMATED_VERT_FIELDS,
440
463
  DEPTH_INSTANCED_CUTOUT_VERT_FIELDS,
441
464
  DEPTH_VERT_FIELDS,
442
465
  DEPTH_VERT_SIZE,
@@ -909,6 +932,14 @@ const MAX_WATER_BODIES_PER_FRAME = 16;
909
932
  */
910
933
  const MAX_BATCHES_PER_FRAME = 8;
911
934
 
935
+ /**
936
+ * Mesh passes a frame may open before the ring grows: the frame's own, a mirror's, a capture's, and
937
+ * a probe face's, which gives its slot back once submitted. A frame past it loses the draws of the
938
+ * passes that found no slot, counted on the `materials` line, and the ring is grown for the next.
939
+ * Each slot is the pass's block, 3,328 to 6,656 bytes by variant, so the ring is 213 to 426 KB.
940
+ */
941
+ const PASSES_PER_FRAME = 64;
942
+
912
943
  /** Buffer usage flags, from the specification. See `buffers.ts` for why they are not read off the global. */
913
944
  const USAGE_UNIFORM_DST = 0x0040 | 0x0008;
914
945
 
@@ -1423,6 +1454,15 @@ interface SunTintTarget {
1423
1454
  readonly mips: readonly [SnapshotMips, SnapshotMips];
1424
1455
  }
1425
1456
 
1457
+ /** A skinned or morphed flat group, and what it was built with, compared by identity. */
1458
+ interface SkinnedTwin {
1459
+ readonly group: GPUBindGroup;
1460
+ readonly palette: GPUTextureView | null;
1461
+ readonly deltas: GPUTextureView | null;
1462
+ readonly cloth: GPUTextureView | null;
1463
+ readonly clothBinding: GPUTextureView | null;
1464
+ }
1465
+
1426
1466
  export class WebGPURenderer implements RendererApi {
1427
1467
  /**
1428
1468
  * Real timestamp queries, where the adapter offers them.
@@ -1506,6 +1546,8 @@ export class WebGPURenderer implements RendererApi {
1506
1546
  private readonly skinnedBindGroupLayout: GPUBindGroupLayout;
1507
1547
  private readonly morphedBindGroupLayout: GPUBindGroupLayout;
1508
1548
  private readonly bothBindGroupLayout: GPUBindGroupLayout;
1549
+ /** The instanced layout of a batch playing a bone animation. See `animatedGroup`. */
1550
+ private readonly animatedBindGroupLayout: GPUBindGroupLayout;
1509
1551
  /** The morph weights the following draws use, or null for none. */
1510
1552
  private morphWeights: Float32Array | null = null;
1511
1553
  /**
@@ -1518,14 +1560,15 @@ export class WebGPURenderer implements RendererApi {
1518
1560
  * group per character per frame — an allocation in the frame loop, which `AGENTS.md` forbids.
1519
1561
  * Keyed by slot instead, the groups are built once and reused for the life of the renderer.
1520
1562
  */
1521
- private readonly skinnedGroups: (GPUBindGroup | null)[] = [];
1522
- /** The unskinned group each was built beside, for identity-based invalidation. */
1523
- private readonly skinnedGroupsBeside: (GPUBindGroup | null)[] = [];
1524
- private readonly skinnedGroupsPalette: (GPUTextureView | null)[] = [];
1525
- private readonly skinnedGroupsDeltas: (GPUTextureView | null)[] = [];
1526
- /** The cloth views each skinned twin was built with, compared by identity like the palette. */
1527
- private readonly skinnedGroupsCloth: (GPUTextureView | null)[] = [];
1528
- private readonly skinnedGroupsClothBinding: (GPUTextureView | null)[] = [];
1563
+ /*
1564
+ * **And by material within a slot**, keyed by the unskinned group each was built beside. One
1565
+ * group a slot was rebuilt whenever the material beside it changed, so a character drawn in
1566
+ * thirty-odd sections, each its own material, built one a section a frame — about ninety a frame
1567
+ * for two characters, reported by a consumer. A slot's map holds one entry a material its rigs
1568
+ * are drawn in, and is emptied wherever the unskinned groups are, whose identities it is keyed
1569
+ * by.
1570
+ */
1571
+ private readonly skinnedGroups: (Map<GPUBindGroup, SkinnedTwin> | undefined)[] = [];
1529
1572
  /** The cloth the next skinned draws are placed by, or null. See `setCloth`. */
1530
1573
  private cloth: { binding: GpuClothBinding; particles: GpuClothParticles } | null = null;
1531
1574
  private clothStandCache: { texture: GPUTexture; views: ClothViews } | null = null;
@@ -1595,22 +1638,51 @@ export class WebGPURenderer implements RendererApi {
1595
1638
  /** Every distinct cache above, so a mesh's pipelines are built for each. One entry when aliased. */
1596
1639
  private readonly pipelineTargets: readonly PipelineCache[];
1597
1640
  private readonly bindGroupLayout: GPUBindGroupLayout;
1598
- /** One slot per draw, uploaded once a frame. See `UniformRing`. */
1599
- private readonly perDraw: UniformRing;
1600
- /** The lights, fog and camera, settled once per frame by `bindMeshPass`. */
1601
- private readonly perFrame: UniformRing;
1641
+ /** One slot per draw, uploaded once a frame. See `UniformRing`. Swapped while a static list records. */
1642
+ private perDraw: UniformRing;
1602
1643
  /**
1603
- * The material block as the CPU holds it, patched by the setters and copied into a slot.
1644
+ * The lights, fog and camera, a slot per mesh pass a frame opens: `bindMeshPass` writes them, and
1645
+ * the first draw after it takes a slot. A ring rather than one buffer because a frame opens more
1646
+ * than one pass — a mirror's, a capture's, a probe face's — and a queue write does not interleave
1647
+ * with the draws recorded between them. See `UniformRing`.
1648
+ */
1649
+ private passRing: UniformRing;
1650
+ /** Each material's block, kept across frames for as long as it does not change. */
1651
+ private readonly materialStore: MaterialStore;
1652
+ /**
1653
+ * Both blocks as the CPU holds them, the pass's then the material's, patched by name and copied
1654
+ * into a slot by the next draw after something changed them. See `FlatFragmentScratch`.
1604
1655
  *
1605
1656
  * Authoritative between draws: a setter changes one field here and marks the block dirty, and
1606
- * the next `drawMesh` takes a slot and copies the whole thing in. That keeps a setter free of
1607
- * any knowledge of slots, which is what lets it be called anywhere a scene wants.
1657
+ * the next `drawMesh` places it. That keeps a setter free of any knowledge of slots, which is
1658
+ * what lets it be called anywhere a scene wants.
1608
1659
  */
1609
1660
  private readonly perFrameStaging: ArrayBuffer;
1610
1661
  private readonly perFrameFloats: Float32Array;
1611
1662
  private readonly perFrameInts: Int32Array;
1663
+ /** The two halves of the scratch, as the words a slot is written from. */
1664
+ private readonly passInts: Int32Array;
1665
+ private readonly materialInts: Int32Array;
1612
1666
  /** The slot the open material occupies, and whether the next draw must take a new one. */
1613
1667
  private readonly materials = new MaterialChanges();
1668
+ /** The same for the pass's block: reopened by `bindMeshPass` and by nothing a material sets. */
1669
+ private readonly passBlock = new MaterialChanges();
1670
+ /**
1671
+ * The vertex stage's view block — the camera, the light's matrix, the wind — a slot a pass, and a
1672
+ * second, unjittered, for a blended draw landing after the upscale. Reopened with the pass, and by
1673
+ * `setWind` and `beginShadowPass`, which change what it holds.
1674
+ */
1675
+ private viewRing: UniformRing;
1676
+ private readonly viewBlock = new MaterialChanges();
1677
+ private readonly lateViewBlock = new MaterialChanges();
1678
+ /** The view slot the draw being submitted bound, read by the halves it keeps for later passes. */
1679
+ private drawViewOffset = 0;
1680
+ /** Pass slots asked for and refused this frame, and materials refused: what grows the two. */
1681
+ private passesAsked = 0;
1682
+ private passesRefused = 0;
1683
+ private viewsAsked = 0;
1684
+ private viewsRefused = 0;
1685
+ private materialsRefused = 0;
1614
1686
 
1615
1687
  /*
1616
1688
  * The frame graph's recording, and the machinery that replays it.
@@ -1879,8 +1951,8 @@ export class WebGPURenderer implements RendererApi {
1879
1951
  private readonly variant: FlatVariant;
1880
1952
  /** Whether this profile compiled the point-shadow path, and so has sentinels to write. */
1881
1953
  private readonly pointShadowsCompiled: boolean;
1882
- /** What the generator recorded about that permutation: sizes, offsets and bindings. */
1883
- private readonly fragment: ReturnType<typeof flatFragmentBindings>;
1954
+ /** What the generator recorded about that permutation, as the renderer's scratch lays it out. */
1955
+ private readonly fragment: FlatFragmentScratch;
1884
1956
 
1885
1957
  private depth: GPUTexture | null = null;
1886
1958
  private depthView: GPUTextureView | null = null;
@@ -2624,7 +2696,7 @@ export class WebGPURenderer implements RendererApi {
2624
2696
  if (this.quality.frameGraph) recordNode(this.arena, VERB_SCOPE, 0, MIRROR_TARGET, 0, 0);
2625
2697
  this.pass?.end();
2626
2698
  this.flushRings();
2627
- this.surface.device.queue.submit([this.encoder.finish()]);
2699
+ this.submit([this.encoder.finish()]);
2628
2700
  this.encoder = this.surface.device.createCommandEncoder({ label: 'reflection' });
2629
2701
  /*
2630
2702
  * **Opened on demand, like the frame's own.** Nothing has been recorded into the mirror yet,
@@ -2684,7 +2756,8 @@ export class WebGPURenderer implements RendererApi {
2684
2756
  this.windStreakFrags,
2685
2757
  this.scatterVerts,
2686
2758
  this.scatterFrags,
2687
- this.perFrame,
2759
+ this.passRing,
2760
+ this.viewRing,
2688
2761
  this.filmVerts,
2689
2762
  this.filmFrags,
2690
2763
  this.insetUniforms,
@@ -2705,6 +2778,7 @@ export class WebGPURenderer implements RendererApi {
2705
2778
  /* Culls first: a submit that follows may carry a draw reading what they write. */
2706
2779
  this.instanceCulls?.flush();
2707
2780
  for (const ring of this.rings()) ring.flush();
2781
+ this.materialStore.flush();
2708
2782
  }
2709
2783
 
2710
2784
  /** Where every ring stands before work that submits an encoder of its own. */
@@ -2724,6 +2798,8 @@ export class WebGPURenderer implements RendererApi {
2724
2798
  private rewindRings(skinMark: number): void {
2725
2799
  const rings = this.rings();
2726
2800
  for (let i = 0; i < rings.length; i++) (rings[i] as UniformRing).rewind(this.ringMarks[i] ?? 0);
2801
+ /* The open pass may sit in a slot just given back, which the next pass would overwrite. */
2802
+ this.reopenPass();
2727
2803
  this.skinPalettes.rewind(skinMark);
2728
2804
  if (this.skinPaletteSlot >= skinMark) this.skinPaletteSlot = -1;
2729
2805
  }
@@ -2926,7 +3002,7 @@ export class WebGPURenderer implements RendererApi {
2926
3002
  * ones that move something *every* group holds: the shadow cubemaps, the probe fence, the cookie
2927
3003
  * atlas and the IES rows. None of those is a frame path.
2928
3004
  */
2929
- private readonly flatBindGroups = new Map<
3005
+ private flatBindGroups = new Map<
2930
3006
  GpuSurfaceTexture | null,
2931
3007
  Map<
2932
3008
  GpuSurfaceTexture | null,
@@ -3097,7 +3173,7 @@ export class WebGPURenderer implements RendererApi {
3097
3173
  pass.end();
3098
3174
  this.pass = null;
3099
3175
  this.flushRings();
3100
- device.queue.submit([encoder.finish()]);
3176
+ this.submit([encoder.finish()]);
3101
3177
  this.rewindRings(skinMark);
3102
3178
  this.encoderEpoch = frameEpoch;
3103
3179
  this.encoder = savedEncoder;
@@ -3105,6 +3181,7 @@ export class WebGPURenderer implements RendererApi {
3105
3181
  this.perFrameInts[this.materialField('uOutputTransform')] = heldGrade;
3106
3182
  this.perFrameFloats[this.materialField('uOutputExposure')] = heldExposure;
3107
3183
  this.materials.dirty();
3184
+ this.reopenPass();
3108
3185
  this.capturePassActive = false;
3109
3186
  }
3110
3187
  return true;
@@ -3119,11 +3196,15 @@ export class WebGPURenderer implements RendererApi {
3119
3196
  */
3120
3197
  private readonly retiredTextures: GPUTexture[] = [];
3121
3198
 
3122
- updateSurfaceTexture(texture: GpuSurfaceTexture, source: TexImageSource): void {
3199
+ updateSurfaceTexture(
3200
+ texture: GpuSurfaceTexture,
3201
+ source: TexImageSource | CompressedTextureSource,
3202
+ ): void {
3123
3203
  if (this.surface.lost) return;
3124
- const replaced = texture.update(source);
3204
+ const replaced = texture.update(source, this.compressedFormats);
3125
3205
  if (replaced === null) return;
3126
- /* A new size is a new view, so every group holding the old one is dropped — see below. */
3206
+ /* A new size or format is a new view, so every group holding the old one is dropped — see
3207
+ below. */
3127
3208
  this.retiredTextures.push(replaced);
3128
3209
  this.forgetBindingsOf(texture);
3129
3210
  }
@@ -3175,7 +3256,9 @@ export class WebGPURenderer implements RendererApi {
3175
3256
  /* Same reasoning, for whichever SDF labels were bound against this atlas. */
3176
3257
  this.sdfTextBindGroups.delete(texture);
3177
3258
  this.flatBindGroups.clear();
3259
+ this.flatGroupEpoch += 1;
3178
3260
  this.overlayGroups.clear();
3261
+ this.skinnedGroups.length = 0;
3179
3262
  /*
3180
3263
  * The blank one is built with the albedo held aside, because `buildFlatBindGroup` reads
3181
3264
  * `this.albedo` for that slot — leaving it in place would put a real colour map into the
@@ -3367,6 +3450,13 @@ export class WebGPURenderer implements RendererApi {
3367
3450
  const emissiveMap = (material?.emissive ?? null) as GpuSurfaceTexture | null;
3368
3451
  const modelMap = (material?.modelMap ?? null) as GpuSurfaceTexture | null;
3369
3452
  this.materialModel = material?.model ?? null;
3453
+ const reflectivity = material?.reflectivity;
3454
+ const environmentGain = material?.environmentGain;
3455
+ this.materialReflectivity =
3456
+ reflectivity === undefined ? null : Math.min(1, Math.max(0, reflectivity));
3457
+ this.materialEnvironmentGain =
3458
+ environmentGain === undefined ? null : Math.max(0, environmentGain);
3459
+ this.writeEnvironmentDials();
3370
3460
  /* What the blur spreads this skin by: the last material to name a profile says. */
3371
3461
  const model = material?.model;
3372
3462
  warnUnpagedLightmap(model, modelMap);
@@ -3395,7 +3485,8 @@ export class WebGPURenderer implements RendererApi {
3395
3485
  const cut = this.materialField('uCutout');
3396
3486
  f[cut] = cutoff;
3397
3487
  f[cut + 1] = CUTOUT_RESOLVE_CODE[this.cutoutResolveStaged];
3398
- f[cut + 2] = this.cutoutFrame;
3488
+ /* z unused: the dither's frame is the pass's, in `uHighlightMin.w`. See `uCutout`. */
3489
+ f[cut + 2] = 0;
3399
3490
  /* The material's lighting channels, which the lamps' are compared with. See `uCutout`. */
3400
3491
  f[cut + 3] = lightChannelsOf(material?.lightChannels);
3401
3492
  i[flags + 3] = material?.doubleSided === true ? 1 : 0;
@@ -3585,15 +3676,31 @@ export class WebGPURenderer implements RendererApi {
3585
3676
 
3586
3677
  /** How reflective the surfaces drawn next are. Clamped, as `renderer.ts` clamps it. */
3587
3678
  setSurfaceReflectivity(amount: number): void {
3588
- this.material((f) => {
3589
- f[this.materialField('uEnvironmentDials') + 1] = Math.min(1, Math.max(0, amount));
3590
- });
3679
+ this.passReflectivity = Math.min(1, Math.max(0, amount));
3680
+ this.writeEnvironmentDials();
3591
3681
  }
3592
3682
 
3593
3683
  /** How bright the environment the surfaces drawn next reflect is. See `renderer.ts`. */
3594
3684
  setEnvironmentGain(gain: number): void {
3685
+ this.passEnvironmentGain = Math.max(0, gain);
3686
+ this.writeEnvironmentDials();
3687
+ }
3688
+
3689
+ /**
3690
+ * The reflectivity and environment gain the setters gave the pass, and the ones the material
3691
+ * set now states, or null where it states none (`SurfaceMaterial.reflectivity`). A draw wears the
3692
+ * material's where it has one and the pass's otherwise, the rule `renderer.ts` keeps too.
3693
+ */
3694
+ private passReflectivity = 0;
3695
+ private passEnvironmentGain = 1;
3696
+ private materialReflectivity: number | null = null;
3697
+ private materialEnvironmentGain: number | null = null;
3698
+
3699
+ private writeEnvironmentDials(): void {
3595
3700
  this.material((f) => {
3596
- f[this.materialField('uEnvironmentDials') + 2] = Math.max(0, gain);
3701
+ const dials = this.materialField('uEnvironmentDials');
3702
+ f[dials + 1] = this.materialReflectivity ?? this.passReflectivity;
3703
+ f[dials + 2] = this.materialEnvironmentGain ?? this.passEnvironmentGain;
3597
3704
  });
3598
3705
  }
3599
3706
 
@@ -4112,7 +4219,7 @@ export class WebGPURenderer implements RendererApi {
4112
4219
  }
4113
4220
  this.pass?.end();
4114
4221
  this.flushRings();
4115
- this.surface.device.queue.submit([this.encoder.finish()]);
4222
+ this.submit([this.encoder.finish()]);
4116
4223
  this.encoder = this.surface.device.createCommandEncoder({ label: 'frame.resumed' });
4117
4224
  this.reflectionPassActive = false;
4118
4225
  this.reflectionReady = true;
@@ -5795,6 +5902,7 @@ export class WebGPURenderer implements RendererApi {
5795
5902
  */
5796
5903
  setWind(windX: number, windZ: number, windGust: number, timeSeconds: number): void {
5797
5904
  resolveScatterDeform(windX, windZ, windGust, timeSeconds, null, this.frameWind);
5905
+ this.reopenViews();
5798
5906
  }
5799
5907
 
5800
5908
  /**
@@ -5814,22 +5922,6 @@ export class WebGPURenderer implements RendererApi {
5814
5922
  this.shadowDraws.writeFloats(slot, DEPTH_VERT_FIELDS.uWindSpatialPhase.offset, d.spatialPhase);
5815
5923
  }
5816
5924
 
5817
- /**
5818
- * The five wind fields of one per-draw slot.
5819
- *
5820
- * **Never called for an instanced draw.** That variant declares none of these, and
5821
- * `flatPass.ts` records what writing a field a variant does not declare costs: the block is
5822
- * shorter, so the write lands on whatever occupies that offset instead.
5823
- */
5824
- private writeWind(slot: number): void {
5825
- const d = this.frameWind;
5826
- this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uWindDirection.offset, d.direction);
5827
- this.perDraw.writeFloat(slot, FLAT_VERT_FIELDS.uWindSpeed.offset, d.bend);
5828
- this.perDraw.writeFloat(slot, FLAT_VERT_FIELDS.uWindGust.offset, d.gust);
5829
- this.perDraw.writeFloat(slot, FLAT_VERT_FIELDS.uWindTime.offset, d.time);
5830
- this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uWindSpatialPhase.offset, d.spatialPhase);
5831
- }
5832
-
5833
5925
  /** Refilled per frame rather than allocated; see `resolvePointLights`. */
5834
5926
  private readonly lights: ResolvedPointLights = {
5835
5927
  count: 0,
@@ -5984,6 +6076,9 @@ export class WebGPURenderer implements RendererApi {
5984
6076
  * at submit, and a texture's view is new when an update changes its size.
5985
6077
  */
5986
6078
  private readonly shadowCutoutLayout: GPUBindGroupLayout;
6079
+ /** A crowd's casters: the plain and cutout layouts with a bone animation's three textures. */
6080
+ private readonly shadowAnimatedLayout: GPUBindGroupLayout;
6081
+ private readonly shadowAnimatedCutoutLayout: GPUBindGroupLayout;
5987
6082
  private readonly cutoutGroups = new Map<GpuSurfaceTexture, GPUBindGroup>();
5988
6083
  private readonly cutoutPeelGroups = new Map<GpuSurfaceTexture, GPUBindGroup>();
5989
6084
  private shadowPeelPassUniforms: GPUBuffer | null = null;
@@ -6239,8 +6334,18 @@ export class WebGPURenderer implements RendererApi {
6239
6334
  const count = Math.min(data.count, gpuBatch.capacity);
6240
6335
  if (count === 0) return;
6241
6336
  const cutout = cutoutOf(material);
6337
+ /* A crowd casts where its clip has moved it, through the animated variant. */
6338
+ const animation = gpuBatch.animation === null ? null : this.animationViews(gpuBatch);
6339
+ if (gpuBatch.animation !== null && animation === null) return;
6340
+ const cutoutTexture =
6341
+ cutout === null ? null : (cutout.albedo as unknown as GpuSurfaceTexture);
6242
6342
  const cutoutGroup =
6243
- cutout === null ? null : this.cutoutGroup(cutout.albedo as unknown as GpuSurfaceTexture);
6343
+ animation !== null
6344
+ ? this.animatedDepthGroup(gpuBatch, animation, cutoutTexture)
6345
+ : cutoutTexture === null
6346
+ ? null
6347
+ : this.cutoutGroup(cutoutTexture);
6348
+ if (animation !== null && cutoutGroup === null) return;
6244
6349
 
6245
6350
  const slot = this.shadowDraws.allocate();
6246
6351
  if (slot === null) return;
@@ -6251,23 +6356,43 @@ export class WebGPURenderer implements RendererApi {
6251
6356
  DEPTH_VERT_FIELDS.uLightViewProj.offset,
6252
6357
  this.correctedLightViewProj,
6253
6358
  );
6254
- if (cutout !== null && cutoutGroup !== null) {
6359
+ const cuts = cutoutTexture !== null && cutoutGroup !== null;
6360
+ if (cutout !== null && cuts) {
6255
6361
  this.writeCutout(slot, cutout, DEPTH_INSTANCED_CUTOUT_VERT_FIELDS);
6256
6362
  }
6363
+ const clip = gpuBatch.animation;
6364
+ if (animation !== null && clip !== null) {
6365
+ const fields = cuts
6366
+ ? DEPTH_INSTANCED_ANIMATED_CUTOUT_VERT_FIELDS
6367
+ : DEPTH_INSTANCED_ANIMATED_VERT_FIELDS;
6368
+ this.shadowDraws.writeFloat(slot, fields.uSceneTime.offset, this.animationTime);
6369
+ this.shadowDraws.writeFloat(slot, fields.uBoneClip.offset, clip.framesPerSecond);
6370
+ this.shadowDraws.writeFloat(slot, fields.uBoneClip.offset + 4, clip.boneScale);
6371
+ }
6257
6372
 
6258
- const cuts = cutoutGroup !== null;
6259
6373
  const cull = material?.doubleSided === true ? 'none' : this.depthCullMode;
6374
+ const animated = animation !== null;
6260
6375
  pass.setPipeline(
6261
6376
  depthPipeline(
6262
6377
  this.pipelines,
6263
6378
  this.surface.device,
6264
- cuts ? this.shadowCutoutLayout : this.shadowLayout,
6265
- `${cuts ? 'depth-instanced-cutout' : 'depth-instanced'}|${cull}|${geometry.key ?? ''}`,
6379
+ animated
6380
+ ? cuts
6381
+ ? this.shadowAnimatedCutoutLayout
6382
+ : this.shadowAnimatedLayout
6383
+ : cuts
6384
+ ? this.shadowCutoutLayout
6385
+ : this.shadowLayout,
6386
+ `${cuts ? 'depth-instanced-cutout' : 'depth-instanced'}${animated ? '-animated' : ''}` +
6387
+ `|${cull}|${geometry.key ?? ''}`,
6266
6388
  this.presentOf(geometry),
6267
6389
  cull,
6268
6390
  false,
6269
6391
  true,
6270
6392
  cuts,
6393
+ false,
6394
+ false,
6395
+ animated,
6271
6396
  ),
6272
6397
  );
6273
6398
  pass.setBindGroup(0, cutoutGroup ?? this.shadowGroup(), [slot]);
@@ -6781,7 +6906,7 @@ export class WebGPURenderer implements RendererApi {
6781
6906
  pointShadows: quality.pointShadows,
6782
6907
  });
6783
6908
  this.pointShadowsCompiled = quality.pointShadows;
6784
- this.fragment = flatFragmentBindings(this.variant);
6909
+ this.fragment = flatFragmentScratch(this.variant);
6785
6910
  this.bindGroupLayout = createFlatBindGroupLayout(device, this.variant);
6786
6911
  /*
6787
6912
  * The skinned twin, built eagerly because it is a descriptor rather than a compile — WebGPU
@@ -6791,6 +6916,14 @@ export class WebGPURenderer implements RendererApi {
6791
6916
  this.skinnedBindGroupLayout = createFlatBindGroupLayout(device, this.variant, true);
6792
6917
  this.morphedBindGroupLayout = createFlatBindGroupLayout(device, this.variant, false, true);
6793
6918
  this.bothBindGroupLayout = createFlatBindGroupLayout(device, this.variant, true, true);
6919
+ /* And the instanced twin that plays a bone animation: its clip's and clocks' textures. */
6920
+ this.animatedBindGroupLayout = createFlatBindGroupLayout(
6921
+ device,
6922
+ this.variant,
6923
+ false,
6924
+ false,
6925
+ true,
6926
+ );
6794
6927
  this.perDraw = new UniformRing(
6795
6928
  device,
6796
6929
  FLAT_VERT_SIZE,
@@ -6799,30 +6932,48 @@ export class WebGPURenderer implements RendererApi {
6799
6932
  'flat.vertRing',
6800
6933
  this.drawBudget,
6801
6934
  );
6802
- this.perFrameStaging = new ArrayBuffer(this.fragment.uniformSize);
6935
+ const { passSize, materialSize } = this.fragment;
6936
+ this.perFrameStaging = new ArrayBuffer(passSize + materialSize);
6803
6937
  this.perFrameFloats = new Float32Array(this.perFrameStaging);
6804
6938
  this.perFrameInts = new Int32Array(this.perFrameStaging);
6939
+ this.passInts = new Int32Array(this.perFrameStaging, 0, passSize / 4);
6940
+ this.materialInts = new Int32Array(this.perFrameStaging, passSize, materialSize / 4);
6805
6941
  /*
6806
- * **A ring, because the block holds material state and a material changes between draws.**
6942
+ * **Two blocks, because they change at two rates.** `setSurfaceTexture`, `setSurfaceGrain` and
6943
+ * their siblings are pass state on both backends — a material covers many draws — and on WebGL2
6944
+ * each is a `uniform1f` that lands immediately. Here they live in a block, and
6945
+ * `queue.writeBuffer` does not interleave with recorded commands, so one buffer rewritten between
6946
+ * two draws gives *both* the second value: every surface would wear the last material set, which
6947
+ * is bug 7's shape at pass scale. So each draw binds a slot.
6807
6948
  *
6808
- * `setSurfaceTexture`, `setSurfaceGrain` and their siblings are pass state on both backends
6809
- * — a material covers many draws, so the alternative is rebinding the same values twenty
6810
- * times to say the same thing. On WebGL2 each is a `uniform1f` that lands immediately. Here
6811
- * they live in this block, and `queue.writeBuffer` does not interleave with recorded
6812
- * commands, so one buffer rewritten between two draws gives *both* the second value. Every
6813
- * surface in a scene would wear the last material set, which is bug 7's shape at pass scale.
6814
- *
6815
- * A slot per *material change* rather than per draw: `drawMesh` reuses the open slot until
6816
- * something dirties it, so a scene drawing a hundred meshes of one material spends one.
6949
+ * It was one block until 4.10.0, lights and all, copied whole into a ring slot per material
6950
+ * change and uploaded every frame: a stage of four hundred materials sent 4.8 MB a frame to say
6951
+ * what it had said the frame before. Now the pass's block takes a ring slot per pass, and a
6952
+ * material's block a slot of the store, where an unchanged material stays from frame to frame.
6817
6953
  */
6818
- this.perFrame = new UniformRing(
6954
+ this.passRing = new UniformRing(
6955
+ device,
6956
+ passSize,
6957
+ PASSES_PER_FRAME,
6958
+ USAGE_UNIFORM_DST,
6959
+ 'flat.passRing',
6960
+ );
6961
+ this.materialStore = new MaterialStore(
6819
6962
  device,
6820
- this.fragment.uniformSize,
6963
+ materialSize,
6821
6964
  this.quality.materialChangesPerFrame,
6822
6965
  USAGE_UNIFORM_DST,
6823
- 'flat.fragRing',
6966
+ 'flat.materials',
6824
6967
  this.materialBudget,
6825
6968
  );
6969
+ /* Two a pass at most: the jittered view and the unjittered one. */
6970
+ this.viewRing = new UniformRing(
6971
+ device,
6972
+ FLAT_VIEW_SIZE,
6973
+ 2 * PASSES_PER_FRAME,
6974
+ USAGE_UNIFORM_DST,
6975
+ 'flat.viewRing',
6976
+ );
6826
6977
 
6827
6978
  /*
6828
6979
  * A one-pixel stand-in for the albedo binding. The albedo's flag is zero for untextured
@@ -6971,7 +7122,7 @@ export class WebGPURenderer implements RendererApi {
6971
7122
  })
6972
7123
  .end();
6973
7124
  }
6974
- device.queue.submit([clear.finish()]);
7125
+ this.submit([clear.finish()]);
6975
7126
  /*
6976
7127
  * `nearest` on a depth texture sampled as an ordinary float, matching what
6977
7128
  * `shadowMap.ts` asks WebGL2 for. A filtering sampler cannot be used on a depth format
@@ -7584,6 +7735,8 @@ export class WebGPURenderer implements RendererApi {
7584
7735
  this.shadowLayout = createDepthBindGroupLayout(device);
7585
7736
  this.shadowSkinnedLayout = createDepthBindGroupLayout(device, true);
7586
7737
  this.shadowCutoutLayout = createDepthBindGroupLayout(device, false, true);
7738
+ this.shadowAnimatedLayout = createDepthBindGroupLayout(device, false, false, true);
7739
+ this.shadowAnimatedCutoutLayout = createDepthBindGroupLayout(device, false, true, true);
7587
7740
  this.shadowDraws = new UniformRing(
7588
7741
  device,
7589
7742
  /* The cutout block is the plain one and two fields more, and a slot must hold either. */
@@ -8095,9 +8248,18 @@ export class WebGPURenderer implements RendererApi {
8095
8248
  this.lineVerts.reset();
8096
8249
  this.lineFrags.reset();
8097
8250
  this.skyUniforms.reset();
8098
- /* The material ring, and the open slot with it: last frame's slots are gone. */
8099
- this.perFrame.reset();
8251
+ /* The pass ring, and the open slots with it: last frame's pass slots are gone. Its materials
8252
+ stay in the store, which a draw finds them in again. */
8253
+ this.passRing.reset();
8254
+ this.viewRing.reset();
8255
+ this.materialStore.beginFrame();
8256
+ this.passesAsked = 0;
8257
+ this.passesRefused = 0;
8258
+ this.viewsAsked = 0;
8259
+ this.viewsRefused = 0;
8260
+ this.materialsRefused = 0;
8100
8261
  this.materials.dirty();
8262
+ this.reopenPass();
8101
8263
  /* Spent by `runMotion` at the end of the last frame; a frame states its movers afresh. */
8102
8264
  this.motionPass?.reset();
8103
8265
  this.latePass?.reset();
@@ -8432,6 +8594,40 @@ export class WebGPURenderer implements RendererApi {
8432
8594
  };
8433
8595
  }
8434
8596
 
8597
+ /**
8598
+ * The copies this frame's mesh rewrites owe (`StagedRewrite`), recorded since the last submit and
8599
+ * sent at the head of the next one: one encoder however many meshes are rewritten. Null while
8600
+ * nothing is owed.
8601
+ */
8602
+ private pendingRewrites: GPUCommandEncoder | null = null;
8603
+ /** One record handed to every staged rewrite, refilled each call, so a rewrite allocates nothing. */
8604
+ private readonly stagedRewrite: StagedRewrite = {
8605
+ encoder: null as unknown as GPUCommandEncoder,
8606
+ staging: null as unknown as GPUBuffer,
8607
+ previous: null,
8608
+ };
8609
+
8610
+ private rewriteCopies(): GPUCommandEncoder {
8611
+ this.pendingRewrites ??= this.surface.device.createCommandEncoder({
8612
+ label: 'mesh.rewriteCopies',
8613
+ });
8614
+ return this.pendingRewrites;
8615
+ }
8616
+
8617
+ /**
8618
+ * Submit `buffers`, led by any copies the frame's mesh rewrites owe. **Every submit of this
8619
+ * renderer goes through here**, because any of them may draw a rewritten mesh — the frame's own,
8620
+ * a mirror's, a shadow's, a probe's — and the copies must be ahead of the first that does.
8621
+ */
8622
+ private submit(buffers: GPUCommandBuffer[]): void {
8623
+ const rewrites = this.pendingRewrites;
8624
+ if (rewrites !== null) {
8625
+ this.pendingRewrites = null;
8626
+ buffers.unshift(rewrites.finish());
8627
+ }
8628
+ this.surface.device.queue.submit(buffers);
8629
+ }
8630
+
8435
8631
  /**
8436
8632
  * Rewrite a mesh's positions, and its normals where the caller has them.
8437
8633
  *
@@ -8454,20 +8650,35 @@ export class WebGPURenderer implements RendererApi {
8454
8650
  * `changeFrames.ts`.
8455
8651
  */
8456
8652
  const motion = mesh.motion;
8457
- let previous: GPUBuffer | null = null;
8653
+ let staged: StagedRewrite | null = null;
8458
8654
  if (motion !== null && this.reconstructionWanted) {
8459
8655
  const frame = frameOfChange(this.moverFrame, this.inFrame);
8656
+ const size = mesh.vertexBuffers[0]?.size ?? 0;
8657
+ let previous: GPUBuffer | null = null;
8460
8658
  if (keepsPrevious(motion.changed, frame)) {
8461
8659
  motion.previous ??= this.surface.device.createBuffer({
8462
8660
  label: 'recon.dynamicPrevious',
8463
- size: mesh.vertexBuffers[0]?.size ?? 0,
8661
+ size,
8464
8662
  usage: 0x0020 | 0x0008, // VERTEX | COPY_DST
8465
8663
  });
8466
8664
  previous = motion.previous;
8467
8665
  }
8468
8666
  motion.changed = frame;
8667
+ /*
8668
+ * **Staged, and copied in behind the copy aside, in an encoder the frame's next submit leads
8669
+ * with**: no submit of the rewrite's own. See `StagedRewrite` and `submit`.
8670
+ */
8671
+ motion.staging ??= this.surface.device.createBuffer({
8672
+ label: 'mesh.stagedPositions',
8673
+ size,
8674
+ usage: 0x0004 | 0x0008, // COPY_SRC | COPY_DST
8675
+ });
8676
+ staged = this.stagedRewrite;
8677
+ staged.encoder = this.rewriteCopies();
8678
+ staged.staging = motion.staging;
8679
+ staged.previous = previous;
8469
8680
  }
8470
- mesh.update(this.surface.device, positions, normals, previous);
8681
+ mesh.update(this.surface.device, positions, normals, staged);
8471
8682
  }
8472
8683
 
8473
8684
  /**
@@ -8671,6 +8882,11 @@ export class WebGPURenderer implements RendererApi {
8671
8882
  * number, so this is a copy and is marked as one; the two must move together.
8672
8883
  */
8673
8884
  f[at('uGrain')] = 1;
8885
+ /* No reflection and an environment at its own brightness, from the pass and from no material. */
8886
+ this.passReflectivity = 0;
8887
+ this.passEnvironmentGain = 1;
8888
+ this.materialReflectivity = null;
8889
+ this.materialEnvironmentGain = null;
8674
8890
  f[at('uEnvironmentDials') + 1] = 0;
8675
8891
  /* No weighting and no dither until a pass or a crossfade asks. See `setDitherFade`. */
8676
8892
  f[at('uWriteMode')] = 0;
@@ -8785,6 +9001,8 @@ export class WebGPURenderer implements RendererApi {
8785
9001
  */
8786
9002
  if (this.quality.nightEmissive) f[at('uNightEmissive')] = env.nightEmissive ?? 0;
8787
9003
  f.set(env.highlightMin, at('uHighlightMin'));
9004
+ /* The frame the cutout dither moves with, in the near corner's w. See `uHighlightMin`. */
9005
+ f[at('uHighlightMin') + 3] = this.cutoutFrame;
8788
9006
  f.set(env.highlightMax, at('uHighlightMax'));
8789
9007
  /* The gain in the far corner's w. See `uHighlightMax`. */
8790
9008
  f[at('uHighlightMax') + 3] = env.highlightGain;
@@ -9057,6 +9275,7 @@ export class WebGPURenderer implements RendererApi {
9057
9275
  * same terms here, which is why a pass cannot inherit a material from the one before it.
9058
9276
  */
9059
9277
  this.materials.dirty();
9278
+ this.reopenPass();
9060
9279
  /*
9061
9280
  * A pass starts with no material, exactly as `renderer.ts` resets these terms — **every map,
9062
9281
  * not the albedo alone**. `setMaterial` skips the lookup when the maps it is given are the ones
@@ -9081,31 +9300,114 @@ export class WebGPURenderer implements RendererApi {
9081
9300
  }
9082
9301
 
9083
9302
  /**
9084
- * The slot the next draw's material lives in, taking a new one when something dirtied it.
9303
+ * The slot the next draw's material lives in, placing it when something dirtied it, with the
9304
+ * pass's slot taken first where `bindMeshPass` reopened it: `passOffset` is that one's offset.
9085
9305
  *
9086
- * **A copy per material change, not per draw.** A scene drawing a hundred meshes of one
9087
- * material spends one slot and one copy; a scene switching material every draw spends one
9088
- * each, which is what it asked for.
9306
+ * **A placement per material change, not per draw.** A scene drawing a hundred meshes of one
9307
+ * material places it once; a scene switching material every draw places one each, and a material
9308
+ * the store already holds — the same numbers as a draw before it, this frame or an earlier one —
9309
+ * is bound where it is and uploads nothing.
9089
9310
  */
9090
9311
  private materialSlotForDraw(): number | null {
9312
+ if (!this.passBlock.open) {
9313
+ this.passesAsked += 1;
9314
+ const pass = this.passRing.allocate();
9315
+ if (pass === null) {
9316
+ this.passesRefused += 1;
9317
+ this.materialBudget.ask();
9318
+ this.materialBudget.drop();
9319
+ if (!this.warnedPassesFull) {
9320
+ this.warnedPassesFull = true;
9321
+ console.warn(
9322
+ `WebGPU: more than ${this.passRing.slots} mesh passes drew in one frame; the draws of the rest are skipped this frame and the ring grows for the next.`,
9323
+ );
9324
+ }
9325
+ return null;
9326
+ }
9327
+ this.passRing.writeBlock(pass, this.passInts);
9328
+ this.passBlock.slot = pass;
9329
+ }
9091
9330
  if (this.materials.open) return this.materials.slot;
9092
- const slot = this.perFrame.allocate();
9331
+ const slot = this.materialStore.place(this.materialInts);
9332
+ if (slot === null) {
9333
+ this.materialsRefused += 1;
9334
+ this.warnMaterialsFull();
9335
+ return null;
9336
+ }
9337
+ this.materials.slot = slot;
9338
+ return slot;
9339
+ }
9340
+
9341
+ /** The pass slot `materialSlotForDraw` left open, read beside the material's by every lit draw. */
9342
+ private get passOffset(): number {
9343
+ return this.passBlock.slot;
9344
+ }
9345
+
9346
+ /** A new pass: its block, and both its views, are taken afresh by the next draw. */
9347
+ private reopenPass(): void {
9348
+ this.passBlock.dirty();
9349
+ this.reopenViews();
9350
+ }
9351
+
9352
+ /** What a view holds changed — the wind, the light's matrix — so the next draw takes a new one. */
9353
+ private reopenViews(): void {
9354
+ this.viewBlock.dirty();
9355
+ this.lateViewBlock.dirty();
9356
+ }
9357
+
9358
+ /**
9359
+ * The view slot a lit draw reads its camera from: the pass's, or its unjittered twin for a blended
9360
+ * draw landing after the upscale. Taken by the first draw that needs it after the pass, the wind or
9361
+ * the light's matrix changed, and shared by every draw until then. Null when the ring is full, which
9362
+ * skips the draw and grows the ring for the next frame, as the pass ring does.
9363
+ */
9364
+ private viewSlotFor(late: boolean, viewProj: Float32Array): number | null {
9365
+ const block = late ? this.lateViewBlock : this.viewBlock;
9366
+ if (block.open) return block.slot;
9367
+ this.viewsAsked += 1;
9368
+ const slot = this.viewRing.allocate();
9093
9369
  if (slot === null) {
9094
- if (!this.warnedMaterialsFull) {
9095
- this.warnedMaterialsFull = true;
9370
+ this.viewsRefused += 1;
9371
+ this.materialBudget.ask();
9372
+ this.materialBudget.drop();
9373
+ if (!this.warnedPassesFull) {
9374
+ this.warnedPassesFull = true;
9096
9375
  console.warn(
9097
- `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.`,
9376
+ `WebGPU: more than ${this.viewRing.slots} views drew in one frame; the draws of the rest are skipped this frame and the ring grows for the next.`,
9098
9377
  );
9099
9378
  }
9100
9379
  return null;
9101
9380
  }
9102
- this.perFrame.writeBlock(slot, this.perFrameInts);
9103
- this.materials.slot = slot;
9381
+ this.writeView(this.viewRing, slot, viewProj);
9382
+ block.slot = slot;
9104
9383
  return slot;
9105
9384
  }
9106
9385
 
9386
+ /** A view block: the camera, the light's matrix and the frame's wind, into `ring` at `slot`. */
9387
+ private writeView(ring: UniformRing, slot: number, viewProj: Float32Array): void {
9388
+ ring.writeFloats(slot, FLAT_VIEW_FIELDS.uViewProj.offset, viewProj);
9389
+ ring.writeFloats(slot, FLAT_VIEW_FIELDS.uLightViewProj.offset, this.lightViewProj);
9390
+ /* The scene's clock, which a bone animation's instances read their moment from. */
9391
+ ring.writeFloat(slot, FLAT_VIEW_FIELDS.uSceneTime.offset, this.animationTime);
9392
+ const d = this.frameWind;
9393
+ ring.writeFloats(slot, FLAT_VIEW_FIELDS.uWindDirection.offset, d.direction);
9394
+ ring.writeFloat(slot, FLAT_VIEW_FIELDS.uWindSpeed.offset, d.bend);
9395
+ ring.writeFloat(slot, FLAT_VIEW_FIELDS.uWindGust.offset, d.gust);
9396
+ ring.writeFloat(slot, FLAT_VIEW_FIELDS.uWindTime.offset, d.time);
9397
+ ring.writeFloats(slot, FLAT_VIEW_FIELDS.uWindSpatialPhase.offset, d.spatialPhase);
9398
+ }
9399
+
9400
+ private warnMaterialsFull(): void {
9401
+ if (this.warnedMaterialsFull) return;
9402
+ this.warnedMaterialsFull = true;
9403
+ console.warn(
9404
+ `WebGPU: more than ${this.quality.materialChangesPerFrame} distinct materials in a frame; the draws asking for the rest are skipped. renderer.frameBudget names the line and the count.`,
9405
+ );
9406
+ }
9407
+
9107
9408
  /** Said once rather than every frame, for the reason `warnedFull` gives. */
9108
9409
  private warnedMaterialsFull = false;
9410
+ private warnedPassesFull = false;
9109
9411
  private warnedSkyFull = false;
9110
9412
  private warnedParticlesTwice = false;
9111
9413
  /**
@@ -9146,7 +9448,14 @@ export class WebGPURenderer implements RendererApi {
9146
9448
  * What this saves is the GPU's share, which for a heavy mesh is most of the cost and for a
9147
9449
  * trivial one is very little.
9148
9450
  */
9149
- if (this.quality.cullDraws && !this.visible(mesh.bounds, model as ReadonlyMat4)) return;
9451
+ /* A static list draws every entry in every view, since a bundle cannot leave one out. */
9452
+ if (
9453
+ this.quality.cullDraws &&
9454
+ this.staticRecording === null &&
9455
+ !this.visible(mesh.bounds, model as ReadonlyMat4)
9456
+ ) {
9457
+ return;
9458
+ }
9150
9459
  /*
9151
9460
  * And the other half of the same question, where the profile asked for one. Gated on
9152
9461
  * `cullDraws` alongside the frustum test, because both are the renderer doing a consumer's
@@ -9337,21 +9646,16 @@ export class WebGPURenderer implements RendererApi {
9337
9646
  `TranslucentMeshOptions.reconstructed`. */
9338
9647
  const lands = blend && options.reconstructed !== true && this.drawsLate();
9339
9648
  const late = lands || (blend && this.oitReplaying && this.reconstructing);
9340
- this.perDraw.writeFloats(
9341
- slot,
9342
- FLAT_VERT_FIELDS.uViewProj.offset,
9343
- late ? this.correctedViewProj : this.viewProj,
9344
- );
9649
+ /* The camera, the light's matrix and the wind are the pass's, in its view block. */
9650
+ const view = this.viewSlotFor(late, late ? this.correctedViewProj : this.viewProj);
9651
+ if (view === null) return;
9652
+ this.drawViewOffset = view;
9345
9653
  this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uModel.offset, model);
9346
9654
  this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uTint.offset, tint ?? WHITE);
9347
- /* The frame's wind, for a mesh carrying a sway lane. Written on this path and not the
9348
- instanced one, which declares none of these fields. */
9349
- this.writeWind(slot);
9350
9655
  /* The material's repeats, not a constant: geometry authored in metres is textured at
9351
9656
  whatever density the material asks for without rebuilding it. */
9352
9657
  this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uUvScale.offset, this.uvScale);
9353
9658
  this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uUvOffset.offset, this.uvOffset);
9354
- this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uLightViewProj.offset, this.lightViewProj);
9355
9659
  /*
9356
9660
  * Whether this mesh's tangent frame is real. WebGPU has no disabled attribute, so location 10
9357
9661
  * is always backed — by the caller's data or by the constant buffer — which makes the binding
@@ -9548,8 +9852,10 @@ export class WebGPURenderer implements RendererApi {
9548
9852
  command.pipeline = pipeline;
9549
9853
  command.bindGroup = this.flatBindGroupFor(skinned, morphed, deltas);
9550
9854
  command.offsetA = slot;
9551
- command.offsetB = material;
9552
- command.offsetCount = 2;
9855
+ command.offsetB = this.passOffset;
9856
+ command.offsetC = material;
9857
+ command.offsetD = this.drawViewOffset;
9858
+ command.offsetCount = 4;
9553
9859
  for (let index = 0; index < mesh.vertexBuffers.length; index++) {
9554
9860
  command.vertexBuffers[index] = mesh.vertexBuffers[index] as GPUBuffer;
9555
9861
  }
@@ -9561,7 +9867,12 @@ export class WebGPURenderer implements RendererApi {
9561
9867
  const pass = this.openPass();
9562
9868
  if (pass === null) return;
9563
9869
  pass.setPipeline(pipeline);
9564
- pass.setBindGroup(0, this.flatBindGroupFor(skinned, morphed, deltas), [slot, material]);
9870
+ pass.setBindGroup(0, this.flatBindGroupFor(skinned, morphed, deltas), [
9871
+ slot,
9872
+ this.passOffset,
9873
+ material,
9874
+ this.drawViewOffset,
9875
+ ]);
9565
9876
  for (let index = 0; index < mesh.vertexBuffers.length; index++) {
9566
9877
  pass.setVertexBuffer(index, mesh.vertexBuffers[index] as GPUBuffer);
9567
9878
  }
@@ -9677,16 +9988,127 @@ export class WebGPURenderer implements RendererApi {
9677
9988
  'instanced pipeline.',
9678
9989
  );
9679
9990
  }
9991
+ const animation = (options.animation ?? null) as GpuBoneAnimation | null;
9992
+ /* The bone a vertex follows is its second coordinates' u, which a mesh carries in its grain
9993
+ lane; a mesh with none has nothing to name a bone by. See `boneAnimation.ts`. */
9994
+ if (animation !== null && !this.presentOf(mesh as GpuMesh & { key?: string })['grain']) {
9995
+ throw new Error(
9996
+ 'WebGPU: a bone animation reads each vertex’s bone from the mesh’s second coordinates ' +
9997
+ '(`MeshData.lightmapUvs`), and this mesh has none.',
9998
+ );
9999
+ }
9680
10000
  const batch = new GpuInstancedBatch(
9681
10001
  this.surface.device,
9682
10002
  mesh,
9683
10003
  capacity,
9684
10004
  `flat.instances:${(mesh as GpuMesh & { key?: string }).key ?? 'mesh'}`,
9685
10005
  options.cull ?? false,
10006
+ animation,
9686
10007
  );
9687
10008
  return batch;
9688
10009
  }
9689
10010
 
10011
+ /** See `Renderer.setAnimationTime`. Every view taken after it reads the new clock. */
10012
+ setAnimationTime(seconds: number): void {
10013
+ if (!Number.isFinite(seconds) || seconds === this.animationTime) return;
10014
+ this.animationTime = seconds;
10015
+ this.reopenViews();
10016
+ }
10017
+
10018
+ /** The clock `setAnimationTime` set, 0 until it is. */
10019
+ private animationTime = 0;
10020
+
10021
+ /** See `Renderer.createBoneAnimation`. */
10022
+ createBoneAnimation(clip: BoneAnimationClip): BoneAnimationHandle {
10023
+ return new GpuBoneAnimation(this.surface.device, packBoneAnimation(clip));
10024
+ }
10025
+
10026
+ /** See `Renderer.disposeBoneAnimation`. A batch still playing it draws nothing after. */
10027
+ disposeBoneAnimation(animation: BoneAnimationHandle): void {
10028
+ const clip = animation as GpuBoneAnimation;
10029
+ if (!clip.disposed) clip.dispose();
10030
+ }
10031
+
10032
+ /** An animated batch's clip and clocks, as its groups bind them. */
10033
+ private animationViews(batch: GpuInstancedBatch): AnimationViews | null {
10034
+ const animation = batch.animation;
10035
+ const clocks = batch.clocks;
10036
+ if (animation === null || clocks === null || animation.disposed) return null;
10037
+ return { places: animation.placesView, turns: animation.turnsView, clocks: clocks.view };
10038
+ }
10039
+
10040
+ /** An animated batch's groups, dropped wherever the renderer drops its own. */
10041
+ private freshAnimatedGroups(batch: GpuInstancedBatch): void {
10042
+ if (batch.groupEpoch === this.flatGroupEpoch) return;
10043
+ batch.litGroups.clear();
10044
+ batch.depthGroups.clear();
10045
+ batch.depthPeelGroups.clear();
10046
+ batch.groupEpoch = this.flatGroupEpoch;
10047
+ }
10048
+
10049
+ /**
10050
+ * The lit group an animated batch draws with: the material's group, or its overlay twin, again
10051
+ * with the clip's and the clocks' textures — kept on the batch by the group it was built beside,
10052
+ * as a skinned twin is kept by its palette slot.
10053
+ */
10054
+ private animatedGroup(batch: GpuInstancedBatch, views: AnimationViews): GPUBindGroup {
10055
+ this.freshAnimatedGroups(batch);
10056
+ const base = this.overlayGroup();
10057
+ let group = batch.litGroups.get(base);
10058
+ if (group === undefined) {
10059
+ this.overlayBinding = base !== this.bindGroup;
10060
+ group = createFlatBindGroup(
10061
+ this.surface.device,
10062
+ this.animatedBindGroupLayout,
10063
+ this.variant,
10064
+ this.perDraw.buffer,
10065
+ this.passRing.buffer,
10066
+ this.materialStore.buffer,
10067
+ this.viewRing.buffer,
10068
+ this.flatTextures,
10069
+ null,
10070
+ null,
10071
+ null,
10072
+ views,
10073
+ );
10074
+ this.overlayBinding = false;
10075
+ this.bindGroupBudget.ask();
10076
+ batch.litGroups.set(base, group);
10077
+ }
10078
+ return group;
10079
+ }
10080
+
10081
+ /** An animated caster's shadow group, by what it cuts with and whether this is the peel. */
10082
+ private animatedDepthGroup(
10083
+ batch: GpuInstancedBatch,
10084
+ views: AnimationViews,
10085
+ cutout: GpuSurfaceTexture | null,
10086
+ ): GPUBindGroup | null {
10087
+ this.freshAnimatedGroups(batch);
10088
+ const peel = this.shadowLayerIsPeel && this.shadowPeelPassUniforms !== null;
10089
+ const groups = peel ? batch.depthPeelGroups : batch.depthGroups;
10090
+ const held = groups.get(cutout);
10091
+ if (held !== undefined) return held;
10092
+ const passUniforms = peel ? this.shadowPeelPassUniforms : this.shadowPassUniforms;
10093
+ const previousView = peel ? this.sunStaticArrayView : this.shadowBlankView;
10094
+ const previousSampler = peel ? this.shadowPeelSampler : this.shadowBlankSampler;
10095
+ if (passUniforms === null || previousView === null || previousSampler === null) return null;
10096
+ const group = createDepthBindGroup(
10097
+ this.surface.device,
10098
+ cutout === null ? this.shadowAnimatedLayout : this.shadowAnimatedCutoutLayout,
10099
+ this.shadowDraws.buffer,
10100
+ passUniforms,
10101
+ previousView,
10102
+ previousSampler,
10103
+ null,
10104
+ cutout === null ? null : { view: cutout.view, sampler: cutout.sampler },
10105
+ null,
10106
+ views,
10107
+ );
10108
+ groups.set(cutout, group);
10109
+ return group;
10110
+ }
10111
+
9690
10112
  /** The device instance culls, built on the first culling draw. See `InstanceCullPass`. */
9691
10113
  private instanceCulls: InstanceCullPass | null = null;
9692
10114
  /** Counts frames, so a batch knows when its cull slots were last taken. */
@@ -9720,7 +10142,12 @@ export class WebGPURenderer implements RendererApi {
9720
10142
  this.submitInstanced(batch, data, 1, false, {});
9721
10143
  /* A batch that moved this frame pairs each slot with last frame's; the batch is the identity. */
9722
10144
  const previous = batch.previousBuffer;
9723
- if (previous !== null && batch.changed === this.moverFrame && this.tracksMotionNow()) {
10145
+ if (
10146
+ previous !== null &&
10147
+ this.staticRecording === null &&
10148
+ batch.changed === this.moverFrame &&
10149
+ this.tracksMotionNow()
10150
+ ) {
9724
10151
  const paired = pairedInstances(batch.previousCount, Math.min(data.count, batch.capacity));
9725
10152
  if (paired > 0) this.motionPass?.recordInstanced(batch.mesh, batch.buffer, previous, paired);
9726
10153
  }
@@ -9767,6 +10194,8 @@ export class WebGPURenderer implements RendererApi {
9767
10194
  this.perFrameFloats[tintAt] = refractTint?.[0] ?? 1;
9768
10195
  this.perFrameFloats[tintAt + 1] = refractTint?.[1] ?? 1;
9769
10196
  this.perFrameFloats[tintAt + 2] = refractTint?.[2] ?? 1;
10197
+ /* w: this framebuffer's first row is the top, so up the screen is down the rows. */
10198
+ this.perFrameFloats[tintAt + 3] = -1;
9770
10199
  const glassTintAt = this.materialField('uGlassTint');
9771
10200
  this.perFrameFloats[glassTintAt] = glass.tint[0];
9772
10201
  this.perFrameFloats[glassTintAt + 1] = glass.tint[1];
@@ -9842,10 +10271,16 @@ export class WebGPURenderer implements RendererApi {
9842
10271
  /* A culling batch's camera draw: the whole batch against the view and the occluders on the CPU,
9843
10272
  then instance by instance on the device into an indirect draw. Blended draws keep every
9844
10273
  instance and their order. See `InstancedOptions.cull`. */
9845
- const culling = !blend && batch.cull;
10274
+ const culling = !blend && batch.cull && this.staticRecording === null;
9846
10275
  if (culling && !batchBoxVisible(batch.box, this.frustum, this.occlusion)) return;
10276
+ /* A batch playing a bone animation reads its clocks by instance index, which a compaction
10277
+ would reorder: it is culled whole and never instance by instance. */
10278
+ const animation = batch.animation === null ? null : this.animationViews(batch);
10279
+ if (batch.animation !== null && animation === null) return;
9847
10280
  const cut =
9848
- culling && cullsInstances(count, mesh.indexCount) ? this.takeCullSlot(batch, count) : null;
10281
+ culling && animation === null && cullsInstances(count, mesh.indexCount)
10282
+ ? this.takeCullSlot(batch, count)
10283
+ : null;
9849
10284
  const instanceBuffer = cut === null ? batch.buffer : cut.instances;
9850
10285
 
9851
10286
  const slot = this.perDraw.allocate();
@@ -9861,15 +10296,22 @@ export class WebGPURenderer implements RendererApi {
9861
10296
 
9862
10297
  /* Late unless it moves with what it lies on. See `TranslucentMeshOptions.reconstructed`. */
9863
10298
  const lands = blend && options.reconstructed !== true && this.drawsLate();
9864
- this.perDraw.writeFloats(
9865
- slot,
9866
- FLAT_VERT_FIELDS.uViewProj.offset,
9867
- lands ? this.correctedViewProj : this.viewProj,
9868
- );
10299
+ const view = this.viewSlotFor(lands, lands ? this.correctedViewProj : this.viewProj);
10300
+ if (view === null) return;
10301
+ this.drawViewOffset = view;
9869
10302
  this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uUvScale.offset, this.uvScale);
9870
10303
  this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uUvOffset.offset, this.uvOffset);
9871
- this.perDraw.writeFloats(slot, FLAT_VERT_FIELDS.uLightViewProj.offset, this.lightViewProj);
9872
10304
  this.perDraw.writeInt(slot, FLAT_VERT_FIELDS.uHasTangents.offset, mesh.hasTangents ? 1 : 0);
10305
+ /* The clip's speed and bone scale, read by the animated variant's own field. */
10306
+ const clip = batch.animation;
10307
+ if (animation !== null && clip !== null) {
10308
+ this.perDraw.writeFloat(
10309
+ slot,
10310
+ FLAT_ANIMATED_VERT_FIELDS.uBoneClip.offset,
10311
+ clip.framesPerSecond,
10312
+ );
10313
+ this.perDraw.writeFloat(slot, FLAT_ANIMATED_VERT_FIELDS.uBoneClip.offset + 4, clip.boneScale);
10314
+ }
9873
10315
 
9874
10316
  const dimmed = opacity < 1;
9875
10317
  if (dimmed) {
@@ -9907,7 +10349,7 @@ export class WebGPURenderer implements RendererApi {
9907
10349
  plain one would hand a draw the wrong layout. */
9908
10350
  const adds = blend && options.additive === true;
9909
10351
  const key =
9910
- `${base}|inst${blend ? (adds ? '|add' : '|blend') : ''}` +
10352
+ `${base}|inst${animation === null ? '' : '|anim'}${blend ? (adds ? '|add' : '|blend') : ''}` +
9911
10353
  `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
9912
10354
  `${this.materialDoubleSided ? '|2s' : ''}${coverage ? '|a2c' : ''}` +
9913
10355
  `${modelKind === null ? '' : `|m:${modelKind}`}`;
@@ -9922,7 +10364,7 @@ export class WebGPURenderer implements RendererApi {
9922
10364
  pipeline = flatPipeline(
9923
10365
  pipelines,
9924
10366
  this.surface.device,
9925
- this.flatLayoutFor(false, false),
10367
+ animation === null ? this.flatLayoutFor(false, false) : this.animatedBindGroupLayout,
9926
10368
  this.variant,
9927
10369
  key,
9928
10370
  present,
@@ -9938,6 +10380,9 @@ export class WebGPURenderer implements RendererApi {
9938
10380
  false,
9939
10381
  false,
9940
10382
  modelKind,
10383
+ 'whole',
10384
+ false,
10385
+ animation !== null,
9941
10386
  );
9942
10387
  }
9943
10388
 
@@ -9948,10 +10393,15 @@ export class WebGPURenderer implements RendererApi {
9948
10393
  : this.recordDraw(0, this.currentTarget());
9949
10394
  if (command !== null) {
9950
10395
  command.pipeline = pipeline;
9951
- command.bindGroup = this.flatBindGroupFor(false, false, null);
10396
+ command.bindGroup =
10397
+ animation === null
10398
+ ? this.flatBindGroupFor(false, false, null)
10399
+ : this.animatedGroup(batch, animation);
9952
10400
  command.offsetA = slot;
9953
- command.offsetB = material;
9954
- command.offsetCount = 2;
10401
+ command.offsetB = this.passOffset;
10402
+ command.offsetC = material;
10403
+ command.offsetD = this.drawViewOffset;
10404
+ command.offsetCount = 4;
9955
10405
  let index = 0;
9956
10406
  for (; index < mesh.vertexBuffers.length; index++) {
9957
10407
  command.vertexBuffers[index] = mesh.vertexBuffers[index] as GPUBuffer;
@@ -9967,7 +10417,13 @@ export class WebGPURenderer implements RendererApi {
9967
10417
  const pass = this.openPass();
9968
10418
  if (pass === null) return;
9969
10419
  pass.setPipeline(pipeline);
9970
- pass.setBindGroup(0, this.flatBindGroupFor(false, false, null), [slot, material]);
10420
+ pass.setBindGroup(
10421
+ 0,
10422
+ animation === null
10423
+ ? this.flatBindGroupFor(false, false, null)
10424
+ : this.animatedGroup(batch, animation),
10425
+ [slot, this.passOffset, material, this.drawViewOffset],
10426
+ );
9971
10427
  let index = 0;
9972
10428
  for (; index < mesh.vertexBuffers.length; index++) {
9973
10429
  pass.setVertexBuffer(index, mesh.vertexBuffers[index] as GPUBuffer);
@@ -9995,6 +10451,7 @@ export class WebGPURenderer implements RendererApi {
9995
10451
  instanceBuffer,
9996
10452
  count,
9997
10453
  cut === null ? null : cut.args,
10454
+ animation === null ? null : batch,
9998
10455
  );
9999
10456
  }
10000
10457
  }
@@ -10207,6 +10664,7 @@ export class WebGPURenderer implements RendererApi {
10207
10664
  if (view === null) return false;
10208
10665
 
10209
10666
  this.lightViewProj.set(lightViewProj as Float32Array);
10667
+ this.reopenViews();
10210
10668
  mat4.multiply(this.correctedLightViewProj, SHADOW_CLIP_CORRECTION, lightViewProj);
10211
10669
  this.shadowLayerIsPeel = peel;
10212
10670
  this.shadowLayerIsDynamic = dynamic;
@@ -10319,6 +10777,264 @@ export class WebGPURenderer implements RendererApi {
10319
10777
  casters(this.sceneCasterSink);
10320
10778
  }
10321
10779
 
10780
+ /* -- Static draws ---------------------------------------------------------------- */
10781
+
10782
+ /** See `Renderer.createStaticDraws`. Recorded on its first draw, into each view it is drawn in. */
10783
+ createStaticDraws(casters: ShadowCasters): StaticDrawsHandle {
10784
+ return new GpuStaticDraws(
10785
+ this.surface.device,
10786
+ captureStaticDraws(casters),
10787
+ this.fragment.passSize,
10788
+ FLAT_VIEW_SIZE,
10789
+ FLAT_VERT_SIZE,
10790
+ );
10791
+ }
10792
+
10793
+ /**
10794
+ * See `Renderer.drawStaticDraws`. One bundle executed, after a pass block and a view block are
10795
+ * written for this view; the list is recorded first where it has no recording that is current for
10796
+ * this target and starting state. See `staticBundles.ts`.
10797
+ */
10798
+ drawStaticDraws(draws: StaticDrawsHandle): void {
10799
+ if (this.surface.lost) return;
10800
+ const list = draws as GpuStaticDraws;
10801
+ if (list.disposed) {
10802
+ this.warnStaticDisposed();
10803
+ return;
10804
+ }
10805
+ if (!this.canDraw() || this.viewProj === null) return;
10806
+ /* A list's draws are rigid and wear no overlay: put back to none, as `bindMeshPass` does. */
10807
+ this.setSkinPalette(null);
10808
+ this.setMorphWeights(null);
10809
+ this.setCloth(null);
10810
+ this.setSurfaceOverlay(null);
10811
+ this.setMaterial(null);
10812
+ this.sceneCasterMaterial = undefined;
10813
+ /* A probe face gives its slots back after every face; a list replays there draw by draw. */
10814
+ if (this.probePassActive) {
10815
+ this.replayStaticImmediately(list);
10816
+ return;
10817
+ }
10818
+ /* The copy first: taking one may grow the list's rings, which no recording before can bind. */
10819
+ const copy = list.takeCopy(this.moverFrame);
10820
+ const current = this.staticRecordingFor(list);
10821
+ if (current === null || current.immediate) {
10822
+ this.replayStaticImmediately(list);
10823
+ return;
10824
+ }
10825
+ current.usedIn = this.moverFrame;
10826
+ list.writePass(copy, this.passInts);
10827
+ this.writeView(list.views, list.viewOffset(copy), this.viewProj);
10828
+ list.uploadCopy(copy);
10829
+
10830
+ const cache = current.cache;
10831
+ let frame = current.frameBundles[copy];
10832
+ if (frame === undefined || frame === null) {
10833
+ frame = encodeBundle(
10834
+ this.surface.device,
10835
+ {
10836
+ colorFormats: [cache.format],
10837
+ depthStencilFormat: DEPTH_FORMAT,
10838
+ sampleCount: cache.sampleCount,
10839
+ depthReadOnly: false,
10840
+ },
10841
+ current.frame,
10842
+ list.passOffset(copy),
10843
+ list.viewOffset(copy),
10844
+ 'static.frame',
10845
+ );
10846
+ current.frameBundles[copy] = frame;
10847
+ }
10848
+ const command = this.recordDraw(0, this.currentTarget());
10849
+ if (command !== null) {
10850
+ command.bundles = frame;
10851
+ command.pipeline = null;
10852
+ command.bindGroup = null;
10853
+ } else {
10854
+ this.openPass()?.executeBundles(frame);
10855
+ }
10856
+ const surface = this.reflectionSurface;
10857
+ if (current.surfaceKept && surface !== null && current.surface.taken > 0) {
10858
+ let halves = current.surfaceBundles[copy];
10859
+ if (halves === undefined || halves === null) {
10860
+ halves = encodeBundle(
10861
+ this.surface.device,
10862
+ {
10863
+ colorFormats: [null, REFLECTION_SURFACE_FORMAT, REFLECTION_SURFACE_FORMAT],
10864
+ depthStencilFormat: DEPTH_FORMAT,
10865
+ sampleCount: 1,
10866
+ depthReadOnly: true,
10867
+ },
10868
+ current.surface,
10869
+ list.passOffset(copy),
10870
+ list.viewOffset(copy),
10871
+ 'static.surface',
10872
+ );
10873
+ current.surfaceBundles[copy] = halves;
10874
+ }
10875
+ const kept = surface.take();
10876
+ kept.bundles = halves;
10877
+ }
10878
+ /* Counted as the draws would have been, so a frame reports the same on both backends. */
10879
+ for (let draw = 0; draw < current.frame.taken; draw++) this.drawBudget.ask();
10880
+ if (!this.staticJustRecorded) {
10881
+ for (let ask = 0; ask < current.materialAsks; ask++) this.materialBudget.ask();
10882
+ }
10883
+ this.staticJustRecorded = false;
10884
+ }
10885
+
10886
+ /** A list drawn the way `drawSceneCasters` draws, a draw at a time, for where a bundle cannot be. */
10887
+ private replayStaticImmediately(list: GpuStaticDraws): void {
10888
+ this.staticJustRecorded = false;
10889
+ replayStaticDraws(list, this.sceneCasterSink);
10890
+ this.setMaterial(null);
10891
+ this.sceneCasterMaterial = undefined;
10892
+ }
10893
+
10894
+ /** See `Renderer.disposeStaticDraws`. Its pins let go; its meshes and materials are the caller's. */
10895
+ disposeStaticDraws(draws: StaticDrawsHandle): void {
10896
+ const list = draws as GpuStaticDraws;
10897
+ if (list.disposed) return;
10898
+ list.dispose(this.unpinFor(list));
10899
+ }
10900
+
10901
+ /** The recording to replay `list` from here, made now if none is current; null if it cannot be. */
10902
+ private staticRecordingFor(list: GpuStaticDraws): StaticRecording | null {
10903
+ /* Geometry still arriving is not drawn yet, so a recording now would leave it out for good. */
10904
+ for (const entry of list.entries) {
10905
+ const mesh = entry.kind === 'mesh' ? entry.mesh : (entry.batch as GpuInstancedBatch).mesh;
10906
+ if (!mesh.complete) return null;
10907
+ }
10908
+ const cache = this.targetPipelines();
10909
+ const start = this.materialInts;
10910
+ const startHash = hashWords(start, start.length);
10911
+ const surfaceKept = this.reflectionSurfaceKeeps();
10912
+ let recording = list.find(cache, start, startHash, this.cutoutResolveStaged, surfaceKept);
10913
+ if (
10914
+ recording !== null &&
10915
+ recording.cacheVersion === cache.version &&
10916
+ recording.groupEpoch === this.flatGroupEpoch &&
10917
+ recording.storeEpoch === this.materialStore.epoch &&
10918
+ recording.ringEpoch === list.ringEpoch
10919
+ ) {
10920
+ return recording;
10921
+ }
10922
+ const unpin = this.unpinFor(list);
10923
+ if (recording === null) {
10924
+ recording = list.open(cache, start, startHash, this.cutoutResolveStaged, surfaceKept, unpin);
10925
+ } else {
10926
+ list.clear(recording, recording.storeEpoch === this.materialStore.epoch ? unpin : () => {});
10927
+ }
10928
+ this.recordStatic(list, recording);
10929
+ return recording;
10930
+ }
10931
+
10932
+ /** How a list's pins are let go: through the store, unless it has forgotten them since. */
10933
+ private unpinFor(list: GpuStaticDraws): (offset: number) => void {
10934
+ const epoch = this.materialStore.epoch;
10935
+ return (offset) => {
10936
+ if (this.materialStore.epoch === epoch && list.pinEpoch === epoch) {
10937
+ this.materialStore.unpin(offset);
10938
+ }
10939
+ };
10940
+ }
10941
+
10942
+ /**
10943
+ * Record `list` into `recording`: its entries through the scene-caster path, with the list's
10944
+ * rings and group cache swapped in for the frame's and the pass and view slots held at the list's
10945
+ * first copy, so every command the path records binds the list's buffers and offsets the bundle
10946
+ * can rewrite per copy. Everything swapped is put back, whatever the path throws.
10947
+ */
10948
+ private recordStatic(list: GpuStaticDraws, recording: StaticRecording): void {
10949
+ if (list.groupEpoch !== this.flatGroupEpoch) {
10950
+ list.groups = new Map();
10951
+ list.blank = null;
10952
+ list.groupEpoch = this.flatGroupEpoch;
10953
+ }
10954
+ list.drawRing.reset();
10955
+ const perDraw = this.perDraw;
10956
+ const passRing = this.passRing;
10957
+ const viewRing = this.viewRing;
10958
+ const groups = this.flatBindGroups;
10959
+ const blank = this.blankAlbedoBindGroup;
10960
+ const bindGroup = this.bindGroup;
10961
+ const passSlot = this.passBlock.slot;
10962
+ const viewSlot = this.viewBlock.slot;
10963
+ const lateSlot = this.lateViewBlock.slot;
10964
+ const asked = this.materialBudget.used;
10965
+ this.perDraw = list.drawRing;
10966
+ this.passRing = list.passes;
10967
+ this.viewRing = list.views;
10968
+ this.flatBindGroups = list.groups as typeof groups;
10969
+ /* The blank group over the list's rings, built as the frame's is: with no maps held. */
10970
+ list.blank ??= this.buildFlatBindGroup();
10971
+ this.blankAlbedoBindGroup = list.blank;
10972
+ this.bindGroup = this.flatGroupForMaps(null, null, null, null, null);
10973
+ this.passBlock.slot = list.passOffset(0);
10974
+ this.viewBlock.slot = list.viewOffset(0);
10975
+ this.lateViewBlock.slot = list.viewOffset(0);
10976
+ this.materials.dirty();
10977
+ this.staticRecording = recording;
10978
+ try {
10979
+ this.sceneCasterMaterial = undefined;
10980
+ replayStaticDraws(list, this.sceneCasterSink);
10981
+ } finally {
10982
+ this.staticRecording = null;
10983
+ this.perDraw = perDraw;
10984
+ this.passRing = passRing;
10985
+ this.viewRing = viewRing;
10986
+ this.flatBindGroups = groups;
10987
+ this.blankAlbedoBindGroup = blank;
10988
+ this.bindGroup = bindGroup;
10989
+ this.passBlock.slot = passSlot;
10990
+ this.viewBlock.slot = viewSlot;
10991
+ this.lateViewBlock.slot = lateSlot;
10992
+ this.materials.dirty();
10993
+ this.sceneCasterMaterial = undefined;
10994
+ this.setMaterial(null);
10995
+ }
10996
+ recording.materialAsks = this.materialBudget.used - asked;
10997
+ const store = this.materialStore;
10998
+ recording.cacheVersion = recording.cache.version;
10999
+ recording.groupEpoch = this.flatGroupEpoch;
11000
+ recording.storeEpoch = store.epoch;
11001
+ recording.ringEpoch = list.ringEpoch;
11002
+ /* A skin split into halves is replayed draw by draw, so nothing recorded is kept or pinned. */
11003
+ if (recording.immediate) {
11004
+ resetPool(recording.frame);
11005
+ resetPool(recording.surface);
11006
+ return;
11007
+ }
11008
+ /* Every material the commands bind, pinned once, so nothing displaces what a bundle bakes. */
11009
+ for (const pool of [recording.frame, recording.surface]) {
11010
+ for (let i = 0; i < pool.taken; i++) {
11011
+ const offset = (pool.commands[i] as DrawCommand).offsetC;
11012
+ if (!recording.pins.includes(offset)) {
11013
+ store.pin(offset);
11014
+ recording.pins.push(offset);
11015
+ }
11016
+ }
11017
+ }
11018
+ list.pinEpoch = store.epoch;
11019
+ list.uploadDraws();
11020
+ this.staticJustRecorded = true;
11021
+ }
11022
+
11023
+ /** The recording under way, which `recordDraw` and the side passes take their commands into. */
11024
+ private staticRecording: StaticRecording | null = null;
11025
+ /** Whether the replay about to count its material changes recorded them, and so counted them. */
11026
+ private staticJustRecorded = false;
11027
+
11028
+ /** A disposed list drawn, said once: the frame loop does not throw. */
11029
+ private warnStaticDisposed(): void {
11030
+ if (this.warnedStaticDisposed) return;
11031
+ this.warnedStaticDisposed = true;
11032
+ console.warn('drawStaticDraws: this list was disposed, and draws nothing.');
11033
+ }
11034
+ private warnedStaticDisposed = false;
11035
+ /** Bumped wherever the lit groups are dropped, which a static list's own groups follow. */
11036
+ private flatGroupEpoch = 0;
11037
+
10322
11038
  drawShadowCasters(casters: ShadowCasters): void {
10323
11039
  if (this.surface.lost || this.shadowPass === null) return;
10324
11040
  casters(this.casterSink);
@@ -10664,7 +11380,7 @@ export class WebGPURenderer implements RendererApi {
10664
11380
  }
10665
11381
  this.shadowDraws.flush();
10666
11382
  this.scatterDepthDraws.flush();
10667
- this.surface.device.queue.submit([this.shadowEncoder.finish()]);
11383
+ this.submit([this.shadowEncoder.finish()]);
10668
11384
  /* A growth's old texture, copied out of by what was just submitted and needed no longer. */
10669
11385
  this.retiredSunTexture?.destroy();
10670
11386
  this.retiredSunTexture = null;
@@ -10954,7 +11670,7 @@ export class WebGPURenderer implements RendererApi {
10954
11670
  * its own slot.
10955
11671
  */
10956
11672
  this.pointShadowArray?.flush();
10957
- this.surface.device.queue.submit([this.shadowEncoder.finish()]);
11673
+ this.submit([this.shadowEncoder.finish()]);
10958
11674
  this.shadowEncoder = null;
10959
11675
  this.shadowPass = null;
10960
11676
  this.gpuTimer.end();
@@ -11161,7 +11877,9 @@ export class WebGPURenderer implements RendererApi {
11161
11877
  this.bindGroupLayout,
11162
11878
  this.variant,
11163
11879
  this.perDraw.buffer,
11164
- this.perFrame.buffer,
11880
+ this.passRing.buffer,
11881
+ this.materialStore.buffer,
11882
+ this.viewRing.buffer,
11165
11883
  this.flatTextures,
11166
11884
  );
11167
11885
  }
@@ -11206,11 +11924,11 @@ export class WebGPURenderer implements RendererApi {
11206
11924
  /**
11207
11925
  * The bind group a draw uses, skinned or not.
11208
11926
  *
11209
- * **The skinned twin is cached against the two things that can invalidate it**, rather than
11210
- * against a flag somebody has to remember to clear at six call sites: the unskinned group it was
11211
- * built beside — which is rebuilt whenever the material or a shadow slot moves — and the palette
11212
- * view, which changes only when the texture is reallocated. Comparing object identity means a
11213
- * missed invalidation is not possible rather than merely unlikely, and it is why
11927
+ * **The skinned twin is kept by palette slot and by the unskinned group it was built beside**,
11928
+ * which is one a material, and compared against the views it holds — the palette's, which
11929
+ * changes only when the slot's texture grows, the morph deltas and the cloth's — rather than
11930
+ * against a flag somebody has to remember to clear at six call sites. Comparing object identity
11931
+ * means a missed invalidation is not possible rather than merely unlikely, and it is why
11214
11932
  * `SkinPaletteTexture.view()` caches: a fresh view every call never compares equal and would
11215
11933
  * rebuild this every draw.
11216
11934
  */
@@ -11227,35 +11945,45 @@ export class WebGPURenderer implements RendererApi {
11227
11945
  const at = skinned ? this.skinPaletteSlot + 1 : 0;
11228
11946
  /* A skinned group carries the cloth's views, or the stand-in's: see `clothViews`. */
11229
11947
  const cloth = skinned ? this.clothViews() : null;
11948
+ let byMaterial = this.skinnedGroups[at];
11949
+ if (byMaterial === undefined) {
11950
+ byMaterial = new Map();
11951
+ this.skinnedGroups[at] = byMaterial;
11952
+ }
11953
+ let twin = byMaterial.get(base);
11230
11954
  if (
11231
- (this.skinnedGroups[at] ?? null) === null ||
11232
- this.skinnedGroupsBeside[at] !== base ||
11233
- this.skinnedGroupsPalette[at] !== palette ||
11234
- this.skinnedGroupsDeltas[at] !== deltas ||
11235
- this.skinnedGroupsCloth[at] !== (cloth?.particles ?? null) ||
11236
- this.skinnedGroupsClothBinding[at] !== (cloth?.binding ?? null)
11955
+ twin === undefined ||
11956
+ twin.palette !== palette ||
11957
+ twin.deltas !== deltas ||
11958
+ twin.cloth !== (cloth?.particles ?? null) ||
11959
+ twin.clothBinding !== (cloth?.binding ?? null)
11237
11960
  ) {
11238
11961
  /* Built from the live material, with the overlay's atlas where `base` is its twin. */
11239
11962
  this.overlayBinding = base !== this.bindGroup;
11240
- this.skinnedGroups[at] = createFlatBindGroup(
11963
+ const group = createFlatBindGroup(
11241
11964
  this.surface.device,
11242
11965
  this.flatLayoutFor(skinned, morphed),
11243
11966
  this.variant,
11244
11967
  this.perDraw.buffer,
11245
- this.perFrame.buffer,
11968
+ this.passRing.buffer,
11969
+ this.materialStore.buffer,
11970
+ this.viewRing.buffer,
11246
11971
  this.flatTextures,
11247
11972
  palette,
11248
11973
  deltas,
11249
11974
  cloth,
11250
11975
  );
11251
11976
  this.overlayBinding = false;
11252
- this.skinnedGroupsBeside[at] = base;
11253
- this.skinnedGroupsPalette[at] = palette;
11254
- this.skinnedGroupsDeltas[at] = deltas;
11255
- this.skinnedGroupsCloth[at] = cloth?.particles ?? null;
11256
- this.skinnedGroupsClothBinding[at] = cloth?.binding ?? null;
11977
+ twin = {
11978
+ group,
11979
+ palette,
11980
+ deltas,
11981
+ cloth: cloth?.particles ?? null,
11982
+ clothBinding: cloth?.binding ?? null,
11983
+ };
11984
+ byMaterial.set(base, twin);
11257
11985
  }
11258
- return this.skinnedGroups[at] as GPUBindGroup;
11986
+ return twin.group;
11259
11987
  }
11260
11988
 
11261
11989
  /** The layout matching a pair of vertex flags. A pipeline and its bind group must agree. */
@@ -11293,8 +12021,14 @@ export class WebGPURenderer implements RendererApi {
11293
12021
  if (this.drawBudget.dropped > 0) {
11294
12022
  grew = this.perDraw.growTo(roomFor(this.drawBudget.used)) || grew;
11295
12023
  }
11296
- if (this.materialBudget.dropped > 0) {
11297
- grew = this.perFrame.growTo(roomFor(this.materialBudget.used)) || grew;
12024
+ if (this.materialsRefused > 0) {
12025
+ grew = this.materialStore.growTo(roomFor(this.materialBudget.used)) || grew;
12026
+ }
12027
+ if (this.passesRefused > 0) {
12028
+ grew = this.passRing.growTo(roomFor(this.passesAsked)) || grew;
12029
+ }
12030
+ if (this.viewsRefused > 0) {
12031
+ grew = this.viewRing.growTo(roomFor(this.viewsAsked)) || grew;
11298
12032
  }
11299
12033
  if (grew) this.rebuildFlatGroupsForNewRings();
11300
12034
  }
@@ -11315,9 +12049,10 @@ export class WebGPURenderer implements RendererApi {
11315
12049
  */
11316
12050
  private rebuildFlatGroupsForNewRings(): void {
11317
12051
  this.flatBindGroups.clear();
12052
+ this.flatGroupEpoch += 1;
11318
12053
  this.overlayGroups.clear();
11319
- this.flatEnvironment = this.flatTextures('uEnvironment').view;
11320
12054
  this.skinnedGroups.length = 0;
12055
+ this.flatEnvironment = this.flatTextures('uEnvironment').view;
11321
12056
 
11322
12057
  const albedo = this.albedo;
11323
12058
  const normalMap = this.normalMap;
@@ -11345,6 +12080,7 @@ export class WebGPURenderer implements RendererApi {
11345
12080
  );
11346
12081
  /* The open material lives in a slot of a buffer that no longer exists. */
11347
12082
  this.materials.dirty();
12083
+ this.reopenPass();
11348
12084
  }
11349
12085
 
11350
12086
  /**
@@ -11379,7 +12115,9 @@ export class WebGPURenderer implements RendererApi {
11379
12115
  */
11380
12116
  private rebuildFlatBindGroup(): void {
11381
12117
  this.flatBindGroups.clear();
12118
+ this.flatGroupEpoch += 1;
11382
12119
  this.overlayGroups.clear();
12120
+ this.skinnedGroups.length = 0;
11383
12121
  this.flatEnvironment = this.flatTextures('uEnvironment').view;
11384
12122
  if (
11385
12123
  this.albedo === null &&
@@ -13618,17 +14356,20 @@ export class WebGPURenderer implements RendererApi {
13618
14356
  instanceBuffer: GPUBuffer | null,
13619
14357
  instances: number,
13620
14358
  indirect: GPUBuffer | null,
14359
+ /** A batch playing a bone animation, whose surface half moves as its draw does. */
14360
+ animated: GpuInstancedBatch | null = null,
13621
14361
  ): void {
13622
14362
  const surface = this.reflectionSurface;
13623
14363
  const present = this.meshPresent.get(base);
13624
14364
  if (surface === null || present === undefined) return;
14365
+ const animation = animated === null ? null : this.animationViews(animated);
13625
14366
  const key = `${frameKey}|rs`;
13626
14367
  const pipeline =
13627
14368
  this.pipelines.peek(key) ??
13628
14369
  flatPipeline(
13629
14370
  this.pipelines,
13630
14371
  this.surface.device,
13631
- this.flatLayoutFor(skinned, morphed),
14372
+ animation === null ? this.flatLayoutFor(skinned, morphed) : this.animatedBindGroupLayout,
13632
14373
  this.variant,
13633
14374
  key,
13634
14375
  present,
@@ -13646,13 +14387,21 @@ export class WebGPURenderer implements RendererApi {
13646
14387
  modelKind,
13647
14388
  'whole',
13648
14389
  true,
14390
+ animation !== null,
13649
14391
  );
13650
- const command = surface.take();
14392
+ /* A static list's surface halves go into its own pool, beside its draws. */
14393
+ const command =
14394
+ this.staticRecording === null ? surface.take() : takeRecorded(this.staticRecording.surface);
13651
14395
  command.pipeline = pipeline;
13652
- command.bindGroup = this.flatBindGroupFor(skinned, morphed, deltas);
14396
+ command.bindGroup =
14397
+ animated === null || animation === null
14398
+ ? this.flatBindGroupFor(skinned, morphed, deltas)
14399
+ : this.animatedGroup(animated, animation);
13653
14400
  command.offsetA = slot;
13654
- command.offsetB = material;
13655
- command.offsetCount = 2;
14401
+ command.offsetB = this.passOffset;
14402
+ command.offsetC = material;
14403
+ command.offsetD = this.drawViewOffset;
14404
+ command.offsetCount = 4;
13656
14405
  let index = 0;
13657
14406
  for (; index < mesh.vertexBuffers.length; index++) {
13658
14407
  command.vertexBuffers[index] = mesh.vertexBuffers[index] as GPUBuffer;
@@ -13685,6 +14434,11 @@ export class WebGPURenderer implements RendererApi {
13685
14434
  slot: number,
13686
14435
  material: number,
13687
14436
  ): void {
14437
+ /* A skin's halves are not bundled: a static list holding one replays draw by draw. */
14438
+ if (this.staticRecording !== null) {
14439
+ this.staticRecording.immediate = true;
14440
+ return;
14441
+ }
13688
14442
  const skin = this.skinScatter;
13689
14443
  const pipelines = this.skinPipelines;
13690
14444
  const present = this.meshPresent.get(base);
@@ -13720,8 +14474,10 @@ export class WebGPURenderer implements RendererApi {
13720
14474
  command.pipeline = pipeline;
13721
14475
  command.bindGroup = bindGroup;
13722
14476
  command.offsetA = slot;
13723
- command.offsetB = material;
13724
- command.offsetCount = 2;
14477
+ command.offsetB = this.passOffset;
14478
+ command.offsetC = material;
14479
+ command.offsetD = this.drawViewOffset;
14480
+ command.offsetCount = 4;
13725
14481
  for (let index = 0; index < mesh.vertexBuffers.length; index++) {
13726
14482
  command.vertexBuffers[index] = mesh.vertexBuffers[index] as GPUBuffer;
13727
14483
  }
@@ -14167,6 +14923,8 @@ export class WebGPURenderer implements RendererApi {
14167
14923
  * site reads as one `if` either way.
14168
14924
  */
14169
14925
  private recordDraw(reads: number, writes: number): DrawCommand | null {
14926
+ /* A static list recording takes its draws into its own pool, never the frame graph's. */
14927
+ if (this.staticRecording !== null) return takeRecorded(this.staticRecording.frame);
14170
14928
  /* The order-independent replay owns its pass and issues directly into it; a recorded command
14171
14929
  would be replayed into the frame's passes instead, which is not where these belong. */
14172
14930
  if (this.oitEncoder !== null) return null;
@@ -14209,9 +14967,23 @@ export class WebGPURenderer implements RendererApi {
14209
14967
 
14210
14968
  /** One recorded draw, issued into an open pass. Shared by the graph's replay and `runLate`. */
14211
14969
  private issueCommand(pass: GPURenderPassEncoder, command: DrawCommand): void {
14970
+ /* A static list, recorded once: one call, and the pass's bindings are its to set again. */
14971
+ if (command.bundles !== null) {
14972
+ pass.executeBundles(command.bundles);
14973
+ return;
14974
+ }
14212
14975
  if (command.pipeline === null || command.bindGroup === null) return;
14213
14976
  pass.setPipeline(command.pipeline);
14214
- if (command.offsetCount === 2) {
14977
+ if (command.offsetCount === 4) {
14978
+ pass.setBindGroup(0, command.bindGroup, [
14979
+ command.offsetA,
14980
+ command.offsetB,
14981
+ command.offsetC,
14982
+ command.offsetD,
14983
+ ]);
14984
+ } else if (command.offsetCount === 3) {
14985
+ pass.setBindGroup(0, command.bindGroup, [command.offsetA, command.offsetB, command.offsetC]);
14986
+ } else if (command.offsetCount === 2) {
14215
14987
  pass.setBindGroup(0, command.bindGroup, [command.offsetA, command.offsetB]);
14216
14988
  } else if (command.offsetCount === 1) {
14217
14989
  pass.setBindGroup(0, command.bindGroup, [command.offsetA]);
@@ -14473,7 +15245,7 @@ export class WebGPURenderer implements RendererApi {
14473
15245
  this.flushRings();
14474
15246
  this.pass?.end();
14475
15247
  this.pass = null;
14476
- if (!this.surface.lost) this.surface.device.queue.submit([this.encoder.finish()]);
15248
+ if (!this.surface.lost) this.submit([this.encoder.finish()]);
14477
15249
  this.encoder = null;
14478
15250
  }
14479
15251
 
@@ -14533,7 +15305,7 @@ export class WebGPURenderer implements RendererApi {
14533
15305
  * there would have nothing to ride on. See `GpuTimestamps.resolve`.
14534
15306
  */
14535
15307
  this.gpuTimer.resolve(this.encoder);
14536
- this.surface.device.queue.submit([this.encoder.finish()]);
15308
+ this.submit([this.encoder.finish()]);
14537
15309
  this.encoder = null;
14538
15310
  this.framePresented = true;
14539
15311
  /* Latched rather than assigned: `framePresented` goes back to false every `beginFrame`. */
@@ -15275,7 +16047,7 @@ export class WebGPURenderer implements RendererApi {
15275
16047
  definition.dispatch(this.computeContext as ComputeContext);
15276
16048
  pass.end();
15277
16049
  this.flushRings();
15278
- device.queue.submit([encoder.finish()]);
16050
+ this.submit([encoder.finish()]);
15279
16051
  }
15280
16052
 
15281
16053
  /** Let go of a definition. A handle kept past this dispatches nothing; see `ComputeHandle`. */
@@ -16434,7 +17206,7 @@ export class WebGPURenderer implements RendererApi {
16434
17206
  .end();
16435
17207
  }
16436
17208
  }
16437
- device.queue.submit([clear.finish()]);
17209
+ this.submit([clear.finish()]);
16438
17210
  }
16439
17211
 
16440
17212
  /**
@@ -16665,7 +17437,7 @@ export class WebGPURenderer implements RendererApi {
16665
17437
  const whole = endFace === PROBE_FACES.length;
16666
17438
  if (whole) this.buildProbeChain(encoder, probe, this.probeSweeps?.writeLayer(layer) ?? layer);
16667
17439
  this.flushRings();
16668
- device.queue.submit([encoder.finish()]);
17440
+ this.submit([encoder.finish()]);
16669
17441
  this.rewindRings(skinMark);
16670
17442
  this.encoderEpoch = frameEpoch;
16671
17443
 
@@ -16675,6 +17447,7 @@ export class WebGPURenderer implements RendererApi {
16675
17447
  this.perFrameInts[this.materialField('uOutputTransform')] = heldGrade;
16676
17448
  this.perFrameFloats[this.materialField('uOutputExposure')] = heldExposure;
16677
17449
  this.materials.dirty();
17450
+ this.reopenPass();
16678
17451
  this.probePassActive = false;
16679
17452
  this.probeBounce = false;
16680
17453
  this.rebindEnvironment();
@@ -16732,7 +17505,7 @@ export class WebGPURenderer implements RendererApi {
16732
17505
  this.uploadProbeFaces(probe, image);
16733
17506
  const encoder = device.createCommandEncoder({ label: 'probe.environmentImage' });
16734
17507
  this.buildProbeChain(encoder, probe, 0);
16735
- device.queue.submit([encoder.finish()]);
17508
+ this.submit([encoder.finish()]);
16736
17509
 
16737
17510
  this.markProbeFilled(0);
16738
17511
  this.probeAmbient = options?.irradiance ?? true;
@@ -16770,7 +17543,7 @@ export class WebGPURenderer implements RendererApi {
16770
17543
  const { device } = this.surface;
16771
17544
  const encoder = device.createCommandEncoder({ label: 'probe.layerImage' });
16772
17545
  this.buildProbeChain(encoder, probe, this.probeSweeps?.writeLayer(layer) ?? layer);
16773
- device.queue.submit([encoder.finish()]);
17546
+ this.submit([encoder.finish()]);
16774
17547
  this.markProbeFilled(layer);
16775
17548
  this.probeAmbient = options?.irradiance ?? true;
16776
17549
  this.rebuildFlatBindGroup();