Resources Numerical accuracy Calculation policy

glTF export

Writes the model as a binary glTF file: each definition's triangles, each placement's matrix, lamps and saved views.

How the precision is kept

Vertex positions, normals and texture coordinates are written as 32-bit floats, the component type glTF uses for them. They are in each definition's own coordinates; the placement matrices are written into the file's JSON as 64-bit numbers. So the 32-bit rounding applies to a point's distance from its definition's origin, not from the model's origin.

Calculations under this policy

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

Tests tools/modelcraft-test/gltf.mjs

Vertex data of a primitive

64-bit

Writes a primitive's positions, normals, texture coordinates, colors and indices.

The 64-bit corner lists are converted with Float32Array.from when they are written: glTF stores these attributes as 32-bit floats (component type 5126), so the rounding is the format's. Indices are 16-bit up to 65,535 vertices and 32-bit above.

  • io/gltf-write.js
  • lines 109 to 118
  • ModelCraft 0.8.1
const primitive = (b, paint) => {
  const n = b.pos.length / 3;
  const attributes = { POSITION: accessor(Float32Array.from(b.pos), 'VEC3', 5126, 34962, true), NORMAL: accessor(Float32Array.from(b.nor), 'VEC3', 5126, 34962) };
  if (b.uv && b.uv.length === n * 2) attributes.TEXCOORD_0 = accessor(Float32Array.from(b.uv), 'VEC2', 5126, 34962);
  if (b.uv1 && b.uv1.length === n * 2) attributes.TEXCOORD_1 = accessor(Float32Array.from(b.uv1), 'VEC2', 5126, 34962);
  if (b.col && b.col.length === n * 4) attributes.COLOR_0 = accessor(Float32Array.from(b.col), 'VEC4', 5126, 34962);
  const idx = n > 65535 ? Uint32Array.from(b.idx) : Uint16Array.from(b.idx);
  report.triangles += b.idx.length / 3;
  return { attributes, indices: accessor(idx, 'SCALAR', n > 65535 ? 5125 : 5123, 34963), material: material(paint), mode: 4 };
};

Bounds of the positions

64-bit

The smallest and largest x, y and z written with the positions.

The minimum and maximum are taken from the 32-bit array after conversion, so they match the stored data exactly; they are the bounds of the rounded positions.

  • io/gltf-write.js
  • lines 58 to 69
  • ModelCraft 0.8.1
const accessor = (arr, type, comp, target, minmax = false) => {
  const nc = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }[type];
  const a = { bufferView: pushView(arr, target), componentType: comp, count: arr.length / nc, type };
  if (minmax) {
    const min = new Array(nc).fill(Infinity);
    const max = new Array(nc).fill(-Infinity);
    for (let i = 0; i < arr.length; i++) { const k = i % nc; if (arr[i] < min[k]) min[k] = arr[i]; if (arr[i] > max[k]) max[k] = arr[i]; }
    a.min = min; a.max = max;
  }
  gltf.accessors.push(a);
  return gltf.accessors.length - 1;
};

Shared vertices

64-bit

Gives corners with the same position, normal, UV and color one vertex.

key = x, y, z as numbers + normal to 5 decimals + UV + color

Positions must be exactly equal to share a vertex. Normals are compared after rounding to 5 decimals with toFixed, so two normals closer than that share the first one's value; the values written are not rounded by this step.

  • io/gltf-write.js
  • lines 121 to 134
  • ModelCraft 0.8.1
const corner = (b, p, n, uv, uv1, col) => {
  const k = p[0] + ',' + p[1] + ',' + p[2] + '|' + n[0].toFixed(5) + ',' + n[1].toFixed(5) + ',' + n[2].toFixed(5) + (uv ? '|' + uv[0] + ',' + uv[1] : '') + (uv1 ? '|' + uv1[0] + ',' + uv1[1] : '') + (col ? '|' + col.join(',') : '');
  let i = b.keys.get(k);
  if (i === undefined) {
    i = b.pos.length / 3;
    b.keys.set(k, i);
    b.pos.push(p[0], p[1], p[2]);
    b.nor.push(n[0], n[1], n[2]);
    if (uv) (b.uv ??= []).push(uv[0], 1 - uv[1]);
    if (uv1) (b.uv1 ??= []).push(uv1[0], 1 - uv1[1]);
    if (col) (b.col ??= []).push(col[0], col[1], col[2], col[3]);
  }
  b.idx.push(i);
};

Placement matrices

64-bit

Writes a placement's matrix on its node.

entry = 0 when |entry| < 1e-15, else the entry

The 16 numbers go into the JSON as 64-bit values. Entries smaller than 1e-15 are set to 0; that limit is absolute, so a translation that small would also be dropped.

  • io/gltf-write.js
  • line 188
  • ModelCraft 0.8.1
if (m && !isIdent(m)) node.matrix = m.map((v) => (Math.abs(v) < 1e-15 ? 0 : v));

Lamps

64-bit

A lamp's intensity and cone as glTF writes them.

candela = lumens ÷ (4π); outer cone = min(π ÷ 2, angle × π ÷ 360)

Two 64-bit expressions with Math.PI; the inverse of the import's conversion.

  • io/gltf-write.js
  • lines 213 to 214
  • ModelCraft 0.8.1
const light = { name: L.name || (L.kind === 'spot' ? 'Spot' : 'Lamp'), type: L.kind === 'spot' ? 'spot' : 'point', color: hexToLinear(L.color), intensity: L.lumens / (4 * Math.PI) };   // i18n-ignore: written into the file
if (L.kind === 'spot') light.spot = { innerConeAngle: 0, outerConeAngle: Math.min(Math.PI / 2, (L.angle * Math.PI) / 360) };

Saved views as cameras

64-bit

A saved view's field of view or height as a glTF camera.

yfov = fov × π ÷ 180; xmag = ymag = height ÷ 2

The near plane is written as 0.05 and, for parallel views, the far plane as 1e5; both are fixed values in the code.

  • io/gltf-write.js
  • lines 228 to 230
  • ModelCraft 0.8.1
const cam = c.persp !== false
  ? { name: s.name, type: 'perspective', perspective: { yfov: (c.fov * Math.PI) / 180, znear: 0.05 } }
  : { name: s.name, type: 'orthographic', orthographic: { xmag: c.height / 2, ymag: c.height / 2, znear: 0.05, zfar: 1e5 } };

Node matrix for a lamp or camera

64-bit

A matrix at a point whose −Z looks along a direction with +Y as near world up as it can be.

f = dir made unit; r = (f × up) made unit; u = r × f

The up reference switches to +Y when |f_z| is above 0.999. Entries under 1e-15 in size are written as 0.

  • io/gltf-write.js
  • lines 246 to 254
  • ModelCraft 0.8.1
/** A node matrix (Z-up model coordinates) at `p` whose −Z looks along `dir` and whose +Y is as near world up (+Z) as it can be. */
function aim(p, dir) {
  const f = norm(dir);
  let up = [0, 0, 1];
  if (Math.abs(f[2]) > 0.999) up = [0, 1, 0];
  const r = norm(cross(f, up));
  const u = cross(r, f);
  return [r[0], r[1], r[2], 0, u[0], u[1], u[2], 0, -f[0], -f[1], -f[2], 0, p[0], p[1], p[2], 1].map((v) => (Math.abs(v) < 1e-15 ? 0 : v));
}

In detail

Method
One mesh per definition and paint; corners that share position, normal, UV and color become one vertex; a root node turns Z up into Y up.
Number types
64-bit numbers until the vertex arrays are built; Float32Array for positions, normals, UVs and colors; 16- or 32-bit integers for indices; 64-bit numbers in the JSON.
Tolerances
A matrix within 1e-12 of the identity is left out; matrix entries under 1e-15 in size are written as 0; a push/pull triangle with an edge cross product not above 1e-18 is skipped (all absolute).
Limits
A coordinate keeps about 7 digits in the file. Geometry drawn far from its definition's origin loses the most.

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