76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
|
|
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)
|
||
|
|
}
|
||
|
|
}
|