@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
@@ -99,6 +99,20 @@ export interface StageBindings {
99
99
  readonly textures: Readonly<Record<string, TextureBinding>>;
100
100
  /** Each pipeline-overridable constant's id, by the GLSL name it was declared under. */
101
101
  readonly overrides?: Readonly<Record<string, number>>;
102
+ /**
103
+ * The second block, of the uniforms marked `// wgsl:material`: its binding, size and fields.
104
+ * Absent from a stage that marks none, which is every stage but the lit fragment's.
105
+ */
106
+ readonly materialUniforms?: number;
107
+ readonly materialSize?: number;
108
+ readonly materialFields?: StageBindings['fields'];
109
+ /**
110
+ * The vertex stage's view block, of the uniforms marked `// wgsl:view`: the camera, the light's
111
+ * matrix and the wind, which a pass settles. Absent from every stage but the lit vertex one.
112
+ */
113
+ readonly viewUniforms?: number;
114
+ readonly viewSize?: number;
115
+ readonly viewFields?: StageBindings['fields'];
102
116
  }
103
117
 
104
118
  /*
@@ -130,9 +144,16 @@ const VERTEX_BINDINGS = FLAT_BINDINGS.flatVert as unknown as Readonly<
130
144
  >;
131
145
 
132
146
  /** The key for a set of vertex flags: the same rule `variantKey` uses, flags on, sorted, joined. */
133
- export function flatVertexKey(skinned: boolean, morphed: boolean, instanced = false): string {
147
+ export function flatVertexKey(
148
+ skinned: boolean,
149
+ morphed: boolean,
150
+ instanced = false,
151
+ /** Whether an instanced variant plays a bone animation. See `shaders/boneAnimation.ts`. */
152
+ animated = false,
153
+ ): string {
134
154
  const on: string[] = [];
135
155
  /* Alphabetical, because the generator's own `variantKey` sorts and the two keys must agree. */
156
+ if (animated) on.push('animated');
136
157
  if (instanced) on.push('instanced');
137
158
  if (morphed) on.push('morphed');
138
159
  if (skinned) on.push('skinned');
@@ -144,8 +165,9 @@ export function flatVertexBindings(
144
165
  skinned: boolean,
145
166
  morphed = false,
146
167
  instanced = false,
168
+ animated = false,
147
169
  ): StageBindings {
148
- const key = flatVertexKey(skinned, morphed, instanced);
170
+ const key = flatVertexKey(skinned, morphed, instanced, animated);
149
171
  const found = VERTEX_BINDINGS[key];
150
172
  if (found === undefined) throw new Error(`flatPass: no vertex bindings for variant "${key}"`);
151
173
  return found;
@@ -221,6 +243,8 @@ for (const [key, bindings] of Object.entries(VERTEX_BINDINGS)) {
221
243
  }
222
244
 
223
245
  export const FLAT_VERT_FIELDS = FLAT_BINDINGS.flatVert.none.fields;
246
+ /** The animated instanced variant's block: the instanced fields, and the clip's `uBoneClip`. */
247
+ export const FLAT_ANIMATED_VERT_FIELDS = FLAT_BINDINGS.flatVert['animated+instanced'].fields;
224
248
  /**
225
249
  * The **largest** variant's block, because one ring serves every draw.
226
250
  *
@@ -233,6 +257,35 @@ export const FLAT_VERT_SIZE = Math.max(
233
257
  );
234
258
  const FLAT_VERT_BINDING = PLAIN_VERTEX.uniforms;
235
259
 
260
+ /*
261
+ * **The view block may be shorter in a variant, never different.** One slot serves every lit draw
262
+ * of a pass, plain and instanced alike, so every field a variant's view block declares must sit
263
+ * where the plain variant has it. The instanced variant declares no wind, and its block is the plain
264
+ * one's first two matrices.
265
+ */
266
+ const PLAIN_VIEW_FIELDS = PLAIN_VERTEX.viewFields ?? {};
267
+ for (const [key, bindings] of Object.entries(VERTEX_BINDINGS)) {
268
+ if (bindings.viewUniforms !== PLAIN_VERTEX.viewUniforms) {
269
+ throw new Error(`flatPass: vertex variant "${key}" binds its view block elsewhere`);
270
+ }
271
+ for (const [field, here] of Object.entries(bindings.viewFields ?? {})) {
272
+ if (PLAIN_VIEW_FIELDS[field]?.offset !== here.offset) {
273
+ throw new Error(
274
+ `flatPass: vertex variant "${key}" puts view field ${field} at ${here.offset} where the ` +
275
+ `plain variant has it at ${PLAIN_VIEW_FIELDS[field]?.offset}.`,
276
+ );
277
+ }
278
+ }
279
+ }
280
+
281
+ /** The view block's fields, binding and size: the largest variant's, as `FLAT_VERT_SIZE` is. */
282
+ export const FLAT_VIEW_FIELDS = PLAIN_VIEW_FIELDS;
283
+ export const FLAT_VIEW_SIZE = Math.max(
284
+ ...Object.values(VERTEX_BINDINGS).map((bindings) => bindings.viewSize ?? 0),
285
+ );
286
+ const FLAT_VIEW_BINDING = PLAIN_VERTEX.viewUniforms ?? -1;
287
+ if (FLAT_VIEW_BINDING < 0) throw new Error('flatPass: the lit vertex stage declares no view block');
288
+
236
289
  /**
237
290
  * The most sampled textures and distinct samplers any lit variant carrying the environment probe
238
291
  * binds in its fragment stage, counted from the generated bindings: what a device must allow per
@@ -260,6 +313,46 @@ export function flatFragmentBindings(variant: FlatVariant): StageBindings {
260
313
  return found;
261
314
  }
262
315
 
316
+ /**
317
+ * The lit fragment stage's two blocks as the renderer keeps them on the CPU: the pass's, then the
318
+ * material's straight after it, in one scratch, with every field at its offset in that scratch.
319
+ *
320
+ * **One scratch rather than two, so no setter has to know which block its field is in.** A setter
321
+ * writes by name, as it always has; the draw copies the material's bytes — `passSize` onwards — into
322
+ * the store and the pass's bytes into a ring slot, each only when something has changed it.
323
+ */
324
+ export interface FlatFragmentScratch {
325
+ readonly fields: StageBindings['fields'];
326
+ /** Bytes of the pass's block, which is where the material's starts in the scratch. */
327
+ readonly passSize: number;
328
+ readonly passBinding: number;
329
+ readonly materialSize: number;
330
+ readonly materialBinding: number;
331
+ }
332
+
333
+ export function flatFragmentScratch(variant: FlatVariant): FlatFragmentScratch {
334
+ const bindings = flatFragmentBindings(variant);
335
+ const { materialUniforms, materialSize, materialFields } = bindings;
336
+ if (
337
+ materialUniforms === undefined ||
338
+ materialSize === undefined ||
339
+ materialFields === undefined
340
+ ) {
341
+ throw new Error(`flatPass: variant "${variant}" has no material block`);
342
+ }
343
+ const fields: Record<string, StageBindings['fields'][string]> = { ...bindings.fields };
344
+ for (const [name, field] of Object.entries(materialFields)) {
345
+ fields[name] = { ...field, offset: bindings.uniformSize + field.offset };
346
+ }
347
+ return {
348
+ fields,
349
+ passSize: bindings.uniformSize,
350
+ passBinding: bindings.uniforms,
351
+ materialSize,
352
+ materialBinding: materialUniforms,
353
+ };
354
+ }
355
+
263
356
  /**
264
357
  * The lit fragment stage's constants, set from the cache's switches, keyed by each one's **id**.
265
358
  *
@@ -306,6 +399,14 @@ function flatFragmentConstants(
306
399
  return constants;
307
400
  }
308
401
 
402
+ /** The material block's binding, which every lit variant declares. */
403
+ function materialBinding(fragment: StageBindings): number {
404
+ if (fragment.materialUniforms === undefined) {
405
+ throw new Error('flatPass: the lit fragment stage declares no material block');
406
+ }
407
+ return fragment.materialUniforms;
408
+ }
409
+
309
410
  /** The depth format both backends compare with. */
310
411
  /* Re-exported so the twenty passes that already import it from here do not each learn a new
311
412
  module; `depthConvention.ts` is where the decision lives and why. */
@@ -317,15 +418,19 @@ const VISIBILITY_FRAGMENT = 0x2;
317
418
  /**
318
419
  * The layout this variant binds against.
319
420
  *
320
- * The vertex block is **dynamic**, because every draw supplies its own model matrix and tint
321
- * from its own slot of one buffer. The fragment block is not: it holds the lights, the fog
322
- * and the camera, settled once by `bindMeshPass` and unchanged between draws.
421
+ * All four blocks are **dynamic**: the vertex block because every draw supplies its own model
422
+ * matrix and tint from its own slot of one buffer; the fragment stage's pass block — the lights, the
423
+ * fog, the camera — and the vertex stage's view block because a frame binds more than one pass; and
424
+ * the material block because a material changes between draws. Their dynamic offsets go in binding
425
+ * order: the draw's, the pass's, the material's, the view's.
323
426
  */
324
427
  export function createFlatBindGroupLayout(
325
428
  device: GPUDevice,
326
429
  variant: FlatVariant,
327
430
  skinned = false,
328
431
  morphed = false,
432
+ /** The instanced variant that plays a bone animation: its three vertex textures. */
433
+ animated = false,
329
434
  ): GPUBindGroupLayout {
330
435
  const fragment = flatFragmentBindings(variant);
331
436
  const entries: GPUBindGroupLayoutEntry[] = [
@@ -334,15 +439,31 @@ export function createFlatBindGroupLayout(
334
439
  visibility: VISIBILITY_VERTEX,
335
440
  buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: FLAT_VERT_SIZE },
336
441
  },
442
+ {
443
+ binding: FLAT_VIEW_BINDING,
444
+ visibility: VISIBILITY_VERTEX,
445
+ /* Dynamic, a slot a pass, for the reason the fragment stage's pass block is. */
446
+ buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: FLAT_VIEW_SIZE },
447
+ },
337
448
  {
338
449
  binding: fragment.uniforms,
339
450
  visibility: VISIBILITY_FRAGMENT,
340
451
  /*
341
- * Dynamic, because this block carries the material and a material changes between draws.
342
- * See `WebGPURenderer.perFrame` for why one buffer cannot hold two answers.
452
+ * Dynamic, because a pass's lights and camera change between passes of one frame — a
453
+ * mirror's, a capture's — and one buffer cannot hold two answers. See `WebGPURenderer.passRing`.
343
454
  */
344
455
  buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: fragment.uniformSize },
345
456
  },
457
+ {
458
+ binding: materialBinding(fragment),
459
+ visibility: VISIBILITY_FRAGMENT,
460
+ /* Dynamic, because a material changes between draws. See `MaterialStore`. */
461
+ buffer: {
462
+ type: 'uniform',
463
+ hasDynamicOffset: true,
464
+ minBindingSize: fragment.materialSize ?? 0,
465
+ },
466
+ },
346
467
  ];
347
468
 
348
469
  /** What each sampler binding was declared as, so a shared one is declared once and agrees. */
@@ -489,12 +610,43 @@ export function createFlatBindGroupLayout(
489
610
  }
490
611
  }
491
612
 
613
+ /* A bone animation's places, turns and the batch's clocks, read by index as the palette is. */
614
+ if (animated) {
615
+ for (const binding of animationBindings()) {
616
+ entries.push({
617
+ binding,
618
+ visibility: VISIBILITY_VERTEX,
619
+ texture: { sampleType: 'unfilterable-float' },
620
+ });
621
+ }
622
+ }
623
+
492
624
  return device.createBindGroupLayout({
493
- label: `flat.layout:${variant}|${flatVertexKey(skinned, morphed)}`,
625
+ label: `flat.layout:${variant}|${flatVertexKey(skinned, morphed, animated, animated)}`,
494
626
  entries,
495
627
  });
496
628
  }
497
629
 
630
+ /** The three textures a bone animation binds, in the order `AnimationViews` holds them. */
631
+ function animationBindings(): [number, number, number] {
632
+ const textures = flatVertexBindings(false, false, true, true).textures;
633
+ const at = (name: string): number => {
634
+ const found = textures[name];
635
+ if (found === undefined) {
636
+ throw new Error(`flatPass: the animated vertex shader declares no ${name}`);
637
+ }
638
+ return found.texture;
639
+ };
640
+ return [at('uBonePlaces'), at('uBoneTurns'), at('uInstanceClocks')];
641
+ }
642
+
643
+ /** What an animated draw binds besides its material: the clip's two textures and its batch's clocks. */
644
+ export interface AnimationViews {
645
+ readonly places: GPUTextureView;
646
+ readonly turns: GPUTextureView;
647
+ readonly clocks: GPUTextureView;
648
+ }
649
+
498
650
  /**
499
651
  * Bind the buffers and one view per declared texture.
500
652
  *
@@ -509,7 +661,9 @@ export function createFlatBindGroup(
509
661
  layout: GPUBindGroupLayout,
510
662
  variant: FlatVariant,
511
663
  perDraw: GPUBuffer,
512
- perFrame: GPUBuffer,
664
+ passes: GPUBuffer,
665
+ materials: GPUBuffer,
666
+ views: GPUBuffer,
513
667
  resolve: (name: string) => { view: GPUTextureView; sampler: GPUSampler },
514
668
  /** The palette view for a skinned bind group, or null for an unskinned one. */
515
669
  palette: GPUTextureView | null = null,
@@ -517,6 +671,8 @@ export function createFlatBindGroup(
517
671
  deltas: GPUTextureView | null = null,
518
672
  /** A skinned bind group's cloth views — the draw's, or the stand-in where it sets none. */
519
673
  cloth: ClothViews | null = null,
674
+ /** An animated instanced group's views. See `AnimationViews`. */
675
+ animation: AnimationViews | null = null,
520
676
  ): GPUBindGroup {
521
677
  const fragment = flatFragmentBindings(variant);
522
678
  const entries: GPUBindGroupEntry[] = [
@@ -524,7 +680,12 @@ export function createFlatBindGroup(
524
680
  binding: FLAT_VERT_BINDING,
525
681
  resource: { buffer: perDraw, size: FLAT_VERT_SIZE },
526
682
  },
527
- { binding: fragment.uniforms, resource: { buffer: perFrame, size: fragment.uniformSize } },
683
+ { binding: FLAT_VIEW_BINDING, resource: { buffer: views, size: FLAT_VIEW_SIZE } },
684
+ { binding: fragment.uniforms, resource: { buffer: passes, size: fragment.uniformSize } },
685
+ {
686
+ binding: materialBinding(fragment),
687
+ resource: { buffer: materials, size: fragment.materialSize ?? 0 },
688
+ },
528
689
  ];
529
690
 
530
691
  /* The same dedupe the layout does, and for the same reason: the shadow bindings share one
@@ -563,8 +724,15 @@ export function createFlatBindGroup(
563
724
  entries.push({ binding: restAt, resource: cloth.rest });
564
725
  }
565
726
 
727
+ if (animation !== null) {
728
+ const [places, turns, clocks] = animationBindings();
729
+ entries.push({ binding: places, resource: animation.places });
730
+ entries.push({ binding: turns, resource: animation.turns });
731
+ entries.push({ binding: clocks, resource: animation.clocks });
732
+ }
733
+
566
734
  return device.createBindGroup({
567
- label: `flat.bindGroup:${variant}|${flatVertexKey(palette !== null, deltas !== null)}`,
735
+ label: `flat.bindGroup:${variant}|${flatVertexKey(palette !== null, deltas !== null, animation !== null, animation !== null)}`,
568
736
  layout,
569
737
  entries,
570
738
  });
@@ -641,6 +809,8 @@ export function flatPipeline(
641
809
  * targets `reflectionSurfacePass.ts` holds. The key must carry it: `|rs`.
642
810
  */
643
811
  reflectionSurface = false,
812
+ /** An instanced pipeline that plays a bone animation. The key must carry it: `|anim`. */
813
+ animated = false,
644
814
  ): GPURenderPipeline {
645
815
  return cache.get(
646
816
  key,
@@ -666,6 +836,7 @@ export function flatPipeline(
666
836
  model,
667
837
  skinPass,
668
838
  reflectionSurface,
839
+ animated,
669
840
  ),
670
841
  true,
671
842
  );
@@ -799,6 +970,8 @@ function flatDescriptor(
799
970
  model: SurfaceModelKind | null = null,
800
971
  skinPass: SkinHalf = 'whole',
801
972
  reflectionSurface = false,
973
+ /** An instanced pipeline that plays a bone animation. See `shaders/boneAnimation.ts`. */
974
+ animated = false,
802
975
  ): GPURenderPipelineDescriptor {
803
976
  const offset = depthOffsetForLayer(depthLayer);
804
977
  /* The diffuse and colour halves are the same surface again, into the blur's targets; and so is
@@ -814,13 +987,13 @@ function flatDescriptor(
814
987
  * layout validates, draws, and is wrong, exactly as this file's header says.
815
988
  */
816
989
  module: shaderModule(device, {
817
- label: `flat.vert:${flatVertexKey(skinned, morphed, instanced)}`,
818
- code: FLAT_VERT_WGSL[flatVertexKey(skinned, morphed, instanced)] ?? '',
990
+ label: `flat.vert:${flatVertexKey(skinned, morphed, instanced, animated)}`,
991
+ code: FLAT_VERT_WGSL[flatVertexKey(skinned, morphed, instanced, animated)] ?? '',
819
992
  }),
820
993
  entryPoint: 'main',
821
994
  buffers: vertexBufferLayouts(present, instanced),
822
995
  constants: vertexConstants(
823
- flatVertexBindings(skinned, morphed, instanced),
996
+ flatVertexBindings(skinned, morphed, instanced, animated),
824
997
  skinEight,
825
998
  cloth,
826
999
  model === 'lightmap',
@@ -4,6 +4,8 @@ import { destroySlot } from './instanceCullPass.ts';
4
4
  import type { CullSlot } from './instanceCullPass.ts';
5
5
  import type { MeshInstances } from '../../instances.ts';
6
6
  import type { GpuMesh } from './buffers.ts';
7
+ import { packInstanceClocks } from '../../boneAnimation.ts';
8
+ import { GpuInstanceClocks, type GpuBoneAnimation } from './boneAnimations.ts';
7
9
 
8
10
  /** VERTEX | COPY_DST, spelled the way every other pass here spells it. */
9
11
  const USAGE_VERTEX = 0x0020 | 0x0008;
@@ -36,10 +38,34 @@ export class GpuInstancedBatch {
36
38
  slotFrame = -1;
37
39
  slotsTaken = 0;
38
40
 
39
- constructor(device: GPUDevice, mesh: GpuMesh, capacity: number, label: string, cull = false) {
41
+ /** The bone animation every instance plays, and its clocks; null for a batch that plays none. */
42
+ readonly animation: GpuBoneAnimation | null;
43
+ readonly clocks: GpuInstanceClocks | null;
44
+ /**
45
+ * An animated batch's own groups — its lit twins by the material group they were built beside, its
46
+ * shadow groups by what they cut with — and the renderer's group epoch they were built in, after
47
+ * which every one is dropped. See `WebGPURenderer.animatedGroup`.
48
+ */
49
+ readonly litGroups = new Map<GPUBindGroup, GPUBindGroup>();
50
+ readonly depthGroups = new Map<unknown, GPUBindGroup>();
51
+ readonly depthPeelGroups = new Map<unknown, GPUBindGroup>();
52
+ groupEpoch = -1;
53
+
54
+ constructor(
55
+ device: GPUDevice,
56
+ mesh: GpuMesh,
57
+ capacity: number,
58
+ label: string,
59
+ cull = false,
60
+ animation: GpuBoneAnimation | null = null,
61
+ ) {
40
62
  this.mesh = mesh;
41
63
  this.capacity = capacity;
64
+ /* An animated batch is culled whole and never by instance: see `submitInstanced`. */
42
65
  this.cull = cull;
66
+ this.animation = animation;
67
+ this.clocks =
68
+ animation === null ? null : new GpuInstanceClocks(device, capacity, `${label}.clocks`);
43
69
  this.staging = new Float32Array(capacity * INSTANCE_FLOATS);
44
70
  this.buffer = device.createBuffer({
45
71
  /*
@@ -83,11 +109,16 @@ export class GpuInstancedBatch {
83
109
  if (count === 0) return;
84
110
  packInstances(data, this.staging);
85
111
  queue.writeBuffer(this.buffer, 0, this.staging, 0, count * INSTANCE_FLOATS);
112
+ if (this.clocks !== null) {
113
+ packInstanceClocks(data, this.clocks.staging);
114
+ this.clocks.upload(queue, count);
115
+ }
86
116
  }
87
117
 
88
118
  dispose(): void {
89
119
  this.previousBuffer?.destroy();
90
120
  this.buffer.destroy();
121
+ this.clocks?.dispose();
91
122
  for (const slot of this.slots) destroySlot(slot);
92
123
  }
93
124
  }
@@ -0,0 +1,276 @@
1
+ import type { BudgetCounter } from '../budget.ts';
2
+ import { DYNAMIC_ALIGNMENT, MAX_RING_BYTES } from './uniformRing.ts';
3
+
4
+ /**
5
+ * The lit stage's material blocks, each kept in its slot for as long as later draws ask for the
6
+ * same numbers, so a material that has not changed since the last frame costs no upload at all.
7
+ *
8
+ * **Keyed by content, because nothing else names a material.** A material here is whatever the
9
+ * setters left in the block when a draw took it — a consumer never hands over an object that could
10
+ * carry an identity — so the block's own words are the key: hashed to find a bucket, compared word
11
+ * for word to accept a match, and bound by the slot's offset. What it gives up is a hash and a
12
+ * compare of a few hundred bytes per material change, against copying and uploading the whole
13
+ * block every frame, which a stage of four hundred materials measured at megabytes a frame.
14
+ *
15
+ * **A slot the frame has asked for is never given away within it**, for the reason `UniformRing`
16
+ * exists: a queue write does not interleave with recorded draws, so rewriting a slot a recorded draw
17
+ * reads would hand that draw the new numbers. A frame asking for more distinct blocks than there are
18
+ * slots is refused, counted and skipped, as the ring was. Across frames rewriting is safe, because a
19
+ * write issued after a submit is ordered after the commands in it.
20
+ *
21
+ * **What a new block displaces is a slot nobody asked for this frame or the last**, found by a hand
22
+ * that sweeps the slots in turn; only when every slot was asked for recently does it take one the
23
+ * last frame used. What would make that wrong is a scene whose distinct materials a frame approach
24
+ * the store's size: it then rewrites some of its steady materials every frame, which costs their
25
+ * upload and nothing else.
26
+ */
27
+ export class MaterialStore {
28
+ /** Replaced by `growTo`, so every bind group over it is rebuilt when that says so. */
29
+ buffer: GPUBuffer;
30
+ /** Bytes per slot, rounded up to the alignment a dynamic offset requires. */
31
+ readonly slotSize: number;
32
+
33
+ private readonly words: number;
34
+ private readonly stride: number;
35
+ private capacity: number;
36
+ private staging: ArrayBuffer;
37
+ private ints: Int32Array;
38
+ /** Per slot: its block's hash, the next slot in its bucket or -1, and whether it holds one. */
39
+ private hashes: Int32Array;
40
+ private next: Int32Array;
41
+ private held: Uint8Array;
42
+ /** Per slot: the frame that last asked for it, and whether it was written since the last flush. */
43
+ private askedIn: Int32Array;
44
+ private pending: Uint8Array;
45
+ /** Per slot: how many holders pinned it. A pinned slot is never displaced. */
46
+ private pins: Int32Array;
47
+ /**
48
+ * How many times the store has forgotten everything it held, which growth does: a holder of an
49
+ * offset compares this, because an offset from before is a slot of a buffer that is gone.
50
+ */
51
+ epoch = 0;
52
+ /** Per bucket: its first slot, or -1. A power of two at least twice the slots. */
53
+ private heads: Int32Array;
54
+ private mask: number;
55
+ private lowPending: number;
56
+ private highPending = -1;
57
+ private hand = 0;
58
+ /** Starts past zero, so a slot never asked for reads as long unasked. */
59
+ private frame = 2;
60
+
61
+ constructor(
62
+ private readonly device: GPUDevice,
63
+ bytesPerBlock: number,
64
+ slots: number,
65
+ private readonly usage: number,
66
+ private readonly label = 'materials.store',
67
+ private readonly budget: BudgetCounter | null = null,
68
+ ) {
69
+ this.words = Math.ceil(bytesPerBlock / 4);
70
+ this.slotSize = Math.ceil(bytesPerBlock / DYNAMIC_ALIGNMENT) * DYNAMIC_ALIGNMENT;
71
+ this.stride = this.slotSize / 4;
72
+ this.capacity = slots;
73
+ this.lowPending = slots;
74
+ this.staging = new ArrayBuffer(this.slotSize * slots);
75
+ this.ints = new Int32Array(this.staging);
76
+ this.hashes = new Int32Array(slots);
77
+ this.next = new Int32Array(slots);
78
+ this.held = new Uint8Array(slots);
79
+ this.askedIn = new Int32Array(slots);
80
+ this.pending = new Uint8Array(slots);
81
+ this.pins = new Int32Array(slots);
82
+ this.heads = new Int32Array(bucketsFor(slots)).fill(-1);
83
+ this.mask = this.heads.length - 1;
84
+ this.buffer = device.createBuffer({ label, size: this.staging.byteLength, usage });
85
+ }
86
+
87
+ /** How many distinct blocks a frame may hold. */
88
+ get slots(): number {
89
+ return this.capacity;
90
+ }
91
+
92
+ /** Start a frame: every slot the last one asked for may be displaced from here on, last. */
93
+ beginFrame(): void {
94
+ this.frame += 1;
95
+ }
96
+
97
+ /**
98
+ * The byte offset of a slot holding exactly `block`, written there if no slot did, or null when
99
+ * every slot is one this frame has asked for. `block` is the whole block, word for word.
100
+ */
101
+ place(block: Int32Array): number | null {
102
+ this.budget?.ask();
103
+ const hash = hashWords(block, this.words);
104
+ const bucket = hash & this.mask;
105
+ for (let slot = this.heads[bucket] as number; slot >= 0; slot = this.next[slot] as number) {
106
+ if (this.hashes[slot] === hash && this.holds(slot, block)) {
107
+ this.askedIn[slot] = this.frame;
108
+ return slot * this.slotSize;
109
+ }
110
+ }
111
+ const slot = this.displace();
112
+ if (slot < 0) {
113
+ this.budget?.drop();
114
+ return null;
115
+ }
116
+ if (this.held[slot] === 1) this.unlink(slot);
117
+ this.ints.set(block, slot * this.stride);
118
+ this.hashes[slot] = hash;
119
+ this.next[slot] = this.heads[bucket] as number;
120
+ this.heads[bucket] = slot;
121
+ this.held[slot] = 1;
122
+ this.askedIn[slot] = this.frame;
123
+ this.pending[slot] = 1;
124
+ if (slot < this.lowPending) this.lowPending = slot;
125
+ if (slot > this.highPending) this.highPending = slot;
126
+ return slot * this.slotSize;
127
+ }
128
+
129
+ /** Upload every slot written since the last flush, one write per run of neighbouring slots. */
130
+ flush(): void {
131
+ let slot = this.lowPending;
132
+ while (slot <= this.highPending) {
133
+ if (this.pending[slot] !== 1) {
134
+ slot += 1;
135
+ continue;
136
+ }
137
+ const from = slot;
138
+ while (slot <= this.highPending && this.pending[slot] === 1) {
139
+ this.pending[slot] = 0;
140
+ slot += 1;
141
+ }
142
+ const at = from * this.slotSize;
143
+ this.device.queue.writeBuffer(
144
+ this.buffer,
145
+ at,
146
+ this.staging,
147
+ at,
148
+ (slot - from) * this.slotSize,
149
+ );
150
+ }
151
+ this.lowPending = this.capacity;
152
+ this.highPending = -1;
153
+ }
154
+
155
+ /**
156
+ * Make room for at least `slots`, forgetting every block held, and say whether the buffer was
157
+ * replaced. Called where `UniformRing.growTo` is, at a frame's start, for the reasons it gives,
158
+ * and refused past the same ceiling.
159
+ */
160
+ growTo(slots: number): boolean {
161
+ if (slots <= this.capacity) return false;
162
+ if (this.slotSize * slots > MAX_RING_BYTES) return false;
163
+ this.buffer.destroy();
164
+ this.capacity = slots;
165
+ this.staging = new ArrayBuffer(this.slotSize * slots);
166
+ this.ints = new Int32Array(this.staging);
167
+ this.hashes = new Int32Array(slots);
168
+ this.next = new Int32Array(slots);
169
+ this.held = new Uint8Array(slots);
170
+ this.askedIn = new Int32Array(slots);
171
+ this.pending = new Uint8Array(slots);
172
+ this.pins = new Int32Array(slots);
173
+ this.heads = new Int32Array(bucketsFor(slots)).fill(-1);
174
+ this.mask = this.heads.length - 1;
175
+ this.lowPending = slots;
176
+ this.epoch += 1;
177
+ this.highPending = -1;
178
+ this.hand = 0;
179
+ this.buffer = this.device.createBuffer({
180
+ label: this.label,
181
+ size: this.staging.byteLength,
182
+ usage: this.usage,
183
+ });
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Keep the block at `offset` where it is until `unpin`: a holder binding it by offset frame after
189
+ * frame — a list recorded once — whether or not anything asks for it. Counted, so two holders of
190
+ * one block each let go of their own pin.
191
+ */
192
+ pin(offset: number): void {
193
+ const slot = offset / this.slotSize;
194
+ this.pins[slot] = (this.pins[slot] as number) + 1;
195
+ }
196
+
197
+ unpin(offset: number): void {
198
+ const slot = offset / this.slotSize;
199
+ if ((this.pins[slot] as number) > 0) this.pins[slot] = (this.pins[slot] as number) - 1;
200
+ }
201
+
202
+ dispose(): void {
203
+ this.buffer.destroy();
204
+ }
205
+
206
+ private holds(slot: number, block: Int32Array): boolean {
207
+ const base = slot * this.stride;
208
+ for (let word = 0; word < this.words; word++) {
209
+ if (this.ints[base + word] !== block[word]) return false;
210
+ }
211
+ return true;
212
+ }
213
+
214
+ /**
215
+ * The slot a new block goes in: the first from the hand nobody asked for this frame or the last,
216
+ * else the first this frame has not asked for, else none. One sweep at most.
217
+ */
218
+ private displace(): number {
219
+ let fallback = -1;
220
+ for (let step = 0; step < this.capacity; step++) {
221
+ const slot = this.hand;
222
+ this.hand = slot + 1 === this.capacity ? 0 : slot + 1;
223
+ if ((this.pins[slot] as number) > 0) continue;
224
+ const asked = this.askedIn[slot] as number;
225
+ if (asked < this.frame - 1) return slot;
226
+ if (fallback < 0 && asked !== this.frame) fallback = slot;
227
+ }
228
+ return fallback;
229
+ }
230
+
231
+ private unlink(slot: number): void {
232
+ const bucket = (this.hashes[slot] as number) & this.mask;
233
+ let at = this.heads[bucket] as number;
234
+ if (at === slot) {
235
+ this.heads[bucket] = this.next[slot] as number;
236
+ return;
237
+ }
238
+ while (at >= 0) {
239
+ const after = this.next[at] as number;
240
+ if (after === slot) {
241
+ this.next[at] = this.next[slot] as number;
242
+ return;
243
+ }
244
+ at = after;
245
+ }
246
+ }
247
+ }
248
+
249
+ function bucketsFor(slots: number): number {
250
+ let buckets = 16;
251
+ while (buckets < slots * 2) buckets *= 2;
252
+ return buckets;
253
+ }
254
+
255
+ /**
256
+ * MurmurHash3's 32-bit mix over the block's words. Not FNV, whose multiply carries a difference only
257
+ * upward: the bucket is the hash's low bits, and a material's words are mostly floats, whose low
258
+ * mantissa bits are zero for every value a person types — 0.5, 1, 2 — so blocks differing only in
259
+ * those would all have shared one bucket. The compare that follows a match is what decides.
260
+ */
261
+ export function hashWords(block: Int32Array, words: number): number {
262
+ let hash = 0x9747b28c;
263
+ for (let word = 0; word < words; word++) {
264
+ let k = Math.imul(block[word] as number, 0xcc9e2d51);
265
+ k = (k << 15) | (k >>> 17);
266
+ hash ^= Math.imul(k, 0x1b873593);
267
+ hash = (hash << 13) | (hash >>> 19);
268
+ hash = (Math.imul(hash, 5) + 0xe6546b64) | 0;
269
+ }
270
+ hash ^= words * 4;
271
+ hash ^= hash >>> 16;
272
+ hash = Math.imul(hash, 0x85ebca6b);
273
+ hash ^= hash >>> 13;
274
+ hash = Math.imul(hash, 0xc2b2ae35);
275
+ return hash ^ (hash >>> 16);
276
+ }