Resources Numerical accuracy Calculation policy

glTF import: meshes, skins and placements

Turns a read glTF scene into mesh definitions and placements with Z up: welds vertices, applies skins and morph weights, drops empty triangles and marks sharp edges.

How the precision is kept

Plain meshes keep the file's 32-bit positions and are copied to 64-bit points with the axes swapped. A skinned mesh is posed with 64-bit joint matrices, but the posed positions are written into a Float32Array before they become the mesh's points; morphed positions are added up inside a copy of the file's Float32Array. Node matrices stay 64-bit and are changed to Z up by a swap of entries.

Open item. Write the skinned and morphed positions into a Float64Array, so a posed mesh keeps the 64-bit result of the joint matrices.

Calculations under this policy

9 calculations in 1 source file. 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.

io/gltf-import.js ModelCraft 0.8.1 32-bit in part 9 calculations Reviewed

Tests tools/modelcraft-test/gltf.mjs

Morph weights and the skin

32-bit in part

A primitive's positions and normals as the scene shows them: morph targets added by their weights, then moved by the skin's joints.

morph: p += w × target; skin: p′ = Σ wᵢ (Jᵢ × IBMᵢ × p) ÷ Σ wᵢ

The joint matrices are 64-bit products, but the posed positions are stored in a new Float32Array, and the morph sums are accumulated in a copy of the file's Float32Array. The result is rounded to 32-bit after it was computed in 64-bit, so a posed coordinate keeps about 7 digits whatever the joint matrices are. A vertex whose weights add to 1e-9 or less keeps its position.

  • io/gltf-import.js
  • lines 325 to 375
  • ModelCraft 0.8.1
/** A primitive's positions (and normals) as the scene shows them: morph weights, then the skin; in glTF space. */
function posed(p, weights, skin) {
  let P = p.positions;
  let Nm = p.normals;
  if (weights && p.targets.length) {
    P = P.slice();
    Nm = Nm ? Nm.slice() : null;
    p.targets.forEach((tg, k) => {
      const w = weights[k] || 0;
      if (!w) return;
      if (tg.positions) for (let i = 0; i < P.length; i++) P[i] += w * tg.positions[i];
      if (tg.normals && Nm) for (let i = 0; i < Nm.length; i++) Nm[i] += w * tg.normals[i];
    });
  }
  if (skin && p.joints.length) {
    const { skin: s, world } = skin;
    const mats = s.joints.map((j, k) => {
      const ibm = s.ibm ? Array.from(s.ibm.subarray(k * 16, k * 16 + 16)) : ident();
      return mmul(world[j] ?? ident(), ibm);
    });
    const J = p.joints[0]; const W = p.weights[0];
    const out = new Float32Array(P.length);
    const outN = Nm ? new Float32Array(Nm.length) : null;
    for (let i = 0; i < P.length / 3; i++) {
      let x = 0; let y = 0; let z = 0; let nx = 0; let ny = 0; let nz = 0; let ws = 0;
      for (let k = 0; k < 4; k++) {
        const w = W[i * 4 + k];
        const m = mats[J[i * 4 + k]];
        if (!w || !m) continue;
        const q = apply(m, [P[i * 3], P[i * 3 + 1], P[i * 3 + 2]]);
        x += w * q[0]; y += w * q[1]; z += w * q[2]; ws += w;
        if (Nm) { const d = applyDir(m, [Nm[i * 3], Nm[i * 3 + 1], Nm[i * 3 + 2]]); nx += w * d[0]; ny += w * d[1]; nz += w * d[2]; }
      }
      if (ws > 1e-9) { out[i * 3] = x / ws; out[i * 3 + 1] = y / ws; out[i * 3 + 2] = z / ws; } else { out[i * 3] = P[i * 3]; out[i * 3 + 1] = P[i * 3 + 1]; out[i * 3 + 2] = P[i * 3 + 2]; }
      if (outN) { const l = Math.hypot(nx, ny, nz) || 1; outN[i * 3] = nx / l; outN[i * 3 + 1] = ny / l; outN[i * 3 + 2] = nz / l; }
    }
    P = out;
    Nm = outN ?? Nm;
  }
  // normals into Z up, unit length (the mesh layer is the source of shading)
  let N = null;
  if (Nm) {
    N = new Float32Array(Nm.length);
    for (let i = 0; i < Nm.length / 3; i++) {
      const x = Nm[i * 3]; const y = Nm[i * 3 + 1]; const z = Nm[i * 3 + 2];
      const l = Math.hypot(x, y, z) || 1;
      N[i * 3] = x / l; N[i * 3 + 1] = -z / l; N[i * 3 + 2] = y / l;
    }
  }
  return { P, N };
}

Welding vertices

64-bit

Makes one mesh vertex for every distinct point of a primitive and swaps the axes to Z up.

same vertex when x, y and z (and joints and weights) are equal as text; stored as (x, −z, y)

The key is the three coordinates printed as numbers, so only exactly equal values weld; there is no distance tolerance. The stored point is a swap and a sign change of the value read.

  • io/gltf-import.js
  • lines 193 to 213
  • ModelCraft 0.8.1
// weld: one mesh vertex per distinct point (and skin data, when there is any)
const keyOf = new Map();
const vp = [];
const vSrc = [];   // [prim index, vertex] that made each mesh vertex
const cornerV = new Int32Array(corners);
const cornerSrc = new Int32Array(corners * 2);
let c = 0;
prims.forEach((pr, pi) => {
  const P = pr.pos;
  const J = pr.p.joints[0];
  const W = pr.p.weights[0];
  const ids = new Int32Array(pr.p.count).fill(-1);
  for (const i of pr.tri) {
    if (ids[i] < 0) {
      const x = P[i * 3]; const y = P[i * 3 + 1]; const z = P[i * 3 + 2];
      let k = x + ',' + y + ',' + z;
      if (J) k += '|' + J[i * 4] + ',' + J[i * 4 + 1] + ',' + J[i * 4 + 2] + ',' + J[i * 4 + 3] + '|' + W[i * 4] + ',' + W[i * 4 + 1] + ',' + W[i * 4 + 2] + ',' + W[i * 4 + 3];
      let v = keyOf.get(k);
      if (v === undefined) { v = vp.length / 3; keyOf.set(k, v); vp.push(x, -z, y); vSrc.push(pi, i); }
      ids[i] = v;
    }

Triangles with no area

64-bit

Drops triangles that repeat a vertex or whose corners lie on one line.

kept when |(b − a) × (c − a)| > 1e-18

The limit is absolute: the cross product's length is twice the area in square meters, so only triangles far below any drawn size are dropped. It does not scale with the model.

  • io/gltf-import.js
  • lines 220 to 229
  • ModelCraft 0.8.1
// drop triangles with a repeated vertex or no area
const nv = vp.length / 3;
const keepTri = new Uint8Array(corners / 3);
let nf = 0;
for (let f = 0; f < corners / 3; f++) {
  const a = cornerV[f * 3]; const b = cornerV[f * 3 + 1]; const d = cornerV[f * 3 + 2];
  if (a === b || b === d || a === d) continue;
  const A = [vp[a * 3], vp[a * 3 + 1], vp[a * 3 + 2]];
  const n = cross(sub([vp[b * 3], vp[b * 3 + 1], vp[b * 3 + 2]], A), sub([vp[d * 3], vp[d * 3 + 1], vp[d * 3 + 2]], A));
  if (!(len(n) > 1e-18)) continue;

Sharp edges

64-bit

Marks an edge sharp where the file's normals break across it, or where the two faces bend.

with normals: sharp unless n₁ · n₂ > 0.9995 at both ends; without: sharp when the face normals' dot product < 0.99996

0.9995 is the cosine of about 1.8° and 0.99996 of about 0.5°. The corner normals compared are the mesh's 32-bit 'normal' layer; the face normals are 64-bit.

  • io/gltf-import.js
  • lines 302 to 312
  • ModelCraft 0.8.1
const same = (a, b) => N[a * 3] * N[b * 3] + N[a * 3 + 1] * N[b * 3 + 1] + N[a * 3 + 2] * N[b * 3 + 2] > 0.9995;
for (let h = 0; h < mesh.nh; h++) {
  const g = hTw[h];
  if (g < 0 || g < h) continue;
  let sharp;
  const fa = hF[h]; const fb = hF[g];
  // h starts at vertex a in one face; the twin's next starts at a in the other (and the same for b)
  if (has(h) && has(g) && has(hNx[h]) && has(hNx[g])) sharp = !same(h, hNx[g]) || !same(hNx[h], g);
  else sharp = fnorm[fa * 3] * fnorm[fb * 3] + fnorm[fa * 3 + 1] * fnorm[fb * 3 + 1] + fnorm[fa * 3 + 2] * fnorm[fb * 3 + 2] < 0.99996;
  if (sharp) { hFl[h] |= FL_SHARP; hFl[g] |= FL_SHARP; }
}

Node matrix into Z up

64-bit

Changes a node's matrix from Y up to Z up.

M′ = C × M × C⁻¹

C and its inverse hold only 0, 1 and −1, so the products only move and negate entries of the 64-bit matrix.

  • io/gltf-import.js
  • line 38
  • ModelCraft 0.8.1
const conj = (m) => mmul(C, mmul(m, CI));

Instanced placements

64-bit

One placement per instance from the file's per-instance translation, rotation and scale.

The per-instance values are the file's 32-bit floats; the matrix built from them and its change to Z up are 64-bit.

  • io/gltf-import.js
  • lines 131 to 134
  • ModelCraft 0.8.1
const T = I.t ? [I.t[k * 3], I.t[k * 3 + 1], I.t[k * 3 + 2]] : [0, 0, 0];
const R = I.r ? [I.r[k * 4], I.r[k * 4 + 1], I.r[k * 4 + 2], I.r[k * 4 + 3]] : [0, 0, 0, 1];
const S = I.s ? [I.s[k * 3], I.s[k * 3 + 1], I.s[k * 3 + 2]] : [1, 1, 1];
place(g, md, conj(trs(T, R, S)), null);

Texture coordinate transform

64-bit

Applies a texture's offset, rotation and scale to a UV pair.

u′ = cos r × u s_u + sin r × v s_v + o_u; v′ = −sin r × u s_u + cos r × v s_v + o_v

Computed in 64-bit; the result is then stored in the mesh's 32-bit 'uv' layer, which places a picture and is not a measured value.

  • io/gltf-import.js
  • lines 316 to 323
  • ModelCraft 0.8.1
/** KHR_texture_transform: offset · rotation · scale, applied to [u, v]. */
function uvTransform({ offset, rotation, scale }, u, v) {
  const su = u * scale[0];
  const sv = v * scale[1];
  const c = Math.cos(rotation);
  const s = Math.sin(rotation);
  return [c * su + s * sv + offset[0], -s * su + c * sv + offset[1]];
}

Lamps

64-bit

A lamp's position, direction, brightness and cone from a glTF light.

lumens = candela × 4π; spot angle (°) = 2 × outer cone × 180 ÷ π

Math.PI is used for both conversions. Position and direction go through the node's 64-bit world matrix changed to Z up.

  • io/gltf-import.js
  • lines 381 to 393
  • ModelCraft 0.8.1
function addLight(model, scene, ni, world, report, skip) {
  const L = scene.lights[scene.nodes[ni].light];
  if (L.type === 'directional') { skip(t('gltf.note.directional')); return; }
  if (!model.addLight) return;
  const m = worldOf(world, ni);
  const p = apply(m, [0, 0, 0]);
  const d = norm(applyDir(m, applyDir(C, [0, 0, -1])));   // the light shines along its own −Z
  model.addLight({
    kind: L.type === 'spot' ? 'spot' : 'point', p, dir: len(d) ? d : [0, 0, -1], color: linearToHex(L.color),
    lumens: L.intensity * 4 * Math.PI, angle: L.type === 'spot' ? (2 * L.outer * 180) / Math.PI : 60, name: L.name,
  });
  report.lights++;
}

Cameras

64-bit

A saved view's eye, target and field of view from a glTF camera.

target = eye + 10 × forward; field of view (°) = yfov × 180 ÷ π; parallel view height = 2 × |ymag|

The target distance of 10 is a fixed choice in the code. A parallel view with ymag of 0 gets a height of 10.

  • io/gltf-import.js
  • lines 395 to 410
  • ModelCraft 0.8.1
function addCamera(model, scene, ni, world, report) {
  const cam = scene.cameras[scene.nodes[ni].camera];
  if (!Array.isArray(model.scenes)) return;
  const m = worldOf(world, ni);
  const eye = apply(m, [0, 0, 0]);
  const fwd = norm(applyDir(m, applyDir(C, [0, 0, -1])));
  if (!len(fwd)) return;
  const dist = 10;
  const target = [eye[0] + fwd[0] * dist, eye[1] + fwd[1] * dist, eye[2] + fwd[2] * dist];
  const s = cleanScene({
    name: cam.name, cam: cam.type === 'orthographic'
      ? { eye, target, fov: 35, persp: false, height: 2 * Math.abs(cam.ymag) || 10 }
      : { eye, target, fov: (cam.yfov * 180) / Math.PI, persp: true },
  });
  if (s) { model.scenes.push(s); report.cameras++; }
}

In detail

Method
Vertices welded where position (and skin data) are exactly equal; skin = Σ weight × (joint world × inverse bind) × point, divided by the sum of the weights; Y up to Z up by (x, y, z) → (x, −z, y).
Number types
Float32Array for positions as read, and for skinned and morphed positions; Float64Array for the mesh's points; 64-bit numbers for matrices.
Tolerances
A triangle is dropped when the length of its edge cross product is not above 1e-18 (absolute). Edges are sharp where corner normals differ by a dot product of 0.9995 or less, or, without normals, where face normals have a dot product under 0.99996.
Limits
Welding needs exactly equal coordinates; nearly equal points stay apart. Skinned and morphed points are rounded to 32-bit after they are computed.

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