Resources Numerical accuracy Calculation policy

Material maps and statistics from a photo

Makes the color, roughness and normal maps of a tiling material from one photo, and reports how visible the tile's join is and how much relief the photo has.

How the precision is kept

The photo's 8-bit pixels are resampled into 512 × 512 planes held in Float32Array and processed there: blurring, flattening the light, blending the edges, a height estimate and a normal map. The maps are written back as 8-bit pixels and are used for drawing. The statistics returned with them are sums over those 32-bit planes, rounded to 2 or 3 decimals.

Open item. Hold the image planes in Float64Array, or accumulate the statistics from the 8-bit pixels, so the seam ratio, height spread and mean roughness shown do not pass through 32-bit values.

Calculations under this policy

4 calculations in 1 source file. 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.

photo-material.js BimCraft 0.8.1 32-bit in part 4 calculations Reviewed

Tests None found by the review

Resampling the chosen part of the photo

32-bit, display only

Averages the chosen part of the photo down, or interpolates it up, to the tile size.

Each tile pixel is the mean of up to 6 × 6 bilinear samples of the 8-bit photo, stored in Float32Array planes. The result is used for the color map.

  • photo-material.js
  • lines 73 to 95
  • BimCraft 0.8.1
/** The picture's chosen part (rows top-down) as three N × N planes, averaged down (or bilinearly up) to the tile size. */
function cropTo(photo, crop, n) {
  const { w, h, data } = photo;
  const [cx, cy, cw, ch] = crop;
  const sx = cx * w, sy = cy * h, sw = cw * w, sh = ch * h;
  const taps = clamp(Math.ceil(Math.max(sw, sh) / n), 1, 6);
  const R = new Float32Array(n * n), G = new Float32Array(n * n), B = new Float32Array(n * n);
  const at = (x, y, c) => {
    const fx = clamp(x - 0.5, 0, w - 1), fy = clamp(y - 0.5, 0, h - 1), x0 = Math.floor(fx), y0 = Math.floor(fy), x1 = Math.min(w - 1, x0 + 1), y1 = Math.min(h - 1, y0 + 1), tx = fx - x0, ty = fy - y0;
    const p = (xx, yy) => data[(yy * w + xx) * 4 + c];
    return (p(x0, y0) * (1 - tx) + p(x1, y0) * tx) * (1 - ty) + (p(x0, y1) * (1 - tx) + p(x1, y1) * tx) * ty;
  };
  for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
    let r = 0, g = 0, b = 0;
    for (let j = 0; j < taps; j++) for (let i = 0; i < taps; i++) {
      const px = sx + ((x + (i + 0.5) / taps) / n) * sw, py = sy + ((y + (j + 0.5) / taps) / n) * sh;
      r += at(px, py, 0); g += at(px, py, 1); b += at(px, py, 2);
    }
    const o = y * n + x, k = taps * taps;
    R[o] = r / k; G[o] = g / k; B[o] = b / k;
  }
  return [R, G, B];
}

Normal map from the height estimate

32-bit, display only

The surface direction at each pixel of the tile.

v = (−Δh_x × S, −Δh_y × S, 1) ÷ |v|, S = 4 × bump; stored as (v ÷ 2 + ½) × 255

Central differences of a Float32Array height plane with wrap-around, normalized with Math.hypot and stored in Float32Array before being rounded to 8-bit pixels.

  • photo-material.js
  • lines 173 to 179
  • BimCraft 0.8.1
const S = 4 * bump, nx = new Float32Array(n * n), ny = new Float32Array(n * n), nz = new Float32Array(n * n);
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
  const xm = (x + n - 1) % n, xp = (x + 1) % n, ym = (y + n - 1) % n, yp = (y + 1) % n;
  const dx = (hgt[y * n + xp] - hgt[y * n + xm]) * 0.5, dy = (hgt[ym * n + x] - hgt[yp * n + x]) * 0.5;   // rows top-down here: y up the tile is y − 1
  const vx = -dx * S, vy = -dy * S, l = Math.hypot(vx, vy, 1), o = y * n + x;
  nx[o] = (vx / l * 0.5 + 0.5) * 255; ny[o] = (vy / l * 0.5 + 0.5) * 255; nz[o] = (1 / l * 0.5 + 0.5) * 255;
}

Seam ratio

32-bit in part

How big the jump across the tile's join is compared with the usual jump between neighboring pixels.

ratio = mean |jump across the join| ÷ max(mean |jump between neighbors|, 1e-6)

The brightness plane it reads is a Float32Array; the absolute differences are summed in a 64-bit accumulator. The value is shown rounded to 0.01 and, when above 2.2, quoted in a note to one decimal.

  • photo-material.js
  • lines 97 to 106
  • BimCraft 0.8.1
/** How big the jump across the tile's join is, against the usual jump between neighbouring pixels inside it. 1 = invisible, 3+ = a visible seam. */
export function seamRatio(a, n) {
  let join = 0, inner = 0;
  for (let i = 0; i < n; i++) {
    join += Math.abs(a[i * n] - a[i * n + n - 1]) + Math.abs(a[i] - a[(n - 1) * n + i]);
    for (let j = 0; j < n - 1; j++) inner += Math.abs(a[i * n + j] - a[i * n + j + 1]) + Math.abs(a[j * n + i] - a[(j + 1) * n + i]);
  }
  join /= 2 * n; inner /= 2 * n * (n - 1);
  return join / Math.max(inner, 1e-6);
}

Statistics returned with the maps

32-bit in part

The crop, seam ratios, mean color, height spread and mean roughness reported for the material.

σ = √(Σ band² ÷ n); seam and σ rounded to 0.01, mean roughness to 0.001, crop to 0.001

The height spread, mean color and mean roughness are sums over Float32Array planes taken in 64-bit accumulators, then rounded. The source data is 8-bit, so the 32-bit planes hold more digits than the pixels do, but the values still pass through 32-bit storage.

  • photo-material.js
  • line 192
  • BimCraft 0.8.1
stats: { size: n, source: { w: photo.w, h: photo.h }, crop: crop.map((v) => Math.round(v * 1000) / 1000), seam: { before: Math.round(before * 100) / 100, after: Math.round(after * 100) / 100 }, meanColor: '#' + hex(mc[0]) + hex(mc[1]) + hex(mc[2]), heightSigma: Math.round(sigma * 100) / 100, roughnessMean: Math.round(mean(rs) * 1000) / 1000 },

In detail

Method
Box blurs by running sums, a half-tile blend with a smoothstep mask, relief from the difference of a fine and a coarse blur passed through tanh, normals from central differences of the height.
Number types
Uint8Array pixels in and out; Float32Array planes in between; 64-bit accumulators for sums.
Tolerances
Seam ratio divides by at least 1e-6. Light flattening is clamped to 0.6 … 1.7 times. A tile is called joined badly above a ratio of 2.2.
Limits
The file's header says the relief is the photo's look of relief, not a measurement, and the roughness is an estimate.

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