Resources Numerical accuracy Calculation policy

Render hand-off manifest

The camera, sun, lamps, materials, exposure and frame numbers written into the package that an outside render engine reads.

How the precision is kept

The values are worked out in 64-bit and then every number written to the manifest is rounded to a fixed count of decimals: 7 for matrices, 6 for positions and directions, 5 for colors and material values, 4 for the view angle and exposure, 3 for lumens. The rounding is absolute, not scaled to the size of the number.

Open item. Write the manifest's numbers with their full 64-bit digits instead of a fixed count of decimals.

Calculations under this policy

8 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.

render/handoff.js BimCraft 0.8.1 64-bit 8 calculations Reviewed

Tests bin/test-render-engines.mjs

Rounding of manifest numbers

64-bit

The rounding every number in the manifest goes through.

round(x, k) = round(x × 10ᵏ) ÷ 10ᵏ, k = 6 unless stated

Multiplies by a power of ten, rounds to the nearest whole number and divides back in 64-bit. A value that is not finite is written as 0.

  • render/handoff.js
  • line 36
  • BimCraft 0.8.1
const round = (x, k = 6) => (Number.isFinite(x) ? Math.round(x * 10 ** k) / 10 ** k : 0);

Color to linear values

64-bit

A hex color turned into the linear Rec.709 triple the manifest carries.

c ≤ 0.04045: c ÷ 12.92; else ((c + 0.055) ÷ 1.055)^2.4

Each 8-bit channel is divided by 255 and decoded with the standard sRGB constants in 64-bit, then rounded to 5 decimals.

  • render/handoff.js
  • lines 39 to 44
  • BimCraft 0.8.1
/** sRGB hex → linear Rec.709, the USD file's own colour space for colours (USD-CONVENTIONS § 7). */
function hexToLinear(hex) {
  const n = parseInt(String(hex).slice(1, 7), 16) || 0;
  const c = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
  return [c((n >> 16) & 255), c((n >> 8) & 255), c(n & 255)].map((x) => round(x, 5));
}

Points and directions on the stage

64-bit

A point or a direction of the scene moved to the USD stage's coordinates.

p′ = M p; d′ = (M d) ÷ |M d|

64-bit matrix products. A direction is normalized with Math.hypot, or left undivided when its length is zero. Each component is rounded to 6 decimals.

  • render/handoff.js
  • lines 51 to 52
  • BimCraft 0.8.1
const point = (m, p) => [0, 1, 2].map((r) => round(m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]));
const direction = (m, d) => { const v = [0, 1, 2].map((r) => m[r] * d[0] + m[4 + r] * d[1] + m[8 + r] * d[2]), l = Math.hypot(...v) || 1; return v.map((x) => round(x / l)); };

Camera entry

64-bit

The camera's matrices, position, view direction, aspect and view angle or height.

fovY = 2 atan(1 ÷ |P₅|) × 180 ÷ π; ortho height = 2 ÷ |P₅|

The world and projection matrices are rounded to 7 decimals, the position and directions to 6, the aspect and orthographic height to 5 and the view angle to 4. The division by the projection term is not guarded against zero.

  • render/handoff.js
  • lines 117 to 125
  • BimCraft 0.8.1
const ortho = Math.abs(proj[15] - 1) < 1e-9 && Math.abs(proj[11]) < 1e-9;
const camera = {
  projection: ortho ? 'orthographic' : settings.projection === 'normal' || !settings.projection ? 'perspective' : settings.projection,
  world: world.map((x) => round(x, 7)), proj: Array.from(proj).map((x) => round(x, 7)),
  position: point(M, [cam.world[12], cam.world[13], cam.world[14]]),
  forward: direction(world, [0, 0, -1]), up: direction(world, [0, 1, 0]), aspect: round(w / h, 5),
};
if (ortho) camera.orthoHeight = round(2 / Math.abs(proj[5]), 5);
else camera.fovY = round((2 * Math.atan(1 / Math.abs(proj[5])) * 180) / Math.PI, 4);

Sun and lamp entries

64-bit

The sun's direction and strength, and each lamp's lumens, color, position, aim and size.

Lumens are rounded to 3 decimals, colors to 5, positions, directions and sizes to 6, a spot's angle to 3. The sun's diameter is written as the fixed value 0.53°. Only the 64 lamps with the most lumens are written.

  • render/handoff.js
  • lines 128 to 134
  • BimCraft 0.8.1
const sun = scene.sun ? { on: true, dir: direction(M, scene.sun), intensity: round(base.sun, 4), tint: base.tint, diameterDeg: 0.53 } : { on: false };
// lamps that are on, as the renderer draws them (the brightest 64), their IES profile beside the USD
const lamps = [], iesFiles = {};
for (const L of (scene.lights || []).filter((x) => x && x.on !== false && x.lumens > 0).sort((a, b) => b.lumens - a.lumens).slice(0, MAX_LAMPS)) {
  const o = { name: String(L.name || 'Lamp').slice(0, 120), kind: L.kind === 'spot' || L.kind === 'area' ? L.kind : 'point', lumens: round(L.lumens, 3), color: hexToLinear(typeof L.color === 'string' ? L.color : '#ffe7c4'), p: point(M, L.p || [0, 0, 0]), dir: direction(M, L.dir || [0, 0, -1]) };
  if (L.kind === 'spot') o.angle = round(num(L.angle, 90), 3);
  if (L.kind === 'area') o.size = vec(L.size, 2, 0.5);

Scene, exposure and white balance entries

64-bit

The ground height, sample count, exposure in stops, shutter time and white-balance gains.

The ground height is rounded to 5 decimals, the exposure to 4, the shutter time to 6 and the white-balance gains to 5. A shutter time that is not a short decimal, such as 1/60 s, is therefore written rounded.

  • render/handoff.js
  • lines 143 to 154
  • BimCraft 0.8.1
const stops = cameraStops(settings);
const m = {
  format: MANIFEST_FORMAT,
  app: { name: app, version: appVersion },
  created: created || null,
  scene: { file: usdFile, upAxis: 'Z', metersPerUnit: 1, up: direction(M, scene.up || [0, 0, 1]), groundZ: settings.ground === false ? null : round(point(M, [0, 0, num(scene.groundAt)])[2], 5) },
  image: { width: w, height: h, quality: settings.quality, samples: Math.max(1, num(({ draft: 16, good: 64, best: 256 })[settings.quality], 64)), denoise: !!settings.denoise, minutes: settings.minutes },
  camera,
  cameras: (cameras || []).slice(0, MAX_CAMERAS).map((c) => ({ name: String(c.name || '').slice(0, 120), prim: String(c.prim || '').slice(0, 400) })),
  sun, sky,
  exposure: { stops: round(stops, 4), slider: settings.exposure, mode: settings.exposureMode, fstop: settings.fstop, shutter: round(settings.shutter, 6), iso: settings.iso },
  whiteBalance: { kelvin: settings.whiteBalance, gains: whiteBalance(settings.whiteBalance).map((x) => round(x, 5)) },

Animation frames entry

64-bit

The start and end time, frame rate, frame count and the camera matrix of each frame.

Start and end are rounded to 4 decimals, the frame rate to a whole number from 1 to 240, and each frame's camera matrix to 7 decimals after it is moved to the stage.

  • render/handoff.js
  • lines 157 to 158
  • BimCraft 0.8.1
frames: frames ? { from: round(num(frames.from), 4), to: round(num(frames.to), 4), fps: Math.max(1, Math.min(240, Math.round(num(frames.fps, 24)))), count: Math.max(1, Math.min(100000, Math.round(num(frames.count, 1)))),
  cameras: (frames.cameras || []).slice(0, 100000).map((c) => ({ world: matMul(M, c.world).map((x) => round(x, 7)) })) } : null,

Material values

64-bit

The OpenPBR parameters written for each material, where they differ from the defaults.

A value is compared with the default within 1e-9 (1e-6 per color channel) and written only if it differs. Written values are rounded to 5 decimals.

  • render/handoff.js
  • lines 54 to 85
  • BimCraft 0.8.1
/**
 * The OpenPBR values of one of the apps' material keys (looks.js looks, materials.js `mat:` ids, `img:` textures, the ground) as
 * named parameters, only where they differ from OpenPBR's defaults (USD-CONVENTIONS § 7). Colours are linear Rec.709 triples.
 * The same mapping pt-pack.js `materialRecord` packs for our path tracer (RENDER-SPEC § 5.1), so the engines start from one material.
 */
export function openpbrFor(key, settings, scene) {
  let p;
  if (key === '__ground') p = { base_color: hexToLinear(SKIES[settings && settings.sky]?.plain ? '#f4f4f2' : '#cbc6bb'), specular_roughness: 1 };
  else if (typeof key === 'string' && key.startsWith('mat:') && libMaterial(key.slice(4))) {
    const m = libMaterial(key.slice(4));
    if (m.pbr) p = { ...m.pbr };
    else {
      p = { base_color: hexToLinear(m.color), specular_roughness: m.rough, base_metalness: m.metal || 0 };
      if (m.glass) Object.assign(p, { transmission_weight: m.glass, transmission_color: hexToLinear(m.color), specular_roughness: 0.02, geometry_thin_walled: true });
    }
  } else if (typeof key === 'string' && key.startsWith('img:')) {
    const avg = /#[0-9a-f]{6}$/i.exec(key);
    p = { base_color: hexToLinear(avg ? avg[0] : '#b4b4b4'), specular_roughness: 0.7 };
  } else {
    const L = lookFor(key, settings, scene && scene.defaultColor, scene && scene.looks && scene.looks[key]);
    p = { base_color: hexToLinear(L.color), specular_roughness: L.rough, base_metalness: L.metal };
    if (L.glass) Object.assign(p, { transmission_weight: L.glass, transmission_color: hexToLinear(L.color), geometry_thin_walled: true });
  }
  const out = {};
  for (const [k, v] of Object.entries(p)) {
    const d = OPENPBR_DEFAULTS[k];
    if (d === undefined) continue;
    const same = Array.isArray(d) ? Array.isArray(v) && v.length === d.length && v.every((x, i) => Math.abs(x - d[i]) < 1e-6) : v === d || (typeof v === 'number' && typeof d === 'number' && Math.abs(v - d) < 1e-9);
    if (!same) out[k] = Array.isArray(v) ? v.map((x) => round(x, 5)) : typeof v === 'number' ? round(v, 5) : v;
  }
  return out;
}

In detail

Method
Matrix products move the scene's camera, points and directions to the USD stage. Colors are decoded from sRGB to linear with the IEC 61966-2-1 curve. The view angle comes from the projection matrix.
Number types
64-bit numbers, written as JSON text.
Tolerances
A projection is taken as orthographic when |P[15] − 1| < 1e-9 and |P[11]| < 1e-9. A material value is left out when it is within 1e-9 of the default (1e-6 per channel for a color).
Limits
Rounding to 7 decimals keeps fewer significant digits for small matrix terms. Shutter times such as 1/60 s are written as 6-decimal values.

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