@driftengine/core 4.13.2 → 4.13.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 (49) hide show
  1. package/CHANGELOG.json +27 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +1 -0
  5. package/dist/render/backend/webgl2/renderer.d.ts +12 -0
  6. package/dist/render/backend/webgl2/renderer.js +47 -2
  7. package/dist/render/backend/webgpu/clothSetPack.js +1 -0
  8. package/dist/render/backend/webgpu/clothSolvePack.d.ts +5 -2
  9. package/dist/render/backend/webgpu/clothSolvePack.js +2 -1
  10. package/dist/render/backend/webgpu/gpuClothSet.js +1 -1
  11. package/dist/render/backend/webgpu/renderer.d.ts +9 -0
  12. package/dist/render/backend/webgpu/renderer.js +33 -1
  13. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +1 -1
  14. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +7 -5
  15. package/dist/render/hairLobes.d.ts +23 -0
  16. package/dist/render/hairLobes.js +50 -1
  17. package/dist/render/shaders/flat/detail.d.ts +21 -0
  18. package/dist/render/shaders/flat/detail.js +74 -0
  19. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  20. package/dist/render/shaders/flat/glassTint.js +1 -0
  21. package/dist/render/shaders/flat/hair.js +51 -25
  22. package/dist/render/shaders/flat/index.d.ts +5 -0
  23. package/dist/render/shaders/flat/index.js +3 -0
  24. package/dist/render/shaders/flat/main.d.ts +1 -1
  25. package/dist/render/shaders/flat/main.js +5 -2
  26. package/dist/render/shaders/flat/models.js +11 -4
  27. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  28. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  29. package/dist/render/surfaceDetail.d.ts +79 -0
  30. package/dist/render/surfaceDetail.js +86 -0
  31. package/dist/render/surfaceTexture.d.ts +9 -0
  32. package/package.json +6 -6
  33. package/src/index.ts +2 -0
  34. package/src/render/backend/webgl2/renderer.ts +58 -2
  35. package/src/render/backend/webgpu/clothSetPack.ts +1 -0
  36. package/src/render/backend/webgpu/clothSolvePack.ts +6 -2
  37. package/src/render/backend/webgpu/gpuClothSet.ts +1 -1
  38. package/src/render/backend/webgpu/renderer.ts +42 -1
  39. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +7 -5
  40. package/src/render/hairLobes.ts +76 -1
  41. package/src/render/shaders/flat/detail.ts +74 -0
  42. package/src/render/shaders/flat/glassTint.ts +3 -1
  43. package/src/render/shaders/flat/hair.ts +51 -25
  44. package/src/render/shaders/flat/index.ts +8 -0
  45. package/src/render/shaders/flat/main.ts +5 -2
  46. package/src/render/shaders/flat/models.ts +11 -4
  47. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  48. package/src/render/surfaceDetail.ts +136 -0
  49. package/src/render/surfaceTexture.ts +9 -0
package/CHANGELOG.json CHANGED
@@ -1,4 +1,31 @@
1
1
  [
2
+ {
3
+ "version": "4.13.4",
4
+ "date": "2026-10-12",
5
+ "entries": [
6
+ {
7
+ "kind": "changed",
8
+ "short": "Hair takes the sky, a probe's light and DriftLight through its own highlights, for the eye's side of each strand, and reflects none of the environment, so a fibre keeps its colour in shade.",
9
+ "text": "Hair is lit from every side through its own lobes. The sky, a probe's irradiance and DriftLight reached a hair surface through its scatter alone, and the environment's reflection was laid over it, white, at the strand's Fresnel: a consumer's golden hair, base colour (1, 0.43, 0.115) and scatter 0.2, was reported drawing as platinum where its source draws it gold. Now the lobes are evaluated once for a light along the eye flattened across the strand, unshadowed, with no transmitted lobe because that direction stands for the eye's side, and every lobe widened by 0.2 radians as a source that large would widen it; twice their sum, at most 1, multiplies the light from every side, under the model map's occlusion as before. The scatter's half of that is exactly what the scatter alone gave, its own average over the sphere, so what is new is the reflected and the internally reflected lobes, which carry the fibre's colour into the shade. Hair reflects none of the environment, the probe, the sky's gradient and screen-space reflections alike, because the reflected lobe already holds that light. Measured on `demo/dev/models.html?view=hair&sky=only`, strands under an even white sky: a strand's middle drew (149, 127, 102) and draws (235, 189, 142), where the reference in `hairLobes.ts` gives (234, 189, 142) for the same geometry; the row of five models moves 8,340 pixels, every one of them the hair sphere's; and WebGPU and WebGL2, on Vulkan and on OpenGL, agree as closely as they did. **What it gives up**: hair lit from behind by an open sky shows no rim from the sky, which the lamps still give it, and the share moves with the eye as the lobes do rather than holding still as an average would."
10
+ },
11
+ {
12
+ "kind": "added",
13
+ "short": "A skinned cloth's pull is weighed particle by particle, so a strap is held at its root and hangs free at its tip: `SkinnedClothSetup.pullWeights`.",
14
+ "text": "`SkinnedClothSetup.pullWeights` weighs a skinned cloth's pull particle by particle, one a particle from 0 to 1: each keeps `1 / (1 + w · pull · h²)` of its offset from its skinned place a substep, and hangs `9.81 / (w · pull)` metres below it at rest. One pull for a whole garment had to choose, as a consumer reported: held at its mean, a belt's tips stood out from the waist they hang against; held at none, a styled fringe fell past the jaw it is swept up from. Now a strap is held at its root and hangs free at its tip, a fringe is held nearly whole and a coat's body swings free, in one garment. Absent, every particle takes the whole pull, as before; a weight of 0 is no pull at all, a free particle rather than a pinned one, and a weight outside 0 to 1 is refused by its index. The device carries the weights as a section of its statics and the pull as a substep's stiffness, `pull · h²`, and divides as the CPU does. Measured on `demo/dev/skinnedCloth.html?pull=400&weights=ramp`, a cape weighed 1 at its shoulders to 0 at its hem: it moves 39,267 pixels from the same cape under the whole pull; weights of 1 everywhere draw the unweighted cape to the pixel; WebGPU and WebGL2 agree to 159 pixels; and the device parts from the CPU by 1.8 mm at most and 0.03 mm by the 150th frame, inside what an unweighted pull of 100 parted by on the build before (1.8 mm, 0.07 mm), a hem that hangs free being as sensitive as a weak pull. An unweighted pull draws within 11 pixels of what it drew, the device now dividing in single precision where it was handed the share."
15
+ }
16
+ ]
17
+ },
18
+ {
19
+ "version": "4.13.3",
20
+ "date": "2026-10-12",
21
+ "entries": [
22
+ {
23
+ "kind": "added",
24
+ "short": "A material can lay fine grain over its maps, a region at a time: up to sixteen detail normals and occlusions at their own repeats, chosen per texel: `SurfaceMaterial.detail`.",
25
+ "text": "`SurfaceMaterial.detail` lays a surface's finest grain over its base maps: up to sixteen regions, each a tangent-space detail normal map, and an occlusion, at its own repeat, offset and strengths, and a region map choosing which one each texel wears. A surface's grain, leather, a weave, brushed metal, a face's pores, is a map of a few hundred texels repeated tens of times across it, and baked into the base maps it is lost: a 256-texel detail tiled forty times over a 2048-texel map would want some ten thousand texels a side, so a consumer's characters looked smooth beside the surfaces they were authored with. Each texel reads its own region's detail alone, turned through a frame built on the normal the base maps made, and multiplies its occlusion into the surface's; the reads take explicit gradients, the base coordinates' times the region's repeat, since the region is the pixel's own. **Where the maps are, and why**: the lit stage had no texture binding to spare on either backend, so the detail maps are layers of the array read where a shading model's map is, and a material with a `modelMap` puts its detail layers in that array; the region map is a layer of the material's ORM array, one index a texel in its red, read exactly so a region's edge is sharp, which wants an uncompressed array. A material whose model-map slot holds another array, or that is placed by the world, is drawn without its detail, said once. Sixteen regions are thirty-three vectors: WebGL2 compiles them in only when a material first asks (`SURFACE_DETAIL`) and refuses them, said once, on a part with no room; on WebGPU every material's block grows from 1,024 bytes to 1,536. Measured on an RX 9070 XT (`demo/dev/detail.html`): where a material reads region 0 it draws exactly what one region with no region map draws, on WebGPU and on WebGL2 over Vulkan and over OpenGL; its other regions differ from it by 166,558 pixels; and the backends part by at most one level in a channel."
26
+ }
27
+ ]
28
+ },
2
29
  {
3
30
  "version": "4.13.2",
4
31
  "date": "2026-10-11",
package/README.md CHANGED
@@ -6,7 +6,7 @@ WebGL2 everywhere else, behind one API, and carries the fixed-step loop, the ren
6
6
  input, the scene graph, geometry, text and the whole of `@driftengine/physics`, which it re-exports.
7
7
  Everything else is a separate package a game adds when it reaches for it.
8
8
 
9
- **Cost: 924.2 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
9
+ **Cost: 930.5 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
10
10
  which fails the build if the figure drifts by more than 3%.
11
11
 
12
12
  The manual is at **[driftengine.dev/docs](https://driftengine.dev/docs)**, with an example running
package/dist/index.d.ts CHANGED
@@ -231,6 +231,8 @@ export { SurfaceTexture } from './render/surfaceTexture.ts';
231
231
  export type { SurfaceMaterial, SurfaceTextureOptions } from './render/surfaceTexture.ts';
232
232
  export type { SurfaceProjection } from './render/surfaceProjection.ts';
233
233
  export type { SurfaceLayers } from './render/surfaceLayers.ts';
234
+ export type { SurfaceDetail, SurfaceDetailRegion } from './render/surfaceDetail.ts';
235
+ export { MAX_DETAIL_REGIONS } from './render/surfaceDetail.ts';
234
236
  export type { SurfaceLayerLook } from './render/surfaceLayerLooks.ts';
235
237
  export { compressedFormatName, isCompressedSource, levelBytes, uploadsCompressed, } from './render/compressedSource.ts';
236
238
  export type { BlockFormat, CompressedTextureFormat, CompressedTextureSource, SurfaceSource, } from './render/compressedSource.ts';
package/dist/index.js CHANGED
@@ -287,6 +287,7 @@ export { MAX_POINT_LIGHTS, SURFACE_TEXTURE_UNIT } from './render/lightBudget.js'
287
287
  /* The width a light buffer can usefully be with `clusteredLights` on; `createPointLightBuffer`. */
288
288
  export { MAX_CLUSTERED_LIGHTS } from './render/clusteredLights.js';
289
289
  export { SurfaceTexture } from './render/surfaceTexture.js';
290
+ export { MAX_DETAIL_REGIONS } from './render/surfaceDetail.js';
290
291
  /*
291
292
  * Compressed textures — BC, ETC2 and EAC, ASTC: the source `createSurfaceTexture` takes as blocks,
292
293
  * the one question to ask before handing one over — whether this device takes its format
@@ -1300,6 +1300,13 @@ export declare class WebGL2Renderer implements RendererApi {
1300
1300
  /** Whether it has room for the layers' looks beside them, and whether the refusal was said. */
1301
1301
  private looksRoom;
1302
1302
  private warnedLooksRoom;
1303
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
1304
+ private readonly detailFloats;
1305
+ /** Whether the lit stage has room for detail on this part, asked once. See `detailOfMaterial`. */
1306
+ private detailRoom;
1307
+ private warnedDetailRoom;
1308
+ /** The refusals already said, each once. */
1309
+ private readonly warnedDetail;
1303
1310
  private warnedLayersModelMap;
1304
1311
  private warnedLayersTriplanar;
1305
1312
  private warnedOverlayRoom;
@@ -2199,6 +2206,11 @@ export declare class WebGL2Renderer implements RendererApi {
2199
2206
  * as they are, said once — which on a material sharing its arrays reads the wrong textures, so
2200
2207
  * the sentence names the way out.
2201
2208
  */
2209
+ /**
2210
+ * A material's detail, or null where it is refused: its slot holds another array, it is placed by
2211
+ * the world, or the lit stage has no room for its thirty-three vectors. Each said once.
2212
+ */
2213
+ private detailOfMaterial;
2202
2214
  private looksFit;
2203
2215
  /** Says once that a layered material's triplanar projection is laid on the horizontal plane. */
2204
2216
  private warnLayersTriplanar;
@@ -41,6 +41,7 @@ import { resolveTransmission } from '../../transmission.js';
41
41
  import { resolveOcclusionFade } from '../../occlusionFade.js';
42
42
  import { orthographicDepthSpan } from '../../lightMatrix.js';
43
43
  import { LAYER_FLOATS, layerMaskMap, layeredProjection, layersFitBeside, packSurfaceLayers, } from '../../surfaceLayers.js';
44
+ import { DETAIL_FLOATS, detailOf, detailRefusal, detailRefusalWarning, packSurfaceDetail, } from '../../surfaceDetail.js';
44
45
  import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.js';
45
46
  import { FILM_FRAG, FILM_VERT } from '../../shaders/film.js';
46
47
  import { SceneTarget } from '../../sceneTarget.js';
@@ -976,6 +977,13 @@ export class WebGL2Renderer {
976
977
  /** Whether it has room for the layers' looks beside them, and whether the refusal was said. */
977
978
  looksRoom = null;
978
979
  warnedLooksRoom = false;
980
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
981
+ detailFloats = new Float32Array(DETAIL_FLOATS);
982
+ /** Whether the lit stage has room for detail on this part, asked once. See `detailOfMaterial`. */
983
+ detailRoom = null;
984
+ warnedDetailRoom = false;
985
+ /** The refusals already said, each once. */
986
+ warnedDetail = new Set();
979
987
  warnedLayersModelMap = false;
980
988
  warnedLayersTriplanar = false;
981
989
  warnedOverlayRoom = false;
@@ -3307,11 +3315,18 @@ export class WebGL2Renderer {
3307
3315
  const modelMap = material?.modelMap ?? null;
3308
3316
  /* A layered material's mask, where it is a map, goes where a model's would: surfaceLayers.ts. */
3309
3317
  const layers = this.layersOf(material);
3310
- const boundMap = modelMap ?? layerMaskMap(layers);
3318
+ const taken = modelMap ?? layerMaskMap(layers);
3319
+ /* And its detail's array, where nothing else is read there: surfaceDetail.ts. */
3320
+ const detail = this.detailOfMaterial(material, taken);
3321
+ const boundMap = taken ?? detail?.maps ?? null;
3311
3322
  gl.activeTexture(gl.TEXTURE0 + MODEL_MAP_TEXTURE_UNIT);
3312
3323
  gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
3313
3324
  if (boundMap !== null)
3314
3325
  boundMap.bind(gl, MODEL_MAP_TEXTURE_UNIT);
3326
+ packSurfaceDetail(detail, (material?.orm ?? null) !== null, this.detailFloats);
3327
+ if (detail !== null)
3328
+ this.askLit('SURFACE_DETAIL');
3329
+ gl.uniform4fv(u['uDetail'] ?? null, this.detailFloats);
3315
3330
  packSurfaceLayers(layers, this.layerFloats);
3316
3331
  if (layers !== null)
3317
3332
  this.askLit('LAYERED');
@@ -3598,6 +3613,33 @@ export class WebGL2Renderer {
3598
3613
  * as they are, said once — which on a material sharing its arrays reads the wrong textures, so
3599
3614
  * the sentence names the way out.
3600
3615
  */
3616
+ /**
3617
+ * A material's detail, or null where it is refused: its slot holds another array, it is placed by
3618
+ * the world, or the lit stage has no room for its thirty-three vectors. Each said once.
3619
+ */
3620
+ detailOfMaterial(material, taken) {
3621
+ const detail = detailOf(material);
3622
+ if (detail === null)
3623
+ return null;
3624
+ const refusal = detailRefusal(detail, taken, (material?.projection ?? null) !== null);
3625
+ if (refusal !== null) {
3626
+ if (!this.warnedDetail.has(refusal)) {
3627
+ this.warnedDetail.add(refusal);
3628
+ console.warn(detailRefusalWarning(refusal));
3629
+ }
3630
+ return null;
3631
+ }
3632
+ this.detailRoom ??= this.switchFits('SURFACE_DETAIL');
3633
+ if (this.detailRoom)
3634
+ return detail;
3635
+ if (!this.warnedDetailRoom) {
3636
+ this.warnedDetailRoom = true;
3637
+ console.warn("Renderer: a material's detail is refused on this device. It takes thirty-three fragment " +
3638
+ 'uniform vectors the lit shader does not have, so the material is drawn without it. A ' +
3639
+ 'lower `maxLights` makes the room, or WebGPU has it.');
3640
+ }
3641
+ return null;
3642
+ }
3601
3643
  looksFit(layers) {
3602
3644
  if (!asksLayerLooks(layers))
3603
3645
  return false;
@@ -5595,6 +5637,7 @@ export class WebGL2Renderer {
5595
5637
  movingSun: asIf.MOVING_SUN ?? this.litOn.MOVING_SUN,
5596
5638
  layered: asIf.LAYERED ?? this.litOn.LAYERED,
5597
5639
  layerLooks: asIf.LAYER_LOOKS ?? this.litOn.LAYER_LOOKS,
5640
+ surfaceDetail: asIf.SURFACE_DETAIL ?? this.litOn.SURFACE_DETAIL,
5598
5641
  maxLights: budget.maxLights,
5599
5642
  maxAreaLights: budget.maxAreaLights,
5600
5643
  });
@@ -7454,9 +7497,10 @@ export class WebGL2Renderer {
7454
7497
  this.projection.fill(0);
7455
7498
  /* Nor light through from behind. See `SurfaceMaterial.diffuseTransmission`. */
7456
7499
  resolveTransmission(null, this.transmission);
7457
- /* Nor layers: one layer until a material asks for more. */
7500
+ /* Nor layers: one layer until a material asks for more. Nor detail. */
7458
7501
  this.layerFloats.fill(0);
7459
7502
  this.lookFloats.fill(0);
7503
+ this.detailFloats.fill(0);
7460
7504
  this.overlayMaps = null;
7461
7505
  this.overlayTexture = null;
7462
7506
  /* Nor an ambient of its own: the frame's, until a draw asks. See `setAmbientSH`. */
@@ -7601,6 +7645,7 @@ export class WebGL2Renderer {
7601
7645
  gl.uniform4fv(u['uWorldUv'] ?? null, this.projection);
7602
7646
  gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
7603
7647
  gl.uniform4fv(u['uLayerLooks'] ?? null, this.lookFloats);
7648
+ gl.uniform4fv(u['uDetail'] ?? null, this.detailFloats);
7604
7649
  /* Neither refracting nor glass, which is every draw that does not say otherwise. See
7605
7650
  `bindSeeThrough` for the four numbers in the one vector. */
7606
7651
  gl.uniform4f(u['uSeeThrough'] ?? null, 0, 0, 0, 0);
@@ -31,6 +31,7 @@ const SECTIONS = [
31
31
  [7, 3],
32
32
  [10, 3],
33
33
  [13, 16],
34
+ [29, 1],
34
35
  ];
35
36
  /** Where the influences start, and where the garment's index sits, in particle counts. */
36
37
  const INFLUENCES = 13;
@@ -1,13 +1,16 @@
1
1
  import type { SkinnedClothSetup } from '@driftengine/physics';
2
2
  /** Floats of statics a particle, across its sections. */
3
- export declare const CLOTH_STATIC_FLOATS = 29;
3
+ export declare const CLOTH_STATIC_FLOATS = 30;
4
4
  /** Bit flags of the limits a set-up has: a max distance, a backstop, a frontstop. */
5
5
  export declare const CLOTH_LIMIT_MAX = 1;
6
6
  export declare const CLOTH_LIMIT_BACK = 2;
7
7
  export declare const CLOTH_LIMIT_FRONT = 4;
8
8
  export interface PackedCloth {
9
9
  readonly count: number;
10
- /** inverse mass, max distance, backstop ×2, frontstop ×2, thickness, rest ×3, normal ×3, influences ×16. */
10
+ /**
11
+ * inverse mass, max distance, backstop ×2, frontstop ×2, thickness, rest ×3, normal ×3,
12
+ * influences ×16, pull weight.
13
+ */
11
14
  readonly statics: Float32Array;
12
15
  /** Distance records (a, b, rest, compliance), bending (four ids, rest, compliance), tethers (particle, anchor, length). */
13
16
  readonly constraints: Uint32Array;
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { batchesOf } from '@driftengine/physics';
20
20
  /** Floats of statics a particle, across its sections. */
21
- export const CLOTH_STATIC_FLOATS = 29;
21
+ export const CLOTH_STATIC_FLOATS = 30;
22
22
  /** Bit flags of the limits a set-up has: a max distance, a backstop, a frontstop. */
23
23
  export const CLOTH_LIMIT_MAX = 1;
24
24
  export const CLOTH_LIMIT_BACK = 2;
@@ -50,6 +50,7 @@ export function packCloth(setup) {
50
50
  statics[at + i * 2 + 1] = real ? radius : 0;
51
51
  }
52
52
  statics[6 * n + i] = limits.thickness?.[i] ?? 0;
53
+ statics[29 * n + i] = setup.pullWeights?.[i] ?? 1;
53
54
  for (let k = 0; k < 3; k++) {
54
55
  statics[7 * n + i * 3 + k] = setup.positions[i * 3 + k];
55
56
  statics[10 * n + i * 3 + k] = setup.normals?.[i * 3 + k] ?? (k === 2 ? 1 : 0);
@@ -278,7 +278,7 @@ export class GpuClothSet {
278
278
  u[base + 32] = garment.range.colliderBase;
279
279
  u[base + 33] = garment.range.colliders;
280
280
  u[base + 34] = garment.particles.width;
281
- f[base + 35] = 1 / (1 + p.pull * h * h);
281
+ f[base + 35] = p.pull * h * h;
282
282
  f[base + 36] = exactExp(p.damping * h);
283
283
  f[base + 37] = exactExp(p.drag * h);
284
284
  f[base + 38] = p.maxDistanceScale;
@@ -1208,6 +1208,10 @@ export declare class WebGPURenderer implements RendererApi {
1208
1208
  */
1209
1209
  private readonly transmission;
1210
1210
  private warnedLayersModelMap;
1211
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
1212
+ private readonly detailFloats;
1213
+ /** The detail refusals already said, each once. See `detailOfMaterial`. */
1214
+ private readonly warnedDetail;
1211
1215
  private warnedLayersTriplanar;
1212
1216
  /** Its numbers, `uModelParams`, packed by `packModel`. */
1213
1217
  private readonly modelParams;
@@ -2112,6 +2116,11 @@ export declare class WebGPURenderer implements RendererApi {
2112
2116
  * A material's layers where they can be drawn: none asked, or a model's map in the mask's place,
2113
2117
  * said once, leave one layer.
2114
2118
  */
2119
+ /**
2120
+ * A material's detail, or null where it is refused: its slot holds another array, or it is placed
2121
+ * by the world. Each said once. This backend has room for its vectors wherever it runs.
2122
+ */
2123
+ private detailOfMaterial;
2115
2124
  private layersOf;
2116
2125
  /** Says once that a layered material's triplanar projection is laid on the horizontal plane. */
2117
2126
  private warnLayersTriplanar;
@@ -70,6 +70,7 @@ import { resolveTransmission } from '../../transmission.js';
70
70
  import { resolveOcclusionFade } from '../../occlusionFade.js';
71
71
  import { orthographicDepthSpan } from '../../lightMatrix.js';
72
72
  import { LAYER_FLOATS, layerMaskMap, layeredProjection, layersFitBeside, packSurfaceLayers, } from '../../surfaceLayers.js';
73
+ import { DETAIL_FLOATS, detailOf, detailRefusal, detailRefusalWarning, packSurfaceDetail, } from '../../surfaceDetail.js';
73
74
  import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.js';
74
75
  import { frameReflectionsRefused, resolveFrameReflections, } from '../../frameReflections.js';
75
76
  import { REFLECTION_SURFACE_FORMAT, ReflectionSurfacePass } from './reflectionSurfacePass.js';
@@ -2284,6 +2285,10 @@ export class WebGPURenderer {
2284
2285
  */
2285
2286
  transmission = new Float32Array([-1, -1, -1, 0]);
2286
2287
  warnedLayersModelMap = false;
2288
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
2289
+ detailFloats = new Float32Array(DETAIL_FLOATS);
2290
+ /** The detail refusals already said, each once. See `detailOfMaterial`. */
2291
+ warnedDetail = new Set();
2287
2292
  warnedLayersTriplanar = false;
2288
2293
  /** Its numbers, `uModelParams`, packed by `packModel`. */
2289
2294
  modelParams = new Float32Array(MODEL_PARAM_FLOATS);
@@ -2688,7 +2693,10 @@ export class WebGPURenderer {
2688
2693
  const ownModelMap = (material?.modelMap ?? null);
2689
2694
  /* A layered material's mask, where it is a map, goes where a model's would: surfaceLayers.ts. */
2690
2695
  const layers = this.layersOf(material);
2691
- const modelMap = ownModelMap ?? layerMaskMap(layers);
2696
+ const taken = ownModelMap ?? layerMaskMap(layers);
2697
+ /* And its detail's array, where nothing else is read there: surfaceDetail.ts. */
2698
+ const detail = this.detailOfMaterial(material, taken);
2699
+ const modelMap = taken ?? detail?.maps ?? null;
2692
2700
  this.materialModel = material?.model ?? null;
2693
2701
  const reflectivity = material?.reflectivity;
2694
2702
  const environmentGain = material?.environmentGain;
@@ -2772,6 +2780,11 @@ export class WebGPURenderer {
2772
2780
  f.set(this.lookFloats, this.materialField('uLayerLooks'));
2773
2781
  if (asksLayerLooks(layers))
2774
2782
  this.enableLit('LAYER_LOOKS');
2783
+ /* And its tiled detail, the first time a material lays any: detail.ts. */
2784
+ packSurfaceDetail(detail, orm !== null, this.detailFloats);
2785
+ f.set(this.detailFloats, this.materialField('uDetail'));
2786
+ if (detail !== null)
2787
+ this.enableLit('SURFACE_DETAIL');
2775
2788
  f.set(this.modelParams, this.materialField('uModelParams'));
2776
2789
  });
2777
2790
  /*
@@ -5142,6 +5155,23 @@ export class WebGPURenderer {
5142
5155
  * A material's layers where they can be drawn: none asked, or a model's map in the mask's place,
5143
5156
  * said once, leave one layer.
5144
5157
  */
5158
+ /**
5159
+ * A material's detail, or null where it is refused: its slot holds another array, or it is placed
5160
+ * by the world. Each said once. This backend has room for its vectors wherever it runs.
5161
+ */
5162
+ detailOfMaterial(material, taken) {
5163
+ const detail = detailOf(material);
5164
+ if (detail === null)
5165
+ return null;
5166
+ const refusal = detailRefusal(detail, taken, (material?.projection ?? null) !== null);
5167
+ if (refusal === null)
5168
+ return detail;
5169
+ if (!this.warnedDetail.has(refusal)) {
5170
+ this.warnedDetail.add(refusal);
5171
+ console.warn(detailRefusalWarning(refusal));
5172
+ }
5173
+ return null;
5174
+ }
5145
5175
  layersOf(material) {
5146
5176
  const layers = material?.layers ?? null;
5147
5177
  if (layers === null || layers.repeats.length === 0)
@@ -7312,6 +7342,8 @@ export class WebGPURenderer {
7312
7342
  /* Nor layers: one layer until a material asks for more. */
7313
7343
  f.fill(0, at('uLayers'), at('uLayers') + LAYER_FLOATS);
7314
7344
  f.fill(0, at('uLayerLooks'), at('uLayerLooks') + LOOK_FLOATS);
7345
+ /* Nor detail. */
7346
+ f.fill(0, at('uDetail'), at('uDetail') + DETAIL_FLOATS);
7315
7347
  f[at('uCutout')] = 0;
7316
7348
  f[at('uCutout') + 1] = 0;
7317
7349
  this.cutoutResolveStaged = 'hard';
@@ -37,4 +37,4 @@
37
37
  * a reset, sits the round out while the rest of the set runs it.
38
38
  */
39
39
  export declare const CLOTH_WORKGROUP = 64;
40
- export declare const CLOTH_SOLVE_WGSL = "\n/* The set's: every garment's particles, records, skin matrices and colliders, and the step length\n they all share. */\nstruct Constants {\n count: u32,\n distances: u32,\n bendings: u32,\n joints: u32,\n colliders: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n h: f32,\n invH: f32,\n invH2: f32,\n pad3: f32,\n}\n\n/* Where this dispatch's round of records starts in paces, at a dynamic offset. */\nstruct Round {\n at: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n/* One garment's numbers for one round: its step's, and its own that never change. */\nstruct Pace {\n fraction: f32,\n blend: f32,\n alpha: f32,\n carry: u32,\n /* Where the pose a step starts from and the one it moves toward sit in pose, in floats. */\n fromAt: u32,\n toAt: u32,\n /* 0 where the garment sits this round out. Not active, which WGSL reserves. */\n live: u32,\n /* 1 a max distance, 2 a backstop, 4 a frontstop: which limits the garment has at all. */\n limits: u32,\n /* The character's turn since the last step, by columns; origin.w is the share carried. */\n turn0: vec4<f32>,\n turn1: vec4<f32>,\n turn2: vec4<f32>,\n origin: vec4<f32>,\n shift: vec4<f32>,\n /* The frame's wind: one wind, sampled once a frame, so a step's rather than the set-up's. */\n wind: vec4<f32>,\n colliderBase: u32,\n colliders: u32,\n width: u32,\n /* The share of its offset from its skinned position a particle keeps a substep: the pull's. */\n pullKeep: f32,\n keep: f32,\n dragKeep: f32,\n maxDistanceScale: f32,\n margin: f32,\n gravity: vec4<f32>,\n}\n\n/* One colour of one kind of constraint across the set, at a dynamic offset; first resets the\n multipliers. Publishing reads one garment's particles as begin to end, and first is the garment. */\nstruct Batch {\n begin: u32,\n end: u32,\n first: u32,\n pad: u32,\n}\n\n@group(0) @binding(0) var<uniform> c: Constants;\n@group(0) @binding(1) var<uniform> round: Round;\n@group(0) @binding(2) var<uniform> batch: Batch;\n@group(0) @binding(3) var<storage, read_write> state: array<f32>;\n@group(0) @binding(4) var<storage, read_write> pose: array<f32>;\n@group(0) @binding(5) var<storage, read> statics: array<f32>;\n@group(0) @binding(6) var<storage, read> constraints: array<u32>;\n@group(0) @binding(7) var<storage, read_write> lambda: array<f32>;\n@group(0) @binding(8) var<storage, read> frame: array<f32>;\n@group(0) @binding(9) var<storage, read> paces: array<Pace>;\n\n/* The record of particle i's garment this round: which garment a particle is, in statics. */\nfn paceAt(i: u32) -> u32 {\n return round.at + u32(statics[29u * c.count + i]);\n}\n\nfn stateAt(section: u32, i: u32) -> u32 {\n return section * 3u * c.count + i * 3u;\n}\n\nfn load(at: u32) -> vec3<f32> {\n return vec3<f32>(state[at], state[at + 1u], state[at + 2u]);\n}\n\nfn store(at: u32, v: vec3<f32>) {\n state[at] = v.x;\n state[at + 1u] = v.y;\n state[at + 2u] = v.z;\n}\n\nfn poseOf(at: u32) -> vec3<f32> {\n return vec3<f32>(pose[at], pose[at + 1u], pose[at + 2u]);\n}\n\n/* Sections of state. */\nconst POSITION = 0u;\nconst PREVIOUS = 1u;\nconst VELOCITY = 2u;\nconst START = 3u;\n\nfn inverseMass(i: u32) -> f32 {\n return statics[i];\n}\n\n/* Where skinning puts particle i, fraction of the way from the last pose to the latest. */\nfn skinnedAt(i: u32, g: u32) -> vec3<f32> {\n let a = poseOf(paces[g].fromAt + i * 3u);\n let b = poseOf(paces[g].toAt + i * 3u);\n return a + (b - a) * paces[g].fraction;\n}\n\nfn skinMatrix(j: u32) -> mat4x4<f32> {\n let at = j * 16u;\n return mat4x4<f32>(\n vec4<f32>(frame[at], frame[at + 1u], frame[at + 2u], frame[at + 3u]),\n vec4<f32>(frame[at + 4u], frame[at + 5u], frame[at + 6u], frame[at + 7u]),\n vec4<f32>(frame[at + 8u], frame[at + 9u], frame[at + 10u], frame[at + 11u]),\n vec4<f32>(frame[at + 12u], frame[at + 13u], frame[at + 14u], frame[at + 15u]));\n}\n\n/* skinParticles: each particle's skinned position and normal by up to eight influences. */\n@compute @workgroup_size(64)\nfn clothPose(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let n = c.count;\n let restAt = 7u * n + i * 3u;\n let normalAt = 10u * n + i * 3u;\n let x = vec4<f32>(statics[restAt], statics[restAt + 1u], statics[restAt + 2u], 1.0);\n let q0 = vec3<f32>(statics[normalAt], statics[normalAt + 1u], statics[normalAt + 2u]);\n var p = vec3<f32>(0.0);\n var q = vec3<f32>(0.0);\n for (var half = 0u; half < 2u; half++) {\n for (var k = 0u; k < 4u; k++) {\n let at = 13u * n + i * 16u + half * 8u;\n let w = statics[at + 4u + k];\n if (w == 0.0) {\n continue;\n }\n let m = skinMatrix(u32(statics[at + k]));\n p += w * (m * x).xyz;\n q += w * (mat3x3<f32>(m[0].xyz, m[1].xyz, m[2].xyz) * q0);\n }\n }\n let to = paces[g].toAt + i * 3u;\n pose[to] = p.x;\n pose[to + 1u] = p.y;\n pose[to + 2u] = p.z;\n let len = sqrt(dot(q, q));\n let inv = select(0.0, 1.0 / len, len > 1e-12);\n let normal = 6u * n + i * 3u;\n pose[normal] = q.x * inv;\n pose[normal + 1u] = q.y * inv;\n pose[normal + 2u] = q.z * inv;\n}\n\n/* SkinnedCloth.rest: every particle at the latest skinned position, at rest. */\n@compute @workgroup_size(64)\nfn clothRest(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let p = poseOf(paces[g].toAt + i * 3u);\n store(stateAt(POSITION, i), p);\n store(stateAt(START, i), p);\n store(stateAt(VELOCITY, i), vec3<f32>(0.0));\n}\n\n/* The step's start: the step start kept, a free particle carried, a kinematic one placed. */\n@compute @workgroup_size(64)\nfn clothBegin(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let at = stateAt(POSITION, i);\n let p = load(at);\n store(stateAt(START, i), p);\n if (inverseMass(i) == 0.0) {\n store(at, skinnedAt(i, g));\n return;\n }\n if (paces[g].carry == 0u) {\n return;\n }\n /* carryParticles: the share of the character's turn about its old origin, and of its move. */\n let turn = mat3x3<f32>(paces[g].turn0.xyz, paces[g].turn1.xyz, paces[g].turn2.xyz);\n let share = paces[g].origin.w;\n let x = p - paces[g].origin.xyz;\n store(at, p + (turn * x - x) * share + paces[g].shift.xyz);\n let vAt = stateAt(VELOCITY, i);\n let v = load(vAt);\n store(vAt, v + (turn * v - v) * share);\n}\n\n/* predictParticles. */\n@compute @workgroup_size(64)\nfn clothPredict(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let at = stateAt(POSITION, i);\n let p = load(at);\n store(stateAt(PREVIOUS, i), p);\n if (inverseMass(i) == 0.0) {\n return;\n }\n let vAt = stateAt(VELOCITY, i);\n let air = paces[g].wind.xyz;\n let v = air + (load(vAt) * paces[g].keep - air) * paces[g].dragKeep + paces[g].gravity.xyz * c.h;\n store(vAt, v);\n store(at, p + v * c.h);\n}\n\n/* solveDistanceBatch: one invocation a constraint of the batch, constraints sorted by batch. */\n@compute @workgroup_size(64)\nfn clothDistance(@builtin(global_invocation_id) id: vec3<u32>) {\n let k = batch.begin + id.x;\n if (k >= batch.end) {\n return;\n }\n let a = constraints[k * 4u];\n let b = constraints[k * 4u + 1u];\n /* A garment sitting the round out solves nothing, and its next step's first pass resets. */\n if (paces[paceAt(a)].live == 0u) {\n return;\n }\n /* The first pass of a substep starts the multiplier at nothing, as the CPU's fill(0) does \u2014\n written before any early return, so a skipped constraint does not carry the last one. */\n if (batch.first == 1u) {\n lambda[k] = 0.0;\n }\n let wa = inverseMass(a);\n let wb = inverseMass(b);\n let w = wa + wb;\n if (w == 0.0) {\n return;\n }\n let aAt = stateAt(POSITION, a);\n let bAt = stateAt(POSITION, b);\n let d = load(bAt) - load(aAt);\n let len = sqrt(dot(d, d));\n if (len < 1e-9) {\n return;\n }\n let alpha = bitcast<f32>(constraints[k * 4u + 3u]) * c.invH2;\n let old = lambda[k];\n let delta = (-(len - bitcast<f32>(constraints[k * 4u + 2u])) - alpha * old) / (w + alpha);\n lambda[k] = old + delta;\n let s = delta / len;\n /* A pinned particle is shared across a batch, so it is never written, even with itself. */\n if (wa > 0.0) {\n store(aAt, load(aAt) - d * s * wa);\n }\n if (wb > 0.0) {\n store(bAt, load(bAt) + d * s * wb);\n }\n}\n\n/* solveBendingBatch, with the angle from atan2 (see the header). */\n@compute @workgroup_size(64)\nfn clothBending(@builtin(global_invocation_id) id: vec3<u32>) {\n let k = batch.begin + id.x;\n if (k >= batch.end) {\n return;\n }\n let at = c.distances * 4u + k * 6u;\n let slot = c.distances + k;\n if (paces[paceAt(constraints[at])].live == 0u) {\n return;\n }\n if (batch.first == 1u) {\n lambda[slot] = 0.0;\n }\n let ids = vec4<u32>(constraints[at], constraints[at + 1u], constraints[at + 2u], constraints[at + 3u]);\n let w = vec4<f32>(inverseMass(ids.x), inverseMass(ids.y), inverseMass(ids.z), inverseMass(ids.w));\n if (w.x + w.y + w.z + w.w == 0.0) {\n return;\n }\n let origin = load(stateAt(POSITION, ids.x));\n let e = load(stateAt(POSITION, ids.y)) - origin;\n let p3 = load(stateAt(POSITION, ids.z)) - origin;\n let p4 = load(stateAt(POSITION, ids.w)) - origin;\n var n1 = cross(e, p3);\n var n2 = cross(e, p4);\n let l1 = sqrt(dot(n1, n1));\n let l2 = sqrt(dot(n2, n2));\n if (l1 < 1e-12 || l2 < 1e-12) {\n return;\n }\n n1 = n1 / l1;\n n2 = n2 / l2;\n let d = clamp(dot(n1, n2), -1.0, 1.0);\n let sine = sqrt(dot(cross(n1, n2), cross(n1, n2)));\n if (sine < 1e-6) {\n return;\n }\n /* The q vectors: the negative of the derivative of n1 \u00B7 n2. See solveBendingBatch. */\n let q3 = (cross(e, n2) + cross(n1, e) * d) / l1;\n let q4 = (cross(e, n1) + cross(n2, e) * d) / l2;\n let q2 = -((cross(p3, n2) + cross(n1, p3) * d) / l1 + (cross(p4, n1) + cross(n2, p4) * d) / l2);\n let q1 = -(q2 + q3 + q4);\n var sum = w.x * dot(q1, q1) + w.y * dot(q2, q2) + w.z * dot(q3, q3) + w.w * dot(q4, q4);\n sum = sum / (sine * sine);\n if (sum < 1e-12) {\n return;\n }\n let alpha = bitcast<f32>(constraints[at + 5u]) * c.invH2;\n let old = lambda[slot];\n let angle = atan2(sine, d);\n let delta = (-(angle - bitcast<f32>(constraints[at + 4u])) - alpha * old) / (sum + alpha);\n lambda[slot] = old + delta;\n let scale = delta / sine;\n if (w.x > 0.0) {\n let p = stateAt(POSITION, ids.x);\n store(p, load(p) + w.x * scale * q1);\n }\n if (w.y > 0.0) {\n let p = stateAt(POSITION, ids.y);\n store(p, load(p) + w.y * scale * q2);\n }\n if (w.z > 0.0) {\n let p = stateAt(POSITION, ids.z);\n store(p, load(p) + w.z * scale * q3);\n }\n if (w.w > 0.0) {\n let p = stateAt(POSITION, ids.w);\n store(p, load(p) + w.w * scale * q4);\n }\n}\n\n/* solveTethers, one invocation a tether of the batch: a batch holds a particle once. */\n@compute @workgroup_size(64)\nfn clothTether(@builtin(global_invocation_id) id: vec3<u32>) {\n let k = batch.begin + id.x;\n if (k >= batch.end) {\n return;\n }\n let at = c.distances * 4u + c.bendings * 6u + k * 3u;\n if (paces[paceAt(constraints[at])].live == 0u) {\n return;\n }\n let pAt = stateAt(POSITION, constraints[at]);\n let anchor = load(stateAt(POSITION, constraints[at + 1u]));\n let d = load(pAt) - anchor;\n let dist = sqrt(dot(d, d));\n let reach = bitcast<f32>(constraints[at + 2u]);\n if (dist <= reach || dist < 1e-9) {\n return;\n }\n store(pAt, anchor + d * (reach / dist));\n}\n\n/* pushOut: the point onto the sphere's surface if it is inside. A point at the centre stays. */\nfn pushOut(p: vec3<f32>, centre: vec3<f32>, radius: f32) -> vec3<f32> {\n let d = p - centre;\n let d2 = dot(d, d);\n if (d2 >= radius * radius || d2 < 1e-18) {\n return p;\n }\n return centre + d * (radius / sqrt(d2));\n}\n\n/* applyPull, applyMaxDistance, applyStops (back, then front) and applyColliders, in that order. */\n@compute @workgroup_size(64)\nfn clothLimit(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count || inverseMass(i) == 0.0) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let limits = paces[g].limits;\n let n = c.count;\n let at = stateAt(POSITION, i);\n var p = load(at);\n let t = skinnedAt(i, g);\n /* Only where there is a pull, as on the CPU: t + (p - t) is not p in floats. */\n if (paces[g].pullKeep < 1.0) {\n p = t + (p - t) * paces[g].pullKeep;\n }\n /* A max distance below zero is none: the CPU's Infinity, which a device may not be handed. */\n let maxDistance = statics[n + i];\n if ((limits & 1u) != 0u && maxDistance >= 0.0) {\n let limit = maxDistance * paces[g].maxDistanceScale;\n let d = p - t;\n let d2 = dot(d, d);\n if (d2 > limit * limit) {\n p = t + d * (limit / sqrt(d2));\n }\n }\n let normal = poseOf(6u * n + i * 3u);\n if ((limits & 2u) != 0u) {\n let radius = statics[2u * n + i * 2u + 1u];\n if (radius > 0.0) {\n p = pushOut(p, t - normal * (statics[2u * n + i * 2u] + radius), radius);\n }\n }\n if ((limits & 4u) != 0u) {\n let radius = statics[4u * n + i * 2u + 1u];\n if (radius > 0.0) {\n p = pushOut(p, t + normal * (statics[4u * n + i * 2u] + radius), radius);\n }\n }\n let pad = statics[6u * n + i] + paces[g].margin;\n let ends = c.joints * 16u;\n let radii = ends + c.colliders * 6u;\n let first = paces[g].colliderBase;\n for (var k = first; k < first + paces[g].colliders; k++) {\n let a = vec3<f32>(frame[ends + k * 6u], frame[ends + k * 6u + 1u], frame[ends + k * 6u + 2u]);\n let e = vec3<f32>(frame[ends + k * 6u + 3u], frame[ends + k * 6u + 4u], frame[ends + k * 6u + 5u]) - a;\n let e2 = dot(e, e);\n var s = 0.0;\n if (e2 > 1e-18) {\n s = clamp(dot(p - a, e) / e2, 0.0, 1.0);\n }\n let r0 = frame[radii + k * 2u];\n let r1 = frame[radii + k * 2u + 1u];\n p = pushOut(p, a + e * s, r0 + (r1 - r0) * s + pad);\n }\n store(at, p);\n}\n\n/* finishParticles. */\n@compute @workgroup_size(64)\nfn clothFinish(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n if (paces[paceAt(i)].live == 0u) {\n return;\n }\n let vAt = stateAt(VELOCITY, i);\n if (inverseMass(i) == 0.0) {\n store(vAt, vec3<f32>(0.0));\n return;\n }\n store(vAt, (load(stateAt(POSITION, i)) - load(stateAt(PREVIOUS, i))) * c.invH);\n}\n\n/* SkinnedCloth.blend: after a reset, the simulation's share grows over the blend's steps. */\n@compute @workgroup_size(64)\nfn clothBlend(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n /* A blend of 0 is none, not a last step of one: see ClothDevice.step. */\n let g = paceAt(i);\n let blend = paces[g].blend;\n if (paces[g].live == 0u || blend <= 0.0) {\n return;\n }\n let t = skinnedAt(i, g);\n let at = stateAt(POSITION, i);\n store(at, t + (load(at) - t) * blend);\n let vAt = stateAt(VELOCITY, i);\n store(vAt, load(vAt) * blend);\n}\n\n@group(1) @binding(0) var output: texture_storage_2d<rgba32float, write>;\n\n/* SkinnedCloth.interpolate, into the particle texture the vertex stage reads: one garment's, whose\n particles are batch.begin to batch.end and whose record is batch.first's. */\n@compute @workgroup_size(64)\nfn clothPublish(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = batch.begin + id.x;\n if (i >= batch.end) {\n return;\n }\n let g = round.at + batch.first;\n let a = load(stateAt(START, i));\n let p = a + (load(stateAt(POSITION, i)) - a) * paces[g].alpha;\n let local = id.x;\n let width = paces[g].width;\n textureStore(output, vec2<u32>(local % width, local / width), vec4<f32>(p, 0.0));\n}\n";
40
+ export declare const CLOTH_SOLVE_WGSL = "\n/* The set's: every garment's particles, records, skin matrices and colliders, and the step length\n they all share. */\nstruct Constants {\n count: u32,\n distances: u32,\n bendings: u32,\n joints: u32,\n colliders: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n h: f32,\n invH: f32,\n invH2: f32,\n pad3: f32,\n}\n\n/* Where this dispatch's round of records starts in paces, at a dynamic offset. */\nstruct Round {\n at: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n/* One garment's numbers for one round: its step's, and its own that never change. */\nstruct Pace {\n fraction: f32,\n blend: f32,\n alpha: f32,\n carry: u32,\n /* Where the pose a step starts from and the one it moves toward sit in pose, in floats. */\n fromAt: u32,\n toAt: u32,\n /* 0 where the garment sits this round out. Not active, which WGSL reserves. */\n live: u32,\n /* 1 a max distance, 2 a backstop, 4 a frontstop: which limits the garment has at all. */\n limits: u32,\n /* The character's turn since the last step, by columns; origin.w is the share carried. */\n turn0: vec4<f32>,\n turn1: vec4<f32>,\n turn2: vec4<f32>,\n origin: vec4<f32>,\n shift: vec4<f32>,\n /* The frame's wind: one wind, sampled once a frame, so a step's rather than the set-up's. */\n wind: vec4<f32>,\n colliderBase: u32,\n colliders: u32,\n width: u32,\n /* The garment's pull as a substep's stiffness, pull \u00B7 h\u00B2: a particle weighted w keeps\n 1 / (1 + w \u00B7 pullStep) of its offset from its skinned position. */\n pullStep: f32,\n keep: f32,\n dragKeep: f32,\n maxDistanceScale: f32,\n margin: f32,\n gravity: vec4<f32>,\n}\n\n/* One colour of one kind of constraint across the set, at a dynamic offset; first resets the\n multipliers. Publishing reads one garment's particles as begin to end, and first is the garment. */\nstruct Batch {\n begin: u32,\n end: u32,\n first: u32,\n pad: u32,\n}\n\n@group(0) @binding(0) var<uniform> c: Constants;\n@group(0) @binding(1) var<uniform> round: Round;\n@group(0) @binding(2) var<uniform> batch: Batch;\n@group(0) @binding(3) var<storage, read_write> state: array<f32>;\n@group(0) @binding(4) var<storage, read_write> pose: array<f32>;\n@group(0) @binding(5) var<storage, read> statics: array<f32>;\n@group(0) @binding(6) var<storage, read> constraints: array<u32>;\n@group(0) @binding(7) var<storage, read_write> lambda: array<f32>;\n@group(0) @binding(8) var<storage, read> frame: array<f32>;\n@group(0) @binding(9) var<storage, read> paces: array<Pace>;\n\n/* The record of particle i's garment this round: which garment a particle is, in statics. */\nfn paceAt(i: u32) -> u32 {\n return round.at + u32(statics[30u * c.count + i]);\n}\n\nfn stateAt(section: u32, i: u32) -> u32 {\n return section * 3u * c.count + i * 3u;\n}\n\nfn load(at: u32) -> vec3<f32> {\n return vec3<f32>(state[at], state[at + 1u], state[at + 2u]);\n}\n\nfn store(at: u32, v: vec3<f32>) {\n state[at] = v.x;\n state[at + 1u] = v.y;\n state[at + 2u] = v.z;\n}\n\nfn poseOf(at: u32) -> vec3<f32> {\n return vec3<f32>(pose[at], pose[at + 1u], pose[at + 2u]);\n}\n\n/* Sections of state. */\nconst POSITION = 0u;\nconst PREVIOUS = 1u;\nconst VELOCITY = 2u;\nconst START = 3u;\n\nfn inverseMass(i: u32) -> f32 {\n return statics[i];\n}\n\n/* Where skinning puts particle i, fraction of the way from the last pose to the latest. */\nfn skinnedAt(i: u32, g: u32) -> vec3<f32> {\n let a = poseOf(paces[g].fromAt + i * 3u);\n let b = poseOf(paces[g].toAt + i * 3u);\n return a + (b - a) * paces[g].fraction;\n}\n\nfn skinMatrix(j: u32) -> mat4x4<f32> {\n let at = j * 16u;\n return mat4x4<f32>(\n vec4<f32>(frame[at], frame[at + 1u], frame[at + 2u], frame[at + 3u]),\n vec4<f32>(frame[at + 4u], frame[at + 5u], frame[at + 6u], frame[at + 7u]),\n vec4<f32>(frame[at + 8u], frame[at + 9u], frame[at + 10u], frame[at + 11u]),\n vec4<f32>(frame[at + 12u], frame[at + 13u], frame[at + 14u], frame[at + 15u]));\n}\n\n/* skinParticles: each particle's skinned position and normal by up to eight influences. */\n@compute @workgroup_size(64)\nfn clothPose(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let n = c.count;\n let restAt = 7u * n + i * 3u;\n let normalAt = 10u * n + i * 3u;\n let x = vec4<f32>(statics[restAt], statics[restAt + 1u], statics[restAt + 2u], 1.0);\n let q0 = vec3<f32>(statics[normalAt], statics[normalAt + 1u], statics[normalAt + 2u]);\n var p = vec3<f32>(0.0);\n var q = vec3<f32>(0.0);\n for (var half = 0u; half < 2u; half++) {\n for (var k = 0u; k < 4u; k++) {\n let at = 13u * n + i * 16u + half * 8u;\n let w = statics[at + 4u + k];\n if (w == 0.0) {\n continue;\n }\n let m = skinMatrix(u32(statics[at + k]));\n p += w * (m * x).xyz;\n q += w * (mat3x3<f32>(m[0].xyz, m[1].xyz, m[2].xyz) * q0);\n }\n }\n let to = paces[g].toAt + i * 3u;\n pose[to] = p.x;\n pose[to + 1u] = p.y;\n pose[to + 2u] = p.z;\n let len = sqrt(dot(q, q));\n let inv = select(0.0, 1.0 / len, len > 1e-12);\n let normal = 6u * n + i * 3u;\n pose[normal] = q.x * inv;\n pose[normal + 1u] = q.y * inv;\n pose[normal + 2u] = q.z * inv;\n}\n\n/* SkinnedCloth.rest: every particle at the latest skinned position, at rest. */\n@compute @workgroup_size(64)\nfn clothRest(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let p = poseOf(paces[g].toAt + i * 3u);\n store(stateAt(POSITION, i), p);\n store(stateAt(START, i), p);\n store(stateAt(VELOCITY, i), vec3<f32>(0.0));\n}\n\n/* The step's start: the step start kept, a free particle carried, a kinematic one placed. */\n@compute @workgroup_size(64)\nfn clothBegin(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let at = stateAt(POSITION, i);\n let p = load(at);\n store(stateAt(START, i), p);\n if (inverseMass(i) == 0.0) {\n store(at, skinnedAt(i, g));\n return;\n }\n if (paces[g].carry == 0u) {\n return;\n }\n /* carryParticles: the share of the character's turn about its old origin, and of its move. */\n let turn = mat3x3<f32>(paces[g].turn0.xyz, paces[g].turn1.xyz, paces[g].turn2.xyz);\n let share = paces[g].origin.w;\n let x = p - paces[g].origin.xyz;\n store(at, p + (turn * x - x) * share + paces[g].shift.xyz);\n let vAt = stateAt(VELOCITY, i);\n let v = load(vAt);\n store(vAt, v + (turn * v - v) * share);\n}\n\n/* predictParticles. */\n@compute @workgroup_size(64)\nfn clothPredict(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let at = stateAt(POSITION, i);\n let p = load(at);\n store(stateAt(PREVIOUS, i), p);\n if (inverseMass(i) == 0.0) {\n return;\n }\n let vAt = stateAt(VELOCITY, i);\n let air = paces[g].wind.xyz;\n let v = air + (load(vAt) * paces[g].keep - air) * paces[g].dragKeep + paces[g].gravity.xyz * c.h;\n store(vAt, v);\n store(at, p + v * c.h);\n}\n\n/* solveDistanceBatch: one invocation a constraint of the batch, constraints sorted by batch. */\n@compute @workgroup_size(64)\nfn clothDistance(@builtin(global_invocation_id) id: vec3<u32>) {\n let k = batch.begin + id.x;\n if (k >= batch.end) {\n return;\n }\n let a = constraints[k * 4u];\n let b = constraints[k * 4u + 1u];\n /* A garment sitting the round out solves nothing, and its next step's first pass resets. */\n if (paces[paceAt(a)].live == 0u) {\n return;\n }\n /* The first pass of a substep starts the multiplier at nothing, as the CPU's fill(0) does \u2014\n written before any early return, so a skipped constraint does not carry the last one. */\n if (batch.first == 1u) {\n lambda[k] = 0.0;\n }\n let wa = inverseMass(a);\n let wb = inverseMass(b);\n let w = wa + wb;\n if (w == 0.0) {\n return;\n }\n let aAt = stateAt(POSITION, a);\n let bAt = stateAt(POSITION, b);\n let d = load(bAt) - load(aAt);\n let len = sqrt(dot(d, d));\n if (len < 1e-9) {\n return;\n }\n let alpha = bitcast<f32>(constraints[k * 4u + 3u]) * c.invH2;\n let old = lambda[k];\n let delta = (-(len - bitcast<f32>(constraints[k * 4u + 2u])) - alpha * old) / (w + alpha);\n lambda[k] = old + delta;\n let s = delta / len;\n /* A pinned particle is shared across a batch, so it is never written, even with itself. */\n if (wa > 0.0) {\n store(aAt, load(aAt) - d * s * wa);\n }\n if (wb > 0.0) {\n store(bAt, load(bAt) + d * s * wb);\n }\n}\n\n/* solveBendingBatch, with the angle from atan2 (see the header). */\n@compute @workgroup_size(64)\nfn clothBending(@builtin(global_invocation_id) id: vec3<u32>) {\n let k = batch.begin + id.x;\n if (k >= batch.end) {\n return;\n }\n let at = c.distances * 4u + k * 6u;\n let slot = c.distances + k;\n if (paces[paceAt(constraints[at])].live == 0u) {\n return;\n }\n if (batch.first == 1u) {\n lambda[slot] = 0.0;\n }\n let ids = vec4<u32>(constraints[at], constraints[at + 1u], constraints[at + 2u], constraints[at + 3u]);\n let w = vec4<f32>(inverseMass(ids.x), inverseMass(ids.y), inverseMass(ids.z), inverseMass(ids.w));\n if (w.x + w.y + w.z + w.w == 0.0) {\n return;\n }\n let origin = load(stateAt(POSITION, ids.x));\n let e = load(stateAt(POSITION, ids.y)) - origin;\n let p3 = load(stateAt(POSITION, ids.z)) - origin;\n let p4 = load(stateAt(POSITION, ids.w)) - origin;\n var n1 = cross(e, p3);\n var n2 = cross(e, p4);\n let l1 = sqrt(dot(n1, n1));\n let l2 = sqrt(dot(n2, n2));\n if (l1 < 1e-12 || l2 < 1e-12) {\n return;\n }\n n1 = n1 / l1;\n n2 = n2 / l2;\n let d = clamp(dot(n1, n2), -1.0, 1.0);\n let sine = sqrt(dot(cross(n1, n2), cross(n1, n2)));\n if (sine < 1e-6) {\n return;\n }\n /* The q vectors: the negative of the derivative of n1 \u00B7 n2. See solveBendingBatch. */\n let q3 = (cross(e, n2) + cross(n1, e) * d) / l1;\n let q4 = (cross(e, n1) + cross(n2, e) * d) / l2;\n let q2 = -((cross(p3, n2) + cross(n1, p3) * d) / l1 + (cross(p4, n1) + cross(n2, p4) * d) / l2);\n let q1 = -(q2 + q3 + q4);\n var sum = w.x * dot(q1, q1) + w.y * dot(q2, q2) + w.z * dot(q3, q3) + w.w * dot(q4, q4);\n sum = sum / (sine * sine);\n if (sum < 1e-12) {\n return;\n }\n let alpha = bitcast<f32>(constraints[at + 5u]) * c.invH2;\n let old = lambda[slot];\n let angle = atan2(sine, d);\n let delta = (-(angle - bitcast<f32>(constraints[at + 4u])) - alpha * old) / (sum + alpha);\n lambda[slot] = old + delta;\n let scale = delta / sine;\n if (w.x > 0.0) {\n let p = stateAt(POSITION, ids.x);\n store(p, load(p) + w.x * scale * q1);\n }\n if (w.y > 0.0) {\n let p = stateAt(POSITION, ids.y);\n store(p, load(p) + w.y * scale * q2);\n }\n if (w.z > 0.0) {\n let p = stateAt(POSITION, ids.z);\n store(p, load(p) + w.z * scale * q3);\n }\n if (w.w > 0.0) {\n let p = stateAt(POSITION, ids.w);\n store(p, load(p) + w.w * scale * q4);\n }\n}\n\n/* solveTethers, one invocation a tether of the batch: a batch holds a particle once. */\n@compute @workgroup_size(64)\nfn clothTether(@builtin(global_invocation_id) id: vec3<u32>) {\n let k = batch.begin + id.x;\n if (k >= batch.end) {\n return;\n }\n let at = c.distances * 4u + c.bendings * 6u + k * 3u;\n if (paces[paceAt(constraints[at])].live == 0u) {\n return;\n }\n let pAt = stateAt(POSITION, constraints[at]);\n let anchor = load(stateAt(POSITION, constraints[at + 1u]));\n let d = load(pAt) - anchor;\n let dist = sqrt(dot(d, d));\n let reach = bitcast<f32>(constraints[at + 2u]);\n if (dist <= reach || dist < 1e-9) {\n return;\n }\n store(pAt, anchor + d * (reach / dist));\n}\n\n/* pushOut: the point onto the sphere's surface if it is inside. A point at the centre stays. */\nfn pushOut(p: vec3<f32>, centre: vec3<f32>, radius: f32) -> vec3<f32> {\n let d = p - centre;\n let d2 = dot(d, d);\n if (d2 >= radius * radius || d2 < 1e-18) {\n return p;\n }\n return centre + d * (radius / sqrt(d2));\n}\n\n/* applyPull, applyMaxDistance, applyStops (back, then front) and applyColliders, in that order. */\n@compute @workgroup_size(64)\nfn clothLimit(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count || inverseMass(i) == 0.0) {\n return;\n }\n let g = paceAt(i);\n if (paces[g].live == 0u) {\n return;\n }\n let limits = paces[g].limits;\n let n = c.count;\n let at = stateAt(POSITION, i);\n var p = load(at);\n let t = skinnedAt(i, g);\n /* Only where there is a pull, as on the CPU: t + (p - t) is not p in floats. */\n let pull = statics[29u * n + i] * paces[g].pullStep;\n if (pull > 0.0) {\n p = t + (p - t) * (1.0 / (1.0 + pull));\n }\n /* A max distance below zero is none: the CPU's Infinity, which a device may not be handed. */\n let maxDistance = statics[n + i];\n if ((limits & 1u) != 0u && maxDistance >= 0.0) {\n let limit = maxDistance * paces[g].maxDistanceScale;\n let d = p - t;\n let d2 = dot(d, d);\n if (d2 > limit * limit) {\n p = t + d * (limit / sqrt(d2));\n }\n }\n let normal = poseOf(6u * n + i * 3u);\n if ((limits & 2u) != 0u) {\n let radius = statics[2u * n + i * 2u + 1u];\n if (radius > 0.0) {\n p = pushOut(p, t - normal * (statics[2u * n + i * 2u] + radius), radius);\n }\n }\n if ((limits & 4u) != 0u) {\n let radius = statics[4u * n + i * 2u + 1u];\n if (radius > 0.0) {\n p = pushOut(p, t + normal * (statics[4u * n + i * 2u] + radius), radius);\n }\n }\n let pad = statics[6u * n + i] + paces[g].margin;\n let ends = c.joints * 16u;\n let radii = ends + c.colliders * 6u;\n let first = paces[g].colliderBase;\n for (var k = first; k < first + paces[g].colliders; k++) {\n let a = vec3<f32>(frame[ends + k * 6u], frame[ends + k * 6u + 1u], frame[ends + k * 6u + 2u]);\n let e = vec3<f32>(frame[ends + k * 6u + 3u], frame[ends + k * 6u + 4u], frame[ends + k * 6u + 5u]) - a;\n let e2 = dot(e, e);\n var s = 0.0;\n if (e2 > 1e-18) {\n s = clamp(dot(p - a, e) / e2, 0.0, 1.0);\n }\n let r0 = frame[radii + k * 2u];\n let r1 = frame[radii + k * 2u + 1u];\n p = pushOut(p, a + e * s, r0 + (r1 - r0) * s + pad);\n }\n store(at, p);\n}\n\n/* finishParticles. */\n@compute @workgroup_size(64)\nfn clothFinish(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n if (paces[paceAt(i)].live == 0u) {\n return;\n }\n let vAt = stateAt(VELOCITY, i);\n if (inverseMass(i) == 0.0) {\n store(vAt, vec3<f32>(0.0));\n return;\n }\n store(vAt, (load(stateAt(POSITION, i)) - load(stateAt(PREVIOUS, i))) * c.invH);\n}\n\n/* SkinnedCloth.blend: after a reset, the simulation's share grows over the blend's steps. */\n@compute @workgroup_size(64)\nfn clothBlend(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = id.x;\n if (i >= c.count) {\n return;\n }\n /* A blend of 0 is none, not a last step of one: see ClothDevice.step. */\n let g = paceAt(i);\n let blend = paces[g].blend;\n if (paces[g].live == 0u || blend <= 0.0) {\n return;\n }\n let t = skinnedAt(i, g);\n let at = stateAt(POSITION, i);\n store(at, t + (load(at) - t) * blend);\n let vAt = stateAt(VELOCITY, i);\n store(vAt, load(vAt) * blend);\n}\n\n@group(1) @binding(0) var output: texture_storage_2d<rgba32float, write>;\n\n/* SkinnedCloth.interpolate, into the particle texture the vertex stage reads: one garment's, whose\n particles are batch.begin to batch.end and whose record is batch.first's. */\n@compute @workgroup_size(64)\nfn clothPublish(@builtin(global_invocation_id) id: vec3<u32>) {\n let i = batch.begin + id.x;\n if (i >= batch.end) {\n return;\n }\n let g = round.at + batch.first;\n let a = load(stateAt(START, i));\n let p = a + (load(stateAt(POSITION, i)) - a) * paces[g].alpha;\n let local = id.x;\n let width = paces[g].width;\n textureStore(output, vec2<u32>(local % width, local / width), vec4<f32>(p, 0.0));\n}\n";
@@ -87,8 +87,9 @@ struct Pace {
87
87
  colliderBase: u32,
88
88
  colliders: u32,
89
89
  width: u32,
90
- /* The share of its offset from its skinned position a particle keeps a substep: the pull's. */
91
- pullKeep: f32,
90
+ /* The garment's pull as a substep's stiffness, pull · h²: a particle weighted w keeps
91
+ 1 / (1 + w · pullStep) of its offset from its skinned position. */
92
+ pullStep: f32,
92
93
  keep: f32,
93
94
  dragKeep: f32,
94
95
  maxDistanceScale: f32,
@@ -118,7 +119,7 @@ struct Batch {
118
119
 
119
120
  /* The record of particle i's garment this round: which garment a particle is, in statics. */
120
121
  fn paceAt(i: u32) -> u32 {
121
- return round.at + u32(statics[29u * c.count + i]);
122
+ return round.at + u32(statics[30u * c.count + i]);
122
123
  }
123
124
 
124
125
  fn stateAt(section: u32, i: u32) -> u32 {
@@ -445,8 +446,9 @@ fn clothLimit(@builtin(global_invocation_id) id: vec3<u32>) {
445
446
  var p = load(at);
446
447
  let t = skinnedAt(i, g);
447
448
  /* Only where there is a pull, as on the CPU: t + (p - t) is not p in floats. */
448
- if (paces[g].pullKeep < 1.0) {
449
- p = t + (p - t) * paces[g].pullKeep;
449
+ let pull = statics[29u * n + i] * paces[g].pullStep;
450
+ if (pull > 0.0) {
451
+ p = t + (p - t) * (1.0 / (1.0 + pull));
450
452
  }
451
453
  /* A max distance below zero is none: the CPU's Infinity, which a device may not be handed. */
452
454
  let maxDistance = statics[n + i];
@@ -46,6 +46,16 @@ export declare const HAIR_MIN_BETA = 0.05;
46
46
  * and its integral 4π times that. Over π: **1.33**.
47
47
  */
48
48
  export declare const HAIR_SCATTER_SPHERE = 1.33;
49
+ /**
50
+ * How far every lobe is widened for light from every side, in radians: the angle a source would
51
+ * subtend to widen them as much, `sourceRadius / 2·dist` in `hairLight`'s terms.
52
+ *
53
+ * **0.2, the real-time form's own value**, which stands one direction in for the sphere: a lobe as
54
+ * narrow as a lamp's would put the whole sky's light into one highlight wherever the eye's
55
+ * direction happens to meet it. What it gives up is the sky's shape: a sun low in a clear sky and
56
+ * an even overcast light hair through the same widened lobes.
57
+ */
58
+ export declare const HAIR_AREA = 0.2;
49
59
  /** A longitudinal lobe: a normalised Gaussian of width `beta` at `x` from its centre. */
50
60
  export declare function hairLongitudinal(beta: number, x: number): number;
51
61
  /** Schlick's Fresnel for the strand at a cosine, clamped to the unit interval. */
@@ -68,3 +78,16 @@ export declare const HAIR_OUT: {
68
78
  * light rather than more. The sun passes zeros.
69
79
  */
70
80
  export declare function hairLight(tangent: ArrayLike<number>, toLight: ArrayLike<number>, toEye: ArrayLike<number>, fibre: Vec3, roughness: number, shift: number, scatter: number, backlit: number, sourceRadius: number, dist: number, out: Float64Array): void;
81
+ /**
82
+ * The share of light from every side a strand returns, a colour into `out`, with the one
83
+ * direction's lobes it is made of into `lobes` (`HAIR_OUT`'s layout). `normal` is the surface's,
84
+ * which stands in for the flattened eye where the eye runs along the strand and there is none.
85
+ *
86
+ * **One direction for the sphere**: the eye flattened across the strand, `V − T(V·T)`, unshadowed,
87
+ * with no light from behind and so no transmitted lobe, every lobe widened by `HAIR_AREA`. Twice
88
+ * the sum, and at most all of what arrives. **Why twice**: the scatter's half is then
89
+ * `HAIR_SCATTER_SPHERE`, its own answer to an even sky — along the flattened eye its shape is
90
+ * `mix(1/2, 1, 0.33)`, 0.665, which doubled is that constant's 1.33 — so the lobes ride on a
91
+ * scatter the sphere's quadrature already holds to, and R and TRT are what the direction adds.
92
+ */
93
+ export declare function hairIndirect(tangent: ArrayLike<number>, toEye: ArrayLike<number>, normal: ArrayLike<number>, fibre: Vec3, roughness: number, shift: number, scatter: number, lobes: Float64Array, out: Float64Array): void;
@@ -17,6 +17,16 @@ export const HAIR_MIN_BETA = 0.05;
17
17
  * and its integral 4π times that. Over π: **1.33**.
18
18
  */
19
19
  export const HAIR_SCATTER_SPHERE = 1.33;
20
+ /**
21
+ * How far every lobe is widened for light from every side, in radians: the angle a source would
22
+ * subtend to widen them as much, `sourceRadius / 2·dist` in `hairLight`'s terms.
23
+ *
24
+ * **0.2, the real-time form's own value**, which stands one direction in for the sphere: a lobe as
25
+ * narrow as a lamp's would put the whole sky's light into one highlight wherever the eye's
26
+ * direction happens to meet it. What it gives up is the sky's shape: a sun low in a clear sky and
27
+ * an even overcast light hair through the same widened lobes.
28
+ */
29
+ export const HAIR_AREA = 0.2;
20
30
  /** How much the scatter term leans toward the strand's own direction rather than the pseudo-normal. */
21
31
  const KAJIYA_SHARE = 0.33;
22
32
  /** A longitudinal lobe: a normalised Gaussian of width `beta` at `x` from its centre. */
@@ -46,6 +56,46 @@ export const HAIR_OUT = { r: 0, tt: 1, trt: 4, scatter: 7, length: 10 };
46
56
  * light rather than more. The sun passes zeros.
47
57
  */
48
58
  export function hairLight(tangent, toLight, toEye, fibre, roughness, shift, scatter, backlit, sourceRadius, dist, out) {
59
+ const grow = sourceRadius / Math.max(2 * dist, 1e-3);
60
+ hairLobes(tangent, toLight, toEye, fibre, roughness, shift, scatter, backlit, grow, out);
61
+ }
62
+ /** The flattened eye `hairIndirect` lights a strand along, as the shader finds it. */
63
+ const flattened = new Float64Array(3);
64
+ /**
65
+ * The share of light from every side a strand returns, a colour into `out`, with the one
66
+ * direction's lobes it is made of into `lobes` (`HAIR_OUT`'s layout). `normal` is the surface's,
67
+ * which stands in for the flattened eye where the eye runs along the strand and there is none.
68
+ *
69
+ * **One direction for the sphere**: the eye flattened across the strand, `V − T(V·T)`, unshadowed,
70
+ * with no light from behind and so no transmitted lobe, every lobe widened by `HAIR_AREA`. Twice
71
+ * the sum, and at most all of what arrives. **Why twice**: the scatter's half is then
72
+ * `HAIR_SCATTER_SPHERE`, its own answer to an even sky — along the flattened eye its shape is
73
+ * `mix(1/2, 1, 0.33)`, 0.665, which doubled is that constant's 1.33 — so the lobes ride on a
74
+ * scatter the sphere's quadrature already holds to, and R and TRT are what the direction adds.
75
+ */
76
+ export function hairIndirect(tangent, toEye, normal, fibre, roughness, shift, scatter, lobes, out) {
77
+ const along = dot(tangent, toEye);
78
+ let length2 = 0;
79
+ for (let c = 0; c < 3; c++) {
80
+ const across = toEye[c] - tangent[c] * along;
81
+ flattened[c] = across;
82
+ length2 += across * across;
83
+ }
84
+ for (let c = 0; c < 3; c++) {
85
+ flattened[c] =
86
+ length2 > 1e-8 ? flattened[c] / Math.sqrt(length2) : normal[c];
87
+ }
88
+ hairLobes(tangent, flattened, toEye, fibre, roughness, shift, scatter, 0, HAIR_AREA, lobes);
89
+ for (let c = 0; c < 3; c++) {
90
+ const sum = lobes[HAIR_OUT.r] +
91
+ lobes[HAIR_OUT.tt + c] +
92
+ lobes[HAIR_OUT.trt + c] +
93
+ lobes[HAIR_OUT.scatter + c];
94
+ out[c] = Math.min(1, 2 * sum);
95
+ }
96
+ }
97
+ /** `hairLight`'s arithmetic, every lobe widened by `grow` radians. */
98
+ function hairLobes(tangent, toLight, toEye, fibre, roughness, shift, scatter, backlit, grow, out) {
49
99
  const sinI = clamp(dot(tangent, toLight), -1, 1);
50
100
  const sinO = clamp(dot(tangent, toEye), -1, 1);
51
101
  /*
@@ -68,7 +118,6 @@ export function hairLight(tangent, toLight, toEye, fibre, roughness, shift, scat
68
118
  const cosPhi = lpvp / Math.sqrt(lp2 * vp2 + 1e-4);
69
119
  const cosHalfPhi = Math.sqrt(clamp(0.5 + 0.5 * cosPhi, 0, 1));
70
120
  const beta = Math.max(roughness * roughness, HAIR_MIN_BETA);
71
- const grow = sourceRadius / Math.max(2 * dist, 1e-3);
72
121
  const x = sinI + sinO;
73
122
  const r = hairLongitudinal(beta + grow, x + 2 * shift) *
74
123
  0.25 *
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A material's tiled detail over whatever its maps made: `SurfaceMaterial.detail`, packed into
3
+ * `uDetail` by `surfaceDetail.ts`.
4
+ *
5
+ * **Each texel wears its own region's detail alone.** The region is read exactly from the ORM
6
+ * array's layer the material names, at the base coordinates, so an index is never blended with its
7
+ * neighbour's at a region's edge; then that region's normal map is read at its repeat and offset,
8
+ * turned through a frame built on the normal the base maps made — the construction a layered
9
+ * material lays its layers over a mesh normal with — and its occlusion multiplied into the
10
+ * surface's. A strength scales the detail's slopes as a layer's look does.
11
+ *
12
+ * **Read with explicit gradients**, the base coordinates' times the region's repeat, because the
13
+ * region is the pixel's own: a read whose level is implicit is legal only in uniform control flow
14
+ * (AGENTS.md 2026-08-07), and across a region's edge the repeated coordinates jump, which would
15
+ * read a level as coarse as the jump. The frame and the gradients are taken first, under the
16
+ * uniform branch that calls this.
17
+ *
18
+ * **A lit switch, `SURFACE_DETAIL`**, off until a material first asks, declared after `LAYERED` and
19
+ * `LAYER_LOOKS` so each keeps its id. Compiled out, its thirty-three vectors are not declared.
20
+ */
21
+ export declare function detailGlsl(on: boolean): string;