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.jsBimCraft 0.8.1 32-bit in part3 calculationsReviewed
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-output.jsBimCraft 0.8.1 32-bit in part3 calculationsReviewed
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.jsBimCraft 0.8.1 32-bit in part1 calculationReviewed
TestsNone 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.
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.