import { Color } from "./Color" import type { RenderConfig } from "./RenderConfig" /** CPU color + depth buffer the renderer writes into before it is blitted to a * canvas. Kept as flat typed arrays so it needs no DOM and can also run * headless (server-side rendering, tests, baking). */ export type Framebuffer = { readonly width: number readonly height: number /** Packed RGBA pixels; see Color. Aliased as an ImageData at blit time. */ readonly color: Uint32Array /** Per-pixel depth stored as 1/w. 1/w (unlike w) interpolates linearly in * screen space, so it is both cheap and correct to compare. Larger = nearer; * cleared to 0 = infinitely far. */ readonly depth: Float32Array } export namespace Framebuffer { export function create(width: number, height: number): Framebuffer { return { width, height, color: new Uint32Array(width * height), depth: new Float32Array(width * height), } } /** Reset every pixel to `color` and depth to 0 (farthest). Call once a frame * before drawing; `color` should match the fog color so uncovered pixels * (gaps past the geometry) blend seamlessly. */ export function clear(fb: Framebuffer, color: Color): void { fb.color.fill(color) fb.depth.fill(0) } /** * Posterize the color buffer to `config.colorDepth` bits per channel with a * Bayer 4x4 ordered dither, in place. This is a post-process over the whole * frame (run after all geometry), reproducing the PS1's banded-yet-dithered * 15-bit output. Skipped entirely when it would be a no-op (full depth, no * dither). */ export function quantize(fb: Framebuffer, config: RenderConfig, y0 = 0, y1 = fb.height): void { const levels = (1 << config.colorDepth) - 1 if (levels >= 255 && config.dither === 0) { return } const { width, color } = fb for (let y = y0; y < y1; y++) { for (let x = 0; x < width; x++) { // Per-pixel threshold from the tiled Bayer matrix, centered on 0 and // scaled by strength, nudges each channel before it snaps to a level. const threshold = (BAYER4[(y & 3) * 4 + (x & 3)] / 16 - 0.5) * config.dither const i = y * width + x const c = color[i] color[i] = Color.rgb( channel(Color.r(c), threshold, levels), channel(Color.g(c), threshold, levels), channel(Color.b(c), threshold, levels), ) } } } /** Bayer 4x4 threshold map (values 0..15), read modulo 4 in x and y. */ const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5] /** Snap one 0..255 channel to `levels` steps after applying the dither * threshold, then expand back to 0..255. */ function channel(value: number, threshold: number, levels: number): number { const n = value / 255 + threshold / levels const q = Math.min(levels, Math.max(0, Math.round(n * levels))) return Math.round((q / levels) * 255) } }