Resources Numerical accuracy Calculation policy

Skin weights and skinning

Works out how much each bone moves each vertex, and moves the mesh's points with the bones.

How the precision is kept

Weights are worked out in 64-bit but kept in 32-bit: the automatic weights are returned as Float32Array, the mesh's weight layers are 32-bit (mesh.js), and the four strongest weights per vertex are packed and divided by their sum into a Float32Array. The posed points are 64-bit sums of 64-bit matrices times those 32-bit weights. The posed mesh is what the model shows and measures (model.js reads it through def.shown).

Open item. Keep the packed weights in a Float64Array and divide by their sum in 64-bit, so the posed points do not carry the 32-bit rounding of the weights.

Calculations under this policy

9 calculations in 3 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

anim/rig.js ModelCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests bin/test-animation.mjs

The four strongest bones per vertex

32-bit in part

Keeps each vertex's four largest weights and scales them to add up to 1.

w_q = w_q ÷ Σ of the kept weights

The weights are read from 32-bit layers, sorted in a Float32Array and stored in a Float32Array after the division. Each stored weight is rounded to 32-bit, so the four add to 1 only to about 7 digits.

  • anim/rig.js
  • lines 107 to 133
  • ModelCraft 0.8.1
export function packWeights(mesh, rig, max = 4) {
  const key = mesh.v + ':' + mesh.live + ':' + mesh.nv + ':' + rig.bones.map((b) => b.id + (b.deform === false ? '!' : '')).join(',');
  const c = packCache.get(mesh);
  if (c && c.key === key && c.max === max) return c.packed;
  const n = mesh.nv, joints = new Int32Array(n * max).fill(-1), weights = new Float32Array(n * max);
  const layers = rig.bones.map((b, i) => (b.deform === false ? null : [i, mesh.attrArray('weight.' + b.id)])).filter((x) => x && x[1]);
  const bi = new Int32Array(max), bw = new Float32Array(max);
  for (let v = 0; v < n; v++) {
    let k = 0;
    bi.fill(-1); bw.fill(0);
    for (const [i, A] of layers) {
      const w = A[v];
      if (!(w > 0)) continue;
      // keep the `max` largest (insertion into a short sorted list)
      if (k < max) { bi[k] = i; bw[k] = w; k++; }
      else if (w > bw[max - 1]) { bi[max - 1] = i; bw[max - 1] = w; }
      else continue;
      for (let q = Math.min(k, max) - 1; q > 0 && bw[q] > bw[q - 1]; q--) { const tw = bw[q]; bw[q] = bw[q - 1]; bw[q - 1] = tw; const ti = bi[q]; bi[q] = bi[q - 1]; bi[q - 1] = ti; }
    }
    let s = 0;
    for (let q = 0; q < max; q++) s += bw[q];
    if (s > 0) for (let q = 0; q < max; q++) { joints[v * max + q] = bi[q]; weights[v * max + q] = bw[q] / s; }
  }
  const packed = { joints, weights, n, max };
  packCache.set(mesh, { key, max, packed });
  return packed;
}

Linear blend skinning

32-bit in part

Moves each vertex to the weighted sum of its positions under its bones' matrices.

p′ = Σ wᵢ × (Mᵢ × p)

The matrices and the points are 64-bit, but each term is multiplied by a 32-bit weight. Because the weights add to 1 only to about 7 digits, a posed coordinate is off by up to that fraction of its size. A vertex with no weights is left untouched.

  • anim/rig.js
  • lines 135 to 155
  • ModelCraft 0.8.1
/** The skin: every vertex with weights moves by its bones' matrices, blended linearly ('lbs') or as dual quaternions ('dqs'). */
export function skinPoints(mesh, packed, M, mode, P) {
  const { joints, weights, max } = packed, n = Math.min(packed.n, mesh.nv);
  if (mode === 'dqs') return skinDQS(n, joints, weights, max, M, P);
  for (let v = 0; v < n; v++) {
    const j0 = joints[v * max];
    if (j0 < 0) continue;   // no weights: stays at rest
    const x = P[v * 3], y = P[v * 3 + 1], z = P[v * 3 + 2];
    let ox = 0, oy = 0, oz = 0;
    for (let q = 0; q < max; q++) {
      const j = joints[v * max + q];
      if (j < 0) break;
      const w = weights[v * max + q], o = j * 16;
      ox += w * (M[o] * x + M[o + 4] * y + M[o + 8] * z + M[o + 12]);
      oy += w * (M[o + 1] * x + M[o + 5] * y + M[o + 9] * z + M[o + 13]);
      oz += w * (M[o + 2] * x + M[o + 6] * y + M[o + 10] * z + M[o + 14]);
    }
    P[v * 3] = ox; P[v * 3 + 1] = oy; P[v * 3 + 2] = oz;
  }
  return P;
}

Dual quaternion skinning

32-bit in part

Blends the bones' dual quaternions by weight, makes the result unit and moves the vertex with it.

(r, d) = Σ ±wᵢ (rᵢ, dᵢ); both divided by |r|; p′ = r p r⁻¹ + 2 d r⁻¹

The weights are 32-bit. Dividing the blended pair by |r| removes any error in the sum of the weights, so only the ratios between the 32-bit weights reach the point. A weight's sign is flipped when its turn lies on the far side from the first bone's.

  • anim/rig.js
  • lines 168 to 197
  • ModelCraft 0.8.1
function skinDQS(n, joints, weights, max, M, P) {
  const nb = M.length / 16, DQ = dualQuats(M, nb);
  for (let v = 0; v < n; v++) {
    const j0 = joints[v * max];
    if (j0 < 0) continue;
    let r0 = 0, r1 = 0, r2 = 0, r3 = 0, d0 = 0, d1 = 0, d2 = 0, d3 = 0;
    const a = j0 * 8;
    for (let q = 0; q < max; q++) {
      const j = joints[v * max + q];
      if (j < 0) break;
      let w = weights[v * max + q];
      const o = j * 8;
      // the short way round: blend each turn on the same side as the first
      if (DQ[o] * DQ[a] + DQ[o + 1] * DQ[a + 1] + DQ[o + 2] * DQ[a + 2] + DQ[o + 3] * DQ[a + 3] < 0) w = -w;
      r0 += w * DQ[o]; r1 += w * DQ[o + 1]; r2 += w * DQ[o + 2]; r3 += w * DQ[o + 3];
      d0 += w * DQ[o + 4]; d1 += w * DQ[o + 5]; d2 += w * DQ[o + 6]; d3 += w * DQ[o + 7];
    }
    const l = Math.hypot(r0, r1, r2, r3) || 1;
    r0 /= l; r1 /= l; r2 /= l; r3 /= l; d0 /= l; d1 /= l; d2 /= l; d3 /= l;
    // translation = 2 · d · conj(r)
    const tx = 2 * (-d3 * r0 + d0 * r3 - d1 * r2 + d2 * r1), ty = 2 * (-d3 * r1 + d1 * r3 - d2 * r0 + d0 * r2), tz = 2 * (-d3 * r2 + d2 * r3 - d0 * r1 + d1 * r0);
    const x = P[v * 3], y = P[v * 3 + 1], z = P[v * 3 + 2];
    // rotate (x, y, z) by r: v + 2 r × (r × v + w v)
    const cx = r1 * z - r2 * y + r3 * x, cy = r2 * x - r0 * z + r3 * y, cz = r0 * y - r1 * x + r3 * z;
    P[v * 3] = x + 2 * (r1 * cz - r2 * cy) + tx;
    P[v * 3 + 1] = y + 2 * (r2 * cx - r0 * cz) + ty;
    P[v * 3 + 2] = z + 2 * (r0 * cy - r1 * cx) + tz;
  }
  return P;
}
anim/weights.js ModelCraft 0.8.1 32-bit in part 5 calculations Reviewed

Tests bin/test-animation.mjs

Distance from a point to a bone

64-bit

The distance from a vertex to the segment from a bone's head to its tail.

k = clamp(((p − a) · (b − a)) ÷ |b − a|², 0, 1); d = |a + (b − a) k − p|

64-bit throughout. A bone of zero length is guarded: k is 0 and the distance is to the head.

  • anim/weights.js
  • lines 21 to 28
  • ModelCraft 0.8.1
export function segDist(p, a, b) {
  const abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2];
  const L = abx * abx + aby * aby + abz * abz;
  let k = L > 0 ? ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby + (p[2] - a[2]) * abz) / L : 0;
  k = k < 0 ? 0 : k > 1 ? 1 : k;
  const x = a[0] + abx * k - p[0], y = a[1] + aby * k - p[1], z = a[2] + abz * k - p[2];
  return Math.sqrt(x * x + y * y + z * z);
}

Edge weights of the mesh

64-bit

For each vertex, its neighbors and a weight for each edge, used to spread heat and to smooth.

edge weight = 1 ÷ max(1e-12, length²)

Edge weights and their per-vertex sums are Float64Array. The floor of 1e-12 on the squared length is absolute, so edges shorter than 1e-6 all get the same weight.

  • anim/weights.js
  • lines 32 to 65
  • ModelCraft 0.8.1
function graph(mesh, cached = false) {
  if (cached) {
    const c = graphs.get(mesh), key = mesh.topo + ':' + mesh.liveTopo + ':' + mesh.nv + ':' + mesh.nh;
    if (c && c.key === key) return c.g;
    const g = graph(mesh);
    graphs.set(mesh, { key, g });
    return g;
  }
  const n = mesh.nv, cnt = new Int32Array(n + 1), P = mesh.P, hV = mesh.hV, hNx = mesh.hNx;
  const pairs = [];
  for (let h = 0; h < mesh.nh; h++) {
    if (mesh.heDead(h)) continue;
    const a = hV[h], b = hV[hNx[h]];
    if (a === b || mesh.vertDead(a) || mesh.vertDead(b)) continue;
    pairs.push(a, b);
  }
  // each edge both ways once (a half-edge and its twin give the same pair)
  const seen = new Set(), E = [];
  for (let i = 0; i < pairs.length; i += 2) {
    const a = pairs[i], b = pairs[i + 1], k = a < b ? a * n + b : b * n + a;
    if (seen.has(k)) continue;
    seen.add(k); E.push(a, b); cnt[a + 1]++; cnt[b + 1]++;
  }
  for (let i = 0; i < n; i++) cnt[i + 1] += cnt[i];
  const nbr = new Int32Array(cnt[n]), wt = new Float64Array(cnt[n]), fill = cnt.slice(0, n), deg = new Float64Array(n);
  for (let i = 0; i < E.length; i += 2) {
    const a = E[i], b = E[i + 1];
    const dx = P[a * 3] - P[b * 3], dy = P[a * 3 + 1] - P[b * 3 + 1], dz = P[a * 3 + 2] - P[b * 3 + 2];
    const w = 1 / Math.max(1e-12, dx * dx + dy * dy + dz * dz);
    nbr[fill[a]] = b; wt[fill[a]++] = w; nbr[fill[b]] = a; wt[fill[b]++] = w;
    deg[a] += w; deg[b] += w;
  }
  return { start: cnt, nbr, wt, deg };
}

Automatic weights

32-bit in part

Each bone's weight on each vertex, by bone heat or by distance, with faint weights dropped and each vertex's weights adding up to 1.

heat: solve (L + H) w = H p per bone, H = 1 ÷ nearest distance²; envelope: w = 1 ÷ d⁴ for bones within twice the nearest distance; drop w < 0.01; divide by the sum

The solve and both divisions by the sum are 64-bit, but the result is returned as Float32Array, so the weights add to 1 only to about 7 digits. The heat iteration stops at a largest change under 1e-5 or after 200 sweeps, which limits the weights more than the number type does.

  • anim/weights.js
  • lines 67 to 119
  • ModelCraft 0.8.1
export function autoWeights(mesh, rig, { mode = 'heat', iterations = 200 } = {}) {
  const n = mesh.nv, P = mesh.P, bones = rig.bones.filter((b) => b.deform !== false), nb = bones.length;
  const out = new Map(rig.bones.map((b) => [b.id, new Float32Array(n)]));
  if (!nb || !n) return out;
  // distance from each vertex to each bone; nearest bone(s)
  const D = new Float64Array(n * nb), near = new Float64Array(n), pt = [0, 0, 0];
  for (let v = 0; v < n; v++) {
    if (mesh.vertDead(v)) continue;
    pt[0] = P[v * 3]; pt[1] = P[v * 3 + 1]; pt[2] = P[v * 3 + 2];
    let m = Infinity;
    for (let b = 0; b < nb; b++) { const d = segDist(pt, bones[b].head, bones[b].tail); D[v * nb + b] = d; if (d < m) m = d; }
    near[v] = m;
  }
  const W = bones.map(() => new Float64Array(n));
  if (mode === 'envelope') {
    for (let v = 0; v < n; v++) {
      if (mesh.vertDead(v)) continue;
      let s = 0;
      for (let b = 0; b < nb; b++) { const d = D[v * nb + b]; if (d <= near[v] * 2 + 1e-9) { const w = 1 / Math.pow(Math.max(d, 1e-6), 4); W[b][v] = w; s += w; } }
      if (s > 0) for (let b = 0; b < nb; b++) W[b][v] /= s;
    }
  } else {
    const G = graph(mesh), H = new Float64Array(n);
    for (let v = 0; v < n; v++) H[v] = mesh.vertDead(v) ? 0 : 1 / Math.max(1e-12, near[v] * near[v]);
    for (let b = 0; b < nb; b++) {
      const w = W[b], p = new Float64Array(n);
      for (let v = 0; v < n; v++) if (!mesh.vertDead(v) && D[v * nb + b] <= near[v] * 1.0001 + 1e-12) { p[v] = 1; w[v] = 1; }
      // Gauss–Seidel on (L + H) w = H p, from the nearest-bone guess
      for (let it = 0; it < iterations; it++) {
        let change = 0;
        for (let v = 0; v < n; v++) {
          if (mesh.vertDead(v)) continue;
          let s = H[v] * p[v];
          for (let e = G.start[v]; e < G.start[v + 1]; e++) s += G.wt[e] * w[G.nbr[e]];
          const x = s / (G.deg[v] + H[v]);
          change = Math.max(change, Math.abs(x - w[v]));
          w[v] = x;
        }
        if (change < 1e-5) break;
      }
    }
    // normalise: each vertex's weights add up to 1
    for (let v = 0; v < n; v++) { let s = 0; for (let b = 0; b < nb; b++) s += W[b][v]; if (s > 0) for (let b = 0; b < nb; b++) W[b][v] /= s; }
  }
  // drop the faint ones and add up to 1 again
  for (let v = 0; v < n; v++) {
    let s = 0;
    for (let b = 0; b < nb; b++) { if (W[b][v] < 0.01) W[b][v] = 0; s += W[b][v]; }
    if (s > 0) for (let b = 0; b < nb; b++) W[b][v] /= s;
  }
  bones.forEach((b, i) => out.set(b.id, Float32Array.from(W[i])));
  return out;
}

One dab of the weight brush

32-bit in part

Raises, lowers, sets or smooths a bone's weight on the vertices under the brush, and scales the other bones' weights to share what is left.

falloff f = (1 − d² ÷ r²)² × strength; add: w + f (1 − w); subtract: w − f w; others scaled by max(0, 1 − w) ÷ their sum

The old weight is read from the 32-bit layer and the new one is written back to it, so every dab rounds to 32-bit. The other bones are rescaled only when their sum is above 1e-9.

  • anim/weights.js
  • lines 132 to 166
  • ModelCraft 0.8.1
/** One dab of the weight brush on bone `bone` (its layer made when missing). → how many vertices changed. */
export function paintDab(mesh, bone, { p, r, strength = 0.5, mode = 'add', value = 1, normalise = true, rig = null }) {
  const name = LAYER(bone);
  if (!mesh.hasAttr(name)) mesh.addAttr(name, 'vertex', 1);
  const A = mesh.attrArray(name), P = mesh.P, r2 = r * r, one = [0];
  const others = normalise && rig ? rig.bones.filter((b) => b.id !== bone && b.deform !== false && mesh.hasAttr(LAYER(b.id))).map((b) => LAYER(b.id)) : [];
  let G = null, n = 0;
  for (let v = 0; v < mesh.nv; v++) {
    if (mesh.vertDead(v)) continue;
    const dx = P[v * 3] - p[0], dy = P[v * 3 + 1] - p[1], dz = P[v * 3 + 2] - p[2], d2 = dx * dx + dy * dy + dz * dz;
    if (d2 > r2) continue;
    const f = (1 - d2 / r2) ** 2 * strength, w0 = mesh.attrArray(name)[v];
    let w = w0;
    if (mode === 'add') w = w0 + f * (1 - w0);
    else if (mode === 'subtract') w = w0 - f * w0;
    else if (mode === 'set') w = w0 + f * (value - w0);
    else if (mode === 'smooth') {
      G = G || graph(mesh, true);
      let s = 0, k = 0;
      for (let e = G.start[v]; e < G.start[v + 1]; e++) { s += A[G.nbr[e]]; k++; }
      if (k) w = w0 + f * (s / k - w0);
    }
    w = Math.min(1, Math.max(0, w));
    if (w === w0) continue;
    one[0] = w; mesh.setAttr(name, v, one);
    n++;
    // auto-normalise: the other bones share what is left
    if (others.length) {
      let so = 0;
      for (const o of others) so += mesh.attrArray(o)[v];
      if (so > 1e-9) { const k = Math.max(0, 1 - w) / so; for (const o of others) { one[0] = mesh.attrArray(o)[v] * k; mesh.setAttr(o, v, one); } }
    }
  }
  return n;
}

Weights from packed joint layers

32-bit in part

Turns the four joint numbers and weights per vertex read from a file into one weight layer per bone.

weight of bone b on vertex v = Σ of the packed weights that name b

The sums are accumulated directly in a Float32Array per bone. The weights are not divided by their sum here.

  • anim/weights.js
  • lines 168 to 180
  • ModelCraft 0.8.1
/** glTF-style packed layers ('joints' i32 × 4, 'weights' f32 × 4) → one 'weight.<bone>' layer per rig bone (joint n = bone n). */
export function fromJoints(mesh, rig) {
  const J = mesh.attrArray('joints'), Wt = mesh.attrArray('weights');
  if (!J || !Wt) return false;
  const kj = mesh.attrInfo('joints').size, kw = mesh.attrInfo('weights').size, k = Math.min(kj, kw);
  const map = new Map(rig.bones.map((b) => [b.id, new Float32Array(mesh.nv)]));
  for (let v = 0; v < mesh.nv; v++) for (let q = 0; q < k; q++) {
    const j = J[v * kj + q], w = Wt[v * kw + q], b = rig.bones[j];
    if (b && w > 0) map.get(b.id)[v] += w;
  }
  writeWeights(mesh, rig, map);
  return true;
}
io/fbx-import.js ModelCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests tools/modelcraft-test/fbx.mjs

Skin weights read from an FBX file

32-bit in part

Keeps each control point's four strongest clusters and scales their weights to add up to 1.

w = w ÷ Σ of the four kept weights

The division is 64-bit, but the result is written into the mesh's 'weights' layer, which is made here with the default 32-bit type. A sum of 0 falls back to 1.

  • io/fbx-import.js
  • lines 346 to 348
  • ModelCraft 0.8.1
const top4 = lists[v].sort((a, b) => b[1] - a[1]).slice(0, 4);
const sum = top4.reduce((s, x) => s + x[1], 0) || 1;
top4.forEach(([j, w], k) => { J[v * 4 + k] = j; W[v * 4 + k] = w / sum; });

In detail

Method
Automatic weights: bone heat (the file cites Baran and Popović, 2007) by Gauss–Seidel, or an inverse fourth power of distance. Skinning: linear blend, or dual quaternions blended and made unit.
Number types
Float64Array for distances, heat and the mesh points; Float32Array for the weights that are returned, stored and packed; Int32Array for joint numbers.
Tolerances
Heat iteration stops when the largest change in a sweep is under 1e-5, or after 200 sweeps. Weights under 0.01 are dropped. Edge weights use 1 ÷ max(1e-12, length²); envelope distances are floored at 1e-6.
Checked by
bin/test-animation.mjs loads the development build's copies of anim/rig.js and anim/weights.js and calls packWeights, skinPoints, autoWeights, paintDab and fromJoints.
Limits
The four packed weights add to 1 only to about 7 digits, and in linear blend a posed point inherits that. The heat method has no visibility test: a vertex takes its nearest bone even through a gap.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach