b824308182
An RGBA_F32 image with an sRGB tag comes back off the GPU backend sampled on a 1/255 grid; the same shader on a raster surface returns the floats untouched. The plane is raw/65535, so the shadows the black level is there to keep sit at 1e-3 and quantise to zero -- a 3010x2012 develop landed 41189 pixels under luma 2 with the dark end speckled blue/yellow, against none on the raster surface. A half is uploaded as float, so the plane stays exact either way. Rejects the earlier guess that the render target's colour space was to blame: gpu+rt-srgb and gpu+img-untagged came back byte-identical to gpu. scripts/half-check.mjs checks the conversion: the named encodings, and no plane value in a 14-bit sensor's range moving more than 4.8e-4 relative.
59 lines
2.5 KiB
JavaScript
59 lines
2.5 KiB
JavaScript
// The RAW develop hands its band plane to Skia as half (src/engine/halfFloat.ts)
|
|
// because the GPU backend quantises an F32 image to the 1/255 grid — which is
|
|
// exactly the grid the shadows live under. This checks the conversion itself:
|
|
// the named encodings, and that no plane value in the sensor's range moves more
|
|
// than the 16-bit plane step it came from.
|
|
//
|
|
// node scripts/half-check.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import ts from 'typescript';
|
|
|
|
const src = readFileSync(new URL('../src/engine/halfFloat.ts', import.meta.url), 'utf8');
|
|
const js = ts.transpileModule(src, {
|
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText;
|
|
const { f32ToF16 } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
|
|
|
|
function encode(...values) {
|
|
const src32 = Float32Array.from(values);
|
|
const out = new Uint16Array(values.length);
|
|
f32ToF16(src32, out, values.length);
|
|
return out;
|
|
}
|
|
// Independent decoder, from the IEEE 754 binary16 layout.
|
|
function decode(h) {
|
|
const sign = h & 0x8000 ? -1 : 1;
|
|
const exp = (h >> 10) & 0x1f;
|
|
const man = h & 0x3ff;
|
|
if (exp === 0x1f) return sign * Infinity;
|
|
if (exp === 0) return sign * man * 2 ** -24;
|
|
return sign * (1 + man / 1024) * 2 ** (exp - 15);
|
|
}
|
|
|
|
const named = [
|
|
[0, 0x0000], [1, 0x3c00], [0.5, 0x3800], [2, 0x4000], [-1, 0xbc00],
|
|
[2 ** -24, 0x0001], [1e-9, 0x0000], [70000, 0x7c00], [65504, 0x7bff],
|
|
];
|
|
for (const [v, bits] of named) {
|
|
assert.equal(encode(v)[0], bits, `${v} -> expected ${bits.toString(16)}`);
|
|
}
|
|
|
|
// Every value the plane can carry — a 14-bit sensor from its black floor to its
|
|
// white level — has to come back within half precision's 2^-11 relative error,
|
|
// and the shadows within a far finer absolute one, since that is where the
|
|
// develop's black level lives. 1e-3 relative is a tenth of an output level at
|
|
// white; the absolute bound covers the values whose relative error says nothing.
|
|
let worstRel = 0, worstAbs = 0;
|
|
for (let n = 0; n <= 16312; n++) {
|
|
const v = n / 65535;
|
|
const back = decode(encode(v)[0]);
|
|
worstAbs = Math.max(worstAbs, Math.abs(back - v));
|
|
if (v > 0) worstRel = Math.max(worstRel, Math.abs(back - v) / v);
|
|
}
|
|
assert.ok(worstRel < 1e-3, `worst relative error ${worstRel}`);
|
|
assert.ok(worstAbs < 1e-4, `worst absolute error ${worstAbs} at raw ${Math.round(worstAbs * 65535)}`);
|
|
console.log(`plane 0..16312: worst ${worstRel.toExponential(2)} relative, ${worstAbs.toExponential(2)} absolute`);
|
|
|
|
console.log('half-check ok');
|