@driftengine/core 4.8.3 → 4.8.4

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 (178) hide show
  1. package/CHANGELOG.json +71 -0
  2. package/README.md +1 -1
  3. package/dist/geometry/meshConcat.js +2 -0
  4. package/dist/index.d.ts +10 -4
  5. package/dist/index.js +16 -1
  6. package/dist/math/scalar.js +1 -1
  7. package/dist/render/anisotropicLobe.d.ts +7 -0
  8. package/dist/render/anisotropicLobe.js +33 -0
  9. package/dist/render/backend/api.d.ts +41 -4
  10. package/dist/render/backend/select.js +8 -0
  11. package/dist/render/backend/webgl2/clothTextures.d.ts +27 -0
  12. package/dist/render/backend/webgl2/clothTextures.js +93 -0
  13. package/dist/render/backend/webgl2/renderer.d.ts +136 -3
  14. package/dist/render/backend/webgl2/renderer.js +604 -63
  15. package/dist/render/backend/webgl2/skinScatter.d.ts +50 -0
  16. package/dist/render/backend/webgl2/skinScatter.js +256 -0
  17. package/dist/render/backend/webgpu/buffers.d.ts +25 -11
  18. package/dist/render/backend/webgpu/buffers.js +98 -49
  19. package/dist/render/backend/webgpu/clothSolvePack.d.ts +27 -0
  20. package/dist/render/backend/webgpu/clothSolvePack.js +129 -0
  21. package/dist/render/backend/webgpu/clothTextures.d.ts +68 -0
  22. package/dist/render/backend/webgpu/clothTextures.js +145 -0
  23. package/dist/render/backend/webgpu/compressedUpload.d.ts +18 -0
  24. package/dist/render/backend/webgpu/compressedUpload.js +38 -0
  25. package/dist/render/backend/webgpu/computeHost.d.ts +21 -0
  26. package/dist/render/backend/webgpu/computeHost.js +5 -0
  27. package/dist/render/backend/webgpu/depthPass.d.ts +16 -6
  28. package/dist/render/backend/webgpu/depthPass.js +39 -9
  29. package/dist/render/backend/webgpu/drawCommand.js +6 -2
  30. package/dist/render/backend/webgpu/flatPass.d.ts +38 -4
  31. package/dist/render/backend/webgpu/flatPass.js +109 -12
  32. package/dist/render/backend/webgpu/gpuSkinnedCloth.d.ts +74 -0
  33. package/dist/render/backend/webgpu/gpuSkinnedCloth.js +452 -0
  34. package/dist/render/backend/webgpu/motionPass.d.ts +20 -0
  35. package/dist/render/backend/webgpu/motionPass.js +106 -9
  36. package/dist/render/backend/webgpu/postPass.d.ts +3 -1
  37. package/dist/render/backend/webgpu/postPass.js +6 -3
  38. package/dist/render/backend/webgpu/renderer.d.ts +113 -5
  39. package/dist/render/backend/webgpu/renderer.js +498 -48
  40. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +33 -0
  41. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +469 -0
  42. package/dist/render/backend/webgpu/skinScatterPass.d.ts +51 -0
  43. package/dist/render/backend/webgpu/skinScatterPass.js +253 -0
  44. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  45. package/dist/render/backend/webgpu/surfaceTexturePass.js +37 -12
  46. package/dist/render/clothBindingData.d.ts +41 -0
  47. package/dist/render/clothBindingData.js +72 -0
  48. package/dist/render/clothPlacement.d.ts +25 -0
  49. package/dist/render/clothPlacement.js +85 -0
  50. package/dist/render/compressedSource.d.ts +66 -0
  51. package/dist/render/compressedSource.js +138 -0
  52. package/dist/render/cutoutCaster.d.ts +2 -0
  53. package/dist/render/cutoutCaster.js +2 -0
  54. package/dist/render/cutoutDither.d.ts +51 -0
  55. package/dist/render/cutoutDither.js +90 -0
  56. package/dist/render/eyeRefraction.d.ts +35 -0
  57. package/dist/render/eyeRefraction.js +40 -0
  58. package/dist/render/glCompressed.d.ts +27 -0
  59. package/dist/render/glCompressed.js +53 -0
  60. package/dist/render/hairLobes.d.ts +70 -0
  61. package/dist/render/hairLobes.js +120 -0
  62. package/dist/render/lightBudget.d.ts +22 -0
  63. package/dist/render/lightBudget.js +22 -0
  64. package/dist/render/mesh.d.ts +7 -0
  65. package/dist/render/mesh.js +24 -0
  66. package/dist/render/recon/motionVectors.d.ts +4 -2
  67. package/dist/render/recon/motionVectors.js +6 -3
  68. package/dist/render/renderQuality.d.ts +20 -0
  69. package/dist/render/renderQuality.js +5 -0
  70. package/dist/render/rendererHarness.js +4 -0
  71. package/dist/render/sceneTarget.d.ts +12 -0
  72. package/dist/render/sceneTarget.js +14 -0
  73. package/dist/render/shaders/clothBinding.d.ts +22 -0
  74. package/dist/render/shaders/clothBinding.js +80 -0
  75. package/dist/render/shaders/depth.js +42 -6
  76. package/dist/render/shaders/flat/anisotropic.d.ts +15 -0
  77. package/dist/render/shaders/flat/anisotropic.js +68 -0
  78. package/dist/render/shaders/flat/eye.d.ts +1 -0
  79. package/dist/render/shaders/flat/eye.js +81 -0
  80. package/dist/render/shaders/flat/hair.d.ts +1 -0
  81. package/dist/render/shaders/flat/hair.js +101 -0
  82. package/dist/render/shaders/flat/index.js +36 -2
  83. package/dist/render/shaders/flat/main.d.ts +1 -1
  84. package/dist/render/shaders/flat/main.js +113 -17
  85. package/dist/render/shaders/flat/models.d.ts +14 -0
  86. package/dist/render/shaders/flat/models.js +273 -0
  87. package/dist/render/shaders/flat/preamble.js +26 -24
  88. package/dist/render/shaders/flat/skin.d.ts +1 -0
  89. package/dist/render/shaders/flat/skin.js +114 -0
  90. package/dist/render/shaders/generated/depth.wgsl.d.ts +38 -16
  91. package/dist/render/shaders/generated/depth.wgsl.js +38 -16
  92. package/dist/render/shaders/generated/flat.wgsl.d.ts +1790 -1704
  93. package/dist/render/shaders/generated/flat.wgsl.js +1828 -1742
  94. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +48 -0
  95. package/dist/render/shaders/generated/skinBlur.wgsl.js +54 -0
  96. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  97. package/dist/render/shaders/recon/motion.wgsl.js +102 -0
  98. package/dist/render/shaders/skinBlur.d.ts +1 -0
  99. package/dist/render/shaders/skinBlur.js +103 -0
  100. package/dist/render/shaders/skinning.d.ts +3 -1
  101. package/dist/render/shaders/skinning.js +31 -4
  102. package/dist/render/shadowCasters.d.ts +13 -1
  103. package/dist/render/skinBlur.d.ts +41 -0
  104. package/dist/render/skinBlur.js +49 -0
  105. package/dist/render/skinScatter.d.ts +67 -0
  106. package/dist/render/skinScatter.js +80 -0
  107. package/dist/render/skinnedClothRun.d.ts +26 -0
  108. package/dist/render/skinnedClothRun.js +84 -0
  109. package/dist/render/surfaceModel.d.ts +129 -0
  110. package/dist/render/surfaceModel.js +152 -0
  111. package/dist/render/surfaceTexture.d.ts +44 -4
  112. package/dist/render/surfaceTexture.js +60 -46
  113. package/dist/render/vertexDefaults.js +7 -0
  114. package/package.json +6 -6
  115. package/src/geometry/meshConcat.ts +2 -0
  116. package/src/index.ts +40 -1
  117. package/src/math/scalar.ts +1 -1
  118. package/src/render/anisotropicLobe.ts +41 -0
  119. package/src/render/backend/api.ts +52 -3
  120. package/src/render/backend/select.ts +8 -0
  121. package/src/render/backend/webgl2/clothTextures.ts +120 -0
  122. package/src/render/backend/webgl2/renderer.ts +693 -64
  123. package/src/render/backend/webgl2/skinScatter.ts +309 -0
  124. package/src/render/backend/webgpu/buffers.ts +137 -63
  125. package/src/render/backend/webgpu/clothSolvePack.ts +161 -0
  126. package/src/render/backend/webgpu/clothTextures.ts +194 -0
  127. package/src/render/backend/webgpu/compressedUpload.ts +65 -0
  128. package/src/render/backend/webgpu/computeHost.ts +25 -0
  129. package/src/render/backend/webgpu/depthPass.ts +39 -7
  130. package/src/render/backend/webgpu/drawCommand.ts +6 -2
  131. package/src/render/backend/webgpu/flatPass.ts +145 -7
  132. package/src/render/backend/webgpu/gpuSkinnedCloth.ts +502 -0
  133. package/src/render/backend/webgpu/motionPass.ts +128 -9
  134. package/src/render/backend/webgpu/postPass.ts +5 -1
  135. package/src/render/backend/webgpu/renderer.ts +663 -43
  136. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +470 -0
  137. package/src/render/backend/webgpu/skinScatterPass.ts +309 -0
  138. package/src/render/backend/webgpu/surfaceTexturePass.ts +42 -12
  139. package/src/render/clothBindingData.ts +89 -0
  140. package/src/render/clothPlacement.ts +114 -0
  141. package/src/render/compressedSource.ts +198 -0
  142. package/src/render/cutoutCaster.ts +5 -0
  143. package/src/render/cutoutDither.ts +99 -0
  144. package/src/render/eyeRefraction.ts +72 -0
  145. package/src/render/glCompressed.ts +86 -0
  146. package/src/render/hairLobes.ts +188 -0
  147. package/src/render/lightBudget.ts +25 -0
  148. package/src/render/mesh.ts +23 -0
  149. package/src/render/recon/motionVectors.ts +6 -3
  150. package/src/render/renderQuality.ts +28 -0
  151. package/src/render/rendererHarness.ts +4 -0
  152. package/src/render/sceneTarget.ts +21 -0
  153. package/src/render/shaders/clothBinding.ts +81 -0
  154. package/src/render/shaders/depth.ts +42 -6
  155. package/src/render/shaders/flat/anisotropic.ts +68 -0
  156. package/src/render/shaders/flat/eye.ts +82 -0
  157. package/src/render/shaders/flat/hair.ts +102 -0
  158. package/src/render/shaders/flat/index.ts +36 -2
  159. package/src/render/shaders/flat/main.ts +113 -17
  160. package/src/render/shaders/flat/models.ts +286 -0
  161. package/src/render/shaders/flat/preamble.ts +26 -24
  162. package/src/render/shaders/flat/skin.ts +116 -0
  163. package/src/render/shaders/generated/depth.wgsl.ts +38 -16
  164. package/src/render/shaders/generated/flat.wgsl.ts +1828 -1742
  165. package/src/render/shaders/generated/skinBlur.wgsl.ts +56 -0
  166. package/src/render/shaders/recon/motion.wgsl.ts +102 -0
  167. package/src/render/shaders/skinBlur.ts +105 -0
  168. package/src/render/shaders/skinning.ts +31 -4
  169. package/src/render/shadowCasters.ts +13 -0
  170. package/src/render/skinBlur.ts +53 -0
  171. package/src/render/skinScatter.ts +92 -0
  172. package/src/render/skinnedClothRun.ts +128 -0
  173. package/src/render/surfaceModel.ts +274 -0
  174. package/src/render/surfaceTexture.ts +123 -63
  175. package/src/render/vertexDefaults.ts +7 -0
  176. package/dist/math/exact.d.ts +0 -76
  177. package/dist/math/exact.js +0 -327
  178. package/src/math/exact.ts +0 -340
@@ -164,6 +164,15 @@ import {
164
164
  import type { TextStyle } from '../../textLayout.ts';
165
165
  import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.ts';
166
166
  import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
167
+ import { gpuCompressedFormats } from './compressedUpload.ts';
168
+ import { GpuClothBinding, GpuClothParticles, clothStandIn } from './clothTextures.ts';
169
+ import type { ClothViews } from './clothTextures.ts';
170
+ import type { ComputeHost } from './computeHost.ts';
171
+ import { MODEL_PARAM_FLOATS, eyeAxisInWorld, packModel } from '../../surfaceModel.ts';
172
+ import type { SurfaceModel } from '../../surfaceModel.ts';
173
+ import { validateClothBinding } from '../../clothBindingData.ts';
174
+ import type { ClothBindingData } from '../../clothBindingData.ts';
175
+ import type { CompressedTextureFormat, SurfaceSource } from '../../compressedSource.ts';
167
176
  import {
168
177
  createSdfTextBindGroup,
169
178
  createSdfTextBindGroupLayout,
@@ -383,6 +392,7 @@ import {
383
392
  depthPipeline,
384
393
  } from './depthPass.ts';
385
394
  import { PipelineCache, type LitSwitch } from './pipelineCache.ts';
395
+ import { SKIN_TARGET_FORMAT, SkinScatterPass } from './skinScatterPass.ts';
386
396
  import {
387
397
  SKY_FIELDS,
388
398
  SKY_UNIFORM_SIZE,
@@ -583,6 +593,8 @@ import type { Arena, FlushSchedule, ScheduledPass } from '../../frame/index.ts';
583
593
  import { GpuInstancedBatch } from './instanced.ts';
584
594
  import type { InstancedOptions, MeshInstances } from '../../instances.ts';
585
595
  import { cutoutOf } from '../../cutoutCaster.ts';
596
+ import { CUTOUT_RESOLVE_CODE, resolveCutout } from '../../cutoutDither.ts';
597
+ import type { CutoutResolve } from '../../cutoutDither.ts';
586
598
  import type { CutoutCaster } from '../../cutoutCaster.ts';
587
599
  import { LightField } from '../../driftLight/lightField.ts';
588
600
  import type { LightFieldOptions, LightFieldSource } from '../../driftLight/lightField.ts';
@@ -1457,6 +1469,12 @@ export class WebGPURenderer implements RendererApi {
1457
1469
  private readonly skinnedGroupsBeside: (GPUBindGroup | null)[] = [];
1458
1470
  private readonly skinnedGroupsPalette: (GPUTextureView | null)[] = [];
1459
1471
  private readonly skinnedGroupsDeltas: (GPUTextureView | null)[] = [];
1472
+ /** The cloth views each skinned twin was built with, compared by identity like the palette. */
1473
+ private readonly skinnedGroupsCloth: (GPUTextureView | null)[] = [];
1474
+ private readonly skinnedGroupsClothBinding: (GPUTextureView | null)[] = [];
1475
+ /** The cloth the next skinned draws are placed by, or null. See `setCloth`. */
1476
+ private cloth: { binding: GpuClothBinding; particles: GpuClothParticles } | null = null;
1477
+ private clothStandCache: { texture: GPUTexture; views: ClothViews } | null = null;
1460
1478
  /**
1461
1479
  * Which palette slot the following draws skin by, or -1 for none.
1462
1480
  *
@@ -1872,6 +1890,16 @@ export class WebGPURenderer implements RendererApi {
1872
1890
  private refractSnapshotView: GPUTextureView | null = null;
1873
1891
  /** Whether this frame has taken it. One snapshot serves every refracting draw. */
1874
1892
  private refractSnapshotTaken = false;
1893
+ /**
1894
+ * Skin's screen-space scattering, made where the profile asks for it and has a composite to add
1895
+ * into, and null otherwise — which is every profile by default. See `SkinScatterPass`.
1896
+ */
1897
+ private readonly skinScatter: SkinScatterPass | null;
1898
+ /** The pipelines skin's diffuse half is built for: the blur target's format, the frame's samples. */
1899
+ private readonly skinPipelines: PipelineCache | null;
1900
+ /** Whether this frame has spread its skin: once, at its first blended draw or before its end. */
1901
+ private skinScattered = false;
1902
+ private readonly skinInverseProjection = mat4.create();
1875
1903
  private sceneColorView: GPUTextureView | null = null;
1876
1904
  /** The multisampled twin, when the profile asks for samples. Resolved into `sceneColor`. */
1877
1905
  private sceneColorMsaa: GPUTexture | null = null;
@@ -2052,6 +2080,17 @@ export class WebGPURenderer implements RendererApi {
2052
2080
  private temporalJitterX = 0;
2053
2081
  private temporalJitterY = 0;
2054
2082
  private temporalJittering = false;
2083
+ /**
2084
+ * The frame a dithered cutout's pattern is offset by, advanced at `beginFrame` and wrapped at 64.
2085
+ * See `cutoutDither.ts`.
2086
+ */
2087
+ private cutoutFrame = 0;
2088
+ /**
2089
+ * How the current material's cutout edge is drawn this frame, decided when the material is staged
2090
+ * — after `bindMeshPass` has settled whether the frame is temporal — and read by every draw: a
2091
+ * coverage edge is pipeline state here (`|a2c`), and a blended draw tests hard.
2092
+ */
2093
+ private cutoutResolveStaged: CutoutResolve = 'hard';
2055
2094
  /**
2056
2095
  * Whether this frame is being reconstructed, settled once in `bindMeshPass`.
2057
2096
  *
@@ -2769,6 +2808,12 @@ export class WebGPURenderer implements RendererApi {
2769
2808
  private ormMap: GpuSurfaceTexture | null = null;
2770
2809
  /** The caller's emissive map, or null for the stand-in. See `setMaterial`. */
2771
2810
  private emissiveMap: GpuSurfaceTexture | null = null;
2811
+ /** The material's model map, or null for the stand-in. See `setMaterial` and `surfaceModel.ts`. */
2812
+ private modelMap: GpuSurfaceTexture | null = null;
2813
+ /** The material's shading model, which picks the lit pipeline: null the standard. */
2814
+ private materialModel: SurfaceModel | null = null;
2815
+ /** Its numbers, `uModelParams`, packed by `packModel`. */
2816
+ private readonly modelParams = new Float32Array(MODEL_PARAM_FLOATS);
2772
2817
  /**
2773
2818
  * The flat group for a material, keyed on **all four** of its maps.
2774
2819
  *
@@ -2799,7 +2844,10 @@ export class WebGPURenderer implements RendererApi {
2799
2844
  GpuSurfaceTexture | null,
2800
2845
  Map<
2801
2846
  GpuSurfaceTexture | null,
2802
- Map<GpuSurfaceTexture | null, Map<GpuSurfaceTexture | null, GPUBindGroup>>
2847
+ Map<
2848
+ GpuSurfaceTexture | null,
2849
+ Map<GpuSurfaceTexture | null, Map<GpuSurfaceTexture | null, GPUBindGroup>>
2850
+ >
2803
2851
  >
2804
2852
  >();
2805
2853
  /** The group holding the one-pixel stand-in, for every draw with no texture set. */
@@ -2819,18 +2867,42 @@ export class WebGPURenderer implements RendererApi {
2819
2867
  private materialHasMaps = false;
2820
2868
 
2821
2869
  createSurfaceTexture(
2822
- source: TexImageSource,
2870
+ source: SurfaceSource,
2823
2871
  options: SurfaceTextureOptions = {},
2824
2872
  ): GpuSurfaceTexture {
2825
- return new GpuSurfaceTexture(this.surface.device, this.pipelines, source, options);
2873
+ return new GpuSurfaceTexture(
2874
+ this.surface.device,
2875
+ this.pipelines,
2876
+ source,
2877
+ options,
2878
+ this.compressedFormats,
2879
+ );
2880
+ }
2881
+
2882
+ private compressedCache: readonly CompressedTextureFormat[] | null = null;
2883
+
2884
+ /**
2885
+ * The BC formats this device takes as blocks: every one where the device carries
2886
+ * `texture-compression-bc`, which `select.ts` asks for wherever the adapter offers it, and none
2887
+ * where it does not. See `Renderer.compressedFormats`.
2888
+ */
2889
+ get compressedFormats(): readonly CompressedTextureFormat[] {
2890
+ this.compressedCache ??= gpuCompressedFormats(this.surface.device);
2891
+ return this.compressedCache;
2826
2892
  }
2827
2893
 
2828
2894
  /** Several images of one size as an array's layers. See `RendererApi.createSurfaceTextureArray`. */
2829
2895
  createSurfaceTextureArray(
2830
- sources: readonly TexImageSource[],
2896
+ sources: readonly SurfaceSource[],
2831
2897
  options: SurfaceTextureOptions = {},
2832
2898
  ): GpuSurfaceTexture {
2833
- return new GpuSurfaceTexture(this.surface.device, this.pipelines, sources, options);
2899
+ return new GpuSurfaceTexture(
2900
+ this.surface.device,
2901
+ this.pipelines,
2902
+ sources,
2903
+ options,
2904
+ this.compressedFormats,
2905
+ );
2834
2906
  }
2835
2907
 
2836
2908
  /**
@@ -2878,6 +2950,7 @@ export class WebGPURenderer implements RendererApi {
2878
2950
  if (this.normalMap === texture) this.normalMap = null;
2879
2951
  if (this.ormMap === texture) this.ormMap = null;
2880
2952
  if (this.emissiveMap === texture) this.emissiveMap = null;
2953
+ if (this.modelMap === texture) this.modelMap = null;
2881
2954
  this.forgetBindingsOf(texture);
2882
2955
  texture.dispose();
2883
2956
  }
@@ -2903,7 +2976,13 @@ export class WebGPURenderer implements RendererApi {
2903
2976
  this.albedo = null;
2904
2977
  this.blankAlbedoBindGroup = this.buildFlatBindGroup();
2905
2978
  this.albedo = held;
2906
- this.bindGroup = this.flatGroupForMaps(held, this.normalMap, this.ormMap, this.emissiveMap);
2979
+ this.bindGroup = this.flatGroupForMaps(
2980
+ held,
2981
+ this.normalMap,
2982
+ this.ormMap,
2983
+ this.emissiveMap,
2984
+ this.modelMap,
2985
+ );
2907
2986
  }
2908
2987
 
2909
2988
  /**
@@ -2932,6 +3011,128 @@ export class WebGPURenderer implements RendererApi {
2932
3011
  this.skinPaletteSlot = this.skinPalettes.take(this.surface.device, palette) ?? -1;
2933
3012
  }
2934
3013
 
3014
+ /**
3015
+ * A mesh's cloth binding. See `RendererApi.createClothBinding`. The pipelines that draw it bound
3016
+ * are warmed here, every way this mesh can be drawn, so none compiles inside the first frame
3017
+ * that draws a garment.
3018
+ */
3019
+ createClothBinding(mesh: GpuMesh, data: ClothBindingData): GpuClothBinding {
3020
+ if (!mesh.isSkinned) {
3021
+ throw new Error(
3022
+ 'createClothBinding: this mesh has no rig, and a binding blends each vertex between its ' +
3023
+ 'skinning and the cloth',
3024
+ );
3025
+ }
3026
+ validateClothBinding(data, mesh.vertexCount);
3027
+ const base = (mesh as GpuMesh & { key?: string }).key ?? 'flat:s0:u0';
3028
+ const present = this.meshPresent.get(base);
3029
+ if (present !== undefined) {
3030
+ const skin = mesh.isSkinnedEight ? '|skin8' : '|skin';
3031
+ for (const morphed of mesh.morph !== null ? [false, true] : [false]) {
3032
+ for (const translucent of [false, true]) {
3033
+ void flatPipelineAsync(
3034
+ this.targetPipelines(),
3035
+ this.surface.device,
3036
+ this.flatLayoutFor(true, morphed),
3037
+ this.variant,
3038
+ `${base}${morphed ? '|morph' : ''}${skin}|cloth${translucent ? '|blend' : ''}`,
3039
+ present,
3040
+ translucent,
3041
+ true,
3042
+ morphed,
3043
+ mesh.isSkinnedEight,
3044
+ true,
3045
+ );
3046
+ }
3047
+ }
3048
+ }
3049
+ return new GpuClothBinding(this.surface.device, data);
3050
+ }
3051
+
3052
+ /** One character's particles. See `RendererApi.createClothParticles`. */
3053
+ createClothParticles(count: number): GpuClothParticles {
3054
+ return new GpuClothParticles(this.surface.device, count);
3055
+ }
3056
+
3057
+ /**
3058
+ * This frame's particles. **Once a frame, before the character's draws**: a texture write lands
3059
+ * ahead of every draw of the frame, so a second update between two draws would reach both.
3060
+ */
3061
+ updateClothParticles(particles: GpuClothParticles, positions: Float32Array): void {
3062
+ if (this.surface.lost) return;
3063
+ /* A write between frames is the next frame's, as a rewritten mesh's is. See `changeFrames.ts`. */
3064
+ particles.update(this.surface.device, positions, frameOfChange(this.moverFrame, this.inFrame));
3065
+ }
3066
+
3067
+ /** Place the following skinned draws by a cloth, or by none. See `RendererApi.setCloth`. */
3068
+ setCloth(binding: GpuClothBinding | null, particles: GpuClothParticles | null = null): void {
3069
+ if (binding === null || particles === null) {
3070
+ this.cloth = null;
3071
+ return;
3072
+ }
3073
+ if (binding.particles !== particles.count) {
3074
+ throw new Error(
3075
+ `setCloth: the binding names ${binding.particles} particles and these are ${particles.count}`,
3076
+ );
3077
+ }
3078
+ this.cloth = { binding, particles };
3079
+ }
3080
+
3081
+ /** Retired rather than destroyed: a draw recorded earlier in this frame may still read them. */
3082
+ disposeClothBinding(binding: GpuClothBinding): void {
3083
+ this.retiredTextures.push(...binding.textures);
3084
+ }
3085
+
3086
+ disposeClothParticles(particles: GpuClothParticles): void {
3087
+ this.retiredTextures.push(...particles.textures);
3088
+ }
3089
+
3090
+ /**
3091
+ * The device and the frame's bookkeeping, for compute a consumer opts into by importing it —
3092
+ * `createSkinnedCloth` — rather than for anything this renderer runs itself. See `computeHost.ts`.
3093
+ */
3094
+ computeHost(): ComputeHost {
3095
+ return this.computeHostCache;
3096
+ }
3097
+
3098
+ private readonly computeHostCache: ComputeHost = {
3099
+ device: () => this.surface.device,
3100
+ lost: () => this.surface.lost,
3101
+ changeFrame: () => frameOfChange(this.moverFrame, this.inFrame),
3102
+ retire: (textures) => {
3103
+ this.retiredTextures.push(...textures);
3104
+ },
3105
+ };
3106
+
3107
+ /**
3108
+ * An eye's axis in the world for this draw, into the open material's \`uModelParams\`, and a new
3109
+ * material slot when it moved: the axis is per draw, and a slot already taken by an earlier draw
3110
+ * of the same eye must not be written under it. See \`eyeAxisInWorld\`.
3111
+ */
3112
+ private writeEyeAxis(model: ArrayLike<number>, skinned: boolean): void {
3113
+ const eye = this.materialModel;
3114
+ if (eye?.kind !== 'eye') return;
3115
+ const at = this.materialField('uModelParams') + 4;
3116
+ const f = this.perFrameFloats;
3117
+ const before0 = f[at];
3118
+ const before1 = f[at + 1];
3119
+ const before2 = f[at + 2];
3120
+ eyeAxisInWorld(eye, model, skinned ? this.skinPaletteData : null, f, at);
3121
+ if (f[at] !== before0 || f[at + 1] !== before1 || f[at + 2] !== before2) this.materials.dirty();
3122
+ }
3123
+
3124
+ /** The views the draw's cloth binds, or the stand-in's where no cloth is set. */
3125
+ private clothViews(): ClothViews {
3126
+ const cloth = this.cloth;
3127
+ return cloth === null ? this.clothStand() : clothViewsOf(cloth.binding, cloth.particles);
3128
+ }
3129
+
3130
+ /** The one-texel stand-in a skinned group binds where there is no cloth, made on first need. */
3131
+ private clothStand(): ClothViews {
3132
+ this.clothStandCache ??= clothStandIn(this.surface.device);
3133
+ return this.clothStandCache.views;
3134
+ }
3135
+
2935
3136
  /**
2936
3137
  * Choose the morph weights the following `drawMesh` calls deform by, or null for none.
2937
3138
  *
@@ -2955,14 +3156,41 @@ export class WebGPURenderer implements RendererApi {
2955
3156
  const normal = (material?.normal ?? null) as GpuSurfaceTexture | null;
2956
3157
  const orm = (material?.orm ?? null) as GpuSurfaceTexture | null;
2957
3158
  const emissiveMap = (material?.emissive ?? null) as GpuSurfaceTexture | null;
3159
+ const modelMap = (material?.modelMap ?? null) as GpuSurfaceTexture | null;
3160
+ this.materialModel = material?.model ?? null;
3161
+ /* What the blur spreads this skin by: the last material to name a profile says. */
3162
+ const model = material?.model;
3163
+ if (model?.kind === 'skin' && this.skinScatter !== null) {
3164
+ this.skinScatter.setProfile(
3165
+ model.profile,
3166
+ model.scatterColor[0] * model.radius,
3167
+ model.scatterColor[1] * model.radius,
3168
+ model.scatterColor[2] * model.radius,
3169
+ );
3170
+ }
2958
3171
  this.material((f, i) => {
2959
- i[this.materialField('uAlbedoEnabled')] = albedo === null ? 0 : 1;
2960
- f[this.materialField('uAlbedoCutout')] = material?.cutout ?? 0;
2961
- i[this.materialField('uDoubleSided')] = material?.doubleSided === true ? 1 : 0;
3172
+ /* The material's four switches, one row: albedo, ORM map, emissive map, two-sided. */
3173
+ const flags = this.materialField('uMaterialFlags');
3174
+ i[flags] = albedo === null ? 0 : 1;
3175
+ /* The cutoff, how this frame resolves a dithered edge, and the frame its pattern moves with. */
3176
+ const cutoff = albedo === null ? 0 : (material?.cutout ?? 0);
3177
+ this.cutoutResolveStaged =
3178
+ cutoff > 0
3179
+ ? resolveCutout(
3180
+ material?.cutoutMode ?? 'hard',
3181
+ this.reconstructing || this.temporalJittering,
3182
+ this.samples,
3183
+ )
3184
+ : 'hard';
3185
+ const cut = this.materialField('uCutout');
3186
+ f[cut] = cutoff;
3187
+ f[cut + 1] = CUTOUT_RESOLVE_CODE[this.cutoutResolveStaged];
3188
+ f[cut + 2] = this.cutoutFrame;
3189
+ i[flags + 3] = material?.doubleSided === true ? 1 : 0;
2962
3190
  /* 0 is no map, and gates the whole block in the shader. See `uNormalStrength`. */
2963
3191
  f[this.materialField('uNormalStrength')] =
2964
3192
  normal === null ? 0 : (material?.normalStrength ?? 1);
2965
- i[this.materialField('uOrmEnabled')] = orm === null ? 0 : 1;
3193
+ i[flags + 1] = orm === null ? 0 : 1;
2966
3194
  /*
2967
3195
  * Component-aligned with the map — r occlusion, g roughness, b metallic — so this order is
2968
3196
  * the channel order rather than the field order of `SurfaceMaterial`. Written component by
@@ -2973,7 +3201,7 @@ export class WebGPURenderer implements RendererApi {
2973
3201
  f[scale] = material?.occlusionStrength ?? 1;
2974
3202
  f[scale + 1] = material?.roughnessScale ?? 1;
2975
3203
  f[scale + 2] = material?.metallicScale ?? 1;
2976
- i[this.materialField('uEmissiveMapEnabled')] = emissiveMap === null ? 0 : 1;
3204
+ i[flags + 2] = emissiveMap === null ? 0 : 1;
2977
3205
  /*
2978
3206
  * One rather than zero on every component, because this multiplies rather than adds: an
2979
3207
  * unwritten float is zero, and zero here would switch off every glow the mesh's own vertex
@@ -2984,6 +3212,9 @@ export class WebGPURenderer implements RendererApi {
2984
3212
  f[emissive] = asked?.[0] ?? 1;
2985
3213
  f[emissive + 1] = asked?.[1] ?? 1;
2986
3214
  f[emissive + 2] = asked?.[2] ?? 1;
3215
+ /* The model's numbers and whether its map is bound. See `packModel`. */
3216
+ packModel(material?.model ?? null, modelMap !== null, this.modelParams);
3217
+ f.set(this.modelParams, this.materialField('uModelParams'));
2987
3218
  });
2988
3219
  /*
2989
3220
  * **One lookup for the whole material, and nothing thrown away.**
@@ -2998,7 +3229,8 @@ export class WebGPURenderer implements RendererApi {
2998
3229
  this.albedo === albedo &&
2999
3230
  this.normalMap === normal &&
3000
3231
  this.ormMap === orm &&
3001
- this.emissiveMap === emissiveMap
3232
+ this.emissiveMap === emissiveMap &&
3233
+ this.modelMap === modelMap
3002
3234
  ) {
3003
3235
  return;
3004
3236
  }
@@ -3006,7 +3238,8 @@ export class WebGPURenderer implements RendererApi {
3006
3238
  this.normalMap = normal;
3007
3239
  this.ormMap = orm;
3008
3240
  this.emissiveMap = emissiveMap;
3009
- this.bindGroup = this.flatGroupForMaps(albedo, normal, orm, emissiveMap);
3241
+ this.modelMap = modelMap;
3242
+ this.bindGroup = this.flatGroupForMaps(albedo, normal, orm, emissiveMap, modelMap);
3010
3243
  }
3011
3244
 
3012
3245
  /**
@@ -3035,8 +3268,16 @@ export class WebGPURenderer implements RendererApi {
3035
3268
  normal: GpuSurfaceTexture | null,
3036
3269
  orm: GpuSurfaceTexture | null,
3037
3270
  emissive: GpuSurfaceTexture | null,
3271
+ /** The model map, a fifth level of the same cache. See `flatBindGroups`. */
3272
+ modelMap: GpuSurfaceTexture | null,
3038
3273
  ): GPUBindGroup {
3039
- if (albedo === null && normal === null && orm === null && emissive === null) {
3274
+ if (
3275
+ albedo === null &&
3276
+ normal === null &&
3277
+ orm === null &&
3278
+ emissive === null &&
3279
+ modelMap === null
3280
+ ) {
3040
3281
  return this.blankAlbedoBindGroup;
3041
3282
  }
3042
3283
  let byNormal = this.flatBindGroups.get(albedo);
@@ -3054,11 +3295,16 @@ export class WebGPURenderer implements RendererApi {
3054
3295
  byEmissive = new Map();
3055
3296
  byOrm.set(orm, byEmissive);
3056
3297
  }
3057
- const existing = byEmissive.get(emissive);
3298
+ let byModel = byEmissive.get(emissive);
3299
+ if (byModel === undefined) {
3300
+ byModel = new Map();
3301
+ byEmissive.set(emissive, byModel);
3302
+ }
3303
+ const existing = byModel.get(modelMap);
3058
3304
  if (existing !== undefined) return existing;
3059
3305
  const built = this.buildFlatBindGroup();
3060
3306
  this.bindGroupBudget.ask();
3061
- byEmissive.set(emissive, built);
3307
+ byModel.set(modelMap, built);
3062
3308
  return built;
3063
3309
  }
3064
3310
 
@@ -5287,6 +5533,8 @@ export class WebGPURenderer implements RendererApi {
5287
5533
  private readonly shadowSkinnedGroups: (GPUBindGroup | null)[] = [];
5288
5534
  private readonly shadowSkinnedGroupsBeside: (GPUBindGroup | null)[] = [];
5289
5535
  private readonly shadowSkinnedGroupsPalette: (GPUTextureView | null)[] = [];
5536
+ private readonly shadowSkinnedGroupsCloth: (GPUTextureView | null)[] = [];
5537
+ private readonly shadowSkinnedGroupsClothBinding: (GPUTextureView | null)[] = [];
5290
5538
  /** Held from construction so the skinned group can be rebuilt without them being threaded. */
5291
5539
  private shadowBlankView: GPUTextureView | null = null;
5292
5540
  private shadowBlankSampler: GPUSampler | null = null;
@@ -5425,6 +5673,7 @@ export class WebGPURenderer implements RendererApi {
5425
5673
  this.shadowDraws.writeFloat(slot, fields.uUvOffset.offset, cutout.uOffset);
5426
5674
  this.shadowDraws.writeFloat(slot, fields.uUvOffset.offset + 4, cutout.vOffset);
5427
5675
  this.shadowDraws.writeFloat(slot, fields.uAlphaCutout.offset, cutout.cutoff);
5676
+ this.shadowDraws.writeFloat(slot, fields.uAlphaCutout.offset + 4, cutout.dithered);
5428
5677
  }
5429
5678
 
5430
5679
  private shadowGroup(): GPUBindGroup {
@@ -5440,12 +5689,14 @@ export class WebGPURenderer implements RendererApi {
5440
5689
  * second independently fading occluder can be recorded, and a character is not one — it is the
5441
5690
  * thing in front. Serving it would mean a second cached group for a case nothing produces.
5442
5691
  */
5443
- private shadowSkinnedGroup(palette: GPUTextureView, at: number): GPUBindGroup {
5692
+ private shadowSkinnedGroup(palette: GPUTextureView, at: number, cloth: ClothViews): GPUBindGroup {
5444
5693
  const beside = this.shadowBindGroup;
5445
5694
  if (
5446
5695
  (this.shadowSkinnedGroups[at] ?? null) === null ||
5447
5696
  this.shadowSkinnedGroupsBeside[at] !== beside ||
5448
- this.shadowSkinnedGroupsPalette[at] !== palette
5697
+ this.shadowSkinnedGroupsPalette[at] !== palette ||
5698
+ this.shadowSkinnedGroupsCloth[at] !== cloth.particles ||
5699
+ this.shadowSkinnedGroupsClothBinding[at] !== cloth.binding
5449
5700
  ) {
5450
5701
  this.shadowSkinnedGroups[at] = createDepthBindGroup(
5451
5702
  this.surface.device,
@@ -5455,9 +5706,13 @@ export class WebGPURenderer implements RendererApi {
5455
5706
  this.shadowBlankView as GPUTextureView,
5456
5707
  this.shadowBlankSampler as GPUSampler,
5457
5708
  palette,
5709
+ null,
5710
+ cloth,
5458
5711
  );
5459
5712
  this.shadowSkinnedGroupsBeside[at] = beside;
5460
5713
  this.shadowSkinnedGroupsPalette[at] = palette;
5714
+ this.shadowSkinnedGroupsCloth[at] = cloth.particles;
5715
+ this.shadowSkinnedGroupsClothBinding[at] = cloth.binding;
5461
5716
  }
5462
5717
  return this.shadowSkinnedGroups[at] as GPUBindGroup;
5463
5718
  }
@@ -5496,6 +5751,7 @@ export class WebGPURenderer implements RendererApi {
5496
5751
  void this.pipelines.enable(feature);
5497
5752
  if (this.overlayPipelines !== this.pipelines) void this.overlayPipelines.enable(feature);
5498
5753
  if (this.latePipelines !== this.pipelines) void this.latePipelines.enable(feature);
5754
+ if (this.skinPipelines !== null) void this.skinPipelines.enable(feature);
5499
5755
  }
5500
5756
  /** One resolution every glass caster is read through, so recording one allocates nothing. */
5501
5757
  private readonly glassCasterScratch: ResolvedGlass = {
@@ -5617,7 +5873,7 @@ export class WebGPURenderer implements RendererApi {
5617
5873
  pass.setIndexBuffer(geometry.indexBuffer, 'uint32');
5618
5874
  pass.drawIndexed(geometry.indexCount, count);
5619
5875
  },
5620
- skinnedMesh: (mesh, model, palette, material) => {
5876
+ skinnedMesh: (mesh, model, palette, material, cloth) => {
5621
5877
  if (!this.drawingGlassDepth && !castsDepth(material)) {
5622
5878
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
5623
5879
  this.glassCasters.recordSkinned(mesh, model, palette, material, this.glassCasterScratch);
@@ -5649,18 +5905,30 @@ export class WebGPURenderer implements RendererApi {
5649
5905
 
5650
5906
  const geometry = mesh as GpuMesh & { key?: string };
5651
5907
  if (geometry.vertexBuffers === undefined || geometry.indexCount === 0) return;
5908
+ /* A garment casts placed by its cloth, as it draws; `|cloth` keys the pipeline that does. */
5909
+ const clothed = cloth !== undefined;
5910
+ const clothViews = clothed
5911
+ ? clothViewsOf(
5912
+ cloth.binding as unknown as GpuClothBinding,
5913
+ cloth.particles as unknown as GpuClothParticles,
5914
+ )
5915
+ : this.clothStand();
5652
5916
  pass.setPipeline(
5653
5917
  depthPipeline(
5654
5918
  this.pipelines,
5655
5919
  this.surface.device,
5656
5920
  this.shadowSkinnedLayout,
5657
- `depth-skinned|${this.depthCullMode}|${geometry.key ?? ''}`,
5921
+ `${geometry.isSkinnedEight ? 'depth-skinned8' : 'depth-skinned'}${clothed ? '|cloth' : ''}|${this.depthCullMode}|${geometry.key ?? ''}`,
5658
5922
  this.presentOf(geometry),
5659
5923
  this.depthCullMode,
5660
5924
  true,
5925
+ false,
5926
+ false,
5927
+ geometry.isSkinnedEight,
5928
+ clothed,
5661
5929
  ),
5662
5930
  );
5663
- pass.setBindGroup(0, this.shadowSkinnedGroup(view, paletteSlot), [slot]);
5931
+ pass.setBindGroup(0, this.shadowSkinnedGroup(view, paletteSlot, clothViews), [slot]);
5664
5932
  for (let index = 0; index < geometry.vertexBuffers.length; index++) {
5665
5933
  pass.setVertexBuffer(index, geometry.vertexBuffers[index] as GPUBuffer);
5666
5934
  }
@@ -6036,6 +6304,26 @@ export class WebGPURenderer implements RendererApi {
6036
6304
  this.pipelines.format === RECON_HISTORY_FORMAT && this.samples === 1
6037
6305
  ? this.pipelines
6038
6306
  : new PipelineCache(device, RECON_HISTORY_FORMAT, 1, glassShadows, quality.clusteredLights);
6307
+ /*
6308
+ * Screen-space skin needs the composite: linear light to add into, and the depth it resolves.
6309
+ * Without one it is the pre-integrated fit alone, which is the default anyway, and said once.
6310
+ */
6311
+ const skinScreen = quality.skinScattering === 'screen-space' && quality.screenEffects;
6312
+ if (quality.skinScattering === 'screen-space' && !quality.screenEffects) {
6313
+ console.warn(
6314
+ "WebGPU: skinScattering 'screen-space' needs screenEffects to add into; skin is pre-integrated",
6315
+ );
6316
+ }
6317
+ this.skinScatter = skinScreen ? new SkinScatterPass(device) : null;
6318
+ this.skinPipelines = skinScreen
6319
+ ? new PipelineCache(
6320
+ device,
6321
+ SKIN_TARGET_FORMAT,
6322
+ this.samples,
6323
+ glassShadows,
6324
+ quality.clusteredLights,
6325
+ )
6326
+ : null;
6039
6327
  this.issueBound = this.issueCommand.bind(this);
6040
6328
  this.pipelineTargets =
6041
6329
  this.overlayPipelines === this.pipelines
@@ -6105,7 +6393,7 @@ export class WebGPURenderer implements RendererApi {
6105
6393
  );
6106
6394
 
6107
6395
  /*
6108
- * A one-pixel stand-in for the albedo binding. `uAlbedoEnabled` is zero for untextured
6396
+ * A one-pixel stand-in for the albedo binding. The albedo's flag is zero for untextured
6109
6397
  * geometry, which is nearly all of it, so the shader never samples this — but WebGPU
6110
6398
  * requires every declared binding to be filled. WebGL2 keeps an `emptyTexture2D` for
6111
6399
  * exactly the same reason.
@@ -6324,6 +6612,13 @@ export class WebGPURenderer implements RendererApi {
6324
6612
  ? { view: blankSurfaceView, sampler: blankSampler }
6325
6613
  : { view: map.view, sampler: map.sampler };
6326
6614
  }
6615
+ /* The material's model map, or the stand-in, for the reason `uNormalMap` gives above. */
6616
+ if (name === 'uModelMap') {
6617
+ const map = this.modelMap;
6618
+ return map === null
6619
+ ? { view: blankSurfaceView, sampler: blankSampler }
6620
+ : { view: map.view, sampler: map.sampler };
6621
+ }
6327
6622
  if (name === 'uOrmMap') {
6328
6623
  const map = this.ormMap;
6329
6624
  return map === null
@@ -7231,6 +7526,8 @@ export class WebGPURenderer implements RendererApi {
7231
7526
  }
7232
7527
  /* Not a layout entry of its own — it widens `uvs` — so read from the key beside them. */
7233
7528
  present['layers'] = key.includes(':layers');
7529
+ /* Nor is a dynamic mesh's split, which moves positions and normals into buffers of their own. */
7530
+ present['dynamic'] = key.includes(':dynamic');
7234
7531
  return present;
7235
7532
  }
7236
7533
 
@@ -7244,6 +7541,7 @@ export class WebGPURenderer implements RendererApi {
7244
7541
  */
7245
7542
  beginFrame(clearColor: Vec3): void {
7246
7543
  this.cullFrame += 1;
7544
+ this.cutoutFrame = (this.cutoutFrame + 1) % 64;
7247
7545
  /* Whatever an update replaced during the last frame; nothing recorded can read it now. */
7248
7546
  for (const texture of this.retiredTextures) texture.destroy();
7249
7547
  this.retiredTextures.length = 0;
@@ -7307,6 +7605,8 @@ export class WebGPURenderer implements RendererApi {
7307
7605
  this.volumeDepthTaken = false;
7308
7606
  /* Last frame's snapshot is last frame's scene. */
7309
7607
  this.refractSnapshotTaken = false;
7608
+ this.skinScattered = false;
7609
+ this.skinScatter?.reset();
7310
7610
  this.plumeVerts.reset();
7311
7611
  this.plumeFrags.reset();
7312
7612
  this.waterVerts.reset();
@@ -7489,7 +7789,7 @@ export class WebGPURenderer implements RendererApi {
7489
7789
  * neither the pipeline set nor the key depends on whether the geometry lands in one call or
7490
7790
  * over six frames.
7491
7791
  */
7492
- private warmFlatPipelines(data: MeshData): string {
7792
+ private warmFlatPipelines(data: MeshData, dynamic: boolean): string {
7493
7793
  /*
7494
7794
  * **Every optional attribute, not the two that come to mind.** `present` decides where
7495
7795
  * each attribute sits in the interleaved buffer, and `createGpuMesh` decides the same
@@ -7515,6 +7815,15 @@ export class WebGPURenderer implements RendererApi {
7515
7815
  present['layers'] = true;
7516
7816
  key += ':layers';
7517
7817
  }
7818
+ /*
7819
+ * A dynamic mesh keeps its positions and normals in buffers of their own (`GpuMesh.update`), so
7820
+ * it binds four buffers where a static one binds two: a different layout, and a key that says
7821
+ * so, or the two would share a pipeline whose buffers one of them does not have.
7822
+ */
7823
+ if (dynamic) {
7824
+ present['dynamic'] = true;
7825
+ key += ':dynamic';
7826
+ }
7518
7827
  const fullKey = `${this.variant}|${key}`;
7519
7828
  /*
7520
7829
  * **For every target this mesh can land on, which is two when the overlay is its own.**
@@ -7585,11 +7894,13 @@ export class WebGPURenderer implements RendererApi {
7585
7894
  * guessing which the scene will use.
7586
7895
  */
7587
7896
  const skinnable = data.joints !== undefined;
7897
+ /* An eight-influence rig takes its own skinned pipelines, keyed `|skin8`. See skinning.ts. */
7898
+ const eight = skinnable && data.joints2 !== undefined;
7588
7899
  const morphable = data.morphTargets !== undefined;
7589
7900
  for (const wantSkin of skinnable ? [false, true] : [false]) {
7590
7901
  for (const wantMorph of morphable ? [false, true] : [false]) {
7591
7902
  if (!wantSkin && !wantMorph) continue;
7592
- const suffix = `${wantMorph ? '|morph' : ''}${wantSkin ? '|skin' : ''}`;
7903
+ const suffix = `${wantMorph ? '|morph' : ''}${wantSkin ? (eight ? '|skin8' : '|skin') : ''}`;
7593
7904
  for (const translucent of [false, true]) {
7594
7905
  void flatPipelineAsync(
7595
7906
  pipelines,
@@ -7601,6 +7912,7 @@ export class WebGPURenderer implements RendererApi {
7601
7912
  translucent,
7602
7913
  wantSkin,
7603
7914
  wantMorph,
7915
+ wantSkin && eight,
7604
7916
  );
7605
7917
  }
7606
7918
  }
@@ -7624,7 +7936,7 @@ export class WebGPURenderer implements RendererApi {
7624
7936
  }
7625
7937
 
7626
7938
  createMesh(data: MeshData, options: MeshOptions = {}): GpuMesh {
7627
- const fullKey = this.warmFlatPipelines(data);
7939
+ const fullKey = this.warmFlatPipelines(data, options.dynamic === true);
7628
7940
  return this.keyed(createGpuMesh(this.surface.device, data, options.dynamic === true), fullKey);
7629
7941
  }
7630
7942
 
@@ -7638,7 +7950,7 @@ export class WebGPURenderer implements RendererApi {
7638
7950
  * can do nothing about. Done with `complete` still false is the signal that it was abandoned.
7639
7951
  */
7640
7952
  createMeshIncremental(data: MeshData, options: MeshOptions = {}): IncrementalMeshHandle {
7641
- const fullKey = this.warmFlatPipelines(data);
7953
+ const fullKey = this.warmFlatPipelines(data, options.dynamic === true);
7642
7954
  const { mesh, upload } = createGpuMeshIncremental(
7643
7955
  this.surface.device,
7644
7956
  data,
@@ -7659,20 +7971,20 @@ export class WebGPURenderer implements RendererApi {
7659
7971
  /**
7660
7972
  * Rewrite a mesh's positions, and its normals where the caller has them.
7661
7973
  *
7662
- * Refused in words on a mesh that did not declare itself dynamic, because the thing that makes
7663
- * an update possible on this backend — the interleaved array the vertex data was built from —
7664
- * is only kept when a consumer asked for it. See `GpuMesh.update`.
7974
+ * Refused in words on a mesh that did not declare itself dynamic, because what makes an update
7975
+ * cheap on this backend — positions and normals in buffers of their own — is a layout chosen at
7976
+ * creation. See `GpuMesh.update`.
7665
7977
  */
7666
7978
  updateMesh(mesh: GpuMesh, positions: Float32Array, normals?: Float32Array): void {
7667
7979
  if (mesh.update === null) {
7668
7980
  throw new Error(
7669
- 'this mesh was not created with { dynamic: true }, so the interleaved vertex data it ' +
7670
- 'would be patched from was not kept. Say so at `createMesh` — the flag is what decides ' +
7671
- 'what is kept and what a buffer is hinted as.',
7981
+ 'this mesh was not created with { dynamic: true }, so its positions share one buffer with ' +
7982
+ 'every other attribute and cannot be rewritten alone. Say so at `createMesh` — the flag ' +
7983
+ 'decides the layout, and what a buffer is hinted as.',
7672
7984
  );
7673
7985
  }
7674
7986
  /*
7675
- * **The rows before this rewrite are last frame's**, kept only for a reconstruction and only
7987
+ * **The positions before this rewrite are last frame's**, kept only for a reconstruction and only
7676
7988
  * on the first rewrite of a frame — a second must not replace where the vertices were last
7677
7989
  * frame with where they were a moment ago. A rewrite between frames is the next frame's. See
7678
7990
  * `changeFrames.ts`.
@@ -7806,7 +8118,11 @@ export class WebGPURenderer implements RendererApi {
7806
8118
  i[at('uLightingEnabled')] = 1;
7807
8119
  i[at('uFogEnabled')] = FOG_RECEDE;
7808
8120
  this.surfaceFog = FOG_RECEDE;
7809
- i[at('uAlbedoEnabled')] = 0;
8121
+ /* Every material switch off — albedo, ORM map, emissive map, two-sided — as WebGL2 resets them. */
8122
+ i.fill(0, at('uMaterialFlags'), at('uMaterialFlags') + 4);
8123
+ f[at('uCutout')] = 0;
8124
+ f[at('uCutout') + 1] = 0;
8125
+ this.cutoutResolveStaged = 'hard';
7810
8126
  this.materialHasMaps = false;
7811
8127
 
7812
8128
  /*
@@ -7952,7 +8268,7 @@ export class WebGPURenderer implements RendererApi {
7952
8268
  /*
7953
8269
  * The ORM scales, whose base is 1 and not 0 — the value an unwritten uniform would hold.
7954
8270
  *
7955
- * `uOrmEnabled` needs no line here: an unwritten int is zero, and zero is exactly "no map".
8271
+ * The ORM map's flag is zeroed with the others above, and zero is exactly "no map".
7956
8272
  * These three are the opposite case, the one this block's own header is about: a scale of
7957
8273
  * zero is a real and wrong value, so leaving them silent would not switch the map off, it
7958
8274
  * would read every channel as its floor the moment one was bound.
@@ -8582,10 +8898,18 @@ export class WebGPURenderer implements RendererApi {
8582
8898
  * before last. That is the defined state the two-backends rule asks for.
8583
8899
  */
8584
8900
  const refracting = this.bindSeeThrough(options);
8901
+ /* A dithered cutout tests hard on a blended draw, which has nothing to average a pattern with;
8902
+ put back below with the rest. See `cutoutDither.ts`. */
8903
+ const ditheredBlend = blend && this.cutoutResolveStaged !== 'hard';
8904
+ if (ditheredBlend) this.perFrameFloats[this.materialField('uCutout') + 1] = 0;
8585
8905
  /* Taken for itself when any of those differs from the pass, and put back below: the rule both
8586
8906
  backends count material changes by. See `materialChanges.ts`. */
8587
- const own = ownsMaterial({ opacity, lit, fog, toneMapped, refracting }, this.surfaceFog);
8907
+ const own =
8908
+ ownsMaterial({ opacity, lit, fog, toneMapped, refracting }, this.surfaceFog) || ditheredBlend;
8588
8909
  if (own) this.materials.dirty();
8910
+ /* An eye's axis is the draw's, written into the open material. See `eyeAxisInWorld`. */
8911
+ const modelKind = this.materialModel?.kind ?? null;
8912
+ if (modelKind === 'eye') this.writeEyeAxis(model, this.skinPaletteSlot >= 0 && mesh.isSkinned);
8589
8913
  const base = (mesh as GpuMesh & { key?: string }).key ?? 'flat:s0:u0';
8590
8914
  /*
8591
8915
  * A rigged mesh takes the skinned vertex variant, an unrigged one the plain variant, and the
@@ -8597,7 +8921,13 @@ export class WebGPURenderer implements RendererApi {
8597
8921
  const skinned = this.skinPaletteSlot >= 0 && mesh.isSkinned;
8598
8922
  const morphed = this.morphWeights !== null && mesh.morph !== null;
8599
8923
  const deltas = morphed && mesh.morph !== null ? mesh.morph.view() : null;
8600
- const keyed = `${base}${morphed ? '|morph' : ''}${skinned ? '|skin' : ''}`;
8924
+ const eight = skinned && mesh.isSkinnedEight;
8925
+ /* A cloth set and a skinned draw: the pipeline built with CLOTH_BOUND on, warmed when the
8926
+ binding was made. See `createClothBinding`. */
8927
+ const clothed = skinned && this.cloth !== null;
8928
+ const keyed =
8929
+ `${base}${morphed ? '|morph' : ''}${skinned ? (eight ? '|skin8' : '|skin') : ''}` +
8930
+ `${clothed ? '|cloth' : ''}`;
8601
8931
  /*
8602
8932
  * Depth writing and the overlay layer are pipeline state on this backend, so they belong in
8603
8933
  * the key: two draws of one mesh differing only in these would otherwise share a cached
@@ -8612,6 +8942,9 @@ export class WebGPURenderer implements RendererApi {
8612
8942
  const layer = Math.min(Math.max(Math.round(options.depthLayer ?? 0), 0), MAX_DEPTH_LAYER);
8613
8943
  /* Added rather than blended over: its own blend state, so its own pipeline. */
8614
8944
  const adds = blend && options.additive === true;
8945
+ const coverage = !blend && this.cutoutResolveStaged === 'coverage';
8946
+ /* Skin under the screen-space blur draws its two halves; see `keepSkinDiffuse`. */
8947
+ const splits = modelKind === 'skin' && !blend && this.skinSplits();
8615
8948
  const key =
8616
8949
  `${keyed}${blend ? (adds ? '|add' : '|blend') : ''}` +
8617
8950
  `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
@@ -8619,7 +8952,13 @@ export class WebGPURenderer implements RendererApi {
8619
8952
  one must never share a cached pipeline with the same mesh drawn into the other. */
8620
8953
  `${this.oitMode === 'none' ? '' : `|oit${this.oitMode}`}` +
8621
8954
  /* Culling is pipeline state here: a two-sided surface culls nothing. */
8622
- `${this.materialDoubleSided ? '|2s' : ''}`;
8955
+ `${this.materialDoubleSided ? '|2s' : ''}` +
8956
+ /* And so is alpha-to-coverage, for a dithered cutout in a multisampled frame. */
8957
+ `${coverage ? '|a2c' : ''}` +
8958
+ /* And the material's shading model, which is the fragment stage's own switch. */
8959
+ `${modelKind === null ? '' : `|m:${modelKind}`}` +
8960
+ /* And which half of a skin it draws, under the screen-space blur. */
8961
+ `${splits ? '|ss' : ''}`;
8623
8962
 
8624
8963
  /*
8625
8964
  * The morph uniforms, written only for a morphed draw and read from the *morphed* variant's
@@ -8665,6 +9004,11 @@ export class WebGPURenderer implements RendererApi {
8665
9004
  false,
8666
9005
  this.oitMode,
8667
9006
  this.materialDoubleSided,
9007
+ coverage,
9008
+ eight,
9009
+ clothed,
9010
+ modelKind,
9011
+ splits ? 'scene' : 'whole',
8668
9012
  );
8669
9013
  }
8670
9014
 
@@ -8718,6 +9062,21 @@ export class WebGPURenderer implements RendererApi {
8718
9062
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
8719
9063
  pass.drawIndexed(mesh.indexCount);
8720
9064
  }
9065
+ if (splits) {
9066
+ this.keepSkinDiffuse(
9067
+ mesh,
9068
+ key,
9069
+ base,
9070
+ skinned,
9071
+ morphed,
9072
+ deltas,
9073
+ layer,
9074
+ eight,
9075
+ clothed,
9076
+ slot,
9077
+ material,
9078
+ );
9079
+ }
8721
9080
  }
8722
9081
  /* Back to the defaults for whatever is drawn next, exactly as `renderer.ts` restores them —
8723
9082
  unconditionally, because the scratch state above is shared by every draw after this one
@@ -8736,6 +9095,10 @@ export class WebGPURenderer implements RendererApi {
8736
9095
  if (!toneMapped) {
8737
9096
  this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
8738
9097
  }
9098
+ if (ditheredBlend) {
9099
+ this.perFrameFloats[this.materialField('uCutout') + 1] =
9100
+ CUTOUT_RESOLVE_CODE[this.cutoutResolveStaged];
9101
+ }
8739
9102
  if (own) this.materials.dirty();
8740
9103
  }
8741
9104
 
@@ -8999,8 +9362,16 @@ export class WebGPURenderer implements RendererApi {
8999
9362
  const layer = Math.min(Math.max(Math.round(options.depthLayer ?? 0), 0), MAX_DEPTH_LAYER);
9000
9363
  /* A blended batch shows what is behind it — refraction or glass — as a single draw does. */
9001
9364
  const refracting = blend && this.bindSeeThrough(options);
9002
- const own = ownsMaterial({ opacity, lit, fog, toneMapped, refracting }, this.surfaceFog);
9365
+ /* As a single draw: a blended batch tests a dithered cutout hard, an opaque one in a
9366
+ multisampled frame takes coverage. See `cutoutDither.ts`. */
9367
+ const ditheredBlend = blend && this.cutoutResolveStaged !== 'hard';
9368
+ if (ditheredBlend) this.perFrameFloats[this.materialField('uCutout') + 1] = 0;
9369
+ const coverage = !blend && this.cutoutResolveStaged === 'coverage';
9370
+ const own =
9371
+ ownsMaterial({ opacity, lit, fog, toneMapped, refracting }, this.surfaceFog) || ditheredBlend;
9003
9372
  if (own) this.materials.dirty();
9373
+ /* A batch shades by its material's model too; an eye's axis is a placement's, not a batch's. */
9374
+ const modelKind = this.materialModel?.kind ?? null;
9004
9375
  const base = (mesh as GpuMesh & { key?: string }).key ?? 'flat:s0:u0';
9005
9376
  /* `|inst` in the key, for the reason every other suffix is there: an instanced pipeline binds
9006
9377
  a third vertex buffer and a different vertex module, and sharing a cache entry with the
@@ -9009,7 +9380,8 @@ export class WebGPURenderer implements RendererApi {
9009
9380
  const key =
9010
9381
  `${base}|inst${blend ? (adds ? '|add' : '|blend') : ''}` +
9011
9382
  `${depthWrite ? '' : '|nodepth'}${layer > 0 ? `|dl${layer}` : ''}` +
9012
- `${this.materialDoubleSided ? '|2s' : ''}`;
9383
+ `${this.materialDoubleSided ? '|2s' : ''}${coverage ? '|a2c' : ''}` +
9384
+ `${modelKind === null ? '' : `|m:${modelKind}`}`;
9013
9385
 
9014
9386
  const pipelines = blend ? this.blendedPipelines(lands) : this.targetPipelines();
9015
9387
  let pipeline = pipelines.peek(key);
@@ -9033,6 +9405,10 @@ export class WebGPURenderer implements RendererApi {
9033
9405
  true,
9034
9406
  'none',
9035
9407
  this.materialDoubleSided,
9408
+ coverage,
9409
+ false,
9410
+ false,
9411
+ modelKind,
9036
9412
  );
9037
9413
  }
9038
9414
 
@@ -9087,6 +9463,10 @@ export class WebGPURenderer implements RendererApi {
9087
9463
  this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
9088
9464
  }
9089
9465
  if (refracting) this.releaseSeeThrough();
9466
+ if (ditheredBlend) {
9467
+ this.perFrameFloats[this.materialField('uCutout') + 1] =
9468
+ CUTOUT_RESOLVE_CODE[this.cutoutResolveStaged];
9469
+ }
9090
9470
  if (own) this.materials.dirty();
9091
9471
  }
9092
9472
 
@@ -9324,12 +9704,19 @@ export class WebGPURenderer implements RendererApi {
9324
9704
  this.bindSceneCasterMaterial(material);
9325
9705
  this.drawMesh(mesh as GpuMesh, model);
9326
9706
  },
9327
- skinnedMesh: (mesh, model, palette, material) => {
9707
+ skinnedMesh: (mesh, model, palette, material, cloth) => {
9328
9708
  this.bindSceneCasterMaterial(material);
9329
9709
  this.setSkinPalette(palette);
9710
+ if (cloth !== undefined) {
9711
+ this.setCloth(
9712
+ cloth.binding as unknown as GpuClothBinding,
9713
+ cloth.particles as unknown as GpuClothParticles,
9714
+ );
9715
+ }
9330
9716
  this.drawMesh(mesh as GpuMesh, model);
9331
9717
  /* Put back, or the next rigid draw in the replay skins by whatever this one left bound. */
9332
9718
  this.setSkinPalette(null);
9719
+ this.setCloth(null);
9333
9720
  },
9334
9721
  instanced: (batch, data, material) => {
9335
9722
  this.bindSceneCasterMaterial(material);
@@ -10237,11 +10624,15 @@ export class WebGPURenderer implements RendererApi {
10237
10624
  /* Slot `k` caches at `k + 1`; index 0 is the morphed draw that is not skinned and has no
10238
10625
  slot of its own. See `skinnedGroups`. */
10239
10626
  const at = skinned ? this.skinPaletteSlot + 1 : 0;
10627
+ /* A skinned group carries the cloth's views, or the stand-in's: see `clothViews`. */
10628
+ const cloth = skinned ? this.clothViews() : null;
10240
10629
  if (
10241
10630
  (this.skinnedGroups[at] ?? null) === null ||
10242
10631
  this.skinnedGroupsBeside[at] !== this.bindGroup ||
10243
10632
  this.skinnedGroupsPalette[at] !== palette ||
10244
- this.skinnedGroupsDeltas[at] !== deltas
10633
+ this.skinnedGroupsDeltas[at] !== deltas ||
10634
+ this.skinnedGroupsCloth[at] !== (cloth?.particles ?? null) ||
10635
+ this.skinnedGroupsClothBinding[at] !== (cloth?.binding ?? null)
10245
10636
  ) {
10246
10637
  this.skinnedGroups[at] = createFlatBindGroup(
10247
10638
  this.surface.device,
@@ -10252,10 +10643,13 @@ export class WebGPURenderer implements RendererApi {
10252
10643
  this.flatTextures,
10253
10644
  palette,
10254
10645
  deltas,
10646
+ cloth,
10255
10647
  );
10256
10648
  this.skinnedGroupsBeside[at] = this.bindGroup;
10257
10649
  this.skinnedGroupsPalette[at] = palette;
10258
10650
  this.skinnedGroupsDeltas[at] = deltas;
10651
+ this.skinnedGroupsCloth[at] = cloth?.particles ?? null;
10652
+ this.skinnedGroupsClothBinding[at] = cloth?.binding ?? null;
10259
10653
  }
10260
10654
  return this.skinnedGroups[at] as GPUBindGroup;
10261
10655
  }
@@ -10324,21 +10718,25 @@ export class WebGPURenderer implements RendererApi {
10324
10718
  const normalMap = this.normalMap;
10325
10719
  const ormMap = this.ormMap;
10326
10720
  const emissiveMap = this.emissiveMap;
10721
+ const modelMap = this.modelMap;
10327
10722
  this.albedo = null;
10328
10723
  this.normalMap = null;
10329
10724
  this.ormMap = null;
10330
10725
  this.emissiveMap = null;
10726
+ this.modelMap = null;
10331
10727
  this.blankAlbedoBindGroup = this.buildFlatBindGroup();
10332
10728
  this.albedo = albedo;
10333
10729
  this.normalMap = normalMap;
10334
10730
  this.ormMap = ormMap;
10335
10731
  this.emissiveMap = emissiveMap;
10732
+ this.modelMap = modelMap;
10336
10733
 
10337
10734
  this.bindGroup = this.flatGroupForMaps(
10338
10735
  this.albedo,
10339
10736
  this.normalMap,
10340
10737
  this.ormMap,
10341
10738
  this.emissiveMap,
10739
+ this.modelMap,
10342
10740
  );
10343
10741
  /* The open material lives in a slot of a buffer that no longer exists. */
10344
10742
  this.materials.dirty();
@@ -10381,7 +10779,8 @@ export class WebGPURenderer implements RendererApi {
10381
10779
  this.albedo === null &&
10382
10780
  this.normalMap === null &&
10383
10781
  this.ormMap === null &&
10384
- this.emissiveMap === null
10782
+ this.emissiveMap === null &&
10783
+ this.modelMap === null
10385
10784
  ) {
10386
10785
  this.blankAlbedoBindGroup = this.buildFlatBindGroup();
10387
10786
  this.bindGroupBudget.ask();
@@ -10393,6 +10792,7 @@ export class WebGPURenderer implements RendererApi {
10393
10792
  this.normalMap,
10394
10793
  this.ormMap,
10395
10794
  this.emissiveMap,
10795
+ this.modelMap,
10396
10796
  );
10397
10797
  }
10398
10798
 
@@ -11125,6 +11525,8 @@ export class WebGPURenderer implements RendererApi {
11125
11525
  * the only moment they can land; everything else keeps the path it has.
11126
11526
  */
11127
11527
  private recordBlended(reads: number, lands = this.drawsLate()): DrawCommand | null {
11528
+ /* Skin is spread and added before anything blended lands over it. See `runSkinScatter`. */
11529
+ if (this.skinScatter !== null && !this.skinScattered) this.runSkinScatter();
11128
11530
  const late = this.latePass;
11129
11531
  if (late !== null && lands) return late.take();
11130
11532
  return this.recordDraw(reads, this.currentTarget());
@@ -11212,6 +11614,16 @@ export class WebGPURenderer implements RendererApi {
11212
11614
  const dynamic = mesh.motion;
11213
11615
  const rows = dynamic?.previous ?? null;
11214
11616
  const rewritten = rows !== null && dynamic !== null && dynamic.changed === this.moverFrame;
11617
+ const cloth = this.cloth;
11618
+ if (
11619
+ cloth !== null &&
11620
+ this.skinPaletteSlot >= 0 &&
11621
+ mesh.isSkinned &&
11622
+ mesh.skinOffsets !== null
11623
+ ) {
11624
+ this.recordClothMotion(mesh, model, previous, cloth);
11625
+ return;
11626
+ }
11215
11627
  if (previous === null) {
11216
11628
  if (rewritten) this.motionPass?.recordDynamic(mesh, model, model, rows);
11217
11629
  return;
@@ -11258,6 +11670,58 @@ export class WebGPURenderer implements RendererApi {
11258
11670
  this.motionPass?.recordSkinned(mesh, model, mover.previousModel, now, was);
11259
11671
  }
11260
11672
 
11673
+ /**
11674
+ * A cloth-bound draw's motion. **Recorded even where the caller named no previous place**, as a
11675
+ * rewritten mesh's is: the particles carry their own past, and a garment swinging on a character
11676
+ * standing still is motion nobody else can see. Where the rig is a `Mover` its last pose is taken
11677
+ * as a skinned mover's is; otherwise the rig is taken not to have moved.
11678
+ */
11679
+ private recordClothMotion(
11680
+ mesh: GpuMesh,
11681
+ model: ArrayLike<number>,
11682
+ previous: ArrayLike<number> | Mover | null,
11683
+ cloth: { binding: GpuClothBinding; particles: GpuClothParticles },
11684
+ ): void {
11685
+ const now = this.skinPalettes.view(this.skinPaletteSlot);
11686
+ if (now === null) return;
11687
+ let was: GPUTextureView | null = now;
11688
+ let wasModel: ArrayLike<number> = model;
11689
+ if (previous !== null && isMover(previous)) {
11690
+ const verdict = advanceMover(
11691
+ previous,
11692
+ this.moverRenderer,
11693
+ this.moverFrame,
11694
+ model,
11695
+ this.skinPaletteData,
11696
+ );
11697
+ if (verdict === MOVER_TWICE) {
11698
+ this.warnMoverTwice();
11699
+ return;
11700
+ }
11701
+ if (verdict === MOVER_MOTION) {
11702
+ const slot = this.skinPalettes.take(this.surface.device, previous.previousPalette);
11703
+ was = slot === null ? null : this.skinPalettes.view(slot);
11704
+ wasModel = previous.previousModel;
11705
+ }
11706
+ } else if (previous !== null) {
11707
+ wasModel = previous;
11708
+ }
11709
+ if (was === null) return;
11710
+ const particlesWas = cloth.particles.previousAt(this.moverFrame);
11711
+ /* Nothing said it moved and nothing did: the camera's motion, derived from depth, is the answer. */
11712
+ if (previous === null && particlesWas === cloth.particles.current) return;
11713
+ this.motionPass?.recordCloth(
11714
+ mesh,
11715
+ model,
11716
+ wasModel,
11717
+ now,
11718
+ was,
11719
+ cloth.binding.bindingView,
11720
+ cloth.particles.current,
11721
+ particlesWas,
11722
+ );
11723
+ }
11724
+
11261
11725
  private warnMoverTwice(): void {
11262
11726
  if (this.warnedMoverTwice) return;
11263
11727
  this.warnedMoverTwice = true;
@@ -12347,6 +12811,150 @@ export class WebGPURenderer implements RendererApi {
12347
12811
  return true;
12348
12812
  }
12349
12813
 
12814
+ /**
12815
+ * Whether a skin draw now draws its two halves: the profile asked, the frame has not spread its
12816
+ * skin yet, and this is the frame's own picture — not a mirror's, a probe's, an overlay after
12817
+ * the frame, or an order-independent set, none of which the spread reads.
12818
+ */
12819
+ private skinSplits(): boolean {
12820
+ return (
12821
+ this.skinScatter !== null &&
12822
+ !this.skinScattered &&
12823
+ !this.reflectionPassActive &&
12824
+ !this.probePassActive &&
12825
+ !this.framePresented &&
12826
+ !this.overlayActive &&
12827
+ this.oitMode === 'none'
12828
+ );
12829
+ }
12830
+
12831
+ /**
12832
+ * A skin draw's diffuse half, kept for the spread: the frame's draw again, with the same slots,
12833
+ * buffers and bind group, through the pipeline that writes the diffuse alone into the blur's
12834
+ * target and tests for the depth its frame half wrote.
12835
+ */
12836
+ private keepSkinDiffuse(
12837
+ mesh: GpuMesh,
12838
+ frameKey: string,
12839
+ base: string,
12840
+ skinned: boolean,
12841
+ morphed: boolean,
12842
+ deltas: GPUTextureView | null,
12843
+ layer: number,
12844
+ eight: boolean,
12845
+ clothed: boolean,
12846
+ slot: number,
12847
+ material: number,
12848
+ ): void {
12849
+ const skin = this.skinScatter;
12850
+ const pipelines = this.skinPipelines;
12851
+ const present = this.meshPresent.get(base);
12852
+ if (skin === null || pipelines === null || present === undefined) return;
12853
+ const key = `${frameKey}|sd`;
12854
+ const pipeline =
12855
+ pipelines.peek(key) ??
12856
+ flatPipeline(
12857
+ pipelines,
12858
+ this.surface.device,
12859
+ this.flatLayoutFor(skinned, morphed),
12860
+ this.variant,
12861
+ key,
12862
+ present,
12863
+ false,
12864
+ skinned,
12865
+ morphed,
12866
+ true,
12867
+ layer,
12868
+ false,
12869
+ 'none',
12870
+ this.materialDoubleSided,
12871
+ false,
12872
+ eight,
12873
+ clothed,
12874
+ 'skin',
12875
+ 'diffuse',
12876
+ );
12877
+ const command = skin.take();
12878
+ command.pipeline = pipeline;
12879
+ command.bindGroup = this.flatBindGroupFor(skinned, morphed, deltas);
12880
+ command.offsetA = slot;
12881
+ command.offsetB = material;
12882
+ command.offsetCount = 2;
12883
+ for (let index = 0; index < mesh.vertexBuffers.length; index++) {
12884
+ command.vertexBuffers[index] = mesh.vertexBuffers[index] as GPUBuffer;
12885
+ }
12886
+ command.vertexCount = mesh.vertexBuffers.length;
12887
+ command.indexBuffer = mesh.indexBuffer;
12888
+ command.indexed = true;
12889
+ command.count = mesh.indexCount;
12890
+ command.instances = 1;
12891
+ command.indirect = null;
12892
+ }
12893
+
12894
+ /**
12895
+ * Spread the frame's skin and add it back: once, at the first blended draw or before the frame's
12896
+ * last flush. **A fifth instance of the boundary `takeRefractSnapshot` describes** — flush, end
12897
+ * the pass, work between passes, reopen with `loadOp: 'load'` — and refused where that one is,
12898
+ * for its reasons. Between the passes: the kept diffuse halves into their target against the
12899
+ * frame's depth, the depth resolved, and `SkinScatterPass.spread` across and then down into the
12900
+ * frame's colour.
12901
+ */
12902
+ private runSkinScatter(): void {
12903
+ const skin = this.skinScatter;
12904
+ if (skin === null || this.skinScattered) return;
12905
+ /* Latched whether or not skin was kept: a skin drawn after this point is the whole surface. */
12906
+ this.skinScattered = true;
12907
+ if (!skin.pending) return;
12908
+ if (this.reflectionPassActive || this.probePassActive || this.oitReplaying) return;
12909
+ const encoder = this.encoder;
12910
+ const depth = this.depthView;
12911
+ const resolved = this.resolvedDepthView;
12912
+ const target = this.compositeTarget();
12913
+ const projection = this.frameProjection;
12914
+ if (encoder === null || depth === null || resolved === null) return;
12915
+ if (target === null || projection === null) return;
12916
+ if (this.pass === null && this.openPass() === null) return;
12917
+ if (this.pass === null) return;
12918
+ if (this.quality.frameGraph) this.flushGraph();
12919
+
12920
+ this.pass.end();
12921
+ const size = this.sceneSize();
12922
+ skin.size(size.width, size.height, this.samples);
12923
+ skin.drawDiffuse(encoder, depth, this.issueBound, this.gpuTimer.writesFor());
12924
+ this.resolveDepth(encoder);
12925
+ mat4.invert(this.skinInverseProjection, projection);
12926
+ const multisampled = this.compositeMsaa();
12927
+ skin.spread(
12928
+ encoder,
12929
+ this.pipelines,
12930
+ resolved,
12931
+ multisampled ?? target,
12932
+ multisampled === null ? null : target,
12933
+ this.samples,
12934
+ projection,
12935
+ this.skinInverseProjection,
12936
+ () => this.gpuTimer.writesFor(),
12937
+ );
12938
+ this.pass = encoder.beginRenderPass({
12939
+ label: 'skin.scattered',
12940
+ timestampWrites: this.gpuTimer.writesFor(),
12941
+ colorAttachments: [
12942
+ {
12943
+ view: multisampled ?? target,
12944
+ resolveTarget: multisampled !== null ? target : undefined,
12945
+ /* Loaded, not cleared: the frame so far, with its skin added. */
12946
+ loadOp: 'load',
12947
+ storeOp: resolvedStoreOp(
12948
+ multisampled !== null,
12949
+ false,
12950
+ this.quality.discardResolvedAttachments,
12951
+ ),
12952
+ },
12953
+ ],
12954
+ depthStencilAttachment: { view: depth, depthLoadOp: 'load', depthStoreOp: 'store' },
12955
+ });
12956
+ }
12957
+
12350
12958
  private takeVolumeDepth(): boolean {
12351
12959
  /*
12352
12960
  * **Never while the mirror is open, and this is a correctness fix rather than a saving.**
@@ -12969,6 +13577,8 @@ export class WebGPURenderer implements RendererApi {
12969
13577
  * `beginFrame` resets it. Silent, and exactly the kind of loss the arena is designed
12970
13578
  * against everywhere else.
12971
13579
  */
13580
+ /* Before the last flush, which may let the depth go that the spread still reads. */
13581
+ this.runSkinScatter();
12972
13582
  this.flushGraph('boundary', this.liveOutFinalFlush());
12973
13583
  /*
12974
13584
  * `ensurePass` rather than a null check: the frame's pass is opened on demand now, so a
@@ -16141,6 +16751,7 @@ export class WebGPURenderer implements RendererApi {
16141
16751
  * the 2026-08-13 rule asks for — one decision, bound twice.
16142
16752
  */
16143
16753
  dispose(): void {
16754
+ this.skinScatter?.dispose();
16144
16755
  this.motionPass?.dispose();
16145
16756
  this.motionPass = null;
16146
16757
  this.latePass?.dispose();
@@ -16181,3 +16792,12 @@ export class WebGPURenderer implements RendererApi {
16181
16792
  this.surface.dispose();
16182
16793
  }
16183
16794
  }
16795
+
16796
+ /**
16797
+ * The views a binding and a character's particles bind as, built once a pair and kept on the
16798
+ * particles: a bind group is cached by its views' identity, so a fresh object a draw would rebuild
16799
+ * the group every draw.
16800
+ */
16801
+ function clothViewsOf(binding: GpuClothBinding, particles: GpuClothParticles): ClothViews {
16802
+ return particles.viewsWith(binding);
16803
+ }