SlapStackStudio / studio /studio_core.js
Aluode's picture
Upload 18 files
e38621f verified
Raw
History Blame Contribute Delete
7.63 kB
/* Slapstack Studio layer math — NEW mechanics on top of the verified core.
core.js is untouched; everything here is presentation-adjacent inference
plumbing, tested by tests_studio.js:
renderEnv per-layer envelope-energy buffer (for alpha + coverage)
coverageOf how much front-stack mass sits on each atom center
autoHidden coverage -> per-atom evidence mask (depth occlusion)
compositePainter back-to-front alpha compositing of layer buffers
composePose Sim(2) composition in vote coordinates [tx,ty,rho,lam]
*/
"use strict";
const ALPHA_C = 2.0; // alpha = 1 - exp(-ALPHA_C * bounded envelope energy)
const COVER_THRESH = 0.55; // atom loses evidence when this covered
/* Opacity of a layer = where it actually PAINTS: per-pixel deviation of
the pre-sigmoid field from neutral. Cancelling atom pairs (high energy,
zero sum) are correctly transparent — envelope-based opacity is not used
because it saturates on exactly those invisible pairs.
a = 1 - exp(-ALPHA_C * max(0, |preR|+|preG|+|preB| - FLOOR)). */
const ALPHA_FLOOR = 0.08;
function alphaFromPre(pre, H, out) {
out = out || new Float32Array(H * H);
const n = H * H;
for (let i = 0; i < n; i++) {
const m = Math.abs(pre[i]) + Math.abs(pre[n + i]) + Math.abs(pre[2 * n + i]);
out[i] = m > ALPHA_FLOOR ? 1 - Math.exp(-ALPHA_C * (m - ALPHA_FLOOR)) : 0;
}
return out;
}
/* coverage of each atom center by a front stack of alpha buffers:
cov_i = 1 - prod_front (1 - a_m(x_i)). */
function coverageOf(atoms, frontAlphaBufs, H) {
const out = new Float32Array(atoms.length);
for (let i = 0; i < atoms.length; i++) {
const px = Math.max(0, Math.min(H - 1, Math.round((atoms[i][0] + 1) / 2 * (H - 1))));
const py = Math.max(0, Math.min(H - 1, Math.round((atoms[i][1] + 1) / 2 * (H - 1))));
let keep = 1;
for (const buf of frontAlphaBufs) keep *= 1 - buf[py * H + px];
out[i] = 1 - keep;
}
return out;
}
/* Per-atom evidence mask from the depth stack.
alphaBufs aligned with layers. An atom of layer k is evidence-free if the
strictly-in-front stack covers it beyond COVER_THRESH, or its layer is
user-hidden. */
function autoHidden(obs, layerOf, layers, alphaBufs, H) {
const mask = new Array(obs.length).fill(false);
const covFrac = layers.map(() => 0);
const counts = layers.map(() => 0);
for (let i = 0; i < obs.length; i++) {
const k = layerOf[i];
if (k < 0) continue; // clutter: always evidenced
counts[k]++;
if (layers[k].hidden) { mask[i] = true; covFrac[k]++; continue; }
const front = [];
for (let m = 0; m < layers.length; m++)
if (m !== k && !layers[m].hidden && layers[m].depth > layers[k].depth)
front.push(alphaBufs[m]);
if (!front.length) continue;
const cov = coverageOf([obs[i]], front, H)[0];
if (cov > COVER_THRESH) { mask[i] = true; covFrac[k]++; }
}
for (let k = 0; k < layers.length; k++)
covFrac[k] = counts[k] ? covFrac[k] / counts[k] : 0;
return { mask, covFrac };
}
/* Painter compositing: layers back-to-front by depth.
rgbBufs[k]: Float32Array(3*H*H) pre-sigmoid; alphaBufs[k]: from
alphaFromPre. Base is mid-gray (sigmoid(0)). */
function compositePainter(order, rgbBufs, alphaBufs, H, out) {
out = out || new Uint8ClampedArray(4 * H * H);
const n = H * H;
const acc = new Float32Array(3 * n);
for (let i = 0; i < n; i++) { acc[i] = 127.5; acc[n + i] = 127.5; acc[2 * n + i] = 127.5; }
for (const k of order) {
const pre = rgbBufs[k], al = alphaBufs[k];
for (let i = 0; i < n; i++) {
const a = al[i];
if (a < 1e-3) continue;
acc[i] += a * (255 / (1 + Math.exp(-2 * pre[i])) - acc[i]);
acc[n + i] += a * (255 / (1 + Math.exp(-2 * pre[n + i])) - acc[n + i]);
acc[2 * n + i] += a * (255 / (1 + Math.exp(-2 * pre[2 * n + i])) - acc[2 * n + i]);
}
}
for (let i = 0; i < n; i++) {
out[4 * i] = acc[i]; out[4 * i + 1] = acc[n + i];
out[4 * i + 2] = acc[2 * n + i]; out[4 * i + 3] = 255;
}
return out;
}
/* Backdrop trim: whole-image fits carry large-sigma background pads that
cover the full frame; for OBJECT layers, drop atoms whose envelope is
wider than sigMax. HEURISTIC, honestly: it also kills any genuinely
large object parts. The untrimmed layer is kept in the library. */
function trimAtoms(atoms, sigMax = 0.35) {
const kept = atoms.filter(a => Math.max(a[3], a[4]) <= sigMax);
if (!kept.length) return atoms.slice();
// re-center xy so the trimmed set is a canonical template again
let mx = 0, my = 0;
for (const a of kept) { mx += a[0]; my += a[1]; }
mx /= kept.length; my /= kept.length;
return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
}
/* ---- Bet 9 additions: eraser + backdrop + band gains (pure, testable) ---- */
/* Frequency band of an atom. f is cycles/unit; the frame spans 2 units, so
cycles-per-image = 2f. Bands follow the original splatstack dials:
coarse < 4 c/img, mid 4–10, fine > 10. Returns 0|1|2. */
const BAND_EDGES = [2.0, 5.0];
function bandOf(f) { return f < BAND_EDGES[0] ? 0 : f < BAND_EDGES[1] ? 1 : 2; }
/* Erase mask: true for atoms whose center falls inside the brush circle.
skip(i) can veto (e.g. atoms of hidden layers stay untouched). */
function eraseMask(obs, wx, wy, r, skip) {
const r2 = r * r;
return obs.map((a, i) => {
if (skip && skip(i)) return false;
const dx = a[0] - wx, dy = a[1] - wy;
return dx * dx + dy * dy < r2;
});
}
/* Composite an already-sigmoided field over a backdrop.
img: Uint8ClampedArray RGBA from sigmoidField/compositePainter (mutated).
alpha: Float32Array(H*H) from alphaFromPre of the (band-gained) pre field.
opacity: global gain on alpha, clamped to [0,1] per pixel.
bg: either [r,g,b] flat color or a Uint8ClampedArray(4*H*H) RGBA image.
Identity check: with alpha==1 everywhere or bg==the field itself the
output is unchanged; with opacity such that a==1 the field wins. */
function blendOverBackdrop(img, alpha, opacity, bg, H) {
const n = H * H;
const flat = !(bg && bg.length === 4 * n);
for (let i = 0; i < n; i++) {
const a = Math.min(1, alpha[i] * opacity);
const br = flat ? bg[0] : bg[4 * i],
bgr = flat ? bg[1] : bg[4 * i + 1],
bb = flat ? bg[2] : bg[4 * i + 2];
img[4 * i] = br + a * (img[4 * i] - br);
img[4 * i + 1] = bgr + a * (img[4 * i + 1] - bgr);
img[4 * i + 2] = bb + a * (img[4 * i + 2] - bb);
}
return img;
}
/* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
function composePose(g2, g1) {
const s2 = Math.exp(g2[3]);
const c = Math.cos(g2[2]), s = Math.sin(g2[2]);
const wrap = d => ((d + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
return [s2 * (c * g1[0] - s * g1[1]) + g2[0],
s2 * (s * g1[0] + c * g1[1]) + g2[1],
wrap(g1[2] + g2[2]), g1[3] + g2[3]];
}
function gestureTranslate(dx, dy) { return [dx, dy, 0, 0]; }
function gestureRotateAbout(c, drho) {
const co = Math.cos(drho), si = Math.sin(drho);
return [c[0] - (co * c[0] - si * c[1]), c[1] - (si * c[0] + co * c[1]), drho, 0];
}
function gestureScaleAbout(c, ds) {
const s = Math.exp(ds);
return [c[0] - s * c[0], c[1] - s * c[1], 0, ds];
}
if (typeof module !== "undefined") {
module.exports = {
ALPHA_C, COVER_THRESH, alphaFromPre, coverageOf, autoHidden,
compositePainter, composePose, gestureTranslate, gestureRotateAbout,
gestureScaleAbout, trimAtoms,
BAND_EDGES, bandOf, eraseMask, blendOverBackdrop,
};
}