@driftengine/core 4.13.3 → 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.
package/CHANGELOG.json CHANGED
@@ -1,4 +1,20 @@
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
+ },
2
18
  {
3
19
  "version": "4.13.3",
4
20
  "date": "2026-10-12",
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: 929.4 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
@@ -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;
@@ -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 *
@@ -4,28 +4,34 @@
4
4
  * receives, and every term to a finite answer where the geometry degenerates.
5
5
  *
6
6
  * **The strand is the tangent, root to tip.** The model map's green turns each strand's tilt by up
7
- * to half of it either way (0.5 is none) and its blue is occlusion, which the sky, DriftLight and
8
- * the environment's reflection take and the lamps do not — their shadows are theirs to cast. The
9
- * red is not read: a colour from root to tip is the albedo map's to carry.
7
+ * to half of it either way (0.5 is none) and its blue is occlusion, which light from every side
8
+ * takes and the lamps do not — their shadows are theirs to cast. The red is not read: a colour from
9
+ * root to tip is the albedo map's to carry.
10
10
  *
11
- * **The environment** is the reflected lobe's: mirrored about a normal turned toward the eye across
12
- * the strand, which is the cone R lies on, at the strand's own Fresnel — the lobe integrates to it
13
- * over every direction of arrival. **What it gives up** is the two transmitted lobes against the
14
- * sky, which a head of hair lit from behind by an open sky would show as a rim; the lamps carry
15
- * them.
11
+ * **Light from every side** — the sky, the probes, DriftLight — goes through the lobes rather than
12
+ * past them: they are evaluated once, for a light along the eye flattened across the strand, every
13
+ * lobe widened by \`HAIR_AREA\` as a source that large would widen it, and their sum taken as the
14
+ * share of that light the strand returns (\`hairIndirect\`). That is how a real-time hair model
15
+ * stands one direction in for the sphere: a strand returns most toward the eye what arrives from
16
+ * the eye's side of it, and the R and TRT lobes are what make a fibre gold rather than a wash of
17
+ * its scatter. The environment's reflection leaves hair out, because R already took its share.
18
+ *
19
+ * **What it gives up**: light from behind. The direction stands for the eye's side only, so there
20
+ * is no transmitted lobe in it, and a head of hair lit from behind by an open sky shows no rim
21
+ * from the sky; the lamps carry one. And the share is one direction's, not the sphere's integral,
22
+ * so it moves with the eye as the lobes do rather than holding still as an average would. **What
23
+ * would make it wrong** is hair seen mostly against its light, a head against a bright window,
24
+ * whose sky arrives through the strands rather than off them and wants the transmitted lobe back.
16
25
  */
17
- import { HAIR_F0, HAIR_MIN_BETA, HAIR_SCATTER_SPHERE } from '../../hairLobes.js';
26
+ import { HAIR_AREA, HAIR_F0, HAIR_MIN_BETA } from '../../hairLobes.js';
18
27
  export const HAIR_GLSL = /* glsl */ `
19
28
  const float HAIR_F0 = ${HAIR_F0.toFixed(9)};
20
29
  const float HAIR_MIN_BETA = ${HAIR_MIN_BETA.toFixed(3)};
21
- const float HAIR_SCATTER_SPHERE = ${HAIR_SCATTER_SPHERE.toFixed(3)};
30
+ const float HAIR_AREA = ${HAIR_AREA.toFixed(3)};
22
31
 
23
- /* This texel's tilt, gathered once with the surface. */
32
+ /* This texel's tilt, and the share of light from every side it returns: gathered with the surface. */
24
33
  float hShift;
25
-
26
- void hairSurface() {
27
- hShift = uModelParams[0].x * (0.5 + mMap.g);
28
- }
34
+ vec3 hAround;
29
35
 
30
36
  /** A longitudinal lobe: a normalised Gaussian of width \`beta\` at \`x\` from its centre. */
31
37
  float hairLongitudinal(float beta, float x) {
@@ -37,14 +43,18 @@ float hairFresnel(float cosine) {
37
43
  return HAIR_F0 + (1.0 - HAIR_F0) * c * c * c * c * c;
38
44
  }
39
45
 
40
- /** The normal turned toward the eye across the strand: the scatter's and the environment's. */
46
+ /** The normal turned toward the eye across the strand: the scatter's, and light from every side's. */
41
47
  vec3 hairAcross() {
42
48
  vec3 across = mToEye - mTangent * dot(mTangent, mToEye);
43
49
  float length2 = dot(across, across);
44
50
  return length2 > 1e-8 ? across * inversesqrt(length2) : mNormal;
45
51
  }
46
52
 
47
- void hairLight(vec3 l, float sourceRadius, float dist) {
53
+ /**
54
+ * The lobes for light arriving along \`l\`, into \`mSpecular\` and \`mDiffuse\`: every lobe widened by
55
+ * \`grow\` radians, and the transmitted one scaled by \`backlit\`.
56
+ */
57
+ void hairLobes(vec3 l, float grow, float backlit) {
48
58
  vec3 t = mTangent;
49
59
  float sinI = clamp(dot(t, l), -1.0, 1.0);
50
60
  float sinO = clamp(dot(t, mToEye), -1.0, 1.0);
@@ -55,7 +65,6 @@ void hairLight(vec3 l, float sourceRadius, float dist) {
55
65
  float cosPhi = dot(lp, vp) * inversesqrt(dot(lp, lp) * dot(vp, vp) + 1e-4);
56
66
  float cosHalfPhi = sqrt(clamp(0.5 + 0.5 * cosPhi, 0.0, 1.0));
57
67
  float beta = max(mRoughness * mRoughness, HAIR_MIN_BETA);
58
- float grow = sourceRadius / max(2.0 * dist, 1e-3);
59
68
  float x = sinI + sinO;
60
69
 
61
70
  float r = hairLongitudinal(beta + grow, x + 2.0 * hShift) * 0.25 * cosHalfPhi
@@ -65,7 +74,7 @@ void hairLight(vec3 l, float sourceRadius, float dist) {
65
74
  float h = cosHalfPhi * (1.0 + a * (0.6 - 0.8 * cosPhi));
66
75
  float fTT = hairFresnel(cosD * sqrt(clamp(1.0 - h * h, 0.0, 1.0)));
67
76
  float tt = hairLongitudinal(0.5 * beta + grow, x - hShift) * exp(-3.65 * cosPhi - 3.98)
68
- * (1.0 - fTT) * (1.0 - fTT) * uModelParams[0].z;
77
+ * (1.0 - fTT) * (1.0 - fTT) * backlit;
69
78
  float ttPower = 0.5 * sqrt(clamp(1.0 - h * h * a * a, 0.0, 1.0)) / cosD;
70
79
 
71
80
  float fTRT = hairFresnel(0.5 * cosD);
@@ -80,6 +89,11 @@ void hairLight(vec3 l, float sourceRadius, float dist) {
80
89
  mDiffuse = sqrt(mAlbedo) * uModelParams[0].y * mix(wrapped, 1.0 - abs(sinI), 0.33);
81
90
  }
82
91
 
92
+ /** A lamp, or the sun at no size: widened by the angle it subtends, as \`sphereLobe\` takes it. */
93
+ void hairLight(vec3 l, float sourceRadius, float dist) {
94
+ hairLobes(l, sourceRadius / max(2.0 * dist, 1e-3), uModelParams[0].z);
95
+ }
96
+
83
97
  /**
84
98
  * A shadow on the scatter: light that reaches a strand in another's shadow has passed through
85
99
  * hair to get there, so a penumbra deepens toward the fibre's own hue.
@@ -89,13 +103,25 @@ vec3 hairShade(float shadow) {
89
103
  return shadow * pow(max(hue, vec3(1e-4)), vec3(1.0 - shadow));
90
104
  }
91
105
 
92
- /** Light from every side, through the scatter term: \`HAIR_SCATTER_SPHERE\` says why that number. */
93
- vec3 hairAround(vec3 light) {
94
- return sqrt(mAlbedo) * (uModelParams[0].y * HAIR_SCATTER_SPHERE * mMap.b) * light;
106
+ /**
107
+ * The share of light from every side a strand returns: the lobes for a light along the eye
108
+ * flattened across the strand, unshadowed, with nothing from behind and every lobe widened by
109
+ * \`HAIR_AREA\`, twice their sum and at most all of it. Twice, in this engine's units, because the
110
+ * lobes are per unit irradiance and the scatter's half is then exactly \`HAIR_SCATTER_SPHERE\`, its
111
+ * own average over the sphere: \`hairLobes.ts\` says why, and holds the arithmetic to it.
112
+ */
113
+ vec3 hairIndirect() {
114
+ hairLobes(hairAcross(), HAIR_AREA, 0.0);
115
+ return min(vec3(1.0), 2.0 * (mSpecular + mDiffuse));
95
116
  }
96
117
 
97
- /** The reflected lobe's share of the environment: its Fresnel, which is what it integrates to. */
98
- float hairReflectance() {
99
- return hairFresnel(dot(hairAcross(), mToEye)) * mMap.b;
118
+ void hairSurface() {
119
+ hShift = uModelParams[0].x * (0.5 + mMap.g);
120
+ hAround = hairIndirect();
121
+ }
122
+
123
+ /** Light from every side, through the lobes, under the map's occlusion. */
124
+ vec3 hairAround(vec3 light) {
125
+ return hAround * (mMap.b * light);
100
126
  }
101
127
  `;