// Indexed flat-fill rasteriser. // // Canvas2D antialiases path fills, and antialiasing is exactly what the target // idiom does not have: Animator Pro fills polygons into a 256-colour indexed // raster with hard edges (csd_render_poly). A preview that antialiases would // misrepresent the look it exists to judge, so this writes palette indices into // a byte buffer with an even-odd scanline fill and expands to RGBA only at the // very end. export class IndexedRaster { constructor(w, h) { this.w = w; this.h = h; this.buf = new Uint8Array(w * h); } clear(index) { this.buf.fill(index); } // Even-odd scanline fill. Samples at pixel centres (y + 0.5), so a polygon // edge landing exactly on a pixel boundary resolves consistently. fillPoly(pts, index) { const n = pts.length; if (n < 3) return; let minY = Infinity, maxY = -Infinity; for (const p of pts) { if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y; } const y0 = Math.max(0, Math.ceil(minY - 0.5)); const y1 = Math.min(this.h - 1, Math.floor(maxY - 0.5) + 1); const xs = []; for (let y = y0; y <= y1; y++) { const sy = y + 0.5; xs.length = 0; for (let i = 0; i < n; i++) { const a = pts[i], b = pts[(i + 1) % n]; if (a.y === b.y) continue; const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y); if (sy < lo || sy >= hi) continue; xs.push(a.x + ((sy - a.y) / (b.y - a.y)) * (b.x - a.x)); } if (xs.length < 2) continue; xs.sort((p, q) => p - q); for (let k = 0; k + 1 < xs.length; k += 2) { const xa = Math.max(0, Math.ceil(xs[k] - 0.5)); const xb = Math.min(this.w - 1, Math.floor(xs[k + 1] - 0.5)); const row = y * this.w; for (let x = xa; x <= xb; x++) this.buf[row + x] = index; } } } // `over` is an optional stencil: when given, only pixels that currently hold // that index are written. The indexed buffer is its own clip mask, which is // how Animator Pro would do it - and it is what keeps the iris inside the // eye. A disc clipped by the sclera cannot spill past the lid at any gaze or // any radius, including mid-blink when the opening is a two-pixel sliver, so // the lid crops the iris for free instead of the gaze range needing a // clamp that would flatten the performance at the extremes. fillDisc(cx, cy, r, index, over = null) { const rr = r * r; const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(this.h - 1, Math.ceil(cy + r)); const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(this.w - 1, Math.ceil(cx + r)); for (let y = y0; y <= y1; y++) { for (let x = x0; x <= x1; x++) { const dx = x + 0.5 - cx, dy = y + 0.5 - cy; if (dx * dx + dy * dy > rr) continue; const o = y * this.w + x; if (over === null || this.buf[o] === over) this.buf[o] = index; } } } // An exactly size x size block of pixels, snapped to the pixel grid, with the // same optional stencil as fillDisc. // // The pupil is a SQUARE because at 320x200 it is three pixels across, and a // circle of radius 1.5 is not a circle - it is a plus sign with the corners // gnawed off, and it changes shape as it moves. A square that size is a // deliberate mark that stays the same mark wherever it lands, which is the // whole argument for flat shapes at this resolution. // // The top-left is rounded rather than the centre, so the block is size x size // on every frame. Round the extents instead and a fractional centre gives you // three pixels on one frame and four on the next, which reads as the pupil // breathing. fillRect(cx, cy, size, index, over = null) { if (size < 1) return; const x0 = Math.round(cx - size / 2), y0 = Math.round(cy - size / 2); for (let y = Math.max(0, y0); y < Math.min(this.h, y0 + size); y++) { for (let x = Math.max(0, x0); x < Math.min(this.w, x0 + size); x++) { const o = y * this.w + x; if (over === null || this.buf[o] === over) this.buf[o] = index; } } } // Expand indices through the palette into an ImageData at integer zoom. // Nearest-neighbour by construction, so no filtering softens the result. toImageData(palette, zoom = 1) { const W = this.w * zoom, H = this.h * zoom; const img = new ImageData(W, H); const d = img.data; const rgb = palette.map(hexToRgb); for (let y = 0; y < H; y++) { const srow = Math.floor(y / zoom) * this.w; for (let x = 0; x < W; x++) { const c = rgb[this.buf[srow + Math.floor(x / zoom)]] || [255, 0, 255]; const o = (y * W + x) * 4; d[o] = c[0]; d[o + 1] = c[1]; d[o + 2] = c[2]; d[o + 3] = 255; } } return img; } } export function hexToRgb(hex) { const s = hex.replace('#', ''); return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]; }