Resources Numerical accuracy Calculation policy

glTF import: reading numbers

Reads the positions, normals, weights, matrices and other numbers a glTF file stores, and each node's transform.

How the precision is kept

glTF stores vertex data as 32-bit floats or as small integers, so the reader holds every float or normalized accessor in a Float32Array. Node transforms come from the JSON text and are 64-bit. The accuracy of an imported point is the file's own 32-bit accuracy.

Calculations under this policy

4 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-read.js ModelCraft 0.8.1 64-bit 4 calculations Reviewed

Tests tools/modelcraft-test/gltf.mjs, bin/test-modelcraft.mjs

Reading an accessor

64-bit

Reads one accessor's numbers, with its sparse part, into a typed array.

normalized: v ÷ 127, 255, 32767 or 65535 (signed values not below −1)

32-bit floats are copied as stored into a Float32Array: that is the format's own width. Normalized integers are divided in 64-bit and then stored in the same Float32Array, which rounds a 16-bit value's quotient to 32-bit.

  • io/gltf-read.js
  • lines 216 to 277
  • ModelCraft 0.8.1
const accessor = (ai, what = 'an accessor') => {
  if (accCache.has(ai)) return accCache.get(ai);
  const a = accIn[ai];
  if (!isObj(a)) throw damaged(what + ' names a missing accessor');
  const ct = COMP[a.componentType];
  const nc = NCOMP[a.type];
  if (!ct || !nc) throw damaged('accessor ' + ai + ' has an unknown type');
  const count = a.count;
  if (!Number.isInteger(count) || count < 1) throw damaged('accessor ' + ai + ' has a bad count');
  const n = count * nc;
  if (n > L.accessor) throw new GltfError('too_big', t('gltf.too_big'));
  budget -= n;
  if (budget < 0) throw new GltfError('too_big', t('gltf.too_big'));
  const [size, getter, Arr] = ct;
  const norm = a.normalized === true && a.componentType !== 5126 && a.componentType !== 5125;
  const out = norm || a.componentType === 5126 ? new Float32Array(n) : new Arr(n);
  const scale = !norm ? 1 : { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 }[a.componentType];
  const signed = a.componentType === 5120 || a.componentType === 5122;
  // matrix columns of 1- and 2-byte components start on 4-byte boundaries
  const rows = a.type === 'MAT2' ? 2 : a.type === 'MAT3' ? 3 : a.type === 'MAT4' ? 4 : nc;
  const cols = nc / rows;
  const colBytes = (a.type === 'MAT2' && size === 1) || (a.type === 'MAT3' && size <= 2) ? Math.ceil(rows * size / 4) * 4 : rows * size;
  const elBytes = cols * colBytes;
  const readInto = (bytes, stride, off, cnt, put) => {
    const st = stride || elBytes;
    if (!Number.isInteger(off) || off < 0 || off % size || (cnt && off + st * (cnt - 1) + elBytes > bytes.length)) throw damaged('accessor ' + ai + ' runs past its buffer view');
    const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    for (let i = 0; i < cnt; i++) {
      for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
        let v = dv[getter](off + i * st + c * colBytes + r * size, true);
        if (norm) v = signed ? Math.max(v / scale, -1) : v / scale;
        put(i, c * rows + r, v);
      }
    }
  };
  if (a.bufferView !== undefined) {
    const v = views[idx(a.bufferView, views.length)];
    if (!v) throw damaged('accessor ' + ai + ' names a missing buffer view');
    readInto(v.bytes, v.stride, a.byteOffset ?? 0, count, (i, k, val) => { out[i * nc + k] = val; });
  }
  if (a.sparse !== undefined) {
    const s = a.sparse;
    if (!isObj(s) || !Number.isInteger(s.count) || s.count < 1 || s.count > count || !isObj(s.indices) || !isObj(s.values)) throw damaged('accessor ' + ai + ' has a broken sparse part');
    const it = COMP[s.indices.componentType];
    if (!it || ![5121, 5123, 5125].includes(s.indices.componentType)) throw damaged('accessor ' + ai + ' has bad sparse indices');
    const iv = views[idx(s.indices.bufferView, views.length)];
    const vv = views[idx(s.values.bufferView, views.length)];
    if (!iv || !vv) throw damaged('accessor ' + ai + ' sparse part names a missing buffer view');
    const io = s.indices.byteOffset ?? 0;
    if (!Number.isInteger(io) || io < 0 || io + s.count * it[0] > iv.bytes.length) throw damaged('accessor ' + ai + ' sparse indices run past their view');
    const idv = new DataView(iv.bytes.buffer, iv.bytes.byteOffset, iv.bytes.byteLength);
    const at = new Uint32Array(s.count);
    for (let i = 0; i < s.count; i++) {
      at[i] = idv[it[1]](io + i * it[0], true);
      if (at[i] >= count || (i && at[i] <= at[i - 1])) throw damaged('accessor ' + ai + ' sparse indices are out of order or range');
    }
    readInto(vv.bytes, 0, s.values.byteOffset ?? 0, s.count, (i, k, val) => { out[at[i] * nc + k] = val; });
  }
  const r = { data: out, count, nc, type: a.type, componentType: a.componentType, normalized: norm };
  accCache.set(ai, r);
  return r;
};

Positions

64-bit

Takes a primitive's positions as 32-bit floats and checks each is a sane number.

Integer positions (quantized meshes) are converted with Float32Array.from, which is exact for 8- and 16-bit values. Any position that is not finite or is above 1e7 in size refuses the file.

  • io/gltf-read.js
  • lines 410 to 413
  • ModelCraft 0.8.1
const Pf = floats(P);
prim.positions = Pf;
prim.count = Pf.length / 3;
for (let i = 0; i < Pf.length; i++) if (!Number.isFinite(Pf[i]) || Math.abs(Pf[i]) > 1e7) throw damaged('a position is not a sane number');

Matrix from translation, rotation and scale

64-bit

A node's 4×4 matrix from its translation, quaternion and scale.

quaternion made unit; rotation entries 1 − 2(y² + z²), 2(xy + zw), …, each column times its scale

The quaternion is divided by its Math.hypot length first, so a slightly non-unit quaternion from the file does not add shear. A zero quaternion's length falls back to 1.

  • io/gltf-read.js
  • lines 566 to 576
  • ModelCraft 0.8.1
export function trs(T, R, S) {
  let [x, y, z, w] = R;
  const l = Math.hypot(x, y, z, w) || 1;
  x /= l; y /= l; z /= l; w /= l;
  return [
    (1 - 2 * (y * y + z * z)) * S[0], (2 * (x * y + z * w)) * S[0], (2 * (x * z - y * w)) * S[0], 0,
    (2 * (x * y - z * w)) * S[1], (1 - 2 * (x * x + z * z)) * S[1], (2 * (y * z + x * w)) * S[1], 0,
    (2 * (x * z + y * w)) * S[2], (2 * (y * z - x * w)) * S[2], (1 - 2 * (x * x + y * y)) * S[2], 0,
    T[0], T[1], T[2], 1,
  ];
}

A node's local matrix

64-bit

Uses the node's 16-number matrix as given, or builds it from translation, rotation and scale.

The numbers come from the JSON text and are parsed as 64-bit; a given matrix is copied without change.

  • io/gltf-read.js
  • lines 554 to 564
  • ModelCraft 0.8.1
/** A node's local matrix (column-major), from `matrix` or TRS. */
function nodeMatrix(n, i) {
  if (n.matrix !== undefined) {
    if (!Array.isArray(n.matrix) || n.matrix.length !== 16 || !n.matrix.every((v) => typeof v === 'number' && Number.isFinite(v))) throw damaged('node ' + i + ' has a bad matrix');
    return n.matrix.slice();
  }
  const T = vec(n.translation, 3, [0, 0, 0]);
  const R = vec(n.rotation, 4, [0, 0, 0, 1]);
  const S = vec(n.scale, 3, [1, 1, 1]);
  return trs(T, R, S);
}

In detail

Method
Accessors read through a DataView with the file's stride and offset; normalized integers divided by 127, 255, 32767 or 65535; node matrix from translation, rotation and scale.
Number types
Float32Array for float and normalized data; the file's integer type otherwise; 64-bit numbers for node matrices, cameras and lights.
Tolerances
A position must be finite and no larger than 1e7 in size.
Limits
Positions carry about 7 digits because the format stores them in 32-bit. A normalized 16-bit value is divided in 64-bit and then rounded to 32-bit.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach