// CPU framebuffer + immediate-mode primitives. // // Pixel format: RGBA8, row-major. (0,0) is top-left. // This is the layer the Metal blit will consume — keep it // Foundation-only so it can run from a CLI without AppKit. import Foundation final class KLFramebuffer { let width: Int let height: Int var pixels: [UInt8] // length = width * height * 4, RGBA init(width: Int, height: Int) { self.width = max(1, width) self.height = max(1, height) self.pixels = [UInt8](repeating: 0, count: self.width * self.height * 4) } @inline(__always) private func index(_ x: Int, _ y: Int) -> Int { return (y * width + x) * 4 } /// Standard "over" composite: `dest = src.rgb * α + dest.rgb * (1-α)`. /// The framebuffer stays at alpha=255 throughout — partial alpha lives /// only on the incoming ink color. This matches kidlisp.mjs's draw /// semantics for `ink … N` where N is alpha. @inline(__always) func setPixel(_ x: Int, _ y: Int, _ c: KLColor) { guard x >= 0, y >= 0, x < width, y < height else { return } let i = index(x, y) if c.a == 255 { pixels[i] = c.r pixels[i + 1] = c.g pixels[i + 2] = c.b pixels[i + 3] = 255 return } let alpha = Double(c.a) / 255.0 let inv = 1.0 - alpha let dr = Double(pixels[i]), sr = Double(c.r) let dg = Double(pixels[i + 1]), sg = Double(c.g) let db = Double(pixels[i + 2]), sb = Double(c.b) pixels[i] = UInt8(max(0.0, min(255.0, sr * alpha + dr * inv))) pixels[i + 1] = UInt8(max(0.0, min(255.0, sg * alpha + dg * inv))) pixels[i + 2] = UInt8(max(0.0, min(255.0, sb * alpha + db * inv))) pixels[i + 3] = 255 // canvas stays opaque } func wipe(_ c: KLColor) { let total = width * height var i = 0 for _ in 0..= dy { err += dy; x0 += sx } if e2 <= dx { err += dx; y0 += sy } } } /// Separable Gaussian blur of pixel radius `r`. Kernel = 2r+1, σ = r/3. /// Identity at r ≤ 0. Mirrors graph.mjs Gaussian variant (the /// `applyHorizontalBlur` / `applyVerticalBlur` pair) so visuals stay /// consistent across runtimes. func blur(radius r: Int) { guard r > 0 else { return } let w = width, h = height let kernelSize = min(2 * r + 1, 15) let radius = kernelSize / 2 let sigma = max(0.25, Double(radius) / 3.0) var weights = [Double](repeating: 0, count: kernelSize) var wsum = 0.0 for i in 0.. tmp for y in 0.. pixels for y in 0.. 0 else { setPixel(cx, cy, c); return } for dy in -r...r { let span = Int(Double(r * r - dy * dy).squareRoot().rounded()) let y = cy + dy for dx in -span...span { setPixel(cx + dx, y, c) } } } /// Wrap-around scroll by float pixels. Fractional offsets snap to int. func scroll(dx: Double, dy: Double) { let w = width, h = height let ix = ((Int(dx.rounded()) % w) + w) % w let iy = ((Int(dy.rounded()) % h) + h) % h if ix == 0 && iy == 0 { return } var dst = [UInt8](repeating: 0, count: w * h * 4) for y in 0.. 0 else { return } let w = width, h = height var i = 0 for _ in 0..<(w * h) { for c in 0..<3 { let v = Double(pixels[i + c]) let stretched = (v - 128.0) * f + 128.0 pixels[i + c] = UInt8(max(0.0, min(255.0, stretched))) } i += 4 } } /// Vortex spin (matches graph.mjs §spinBlockBased). `integerSteps` is /// the already-accumulated whole-pixel step count — the evaluator does /// the fractional accumulation. Per-pixel angle change = `steps / /// distance_from_center`, so near-center pixels twist more than edges, /// producing spiral arms. Source coords wrap (modulo) for the tiled- /// tunnel look $roz depends on. func spinVortex(integerSteps: Double) { guard integerSteps != 0 else { return } let w = width, h = height let cx = Double(w) / 2.0, cy = Double(h) / 2.0 var dst = [UInt8](repeating: 0, count: w * h * 4) let twoPi = 2.0 * .pi for y in 0.. 0, scale != 1.0 else { return } let w = width, h = height let ax = Double(w) * anchorX let ay = Double(h) * anchorY let inv = 1.0 / scale var dst = [UInt8](repeating: 0, count: w * h * 4) for y in 0.. 1 else { wipe(colors[0]); return } let stops = colors.count - 1 let h = height for y in 0..