meat/engine/render/Framebuffer.ts

76 lines
2.9 KiB
TypeScript
Raw Normal View History

2026-08-04 02:09:54 +02:00
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): void {
const levels = (1 << config.colorDepth) - 1
if (levels >= 255 && config.dither === 0) {
return
}
const { width, height, color } = fb
for (let y = 0; y < height; 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)
}
}