Resources Numerical accuracy Calculation policy

Path-traced picture sums and the saved file

Reads back the summed light of a path-traced picture from the graphics card and saves it as an EXR, a 16-bit PNG or a render file.

How the precision is kept

The graphics card keeps the picture as 32-bit sums per pixel (red, green, blue and the sample count) and the code reads them back as Float32Array. The saved EXR channels are written as type float (32-bit) and the 16-bit PNG quantizes the 32-bit picture to 65535 steps, so the saved picture is a 32-bit result.

Open item. Say in the EXR attributes and the render dialog that pixel values are 32-bit sums, and keep a 64-bit or compensated sum on the card only if a reference-grade image is ever needed.

Calculations under this policy

7 calculations in 3 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.

render/pt-gpu.js BimCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests tools/modelcraft-test/browser-pt.mjs

Accumulation buffer read back

32-bit in part

The per-pixel sums of red, green, blue and sample count.

The GPU buffer (4 floats per pixel, 16 bytes) is copied and wrapped as a Float32Array; nothing is converted. The sample count in the fourth float is also a 32-bit float.

  • render/pt-gpu.js
  • lines 164 to 173
  • BimCraft 0.8.1
tracer.accum = async () => {
  const rb = device.createBuffer({ size: w * h * 16, usage: U.MAP_READ | U.COPY_DST });
  const enc = device.createCommandEncoder();
  enc.copyBufferToBuffer(B.accum, 0, rb, 0, w * h * 16);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const a = new Float32Array(rb.getMappedRange().slice(0));
  rb.unmap(); rb.destroy();
  return a;
};

Sum of an exact range of samples

32-bit in part

The sum of samples s0 to s0 + spp − 1 for every pixel.

Copies the running sums aside, clears the buffer, adds the range one sample at a time on the card, reads the result back as Float32Array and restores the old sums. The sums are held in a 32-bit buffer on the card.

  • render/pt-gpu.js
  • lines 286 to 298
  • BimCraft 0.8.1
tracer.renderRange = async (s0, spp, { signal = null } = {}) => {
  if (disposed) throw new Error('disposed');   // i18n-ignore: a programming error
  backup ||= device.createBuffer({ size: w * h * 16, usage: U.COPY_SRC | U.COPY_DST });
  let enc = device.createCommandEncoder();
  enc.copyBufferToBuffer(B.accum, 0, backup, 0, w * h * 16);
  enc.clearBuffer(B.accum);
  device.queue.submit([enc.finish()]);
  try {
    for (let s = s0; s < s0 + spp; s++) {
      if (signal?.aborted) throw Object.assign(new Error('stopped'), { code: 'cancelled' });   // i18n-ignore: caught by the caller
      await renderSample(s);
    }
    return await tracer.accum();

Render elements read back

32-bit in part

The per-pixel render elements and light-group sums.

Same read-back of a 32-bit buffer into a Float32Array, with pk.auxStride four-float vectors per pixel; no conversion in this function.

  • render/pt-gpu.js
  • lines 176 to 186
  • BimCraft 0.8.1
tracer.aux = async () => {
  if (!auxBytes) return null;
  const rb = device.createBuffer({ size: auxBytes, usage: U.MAP_READ | U.COPY_DST });
  const enc = device.createCommandEncoder();
  enc.copyBufferToBuffer(B.aux, 0, rb, 0, auxBytes);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const a = new Float32Array(rb.getMappedRange().slice(0));
  rb.unmap(); rb.destroy();
  return a;
};
render/pt-output.js BimCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests bin/test-render.mjs

EXR channels from the sums

32-bit in part

The mean (or raw sum) of each channel for the EXR and render file.

mean = Σ ÷ max(samples, 1)

The mean is a 64-bit division of the 32-bit sum by the sample count, then stored in a Float32Array channel, so the saved value is rounded to 32 bits once more. In raw mode the sums are copied unchanged; a zero sample count is treated as 1.

  • render/pt-output.js
  • lines 66 to 92
  • BimCraft 0.8.1
/** Means for viewing (raw = false) or the Σ values + counts for reopening (raw = true). */
export function exrChannels(L, { raw = false } = {}) {
  const { w, h, accum, aux, stride = 0, nElements: nE = 0, groups = [] } = L, n = w * h, out = [];
  const ch = (name, fill) => { const d = new Float32Array(n); for (let i = 0; i < n; i++) d[i] = fill(i); out.push({ name, type: 'float', data: d }); };
  const cnt = (i) => accum[i * 4 + 3] || 1;
  const div = (i, v) => (raw ? v : v / cnt(i));
  for (const [k, c] of [['R', 0], ['G', 1], ['B', 2]]) ch(k, (i) => div(i, accum[i * 4 + c]));
  if (raw) ch('samples', (i) => accum[i * 4 + 3]);
  if (aux && nE) {
    const gC = (i) => aux[(i * stride + 0) * 4 + 3] || 1;   // the guides' own count (surfaces seen)
    ELEMENTS.slice(0, nE).forEach((e, k) => {   // nE = 2: the denoiser's guides alone
      const at = (i, c) => aux[(i * stride + k) * 4 + c];
      if (e === 'normalDepth') {
        for (const [nm, c] of [['N.X', 0], ['N.Y', 1], ['N.Z', 2]]) ch(nm, (i) => (raw ? at(i, c) : at(i, c) / gC(i)));
        ch('Z', (i) => (raw ? at(i, 3) : aux[(i * stride) * 4 + 3] ? at(i, 3) / gC(i) : 0));
      } else if (e === 'ids') {
        ch('objectId', (i) => at(i, 0)); ch('materialId', (i) => at(i, 1));
        if (raw) { ch('ids.inst', (i) => at(i, 2)); ch('ids.set', (i) => at(i, 3)); }
      } else if (e === 'albedo') {
        for (const [c, x] of [['R', 0], ['G', 1], ['B', 2]]) ch('albedo.' + c, (i) => (raw ? at(i, x) : at(i, x) / gC(i)));
        if (raw) ch('albedo.count', (i) => at(i, 3));
      } else for (const [c, x] of [['R', 0], ['G', 1], ['B', 2]]) ch(elementNames[e] + '.' + c, (i) => div(i, at(i, x)));
    });
  }
  if (aux && groups.length && stride > nE) groups.forEach((g, k) => { for (const [c, x] of [['R', 0], ['G', 1], ['B', 2]]) ch(groupChannel(g) + '.' + c, (i) => div(i, aux[(i * stride + nE + k) * 4 + x])); });
  return out;
}

16-bit PNG

32-bit in part

The picture as 16 bits per channel.

v₁₆ = round(clamp(v, 0, 1) × 65535)

Each value is clamped to the range 0 to 1 and rounded with Math.round(v × 65535) into a Uint16Array. The input is the 32-bit display picture, so the 16-bit step (1 ÷ 65535) is the rounding the saved file gets.

  • render/pt-output.js
  • lines 54 to 58
  • BimCraft 0.8.1
export function png16(f, w, h) {
  const v = new Uint16Array(w * h * 3);
  for (let i = 0; i < v.length; i++) v[i] = Math.round(Math.min(1, Math.max(0, f[i])) * 65535);
  return pngBytes(w, h, v, 16);
}

Reopening a render file

32-bit in part

The sums and counts read back from the layers file.

The channel data from the EXR reader are copied into a Float32Array accum without conversion, so this loop adds no rounding of its own to what the EXR reader returns.

  • render/pt-output.js
  • lines 148 to 149
  • BimCraft 0.8.1
const n = w * h, accum = new Float32Array(n * 4), R = need('R'), G = need('G'), B = need('B'), S = need('samples');
for (let i = 0; i < n; i++) { accum[i * 4] = R[i]; accum[i * 4 + 1] = G[i]; accum[i * 4 + 2] = B[i]; accum[i * 4 + 3] = S[i]; }
render/render-ui.js BimCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests None found by the review

Save as 16-bit PNG

32-bit in part

Chooses the 32-bit or the 8-bit path to write the 16-bit PNG.

With the float picture it calls png16 above. If the composed picture is not a Float32Array (8-bit bytes) it multiplies each byte by 257 into a Uint16Array, which maps 0 to 255 exactly onto 0 to 65535 but adds no precision beyond 8 bits.

  • render/render-ui.js
  • lines 755 to 757
  • BimCraft 0.8.1
const f = compose(layers, settings.mix, { exposure: layers.exposure, wb: layers.wb, show: showKey, post: settings.post, lut, float: true });
bytes = f instanceof Float32Array ? OUT.png16(f, layers.w, layers.h) : OUT.pngBytes(layers.w, layers.h, Uint16Array.from({ length: layers.w * layers.h * 3 }, (_, i) => f[Math.floor(i / 3) * 4 + (i % 3)] * 257), 16);
name = base + ' 16-bit.png';

In detail

Method
Per-pixel sums are divided by the sample count for viewing; the render file keeps the raw sums and counts so reopening gives the same buffers back.
Number types
accum and aux are Float32Array read back from GPU buffers; the divisions are done in 64-bit JavaScript and stored back into Float32Array channels; the 16-bit PNG uses Uint16Array.
Tolerances
png16 clamps each value to 0 to 1 and uses Math.round(v × 65535); a missing sample count is replaced by 1 to avoid dividing by zero; the chromaticities written to the EXR are the constants 0.713, 0.293, 0.165, 0.83, 0.128, 0.044 and a white point of 0.32168, 0.33767.
Limits
The sums are held in a 32-bit buffer on the card, so what is saved can carry no more than 32-bit precision; these files only read the buffer and do not show how the card adds the samples.

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