@driftengine/core 4.13.1 → 4.13.3

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 (78) hide show
  1. package/CHANGELOG.json +42 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +4 -1
  4. package/dist/index.js +1 -0
  5. package/dist/render/backend/api.d.ts +63 -8
  6. package/dist/render/backend/webgl2/pointSkinFeedback.d.ts +23 -0
  7. package/dist/render/backend/webgl2/pointSkinFeedback.js +159 -0
  8. package/dist/render/backend/webgl2/renderer.d.ts +33 -0
  9. package/dist/render/backend/webgl2/renderer.js +88 -4
  10. package/dist/render/backend/webgpu/buffers.d.ts +10 -2
  11. package/dist/render/backend/webgpu/buffers.js +66 -16
  12. package/dist/render/backend/webgpu/clothTextures.d.ts +6 -3
  13. package/dist/render/backend/webgpu/clothTextures.js +14 -11
  14. package/dist/render/backend/webgpu/drawCommand.js +3 -3
  15. package/dist/render/backend/webgpu/gpuClothSet.js +1 -0
  16. package/dist/render/backend/webgpu/groupShelf.d.ts +31 -0
  17. package/dist/render/backend/webgpu/groupShelf.js +54 -0
  18. package/dist/render/backend/webgpu/pointSkinPass.d.ts +27 -0
  19. package/dist/render/backend/webgpu/pointSkinPass.js +104 -0
  20. package/dist/render/backend/webgpu/renderer.d.ts +40 -7
  21. package/dist/render/backend/webgpu/renderer.js +151 -48
  22. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +1 -1
  23. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +7 -2
  24. package/dist/render/backend/webgpu/shaders/pointSkin.wgsl.d.ts +13 -0
  25. package/dist/render/backend/webgpu/shaders/pointSkin.wgsl.js +57 -0
  26. package/dist/render/backend/webgpu/vertexFormats.js +1 -1
  27. package/dist/render/lightBudget.d.ts +6 -0
  28. package/dist/render/lightBudget.js +6 -0
  29. package/dist/render/mesh.d.ts +19 -5
  30. package/dist/render/mesh.js +39 -8
  31. package/dist/render/pointSkin.d.ts +55 -0
  32. package/dist/render/pointSkin.js +121 -0
  33. package/dist/render/rendererHarness.js +6 -0
  34. package/dist/render/shader.d.ts +3 -1
  35. package/dist/render/shader.js +6 -1
  36. package/dist/render/shaders/flat/detail.d.ts +21 -0
  37. package/dist/render/shaders/flat/detail.js +74 -0
  38. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  39. package/dist/render/shaders/flat/glassTint.js +1 -0
  40. package/dist/render/shaders/flat/index.d.ts +5 -0
  41. package/dist/render/shaders/flat/index.js +3 -0
  42. package/dist/render/shaders/flat/main.d.ts +1 -1
  43. package/dist/render/shaders/flat/main.js +2 -0
  44. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  45. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  46. package/dist/render/surfaceDetail.d.ts +79 -0
  47. package/dist/render/surfaceDetail.js +86 -0
  48. package/dist/render/surfaceTexture.d.ts +9 -0
  49. package/dist/render/vertexPacking.d.ts +16 -4
  50. package/dist/render/vertexPacking.js +10 -3
  51. package/package.json +6 -6
  52. package/src/index.ts +4 -0
  53. package/src/render/backend/api.ts +70 -7
  54. package/src/render/backend/webgl2/pointSkinFeedback.ts +173 -0
  55. package/src/render/backend/webgl2/renderer.ts +121 -4
  56. package/src/render/backend/webgpu/buffers.ts +90 -16
  57. package/src/render/backend/webgpu/clothTextures.ts +14 -11
  58. package/src/render/backend/webgpu/drawCommand.ts +3 -3
  59. package/src/render/backend/webgpu/gpuClothSet.ts +1 -0
  60. package/src/render/backend/webgpu/groupShelf.ts +74 -0
  61. package/src/render/backend/webgpu/pointSkinPass.ts +115 -0
  62. package/src/render/backend/webgpu/renderer.ts +192 -83
  63. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +7 -2
  64. package/src/render/backend/webgpu/shaders/pointSkin.wgsl.ts +58 -0
  65. package/src/render/backend/webgpu/vertexFormats.ts +1 -1
  66. package/src/render/lightBudget.ts +7 -0
  67. package/src/render/mesh.ts +49 -7
  68. package/src/render/pointSkin.ts +178 -0
  69. package/src/render/rendererHarness.ts +6 -0
  70. package/src/render/shader.ts +5 -0
  71. package/src/render/shaders/flat/detail.ts +74 -0
  72. package/src/render/shaders/flat/glassTint.ts +3 -1
  73. package/src/render/shaders/flat/index.ts +8 -0
  74. package/src/render/shaders/flat/main.ts +2 -0
  75. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  76. package/src/render/surfaceDetail.ts +136 -0
  77. package/src/render/surfaceTexture.ts +9 -0
  78. package/src/render/vertexPacking.ts +25 -5
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A point skin on WebGPU: its points, joints and normals in storage buffers made once, and a pose
3
+ * recorded as one compute dispatch into the dynamic mesh's own position and normal buffers. What a
4
+ * point skin is, and why it poses into a mesh rather than in a vertex stage, is `pointSkin.ts`.
5
+ *
6
+ * **Each skin owns its joints buffer**, so two skins posed in one frame read their own: a write to
7
+ * one buffer between two dispatches would hand both the last, the per-draw rule `AGENTS.md` keeps
8
+ * for every resource a later write in the frame overwrites. A skin posed twice in a frame draws its
9
+ * last pose everywhere, as a mesh rewritten twice with `updateMesh` does.
10
+ */
11
+ import { reachOf, type PointSkinData, type PointSkinReach } from '../../pointSkin.ts';
12
+ import type { GpuMesh } from './buffers.ts';
13
+ import { POINT_SKIN_WGSL, POINT_SKIN_WORKGROUP } from './shaders/pointSkin.wgsl.ts';
14
+
15
+ const STORAGE = 0x0080;
16
+ const UNIFORM = 0x0040;
17
+ const COPY_DST = 0x0008;
18
+
19
+ /** The pipeline every skin on a device poses with, made at the first skin. */
20
+ export function createPointSkinPipeline(device: GPUDevice): GPUComputePipeline {
21
+ return device.createComputePipeline({
22
+ label: 'pointSkin.pose',
23
+ layout: 'auto',
24
+ compute: {
25
+ module: device.createShaderModule({ label: 'pointSkin.pose', code: POINT_SKIN_WGSL }),
26
+ entryPoint: 'posePoints',
27
+ },
28
+ });
29
+ }
30
+
31
+ export class GpuPointSkin {
32
+ readonly mesh: GpuMesh;
33
+ readonly reach: PointSkinReach;
34
+ private readonly count: number;
35
+ private readonly pipeline: GPUComputePipeline;
36
+ private readonly joints: GPUBuffer;
37
+ private readonly buffers: readonly GPUBuffer[];
38
+ private readonly group: GPUBindGroup;
39
+
40
+ constructor(device: GPUDevice, pipeline: GPUComputePipeline, mesh: GpuMesh, data: PointSkinData) {
41
+ this.mesh = mesh;
42
+ this.pipeline = pipeline;
43
+ this.reach = reachOf(data);
44
+ const count = data.weights.length / 4;
45
+ this.count = count;
46
+
47
+ /* Each influence a vec4: its point, and its weight in w. */
48
+ const points = new Float32Array(count * 16);
49
+ for (let i = 0; i < count * 4; i++) {
50
+ points.set(data.points.subarray(i * 3, i * 3 + 3), i * 4);
51
+ points[i * 4 + 3] = data.weights[i] as number;
52
+ }
53
+ const named = Uint32Array.from(data.joints);
54
+ const normals = new Float32Array(count * 4);
55
+ for (let v = 0; v < count; v++) normals.set(data.normals.subarray(v * 3, v * 3 + 3), v * 4);
56
+ const params = new Uint32Array([count, 0, 0, 0]);
57
+
58
+ const made = (
59
+ label: string,
60
+ contents: Float32Array | Uint32Array,
61
+ usage: number,
62
+ ): GPUBuffer => {
63
+ const buffer = device.createBuffer({
64
+ label,
65
+ size: Math.max(16, contents.byteLength),
66
+ usage: usage | COPY_DST,
67
+ });
68
+ device.queue.writeBuffer(buffer, 0, contents);
69
+ return buffer;
70
+ };
71
+ this.joints = device.createBuffer({
72
+ label: 'pointSkin.joints',
73
+ size: Math.max(1, this.reach.joints) * 64,
74
+ usage: STORAGE | COPY_DST,
75
+ });
76
+ const buffers = [
77
+ made('pointSkin.params', params, UNIFORM),
78
+ made('pointSkin.points', points, STORAGE),
79
+ made('pointSkin.named', named, STORAGE),
80
+ made('pointSkin.normals', normals, STORAGE),
81
+ ];
82
+ this.buffers = buffers;
83
+ const [positionsOut, normalsOut] = mesh.vertexBuffers as [GPUBuffer, GPUBuffer];
84
+ this.group = device.createBindGroup({
85
+ label: 'pointSkin.pose',
86
+ layout: pipeline.getBindGroupLayout(0),
87
+ entries: [
88
+ { binding: 0, resource: { buffer: buffers[0] as GPUBuffer } },
89
+ { binding: 1, resource: { buffer: this.joints } },
90
+ { binding: 2, resource: { buffer: buffers[1] as GPUBuffer } },
91
+ { binding: 3, resource: { buffer: buffers[2] as GPUBuffer } },
92
+ { binding: 4, resource: { buffer: buffers[3] as GPUBuffer } },
93
+ { binding: 5, resource: { buffer: positionsOut } },
94
+ { binding: 6, resource: { buffer: normalsOut } },
95
+ ],
96
+ });
97
+ }
98
+
99
+ /** Write the pose's joints and record the dispatch that poses every vertex into `encoder`. */
100
+ pose(device: GPUDevice, encoder: GPUCommandEncoder, matrices: Float32Array): void {
101
+ if (this.reach.joints > 0) {
102
+ device.queue.writeBuffer(this.joints, 0, matrices, 0, this.reach.joints * 16);
103
+ }
104
+ const pass = encoder.beginComputePass({ label: 'pointSkin.pose' });
105
+ pass.setPipeline(this.pipeline);
106
+ pass.setBindGroup(0, this.group);
107
+ pass.dispatchWorkgroups(Math.ceil(this.count / POINT_SKIN_WORKGROUP));
108
+ pass.end();
109
+ }
110
+
111
+ dispose(): void {
112
+ this.joints.destroy();
113
+ for (const buffer of this.buffers) buffer.destroy();
114
+ }
115
+ }
@@ -181,6 +181,14 @@ import { GpuSceneCaptureTarget } from './sceneCaptureTarget.ts';
181
181
  import { sceneCaptureTexels } from '../../sceneCapture.ts';
182
182
  import { gpuCompressedFormats } from './compressedUpload.ts';
183
183
  import { GpuClothBinding, GpuClothParticles, clothStandIn } from './clothTextures.ts';
184
+ import { GroupShelf } from './groupShelf.ts';
185
+ import { GpuPointSkin, createPointSkinPipeline } from './pointSkinPass.ts';
186
+ import {
187
+ poseBounds,
188
+ validatePointSkin,
189
+ validatePose,
190
+ type PointSkinData,
191
+ } from '../../pointSkin.ts';
184
192
  import type { ClothViews } from './clothTextures.ts';
185
193
  import type { ComputeHost } from './computeHost.ts';
186
194
  import {
@@ -208,6 +216,15 @@ import {
208
216
  packSurfaceLayers,
209
217
  } from '../../surfaceLayers.ts';
210
218
  import type { SurfaceLayers } from '../../surfaceLayers.ts';
219
+ import {
220
+ DETAIL_FLOATS,
221
+ detailOf,
222
+ detailRefusal,
223
+ detailRefusalWarning,
224
+ packSurfaceDetail,
225
+ type DetailRefusal,
226
+ type SurfaceDetail,
227
+ } from '../../surfaceDetail.ts';
211
228
  import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.ts';
212
229
  import {
213
230
  frameReflectionsRefused,
@@ -1476,15 +1493,6 @@ interface SunTintTarget {
1476
1493
  readonly mips: readonly [SnapshotMips, SnapshotMips];
1477
1494
  }
1478
1495
 
1479
- /** A skinned or morphed flat group, and what it was built with, compared by identity. */
1480
- interface SkinnedTwin {
1481
- readonly group: GPUBindGroup;
1482
- readonly palette: GPUTextureView | null;
1483
- readonly deltas: GPUTextureView | null;
1484
- readonly cloth: GPUTextureView | null;
1485
- readonly clothBinding: GPUTextureView | null;
1486
- }
1487
-
1488
1496
  export class WebGPURenderer implements RendererApi {
1489
1497
  /**
1490
1498
  * Real timestamp queries, where the adapter offers them.
@@ -1602,9 +1610,11 @@ export class WebGPURenderer implements RendererApi {
1602
1610
  * thirty-odd sections, each its own material, built one a section a frame — about ninety a frame
1603
1611
  * for two characters, reported by a consumer. A slot's map holds one entry a material its rigs
1604
1612
  * are drawn in, and is emptied wherever the unskinned groups are, whose identities it is keyed
1605
- * by.
1613
+ * by. **And by what each group was built with, on a shelf a material** (`groupShelf.ts`): a
1614
+ * garment's particles take turns between two textures and several meshes may follow one cloth,
1615
+ * so one group a material was made again draw after draw.
1606
1616
  */
1607
- private readonly skinnedGroups: (Map<GPUBindGroup, SkinnedTwin> | undefined)[] = [];
1617
+ private readonly skinnedGroups: (Map<GPUBindGroup, GroupShelf> | undefined)[] = [];
1608
1618
  /** The cloth the next skinned draws are placed by, or null. See `setCloth`. */
1609
1619
  private cloth: { binding: GpuClothBinding; particles: GpuClothParticles } | null = null;
1610
1620
  private clothStandCache: { texture: GPUTexture; views: ClothViews } | null = null;
@@ -3037,6 +3047,10 @@ export class WebGPURenderer implements RendererApi {
3037
3047
  */
3038
3048
  private readonly transmission = new Float32Array([-1, -1, -1, 0]);
3039
3049
  private warnedLayersModelMap = false;
3050
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
3051
+ private readonly detailFloats = new Float32Array(DETAIL_FLOATS);
3052
+ /** The detail refusals already said, each once. See `detailOfMaterial`. */
3053
+ private readonly warnedDetail = new Set<DetailRefusal>();
3040
3054
  private warnedLayersTriplanar = false;
3041
3055
  /** Its numbers, `uModelParams`, packed by `packModel`. */
3042
3056
  private readonly modelParams = new Float32Array(MODEL_PARAM_FLOATS);
@@ -3515,7 +3529,10 @@ export class WebGPURenderer implements RendererApi {
3515
3529
  const ownModelMap = (material?.modelMap ?? null) as GpuSurfaceTexture | null;
3516
3530
  /* A layered material's mask, where it is a map, goes where a model's would: surfaceLayers.ts. */
3517
3531
  const layers = this.layersOf(material);
3518
- const modelMap = ownModelMap ?? layerMaskMap(layers);
3532
+ const taken = ownModelMap ?? layerMaskMap(layers);
3533
+ /* And its detail's array, where nothing else is read there: surfaceDetail.ts. */
3534
+ const detail = this.detailOfMaterial(material, taken);
3535
+ const modelMap = taken ?? detail?.maps ?? null;
3519
3536
  this.materialModel = material?.model ?? null;
3520
3537
  const reflectivity = material?.reflectivity;
3521
3538
  const environmentGain = material?.environmentGain;
@@ -3608,6 +3625,10 @@ export class WebGPURenderer implements RendererApi {
3608
3625
  packLayerLooks(layers, this.lookFloats);
3609
3626
  f.set(this.lookFloats, this.materialField('uLayerLooks'));
3610
3627
  if (asksLayerLooks(layers)) this.enableLit('LAYER_LOOKS');
3628
+ /* And its tiled detail, the first time a material lays any: detail.ts. */
3629
+ packSurfaceDetail(detail, orm !== null, this.detailFloats);
3630
+ f.set(this.detailFloats, this.materialField('uDetail'));
3631
+ if (detail !== null) this.enableLit('SURFACE_DETAIL');
3611
3632
  f.set(this.modelParams, this.materialField('uModelParams'));
3612
3633
  });
3613
3634
  /*
@@ -6157,17 +6178,14 @@ export class WebGPURenderer implements RendererApi {
6157
6178
  /** The same layout plus the joint palette. See `createDepthBindGroupLayout`. */
6158
6179
  private readonly shadowSkinnedLayout: GPUBindGroupLayout;
6159
6180
  /**
6160
- * The skinned shadow bind groups, one per palette slot, cached against what can invalidate them.
6181
+ * The skinned shadow bind groups, a shelf per palette slot, each found by what it was built with.
6161
6182
  *
6162
6183
  * The same argument `flatBindGroupFor` makes, and per slot for the same reason: the group it was
6163
6184
  * built beside, and the palette view, which now differs between two characters casting into one
6164
- * bake. Comparing object identity makes a missed invalidation impossible rather than unlikely.
6185
+ * bake, and the cloth's particles and binding, which take turns. Comparing object identity makes
6186
+ * a missed invalidation impossible rather than unlikely. See `groupShelf.ts`.
6165
6187
  */
6166
- private readonly shadowSkinnedGroups: (GPUBindGroup | null)[] = [];
6167
- private readonly shadowSkinnedGroupsBeside: (GPUBindGroup | null)[] = [];
6168
- private readonly shadowSkinnedGroupsPalette: (GPUTextureView | null)[] = [];
6169
- private readonly shadowSkinnedGroupsCloth: (GPUTextureView | null)[] = [];
6170
- private readonly shadowSkinnedGroupsClothBinding: (GPUTextureView | null)[] = [];
6188
+ private readonly shadowSkinnedShelves: (GroupShelf | undefined)[] = [];
6171
6189
  /** Held from construction so the skinned group can be rebuilt without them being threaded. */
6172
6190
  private shadowBlankView: GPUTextureView | null = null;
6173
6191
  private shadowBlankSampler: GPUSampler | null = null;
@@ -6343,30 +6361,26 @@ export class WebGPURenderer implements RendererApi {
6343
6361
  */
6344
6362
  private shadowSkinnedGroup(palette: GPUTextureView, at: number, cloth: ClothViews): GPUBindGroup {
6345
6363
  const beside = this.shadowBindGroup;
6346
- if (
6347
- (this.shadowSkinnedGroups[at] ?? null) === null ||
6348
- this.shadowSkinnedGroupsBeside[at] !== beside ||
6349
- this.shadowSkinnedGroupsPalette[at] !== palette ||
6350
- this.shadowSkinnedGroupsCloth[at] !== cloth.particles ||
6351
- this.shadowSkinnedGroupsClothBinding[at] !== cloth.binding
6352
- ) {
6353
- this.shadowSkinnedGroups[at] = createDepthBindGroup(
6354
- this.surface.device,
6355
- this.shadowSkinnedLayout,
6356
- this.shadowDraws.buffer,
6357
- this.shadowPassUniforms as GPUBuffer,
6358
- this.shadowBlankView as GPUTextureView,
6359
- this.shadowBlankSampler as GPUSampler,
6360
- palette,
6361
- null,
6362
- cloth,
6363
- );
6364
- this.shadowSkinnedGroupsBeside[at] = beside;
6365
- this.shadowSkinnedGroupsPalette[at] = palette;
6366
- this.shadowSkinnedGroupsCloth[at] = cloth.particles;
6367
- this.shadowSkinnedGroupsClothBinding[at] = cloth.binding;
6364
+ let shelf = this.shadowSkinnedShelves[at];
6365
+ if (shelf === undefined) {
6366
+ shelf = new GroupShelf();
6367
+ this.shadowSkinnedShelves[at] = shelf;
6368
6368
  }
6369
- return this.shadowSkinnedGroups[at] as GPUBindGroup;
6369
+ const kept = shelf.find(beside, palette, cloth.particles, cloth.binding);
6370
+ if (kept !== null) return kept;
6371
+ const group = createDepthBindGroup(
6372
+ this.surface.device,
6373
+ this.shadowSkinnedLayout,
6374
+ this.shadowDraws.buffer,
6375
+ this.shadowPassUniforms as GPUBuffer,
6376
+ this.shadowBlankView as GPUTextureView,
6377
+ this.shadowBlankSampler as GPUSampler,
6378
+ palette,
6379
+ null,
6380
+ cloth,
6381
+ );
6382
+ shelf.keep(group, beside, palette, cloth.particles, cloth.binding);
6383
+ return group;
6370
6384
  }
6371
6385
 
6372
6386
  private scatterDepthGroup(): GPUBindGroup {
@@ -6401,6 +6415,25 @@ export class WebGPURenderer implements RendererApi {
6401
6415
  * A material's layers where they can be drawn: none asked, or a model's map in the mask's place,
6402
6416
  * said once, leave one layer.
6403
6417
  */
6418
+ /**
6419
+ * A material's detail, or null where it is refused: its slot holds another array, or it is placed
6420
+ * by the world. Each said once. This backend has room for its vectors wherever it runs.
6421
+ */
6422
+ private detailOfMaterial(
6423
+ material: SurfaceMaterial<GpuSurfaceTexture> | null,
6424
+ taken: GpuSurfaceTexture | null,
6425
+ ): SurfaceDetail<GpuSurfaceTexture> | null {
6426
+ const detail = detailOf(material);
6427
+ if (detail === null) return null;
6428
+ const refusal = detailRefusal(detail, taken, (material?.projection ?? null) !== null);
6429
+ if (refusal === null) return detail;
6430
+ if (!this.warnedDetail.has(refusal)) {
6431
+ this.warnedDetail.add(refusal);
6432
+ console.warn(detailRefusalWarning(refusal));
6433
+ }
6434
+ return null;
6435
+ }
6436
+
6404
6437
  private layersOf(
6405
6438
  material: SurfaceMaterial<GpuSurfaceTexture> | null,
6406
6439
  ): SurfaceLayers<GpuSurfaceTexture> | null {
@@ -8621,7 +8654,7 @@ export class WebGPURenderer implements RendererApi {
8621
8654
  * neither the pipeline set nor the key depends on whether the geometry lands in one call or
8622
8655
  * over six frames.
8623
8656
  */
8624
- private warmFlatPipelines(data: MeshData, dynamic: boolean): string {
8657
+ private warmFlatPipelines(data: MeshData, dynamic: boolean, dynamicColors = false): string {
8625
8658
  /*
8626
8659
  * **Every optional attribute, not the two that come to mind.** `present` decides where
8627
8660
  * each attribute sits in the interleaved buffer, and `createGpuMesh` decides the same
@@ -8658,9 +8691,10 @@ export class WebGPURenderer implements RendererApi {
8658
8691
  }
8659
8692
  /*
8660
8693
  * And how each attribute travels (`vertexPacking.ts`): a field read as sixteen bits rather than
8661
- * thirty-two, or not interleaved at all, moves every field after it, so the key says so.
8694
+ * thirty-two, or not interleaved at all, moves every field after it, so the key says so. A
8695
+ * `dynamicColors` mesh's colours are one of those, as `:colors~stream`.
8662
8696
  */
8663
- const packing = vertexPackingOf(data, dynamic);
8697
+ const packing = vertexPackingOf(data, dynamic, dynamicColors);
8664
8698
  markPacking(present, packing);
8665
8699
  key += packingKey(packing);
8666
8700
  const fullKey = `${this.variant}|${key}`;
@@ -8777,8 +8811,10 @@ export class WebGPURenderer implements RendererApi {
8777
8811
  createMesh(given: MeshData, options: MeshOptions = {}): GpuMesh {
8778
8812
  /* The streams it is drawn with, before anything records which it has. See `withLightmapUvs`. */
8779
8813
  const data = withLightmapUvs(given);
8780
- const fullKey = this.warmFlatPipelines(data, options.dynamic === true);
8781
- return this.keyed(createGpuMesh(this.surface.device, data, options.dynamic === true), fullKey);
8814
+ const dynamic = options.dynamic === true;
8815
+ const dynamicColors = options.dynamicColors === true;
8816
+ const fullKey = this.warmFlatPipelines(data, dynamic, dynamicColors);
8817
+ return this.keyed(createGpuMesh(this.surface.device, data, dynamic, dynamicColors), fullKey);
8782
8818
  }
8783
8819
 
8784
8820
  /**
@@ -8792,11 +8828,14 @@ export class WebGPURenderer implements RendererApi {
8792
8828
  */
8793
8829
  createMeshIncremental(given: MeshData, options: MeshOptions = {}): IncrementalMeshHandle {
8794
8830
  const data = withLightmapUvs(given);
8795
- const fullKey = this.warmFlatPipelines(data, options.dynamic === true);
8831
+ const dynamic = options.dynamic === true;
8832
+ const dynamicColors = options.dynamicColors === true;
8833
+ const fullKey = this.warmFlatPipelines(data, dynamic, dynamicColors);
8796
8834
  const { mesh, upload } = createGpuMeshIncremental(
8797
8835
  this.surface.device,
8798
8836
  data,
8799
- options.dynamic === true,
8837
+ dynamic,
8838
+ dynamicColors,
8800
8839
  );
8801
8840
  const surface = this.surface;
8802
8841
  return {
@@ -8897,6 +8936,81 @@ export class WebGPURenderer implements RendererApi {
8897
8936
  mesh.update(this.surface.device, positions, normals, staged);
8898
8937
  }
8899
8938
 
8939
+ /** The pipeline every point skin poses with, made at the first one. See `pointSkinPass.ts`. */
8940
+ private pointSkinPipeline: GPUComputePipeline | null = null;
8941
+
8942
+ /**
8943
+ * A dynamic mesh posed on the device from weighted points in its joints' spaces. Refused in words
8944
+ * on a mesh that is not dynamic, whose positions share one buffer with the rest of each vertex.
8945
+ */
8946
+ createPointSkin(mesh: GpuMesh, data: PointSkinData): GpuPointSkin {
8947
+ if (mesh.update === null) {
8948
+ throw new Error(
8949
+ 'a point skin poses a mesh created with { dynamic: true }, whose positions and normals ' +
8950
+ 'have buffers of their own for the pose to write. Say so at `createMesh`.',
8951
+ );
8952
+ }
8953
+ validatePointSkin(data, mesh.vertexCount);
8954
+ const device = this.surface.device;
8955
+ this.pointSkinPipeline ??= createPointSkinPipeline(device);
8956
+ return new GpuPointSkin(device, this.pointSkinPipeline, mesh, data);
8957
+ }
8958
+
8959
+ /**
8960
+ * Pose a skin: `matrices` holds each joint's transform into the mesh's model space, sixteen floats,
8961
+ * column-major. Recorded into the encoder the frame's next submit leads with, behind a copy of
8962
+ * last frame's positions where a reconstruction keeps them, as `updateMesh` records its rewrite.
8963
+ */
8964
+ posePointSkin(skin: GpuPointSkin, matrices: Float32Array): void {
8965
+ validatePose(matrices, skin.reach);
8966
+ if (this.surface.lost) return;
8967
+ const mesh = skin.mesh;
8968
+ poseBounds(matrices, skin.reach, mesh.bounds);
8969
+ const encoder = this.rewriteCopies();
8970
+ const motion = mesh.motion;
8971
+ if (motion !== null && this.reconstructionWanted) {
8972
+ const frame = frameOfChange(this.moverFrame, this.inFrame);
8973
+ if (keepsPrevious(motion.changed, frame)) {
8974
+ const size = mesh.vertexBuffers[0]?.size ?? 0;
8975
+ motion.previous ??= this.surface.device.createBuffer({
8976
+ label: 'recon.dynamicPrevious',
8977
+ size,
8978
+ usage: 0x0020 | 0x0008, // VERTEX | COPY_DST
8979
+ });
8980
+ encoder.copyBufferToBuffer(mesh.vertexBuffers[0] as GPUBuffer, 0, motion.previous, 0, size);
8981
+ }
8982
+ motion.changed = frame;
8983
+ }
8984
+ skin.pose(this.surface.device, encoder, matrices);
8985
+ }
8986
+
8987
+ /**
8988
+ * **The pending rewrites go first**, since a pose recorded into them reads the skin's buffers and
8989
+ * a destroyed buffer in a submit loses the whole command buffer. Nothing else reads them, so once
8990
+ * the rewrites are submitted, which only moves them earlier, the buffers can go at once.
8991
+ */
8992
+ disposePointSkin(skin: GpuPointSkin): void {
8993
+ if (this.pendingRewrites !== null && !this.surface.lost) this.submit([]);
8994
+ skin.dispose();
8995
+ }
8996
+
8997
+ /**
8998
+ * Rewrite a mesh's colours. Refused in words on a mesh that did not ask for `dynamicColors`,
8999
+ * whose colours are interleaved with the rest of a vertex or are one value with no buffer at all.
9000
+ * See `GpuMesh.updateColors`.
9001
+ */
9002
+ updateMeshColors(mesh: GpuMesh, colors: Float32Array): void {
9003
+ if (mesh.updateColors === null) {
9004
+ throw new Error(
9005
+ 'this mesh was not created with { dynamicColors: true }, so its colours are packed with ' +
9006
+ 'the rest of each vertex, or are one value with no buffer at all, and cannot be ' +
9007
+ 'rewritten. Say so at `createMesh`: the flag gives the colours a buffer of their own.',
9008
+ );
9009
+ }
9010
+ if (this.surface.lost) return;
9011
+ mesh.updateColors(this.surface.device, colors);
9012
+ }
9013
+
8900
9014
  /**
8901
9015
  * Settle everything the frame shares: the camera, the lights and the fog.
8902
9016
  *
@@ -9035,6 +9149,8 @@ export class WebGPURenderer implements RendererApi {
9035
9149
  /* Nor layers: one layer until a material asks for more. */
9036
9150
  f.fill(0, at('uLayers'), at('uLayers') + LAYER_FLOATS);
9037
9151
  f.fill(0, at('uLayerLooks'), at('uLayerLooks') + LOOK_FLOATS);
9152
+ /* Nor detail. */
9153
+ f.fill(0, at('uDetail'), at('uDetail') + DETAIL_FLOATS);
9038
9154
  f[at('uCutout')] = 0;
9039
9155
  f[at('uCutout') + 1] = 0;
9040
9156
  this.cutoutResolveStaged = 'hard';
@@ -12391,40 +12507,33 @@ export class WebGPURenderer implements RendererApi {
12391
12507
  byMaterial = new Map();
12392
12508
  this.skinnedGroups[at] = byMaterial;
12393
12509
  }
12394
- let twin = byMaterial.get(base);
12395
- if (
12396
- twin === undefined ||
12397
- twin.palette !== palette ||
12398
- twin.deltas !== deltas ||
12399
- twin.cloth !== (cloth?.particles ?? null) ||
12400
- twin.clothBinding !== (cloth?.binding ?? null)
12401
- ) {
12402
- /* Built from the live material, with the overlay's atlas where `base` is its twin. */
12403
- this.overlayBinding = base !== this.bindGroup;
12404
- const group = createFlatBindGroup(
12405
- this.surface.device,
12406
- this.flatLayoutFor(skinned, morphed),
12407
- this.variant,
12408
- this.perDraw.buffer,
12409
- this.passRing.buffer,
12410
- this.materialStore.buffer,
12411
- this.viewRing.buffer,
12412
- this.flatTextures,
12413
- palette,
12414
- deltas,
12415
- cloth,
12416
- );
12417
- this.overlayBinding = false;
12418
- twin = {
12419
- group,
12420
- palette,
12421
- deltas,
12422
- cloth: cloth?.particles ?? null,
12423
- clothBinding: cloth?.binding ?? null,
12424
- };
12425
- byMaterial.set(base, twin);
12426
- }
12427
- return twin.group;
12510
+ let shelf = byMaterial.get(base);
12511
+ if (shelf === undefined) {
12512
+ shelf = new GroupShelf();
12513
+ byMaterial.set(base, shelf);
12514
+ }
12515
+ const particles = cloth?.particles ?? null;
12516
+ const binding = cloth?.binding ?? null;
12517
+ const kept = shelf.find(palette, deltas, particles, binding);
12518
+ if (kept !== null) return kept;
12519
+ /* Built from the live material, with the overlay's atlas where `base` is its twin. */
12520
+ this.overlayBinding = base !== this.bindGroup;
12521
+ const group = createFlatBindGroup(
12522
+ this.surface.device,
12523
+ this.flatLayoutFor(skinned, morphed),
12524
+ this.variant,
12525
+ this.perDraw.buffer,
12526
+ this.passRing.buffer,
12527
+ this.materialStore.buffer,
12528
+ this.viewRing.buffer,
12529
+ this.flatTextures,
12530
+ palette,
12531
+ deltas,
12532
+ cloth,
12533
+ );
12534
+ this.overlayBinding = false;
12535
+ shelf.keep(group, palette, deltas, particles, binding);
12536
+ return group;
12428
12537
  }
12429
12538
 
12430
12539
  /** The layout matching a pair of vertex flags. A pipeline and its bind group must agree. */
@@ -88,7 +88,8 @@ struct Pace {
88
88
  colliderBase: u32,
89
89
  colliders: u32,
90
90
  width: u32,
91
- pad0: u32,
91
+ /* The share of its offset from its skinned position a particle keeps a substep: the pull's. */
92
+ pullKeep: f32,
92
93
  keep: f32,
93
94
  dragKeep: f32,
94
95
  maxDistanceScale: f32,
@@ -428,7 +429,7 @@ fn pushOut(p: vec3<f32>, centre: vec3<f32>, radius: f32) -> vec3<f32> {
428
429
  return centre + d * (radius / sqrt(d2));
429
430
  }
430
431
 
431
- /* applyMaxDistance, applyStops (back, then front) and applyColliders, in that order. */
432
+ /* applyPull, applyMaxDistance, applyStops (back, then front) and applyColliders, in that order. */
432
433
  @compute @workgroup_size(64)
433
434
  fn clothLimit(@builtin(global_invocation_id) id: vec3<u32>) {
434
435
  let i = id.x;
@@ -444,6 +445,10 @@ fn clothLimit(@builtin(global_invocation_id) id: vec3<u32>) {
444
445
  let at = stateAt(POSITION, i);
445
446
  var p = load(at);
446
447
  let t = skinnedAt(i, g);
448
+ /* Only where there is a pull, as on the CPU: t + (p - t) is not p in floats. */
449
+ if (paces[g].pullKeep < 1.0) {
450
+ p = t + (p - t) * paces[g].pullKeep;
451
+ }
447
452
  /* A max distance below zero is none: the CPU's Infinity, which a device may not be handed. */
448
453
  let maxDistance = statics[n + i];
449
454
  if ((limits & 1u) != 0u && maxDistance >= 0.0) {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A point skin posed on the device, in WGSL, hand-authored under the compute exception `AGENTS.md`
3
+ * records (2026-08-24): a compute shader has no WebGL2 twin to generate from, and `npm run wgsl`
4
+ * neither writes nor polices this file. WebGL2 poses the same skin by transform feedback, in
5
+ * `backend/webgl2/pointSkinFeedback.ts`, by the same arithmetic in the same order.
6
+ *
7
+ * One invocation a vertex: its position the sum of each weighted point moved by its joint, an
8
+ * influence of weight 0 skipped, and its normal the first joint's turn of the normal it was given,
9
+ * made unit length where it has any. Written into the mesh's own position and normal buffers, three
10
+ * floats a vertex, which every pass then draws as it draws any dynamic mesh.
11
+ */
12
+ export const POINT_SKIN_WORKGROUP = 64;
13
+
14
+ export const POINT_SKIN_WGSL = /* wgsl */ `
15
+ struct Params {
16
+ count: u32,
17
+ pad0: u32,
18
+ pad1: u32,
19
+ pad2: u32,
20
+ }
21
+
22
+ @group(0) @binding(0) var<uniform> params: Params;
23
+ @group(0) @binding(1) var<storage, read> joints: array<mat4x4<f32>>;
24
+ @group(0) @binding(2) var<storage, read> points: array<vec4<f32>>;
25
+ @group(0) @binding(3) var<storage, read> named: array<vec4<u32>>;
26
+ @group(0) @binding(4) var<storage, read> normals: array<vec4<f32>>;
27
+ @group(0) @binding(5) var<storage, read_write> posedPositions: array<f32>;
28
+ @group(0) @binding(6) var<storage, read_write> posedNormals: array<f32>;
29
+
30
+ @compute @workgroup_size(${POINT_SKIN_WORKGROUP})
31
+ fn posePoints(@builtin(global_invocation_id) id: vec3<u32>) {
32
+ let v = id.x;
33
+ if (v >= params.count) {
34
+ return;
35
+ }
36
+ let j = named[v];
37
+ var p = vec3<f32>(0.0, 0.0, 0.0);
38
+ for (var k = 0u; k < 4u; k = k + 1u) {
39
+ let q = points[v * 4u + k];
40
+ if (q.w != 0.0) {
41
+ p = p + q.w * (joints[j[k]] * vec4<f32>(q.xyz, 1.0)).xyz;
42
+ }
43
+ }
44
+ let m = joints[j.x];
45
+ let raw = mat3x3<f32>(m[0].xyz, m[1].xyz, m[2].xyz) * normals[v].xyz;
46
+ let l = length(raw);
47
+ var n = raw;
48
+ if (l > 0.0) {
49
+ n = raw / l;
50
+ }
51
+ posedPositions[v * 3u] = p.x;
52
+ posedPositions[v * 3u + 1u] = p.y;
53
+ posedPositions[v * 3u + 2u] = p.z;
54
+ posedNormals[v * 3u] = n.x;
55
+ posedNormals[v * 3u + 1u] = n.y;
56
+ posedNormals[v * 3u + 2u] = n.z;
57
+ }
58
+ `;
@@ -17,7 +17,7 @@ import {
17
17
  */
18
18
 
19
19
  /** The ways an attribute can travel other than as floats, as the key words name them. */
20
- const PACKINGS = ['snorm16', 'unorm16', 'constant'] as const;
20
+ const PACKINGS = ['snorm16', 'unorm16', 'constant', 'stream'] as const;
21
21
 
22
22
  /** The pipeline key's word for one attribute's packing. Not a prefix of any other word. */
23
23
  function word(name: string, packing: AttributePacking): string {
@@ -287,6 +287,13 @@ export const BONE_PLACES_TEXTURE_UNIT = PARTICLE_DEPTH_UNIT + 1;
287
287
  export const BONE_TURNS_TEXTURE_UNIT = BONE_PLACES_TEXTURE_UNIT + 1;
288
288
  export const INSTANCE_CLOCKS_TEXTURE_UNIT = BONE_TURNS_TEXTURE_UNIT + 1;
289
289
 
290
+ /**
291
+ * A point skin's joints (`pointSkin.ts`), read by the transform-feedback program that poses it. Past
292
+ * every unit a draw binds, so a pose taken between two draws leaves nothing a draw expects to find.
293
+ * Twenty-nine.
294
+ */
295
+ export const POINT_SKIN_TEXTURE_UNIT = INSTANCE_CLOCKS_TEXTURE_UNIT + 1;
296
+
290
297
  /**
291
298
  * A cookie's tile, in texels a side.
292
299
  *