Resources Numerical accuracy Calculation policy

MaterialX files: numbers written and read

Writes a material's parameters, tile size and displacement to a MaterialX file, and reads them back from one.

How the precision is kept

Every number written to the file is rounded to six decimals. The tile size is written as its reciprocal rounded that way, so a tile of 3 m is written as 0.333333 and reads back as 3.000003 m. The displacement range is taken from a 32-bit height map and the lowest height is not written. Reading is 64-bit: values are parsed with Number and converted by exact factors of 1000 or by the light scale 0.02.

Open item. Write numbers with their full 64-bit digits instead of six decimals, and write the displacement offset alongside its scale.

Calculations under this policy

9 calculations in 2 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.

shading/mtlx.js BimCraft 0.8.1 32-bit in part 8 calculations Reviewed

Tests bin/test-shading.mjs

A number as written to the file

64-bit

The text of every numeric value in the exported file.

text = round(x × 10⁶) ÷ 10⁶

The value kept in the file is rounded to six decimals with Math.round before it is turned into text, so at most six digits after the decimal point survive whatever the size of the number. Colors go through the AP1 to Rec.709 matrix and are clamped at 0 first.

  • shading/mtlx.js
  • lines 318 to 320
  • BimCraft 0.8.1
const lin709 = (ap1) => mul3(AP1_TO_REC709, ap1).map((x) => Math.max(0, x));
const f3 = (x) => (Math.round(x * 1e6) / 1e6).toString();
const v3s = (a) => a.map(f3).join(', ');

Tile size as uvtiling

64-bit

The repeat of every texture in the file, from the material's tile size in meters.

uvtiling = round(10⁶ ÷ size) ÷ 10⁶

The reciprocal of each tile dimension is rounded to six decimals. A size whose reciprocal does not end within six decimals does not read back the same: 3 m is written as 0.333333, and 1 ÷ 0.333333 = 3.000003.

  • shading/mtlx.js
  • line 333
  • BimCraft 0.8.1
const M = uniq(name), size = bake.size, tile = `${f3(1 / size[0])}, ${f3(1 / size[1])}`, L = bake.layers, n = 512;

Shader parameters

64-bit

The constant inputs of the surface shader written to the file.

Standard Surface: emission = luminance × 0.02; thin film thickness × 1000

Values are 64-bit until they are written, then rounded to six decimals. For the Standard Surface flavor the emission is multiplied by the light scale 0.02 and the thin-film thickness by 1000 before that rounding.

  • shading/mtlx.js
  • lines 390 to 397
  • BimCraft 0.8.1
let v = p[k];
if (v === undefined) continue;
if (Array.isArray(v)) v = lin709(v);
if (cat === 'standard_surface') {
  if (k === 'emission_luminance') v = v * LIGHT_SCALE;
  if (k === 'thin_film_thickness') v = v * 1000;
}
ins.push(`    <input name="${nm}" type="${Array.isArray(v) ? 'color3' : 'float'}" value="${Array.isArray(v) ? v3s(v) : f3(v)}" />`);

Displacement height range

32-bit in part

The lowest height and the span of the height map, which set the displacement texture's levels and the scale written to the file.

span = max h − min h; level = round(255 (h − min h) ÷ span); scale = displacement scale × span

The heights are read from the bake's Float32Array, so the lowest height and the span carry about 7 digits. The lowest height is kept on the bake object only and is not written to the file, and the texture holds 256 levels between the two.

  • shading/mtlx.js
  • lines 361 to 367
  • BimCraft 0.8.1
let lo = Infinity, hi = -Infinity;
for (const h of bake.height) { lo = Math.min(lo, h); hi = Math.max(hi, h); }
const rgb = new Uint8Array(n * n * 3), span = hi > lo ? hi - lo : 1, side = Math.round(Math.sqrt(bake.height.length)) || n;
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { const v = bake.height[Math.min(side - 1, Math.floor(((n - 1 - y) * side) / n)) * side + Math.min(side - 1, Math.floor((x * side) / n))]; rgb[(y * n + x) * 3] = rgb[(y * n + x) * 3 + 1] = rgb[(y * n + x) * 3 + 2] = Math.round(((v - lo) / span) * 255); }
dispFile = png('height', rgb);
notes.push('height:' + M);
bake.dispRange = [lo, span];

Displacement scale written

32-bit in part

The scale input of the displacement shader in the exported file.

scale = round(10⁶ × displacement scale × span) ÷ 10⁶

The span is the difference of two heights read from a Float32Array, multiplied by the material's displacement scale in 64-bit and rounded to six decimals. Only the scale is written: the displacement node gets no offset input.

  • shading/mtlx.js
  • line 404
  • BimCraft 0.8.1
body.push(`  <displacement name="DS_${M}" type="displacementshader">\n    <input name="displacement" type="float" nodegraph="${NG}" output="height_out" />\n    <input name="scale" type="float" value="${f3(bake.displacementScale * bake.dispRange[1])}" />\n  </displacement>`);

A value read from the file

64-bit

The number or list of numbers in a value attribute.

Each comma-separated part is converted with Number in 64-bit; a part that is not a finite number makes the whole value undefined. Nothing is rounded.

  • shading/mtlx.js
  • lines 99 to 106
  • BimCraft 0.8.1
function parseValue(s, type) {
  if (s === undefined) return undefined;
  if (type === 'boolean') return s === 'true';
  if (type === 'string' || type === 'filename') return s;
  const v = String(s).split(',').map((x) => Number(x.trim()));
  if (!v.every(Number.isFinite)) return undefined;
  return v.length === 1 ? v[0] : v;
}

Unit changes on reading

64-bit

Standard Surface values converted to the app's units.

thin film thickness ÷ 1000; emission ÷ 0.02; opacity = (r + g + b) ÷ 3

Divisions in 64-bit. The division by 1000 is by an exact whole number; the light scale 0.02 has no exact binary form, so that division rounds once.

  • shading/mtlx.js
  • lines 84 to 86
  • BimCraft 0.8.1
coat_roughness: ['coat_roughness'], coat_IOR: ['coat_ior'], thin_film_thickness: ['thin_film_thickness', (v) => (typeof v === 'number' ? v / 1000 : v)],
thin_film_IOR: ['thin_film_ior'], emission: ['emission_luminance', (v) => (typeof v === 'number' ? v / LIGHT_SCALE : v)], emission_color: ['emission_color'],
opacity: ['geometry_opacity', (v) => (Array.isArray(v) ? (v[0] + v[1] + v[2]) / 3 : v)], normal: ['normal'],

Tile size from uvtiling

64-bit

The material's tile size in meters from the uvtiling shared by a file's images.

width = 1 ÷ tiling_u; height = 1 ÷ tiling_v

One 64-bit division each, used when every image has the same tiling (compared with ===) and no offset, and both are greater than zero. The result inherits whatever rounding the file's tiling had.

  • shading/mtlx.js
  • lines 303 to 304
  • BimCraft 0.8.1
const t0 = tiling[0].t;
if (tiling.every((x) => x.t[0] === t0[0] && x.t[1] === t0[1] && x.o[0] === 0 && x.o[1] === 0) && t0[0] > 0 && t0[1] > 0) { out.params.width = 1 / t0[0]; out.params.height = 1 / t0[1]; }
shading/bake.js BimCraft 0.8.1 64-bit 1 calculation Reviewed

Tests bin/test-shading.mjs

Mean of an input that varies over the tile

64-bit

The single value given to a shader input the renderers and the file cannot vary across the surface.

mean = Σ f(uᵢ, vⱼ) ÷ 64², a 64 × 64 grid of texel centers

4,096 samples added in order in 64-bit and divided by the count. The grid is fixed at 64 × 64 whatever the bake size, so detail finer than a 64th of the tile is sampled, not integrated.

  • shading/bake.js
  • lines 90 to 95
  • BimCraft 0.8.1
const fn = C.input(inp.id), acc = inp.kind === 'f' ? [0] : [0, 0, 0];
for (let j = 0; j < AVG; j++) for (let i = 0; i < AVG; i++) {
  const x = fn((i + 0.5) / AVG, (j + 0.5) / AVG);
  if (inp.kind === 'f') acc[0] += x; else { acc[0] += x[0]; acc[1] += x[1]; acc[2] += x[2]; }
}
v = inp.kind === 'f' ? acc[0] / (AVG * AVG) : acc.map((a) => a / (AVG * AVG));

In detail

Method
Constants as value attributes; texture layers as PNG files read by tiledimage nodes with uvtiling = 1 ÷ tile size; colors converted from AP1 to linear Rec.709 by a 3 × 3 matrix and clamped at 0. The file header says the reader and writer follow the MaterialX 1.38 and 1.39 specification.
Number types
64-bit numbers for parameters; Float32Array for the height map; 8-bit PNG layers.
Tolerances
Written values: Math.round(x × 1e6) ÷ 1e6. Height texture: 256 levels between the lowest and highest height. On reading, a shared uvtiling must match exactly (===) across images to become the tile size.
Checked by
bin/test-shading.mjs has a MaterialX section that imports shading/mtlx.js.
Limits
Six decimals is an absolute rounding: a parameter smaller than 0.0000005 is written as 0. The exported displacement has no offset, so heights are measured from the lowest point of the map.

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