Reads and writes rendered pictures and environment pictures as OpenEXR and Radiance HDR files.
How the precision is kept
The file formats set the precision of a pixel: OpenEXR stores 16-bit half floats, 32-bit floats or 32-bit unsigned integers, and Radiance HDR stores three 8-bit mantissas with one shared exponent. Reading reproduces the file's values exactly in a Float32Array; writing rounds each value to the channel's type. The arithmetic around it is 64-bit.
Calculations under this policy
6 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.
Puts the samples of one unpacked block into each channel's picture.
half: table[16-bit value]; float: the 32-bit value; uint: the 32-bit integer
Each sample is the file's own value: a half is looked up in a table of its 65536 values, and floats and unsigned integers are read as stored. The channel arrays are Float32Array or Uint32Array, which hold these values without loss; the limit is the file's sample type.
exr/exr.js
lines 450 to 462
BimCraft 0.8.1
function storeBlock(part, layout, bytes, xOff) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (const row of layout.rows) {
const c = part.channels[row.ci];
const sy = (row.y - c.y0) / c.ySampling;
const base = sy * c.width + xOff(c);
const d = c.data;
let at = row.at;
if (c.type === 'half') for (let i = 0; i < row.n; i++, at += 2) d[base + i] = HALF_TO_FLOAT[dv.getUint16(at, true)];
else if (c.type === 'float') for (let i = 0; i < row.n; i++, at += 4) d[base + i] = dv.getFloat32(at, true);
else for (let i = 0; i < row.n; i++, at += 4) d[base + i] = dv.getUint32(at, true);
}
}
Table for linear B44 channels
64-bit
The table that undoes the logarithmic step of B44 compression for channels marked linear.
table[x] = half(8 × log(value of x))
Math.fround is applied after the logarithm and after the multiplication by 8, so the table is computed in single precision on purpose. Entries for infinities, NaN and negative values are 0.
exr/exr.js
lines 347 to 356
BimCraft 0.8.1
function b44LogTable() {
if (!B44_LOG) {
B44_LOG = new Uint16Array(65536);
for (let x = 0; x < 65536; x++) {
if ((x & 0x7c00) === 0x7c00 || x > 0x8000) { B44_LOG[x] = 0; continue; }
B44_LOG[x] = floatToHalf(Math.fround(8 * Math.fround(Math.log(HALF_TO_FLOAT[x]))));
}
}
return B44_LOG;
}
Red, green, blue and alpha of a picture
64-bit
Gathers a layer's channels into one RGBA picture.
subsampled channel: sample at (floor(x ÷ xSampling), floor(y ÷ ySampling))
Values are copied between 32-bit arrays unchanged. A subsampled channel takes the nearest stored sample, with no interpolation; a missing alpha is 1 and a luminance channel fills red, green and blue.
exr/exr.js
lines 636 to 664
BimCraft 0.8.1
export function rgbaOf(part, { layer = null } = {}) {
const layers = layersOf(part);
const L = layer !== null ? layers.find((l) => l.name === layer)
: layers.find((l) => l.name === '' && (l.channels.R || l.channels.Y)) || layers.find((l) => l.channels.R || l.channels.Y) || layers[0];
if (!L) return null;
const { width, height } = part;
const out = new Float32Array(width * height * 4);
const ch = L.channels;
const get = (c) => {
if (!c) return null;
if (c.width === width && c.height === height) return (i) => c.data[i];
return (i) => { // subsampled: nearest sample
const x = i % width;
const y = (i / width) | 0;
return c.data[Math.min(c.height - 1, Math.floor(y / c.ySampling)) * c.width + Math.min(c.width - 1, Math.floor(x / c.xSampling))];
};
};
const R = get(ch.R || ch.r || ch.Y);
const G = get(ch.G || ch.g || ch.Y) || R;
const B = get(ch.B || ch.b || ch.Y) || R;
const A = get(ch.A || ch.a);
for (let i = 0, o = 0; i < width * height; i++, o += 4) {
out[o] = R ? R(i) : 0;
out[o + 1] = G ? G(i) : 0;
out[o + 2] = B ? B(i) : 0;
out[o + 3] = A ? A(i) : 1;
}
return { width, height, data: out, layer: L.name };
}
Samples written to a block
64-bit
Writes each channel's samples in its type before the block is compressed.
half: floatToHalf(v); float: nearest 32-bit float; uint: v as a 32-bit unsigned integer
A half channel rounds each value to 16 bits with the library's float-to-half function (exr/util.js, not covered here), and a float channel to the nearest 32-bit float. For an unsigned channel v >>> 0 drops any fraction and wraps values beyond 32 bits.
exr/exr.js
lines 778 to 780
BimCraft 0.8.1
if (c.type === 'half') for (let i = 0; i < row.n; i++, at += 2) dv.setUint16(at, floatToHalf(src[base + i]), true);
else if (c.type === 'float') for (let i = 0; i < row.n; i++, at += 4) dv.setFloat32(at, src[base + i], true);
else for (let i = 0; i < row.n; i++, at += 4) dv.setUint32(at, src[base + i] >>> 0, true);
Turns the four bytes of a Radiance pixel into red, green and blue values.
value = (m + 0.5) × 2^(E − 136); E = 0 gives 0
The powers of two are exact and held in a Float64Array. Each product has at most 9 significant bits, so storing it in the 32-bit picture loses nothing. The precision of a pixel is the 8-bit mantissa the file holds.
exr/hdr.js
lines 79 to 94
BimCraft 0.8.1
const exp = new Float64Array(256);
for (let e = 1; e < 256; e++) exp[e] = 2 ** (e - 136);
for (let r = 0; r < rows; r++) {
pos = readRow(b, pos, scan, len, r);
for (let c = 0; c < len; c++) {
let x;
let y;
if (yMajor) { y = sY === '-' ? r : height - 1 - r; x = sX === '+' ? c : width - 1 - c; }
else { x = sX === '+' ? r : width - 1 - r; y = sY === '-' ? c : height - 1 - c; }
const o = (y * width + x) * 3;
const e = scan[c * 4 + 3];
if (!e) { data[o] = data[o + 1] = data[o + 2] = 0; continue; }
const f = exp[e];
data[o] = (scan[c * 4] + 0.5) * f;
data[o + 1] = (scan[c * 4 + 1] + 0.5) * f;
data[o + 2] = (scan[c * 4 + 2] + 0.5) * f;
Number to an RGBE pixel
64-bit
Encodes red, green and blue values as three mantissas and a shared exponent.
e: 2^(e−1) ≤ max(r, g, b) < 2^e; mantissa = floor(value × 256 ÷ 2^e); E = e + 128
The exponent from Math.log2 is corrected by one step each way, because the logarithm can land one off through rounding. Mantissas are truncated, which the decoder's + 0.5 centers. A largest value of 1e-32 or less is written as zero, a non-finite one as all 255, and the exponent is capped at 127.
exr/hdr.js
lines 144 to 158
BimCraft 0.8.1
/** Radiance's float → RGBE: the largest component sets the exponent; mantissas truncate (decode adds 0.5). */
function toRgbe(r, g, bb, out, o) {
const v = Math.max(r, g, bb);
if (!(v > 1e-32)) { out[o] = out[o + 1] = out[o + 2] = out[o + 3] = 0; return; }
if (!Number.isFinite(v)) { out[o] = out[o + 1] = out[o + 2] = 255; out[o + 3] = 255; return; }
let e = Math.ceil(Math.log2(v));
if (2 ** e <= v) e++; // v < 2^e
if (2 ** (e - 1) > v) e--; // v ≥ 2^(e−1): the mantissa of the largest is 128…255
if (e > 127) e = 127;
const d = 256 / 2 ** e;
out[o] = Math.min(255, Math.max(0, Math.floor(r * d)));
out[o + 1] = Math.min(255, Math.max(0, Math.floor(g * d)));
out[o + 2] = Math.min(255, Math.max(0, Math.floor(bb * d)));
out[o + 3] = e + 128;
}
In detail
Method
OpenEXR from the published file layout documents; Radiance RGBE as the file's header cites it (Greg Ward, Real Pixels, Graphics Gems II, 1991): a pixel is (m + 0.5) × 2^(E − 136).
Number types
Float32Array for half and float channels, Uint32Array for unsigned channels, 8-bit bytes for RGBE; 64-bit numbers for the conversions.
Limits
A half float keeps 11 significant bits and a float 24. An RGBE pixel keeps 8 bits for its largest channel and fewer for the others, and its mantissas are truncated on writing. B44 and B44A files are lossy by format.
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.