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.jsModelCraft 0.8.1 32-bit in part3 calculationsReviewed
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.jsModelCraft 0.8.1 32-bit in part5 calculationsReviewed
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.jsModelCraft 0.8.1 32-bit in part1 calculationReviewed
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.
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.