Resources Numerical accuracy Calculation policy

Rendered picture: mixing, post and color

The pixel values of a high-quality render as shown and as saved to an 8-bit or 16-bit picture, after light mixing, denoising, exposure, bloom, the view transform and the sRGB curve.

How the precision is kept

The linear picture reaches this code as a Float32Array, and the working image, the blurred copies and the denoiser's input and output are Float32Array too. On the graphics card each pixel's sum of samples is a 32-bit vec4f. The arithmetic per pixel between those stores is 64-bit, and the last step rounds to 8 bits or writes 32-bit floats in [0, 1] for a 16-bit picture.

Open item. Keep the linear picture and the image-wide passes in Float64Array on the processor path up to the last 8-bit or 16-bit step, and state that graphics-card renders add their samples in 32-bit.

Calculations under this policy

14 calculations in 5 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-post.js BimCraft 0.8.1 32-bit in part 4 calculations Reviewed

Tests bin/test-render.mjs

Linear picture to display values

32-bit in part

Each pixel's final red, green and blue from the linear picture and the post settings.

AP1 × wb → Rec.709 × 2^exposure → vignette → highlights → contrast → view transform → sRGB → 8 bits

Reads the linear picture from a Float32Array, keeps the working image in a Float32Array and writes either 8-bit values rounded to the nearest step or 32-bit floats in [0, 1]. The steps for one pixel between those stores are 64-bit.

  • render/pt-post.js
  • lines 119 to 155
  • BimCraft 0.8.1
export function post(lin, w, h, p, { exposure = 0, wb = null, lut = null, float = false } = {}) {
  p = p || POST_DEFAULTS;
  const n = w * h, k = Math.pow(2, exposure);
  let img = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) {
    const a = [lin[i * 3] * (wb ? wb[0] : 1), lin[i * 3 + 1] * (wb ? wb[1] : 1), lin[i * 3 + 2] * (wb ? wb[2] : 1)];
    const c = mul3(AP1_TO_REC709, a);
    for (let j = 0; j < 3; j++) img[i * 3 + j] = Number.isFinite(c[j]) ? Math.max(0, c[j]) * k : 0;
  }
  if (p.bloom > 0) img = bloomPass(img, w, h, p.bloom, p.bloomSize);
  if (p.glare > 0) img = glarePass(img, w, h, p.glare, p.streaks);
  const out = float ? new Float32Array(n * 3) : new Uint8ClampedArray(n * 4), cx = (w - 1) / 2, cy = (h - 1) / 2, rmax = Math.hypot(cx, cy) || 1, px = [0, 0, 0], lo = [0, 0, 0];
  const knee = 1, hs = p.highlights * 2;
  for (let i = 0; i < n; i++) {
    px[0] = img[i * 3]; px[1] = img[i * 3 + 1]; px[2] = img[i * 3 + 2];
    if (p.vignette > 0) {
      const r = Math.hypot((i % w) - cx, Math.floor(i / w) - cy) / rmax, t = Math.min(1, Math.max(0, (r - 0.2) / 0.8)), f = 1 - p.vignette * t * t * (3 - 2 * t);
      px[0] *= f; px[1] *= f; px[2] *= f;
    }
    if (hs > 0) {
      const L = 0.2126 * px[0] + 0.7152 * px[1] + 0.0722 * px[2];
      if (L > knee) { const L2 = knee + (L - knee) / (1 + (L - knee) * hs), s = L2 / L; px[0] *= s; px[1] *= s; px[2] *= s; }
    }
    if (p.contrast) for (let j = 0; j < 3; j++) px[j] = 0.18 * Math.pow(px[j] / 0.18, 1 + p.contrast);
    // ACES keeps its own 1/0.6 pre-scale (acesFilmic), so the default path is displayPixel's, number for number
    const t = p.view === 'aces' ? acesFilmic(px, 0) : viewTransform(p.view, px);
    let r = linearToSrgb(t[0]), g = linearToSrgb(t[1]), b = linearToSrgb(t[2]);
    if (lut && p.lutStrength > 0) {
      applyCube(lut, r, g, b, lo);
      const s = p.lutStrength;
      r += (lo[0] - r) * s; g += (lo[1] - g) * s; b += (lo[2] - b) * s;
    }
    if (float) { out[i * 3] = Math.min(1, Math.max(0, r)); out[i * 3 + 1] = Math.min(1, Math.max(0, g)); out[i * 3 + 2] = Math.min(1, Math.max(0, b)); continue; }
    out[i * 4] = Math.round(Math.min(1, Math.max(0, r)) * 255); out[i * 4 + 1] = Math.round(Math.min(1, Math.max(0, g)) * 255); out[i * 4 + 2] = Math.round(Math.min(1, Math.max(0, b)) * 255); out[i * 4 + 3] = 255;
  }
  return out;
}

View transform

64-bit

The tone curve that brings scene light into the display's range.

64-bit per pixel. The matrices and curve coefficients are published constants written to between 4 and 17 digits. The AgX branch takes a base-2 logarithm over −12.47393 to 4.026069 stops with its input clamped at 1e-10.

  • render/pt-post.js
  • lines 60 to 70
  • BimCraft 0.8.1
export function viewTransform(kind, c) {
  if (kind === 'agx') {
    let v = mul3(AGX_INSET, mul3(R709_TO_2020, c));
    v = v.map((x) => Math.min(1, Math.max(0, (Math.log2(Math.max(x, 1e-10)) - AGX_MIN) / (AGX_MAX - AGX_MIN)))).map(agxCurve);
    v = mul3(AGX_OUTSET, v).map((x) => Math.pow(Math.max(0, x), 2.2));
    return mul3(R2020_TO_709, v).map((x) => Math.min(1, Math.max(0, x)));
  }
  if (kind === 'filmic') return c.map((x) => Math.min(1, Math.max(0, hable(2 * Math.max(0, x)) / HABLE_W)));
  if (kind === 'standard') return c.map((x) => Math.min(1, Math.max(0, x)));
  return acesFilmic(c, 0);
}

Blur for bloom

32-bit in part

The wide blur of the picture that bloom blends back in.

r = max(1, round(√(12σ² ÷ 3 + 1) ÷ 2)); three box blurs of radius r per axis

Each box blur keeps a running sum that adds the pixel entering the window and subtracts the one leaving. The sum is a 64-bit number over values read from a Float32Array, and every blurred value is stored back to 32-bit, six times in all.

  • render/pt-post.js
  • lines 73 to 89
  • BimCraft 0.8.1
/** Three box blurs per axis ≈ a Gaussian of standard deviation σ (pixels); edges clamp. */
function blur(src, w, h, sigma) {
  const r = Math.max(1, Math.round(Math.sqrt((12 * sigma * sigma) / 3 + 1) / 2));
  let a = Float32Array.from(src), b = new Float32Array(src.length);
  const pass = (from, to, horiz) => {
    const n = horiz ? w : h, m = horiz ? h : w, inv = 1 / (2 * r + 1);
    for (let j = 0; j < m; j++) for (let c = 0; c < 3; c++) {
      const at = (i) => (horiz ? (j * w + Math.min(n - 1, Math.max(0, i))) : (Math.min(n - 1, Math.max(0, i)) * w + j)) * 3 + c;
      let s = 0;
      for (let i = -r; i <= r; i++) s += from[at(i)];
      for (let i = 0; i < n; i++) { to[at(i)] = s * inv; s += from[at(i + r + 1)] - from[at(i - r)]; }
    }
  };
  for (let k = 0; k < 3; k++) { pass(a, b, true); [a, b] = [b, a]; }
  for (let k = 0; k < 3; k++) { pass(a, b, false); [a, b] = [b, a]; }
  return a;
}

Bloom

32-bit in part

The picture blended with three blurs of itself.

out = img × (1 − a) + (0.5 b₁ + 0.3 b₂ + 0.2 b₃) × a

The three weights add to 1, so the blend keeps the picture's total light apart from rounding. Input, blurs and output are Float32Array.

  • render/pt-post.js
  • lines 90 to 95
  • BimCraft 0.8.1
export function bloomPass(img, w, h, amount, size) {
  const d = Math.hypot(w, h) * size, out = new Float32Array(img.length);
  const blurs = [[d, 0.5], [d * 2, 0.3], [d * 4, 0.2]].map(([s, wt]) => [blur(img, w, h, s), wt]);
  for (let i = 0; i < img.length; i++) { let b = 0; for (const [bl, wt] of blurs) b += bl[i] * wt; out[i] = img[i] * (1 - amount) + b * amount; }
  return out;
}
render/pt-mix.js BimCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests bin/test-render.mjs

Light group gains

64-bit

The color and brightness factor of each light group in a light mix.

gain = color (linear AP1) × 2^stops

Kept in a Float64Array. The color is decoded to linear AP1 and multiplied by 2 to the power of the stops with Math.pow. A group that is switched off keeps a gain of 0.

  • render/pt-mix.js
  • lines 47 to 57
  • BimCraft 0.8.1
export function mixGains(mix, groups) {
  const g = new Float64Array(groups.length * 3);
  groups.forEach((gr, i) => {
    const m = (mix || {})[mixKey(gr)];
    if (m && m.on === false) return;
    // the colour is a tint: its AP1 value scaled so white is 1 (a tint never changes brightness by its own lightness alone)
    const c = m && m.color ? hexToAp1(m.color) : [1, 1, 1], k = Math.pow(2, (m && m.stops) || 0);
    g[i * 3] = c[0] * k; g[i * 3 + 1] = c[1] * k; g[i * 3 + 2] = c[2] * k;
  });
  return g;
}

Mixed picture

32-bit in part

The linear picture made by adding the light groups with their gains.

pixel = Σ over groups of (group sum × gain) ÷ samples

Each group's sum is multiplied by its 64-bit gain and divided by the sample count, and the terms are added one by one into a Float32Array element, so every partial sum is rounded to 32-bit.

  • render/pt-mix.js
  • lines 65 to 72
  • BimCraft 0.8.1
export function mixedLinear(L, mix) {
  const { w, h, accum, aux, stride, nElements: nE, groups } = L, lin = new Float32Array(w * h * 3), gains = mixGains(mix, groups);
  for (let i = 0; i < w * h; i++) {
    const n = accum[i * 4 + 3] || 1;
    for (let k = 0; k < groups.length; k++) { const o = (i * stride + nE + k) * 4; lin[i * 3] += aux[o] * gains[k * 3] / n; lin[i * 3 + 1] += aux[o + 1] * gains[k * 3 + 1] / n; lin[i * 3 + 2] += aux[o + 2] * gains[k * 3 + 2] / n; }
  }
  return lin;
}

Picture or lighting element from the sums

32-bit in part

The linear picture, light mix or one lighting element as a mean per pixel, handed to the post step.

mean = Σ samples ÷ sample count

The group terms of a mix are added in 64-bit variables before the division. The mean is then stored in a Float32Array. A sample count of 0 is read as 1.

  • render/pt-mix.js
  • lines 86 to 97
  • BimCraft 0.8.1
const lin = new Float32Array(w * h * 3);
const gains = useMix ? mixGains(mix, groups) : null, slot = show === 'beauty' || show === 'noisy' || !aux ? -1 : el(show);
for (let i = 0; i < w * h; i++) {
  const n = accum[i * 4 + 3] || 1;
  if (gains) {
    let r = 0, g = 0, b = 0;
    for (let k = 0; k < groups.length; k++) { const o = (i * stride + nE + k) * 4; r += aux[o] * gains[k * 3]; g += aux[o + 1] * gains[k * 3 + 1]; b += aux[o + 2] * gains[k * 3 + 2]; }
    lin[i * 3] = r / n; lin[i * 3 + 1] = g / n; lin[i * 3 + 2] = b / n;
  } else if (slot >= 0) { const o = (i * stride + slot) * 4; lin[i * 3] = aux[o] / n; lin[i * 3 + 1] = aux[o + 1] / n; lin[i * 3 + 2] = aux[o + 2] / n; }
  else { lin[i * 3] = accum[i * 4] / n; lin[i * 3 + 1] = accum[i * 4 + 1] / n; lin[i * 3 + 2] = accum[i * 4 + 2] / n; }
}
return postImage(lin, w, h, post, { exposure, wb, lut, float });
render/pt-spec.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-render.mjs

ACES filmic curve

64-bit

The default tone curve.

f(x) = (x (x + 0.0245786) − 0.000090537) ÷ (x (0.983729 x + 0.4329510) + 0.238081)

64-bit. The exposure factor is 2^stops ÷ 0.6 and the curve constants are written to 6 or 7 digits. The two matrices it multiplies by (ACES_IN and ACES_OUT in the same file) are written to 5 decimals.

  • render/pt-spec.js
  • lines 100 to 106
  • BimCraft 0.8.1
/** three.js's ACES filmic fit with its exposure handling (× 2^stops / 0.6), so this engine matches the fast render. */
export function acesFilmic(c, exposure = 0) {
  const k = Math.pow(2, exposure) / 0.6;
  const v = mul3(ACES_IN, [c[0] * k, c[1] * k, c[2] * k]);
  const f = v.map((x) => (x * (x + 0.0245786) - 0.000090537) / (x * (0.983729 * x + 0.4329510) + 0.238081));
  return mul3(ACES_OUT, f).map((x) => Math.min(1, Math.max(0, x)));
}

sRGB encoding

64-bit

The curve from display-linear values to stored sRGB values.

c ≤ 0.0031308: 12.92 c; else 1.055 c^(1 ÷ 2.4) − 0.055

The standard's constants in 64-bit with Math.pow.

  • render/pt-spec.js
  • line 89
  • BimCraft 0.8.1
export const linearToSrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);

One pixel to 8 bits

64-bit

A pixel's mean color turned into display bytes with the default settings.

byte = round(sRGB(ACES(Rec.709(AP1 × wb))) × 255)

64-bit through the color matrix (AP1_TO_REC709 in the same file, 7 decimals), the tone curve and the sRGB curve, then rounded to the nearest of 256 steps. A non-finite channel is taken as 0.

  • render/pt-spec.js
  • lines 108 to 113
  • BimCraft 0.8.1
/** A pixel's mean (AP1, linear) → display bytes: AP1 → Rec.709, exposure, ACES filmic, sRGB, ½-LSB rounding. */
export function displayPixel(ap1, exposure = 0, wb = null) {
  const lin = mul3(AP1_TO_REC709, wb ? [ap1[0] * wb[0], ap1[1] * wb[1], ap1[2] * wb[2]] : ap1).map((x) => (Number.isFinite(x) ? Math.max(0, x) : 0));
  const t = acesFilmic(lin, exposure);
  return [Math.round(linearToSrgb(t[0]) * 255), Math.round(linearToSrgb(t[1]) * 255), Math.round(linearToSrgb(t[2]) * 255), 255];
}
render/pt-wgsl.js BimCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests bin/test-render.mjs

Sample sums on the graphics card

32-bit in part

The running sum of every sample's color for a pixel when the render runs on the graphics card.

ACC[pixel] += max(sample, 0)

The sum is a vec4f, which is 32-bit, so the picture a graphics-card render produces is a 32-bit sum of 32-bit samples. A sample that is not a number, or is 1e30 or more in size, is left out.

  • render/pt-wgsl.js
  • lines 1783 to 1787
  • BimCraft 0.8.1
fn finite3(c: vec3f) -> bool { return all(c == c) && all(abs(c) < vec3f(1e30)); }
fn causticsOn() -> bool { let V = hw(${HW.VOL_OFF}u); return V != 0u && (ldu(V + 1u) & ${VOL.CAUSTIC_FLAG}u) != 0u; }
fn addPixel(pix: u32, c: vec3f) {
  if (finite3(c)) { ACC[pix] += vec4f(max(c, vec3f(0.0)), 0.0); }
}
render/pt-denoise.js BimCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests bin/test-render.mjs

Denoiser exposure scale

64-bit

The scale that brings the picture to the brightness the denoising network expects.

scale = 0.18 ÷ 2^(mean of log₂ L over 16 × 16-pixel bins)

Bin means and the mean logarithm are 64-bit sums over values read from the picture. Bins with a mean luminance of 1e-8 or less are left out; if none remain the scale is 1.

  • render/pt-denoise.js
  • lines 66 to 78
  • BimCraft 0.8.1
/** Key 0.18 ÷ the log-average luminance of the picture in 16 × 16-pixel bins (Rec.709 weights, as the network was trained). */
export function autoexposure(rgb, w, h) {
  const nbH = Math.ceil(h / 16), nbW = Math.ceil(w / 16);
  let s = 0, n = 0;
  for (let i = 0; i < nbH; i++) for (let j = 0; j < nbW; j++) {
    const h0 = Math.floor((i * h) / nbH), h1 = Math.floor(((i + 1) * h) / nbH), w0 = Math.floor((j * w) / nbW), w1 = Math.floor(((j + 1) * w) / nbW);
    let L = 0;
    for (let y = h0; y < h1; y++) for (let x = w0; x < w1; x++) { const o = (y * w + x) * 3; L += 0.212671 * Math.max(0, rgb[o] || 0) + 0.71516 * Math.max(0, rgb[o + 1] || 0) + 0.072169 * Math.max(0, rgb[o + 2] || 0); }
    L /= Math.max(1, (h1 - h0) * (w1 - w0));
    if (L > 1e-8) { s += Math.log2(L); n++; }
  }
  return n ? 0.18 / Math.pow(2, s / n) : 1;
}

Denoiser input

32-bit in part

The color, albedo and normal values given to the denoising network.

Color is scaled, clamped to 0 to 65504 and passed through a transfer curve, then stored in a Float32Array with the clamped albedo and the normals mapped to [0, 1].

  • render/pt-denoise.js
  • lines 80 to 89
  • BimCraft 0.8.1
export function prepare(rgb, alb, nrm, w, h, scale = autoexposure(rgb, w, h)) {
  const N = w * h, input = new Float32Array(9 * N);
  const fin = (x) => (Number.isFinite(x) ? x : 0);
  for (let p = 0; p < N; p++) for (let k = 0; k < 3; k++) {
    input[k * N + p] = puForward(Math.min(65504, Math.max(0, fin(rgb[p * 3 + k]) * scale))) * PU_NORM;
    input[(3 + k) * N + p] = Math.min(1, Math.max(0, fin(alb[p * 3 + k])));
    input[(6 + k) * N + p] = Math.min(1, Math.max(-1, fin(nrm[p * 3 + k]))) * 0.5 + 0.5;
  }
  return { input, scale };
}

Denoiser output

32-bit in part

The denoised picture taken back to linear values.

linear = inverse curve(x ÷ norm) ÷ scale

The network's 32-bit output goes through the inverse curve in 64-bit and is stored in a Float32Array. A scale of 0 is not divided by: the factor is set to 0.

  • render/pt-denoise.js
  • lines 90 to 94
  • BimCraft 0.8.1
export function finish(out3, w, h, scale) {
  const N = w * h, res = new Float32Array(N * 3), inv = scale > 0 ? 1 / scale : 0;
  for (let p = 0; p < N; p++) for (let k = 0; k < 3; k++) { const x = out3[k * N + p]; res[p * 3 + k] = puInverse(Number.isFinite(x) ? Math.max(0, x) / PU_NORM : 0) * inv; }
  return res;
}

In detail

Method
AP1 linear means × white balance, to linear Rec.709, × 2^exposure, bloom and glare, vignette, highlight compression, contrast, the view transform (an ACES filmic fit, AgX, Hable's curve or a clip), the sRGB curve, an optional lookup table, then quantization. The file headers cite the sources of each curve.
Number types
Float32Array for the linear picture, the working image and the denoiser; vec4f sums on the graphics card; Float64Array for the light-mix gains; 64-bit numbers per pixel; Uint8ClampedArray or Float32Array out.
Tolerances
Non-finite values become 0 and negatives are clamped to 0. Highlight compression starts above a luminance of 1. The AgX logarithm clamps its input at 1e-10.
Limits
Color matrices are written to 4 to 7 decimals and the curve fits to the digits of their sources. A pixel's sum of many samples in 32-bit loses the low digits of small samples added to a large sum.

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