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.jsBimCraft 0.8.1 32-bit in part4 calculationsReviewed
TestsNone 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.
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.