98 lines
4.0 KiB
JavaScript
98 lines
4.0 KiB
JavaScript
// Layer engine for the viewer Edit modal — pure, DOM-free, browser+node ESM.
|
|
//
|
|
// The brush engine's no-rings guarantee comes from writing OPAQUE final colors
|
|
// with a SINGLE float blend + ONE quantization per pixel (see EditModal's brush
|
|
// notes). A single paint canvas could bake that at write time; layers can't —
|
|
// each layer must keep its own un-flattened contribution and the flatten has to
|
|
// happen at DISPLAY time, still with exactly one quantization, or the rings
|
|
// (8-bit per-step terraces / premultiplied staircase) come back.
|
|
//
|
|
// Layer storage = STRAIGHT (non-premultiplied) RGB 0..255 + coverage 0..1 in a
|
|
// Float32 RGBA buffer (A slot = coverage). The base image is just a layer with
|
|
// coverage 1 everywhere. `composite()` blends bottom→top in float and quantizes
|
|
// once with the same integer-hash dither the brush uses, so a smooth coverage
|
|
// ramp resolves to a smooth gradient instead of banded terraces.
|
|
|
|
// Per-pixel integer-hash white noise in [-0.5, 0.5] — deterministic per
|
|
// position so re-compositing identical layers never shimmers. VERBATIM from
|
|
// EditModal's `dither` (keep in sync; the equivalence harness asserts it).
|
|
export function ditherAt(x, y) {
|
|
let h = (x * 374761393 + y * 668265263) | 0;
|
|
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
return ((h ^ (h >>> 16)) >>> 0) / 4294967296 - 0.5;
|
|
}
|
|
|
|
let _nextLayerId = 1;
|
|
|
|
// A blank transparent layer. `data` is Float32 RGBA (RGB 0..255, A = coverage
|
|
// 0..1). `mask`, when present, is a Float32 coverage field multiplied into the
|
|
// layer's alpha at composite time (a real per-layer mask).
|
|
export function createLayer(width, height, opts = {}) {
|
|
return {
|
|
id: opts.id ?? _nextLayerId++,
|
|
name: opts.name ?? "Layer",
|
|
visible: opts.visible ?? true,
|
|
opacity: opts.opacity ?? 1,
|
|
blendMode: opts.blendMode ?? "normal",
|
|
locked: opts.locked ?? false,
|
|
data: new Float32Array(width * height * 4),
|
|
mask: opts.mask ?? null,
|
|
};
|
|
}
|
|
|
|
// Seed a layer from RGBA pixel bytes (e.g. the source image's ImageData.data) —
|
|
// coverage 1 everywhere it's opaque, scaled by the byte alpha otherwise.
|
|
export function layerFromRGBA(bytes, width, height, opts = {}) {
|
|
const layer = createLayer(width, height, opts);
|
|
const d = layer.data;
|
|
for (let i = 0, n = width * height; i < n; i++) {
|
|
d[i * 4] = bytes[i * 4];
|
|
d[i * 4 + 1] = bytes[i * 4 + 1];
|
|
d[i * 4 + 2] = bytes[i * 4 + 2];
|
|
d[i * 4 + 3] = bytes[i * 4 + 3] / 255;
|
|
}
|
|
return layer;
|
|
}
|
|
|
|
// Straight-alpha "over" of one layer onto a resolved float RGB accumulator.
|
|
// Only "normal" is implemented; unknown modes fall back to normal for now.
|
|
function blendInto(accRGB, layer, width, height) {
|
|
const d = layer.data;
|
|
const op = layer.opacity;
|
|
const mask = layer.mask;
|
|
for (let i = 0, n = width * height; i < n; i++) {
|
|
let a = d[i * 4 + 3] * op;
|
|
if (mask) a *= mask[i];
|
|
if (a <= 0) continue;
|
|
const inv = 1 - a;
|
|
const k = i * 3;
|
|
accRGB[k] = d[i * 4] * a + accRGB[k] * inv;
|
|
accRGB[k + 1] = d[i * 4 + 1] * a + accRGB[k + 1] * inv;
|
|
accRGB[k + 2] = d[i * 4 + 2] * a + accRGB[k + 2] * inv;
|
|
}
|
|
}
|
|
|
|
// Flatten visible layers (array order = bottom→top) into opaque RGBA bytes,
|
|
// quantizing ONCE with dither. Returns Uint8ClampedArray(width*height*4); the
|
|
// browser wraps it in `new ImageData(bytes, width, height)`. The bottom layer
|
|
// is expected to be opaque (the base image); uncovered pixels resolve to black.
|
|
export function composite(layers, width, height, dither = ditherAt) {
|
|
const n = width * height;
|
|
const accRGB = new Float32Array(n * 3);
|
|
for (const layer of layers) {
|
|
if (layer.visible) blendInto(accRGB, layer, width, height);
|
|
}
|
|
const out = new Uint8ClampedArray(n * 4);
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = y * width + x;
|
|
const dq = dither(x, y);
|
|
out[i * 4] = Math.round(accRGB[i * 3] + dq);
|
|
out[i * 4 + 1] = Math.round(accRGB[i * 3 + 1] + dq);
|
|
out[i * 4 + 2] = Math.round(accRGB[i * 3 + 2] + dq);
|
|
out[i * 4 + 3] = 255;
|
|
}
|
|
}
|
|
return out;
|
|
}
|