meat/engine/render/Rasterizer.ts
2026-08-04 02:09:54 +02:00

217 lines
9.3 KiB
TypeScript

import { Color } from "./Color"
import type { Framebuffer } from "./Framebuffer"
import type { RenderConfig } from "./RenderConfig"
import { Texture } from "./Texture"
import { Mat4 } from "../math/Mat4"
import { Vec3 } from "../math/Vec3"
import type { Mesh, Vertex } from "../scene/Mesh"
/** Vertex in clip space, carrying the texture coords that must survive
* near-plane clipping (which splits triangles and creates new vertices). */
type ClipVertex = { x: number; y: number; w: number; u: number; v: number }
/** Vertex after the perspective divide, in framebuffer pixels. `invW` (= 1/w)
* is kept per vertex because it drives both the depth test and perspective-
* correct texturing. */
type ScreenVertex = { sx: number; sy: number; invW: number; u: number; v: number }
/** Anything with w below this is treated as behind the camera and clipped. */
const NEAR_W = 0.01
/** Fixed world-space directional light. Normalized once at module load. */
const LIGHT = Vec3.normalize({ x: 0.4, y: 1, z: 0.35 })
const AMBIENT = 0.4
const DIFFUSE = 0.6
/**
* Software triangle rasterizer — the heart of the PS1 look.
*
* Per triangle the pipeline is: transform to clip space, clip against the near
* plane, perspective-divide to screen pixels (optionally snapping vertices to a
* grid), then fill with an edge-function / barycentric scan. Per pixel it
* interpolates depth as 1/w, texture coords (affine or perspective-correct, see
* `fillTriangle`), and applies flat shading plus distance fog.
*
* The period-accurate rough edges are deliberate, not unfinished: no mipmaps
* (so distant textures shimmer/moire), no antialiasing (jagged silhouettes),
* and affine texturing by default (the texture "swim"). Depth is a plain 1/w
* z-buffer and triangles are drawn double-sided (no backface culling), so mesh
* winding can never cause surfaces to drop out.
*/
export namespace Rasterizer {
/** Draw an indexed mesh into the framebuffer through a view-projection
* matrix. Shading is flat (one normal per face), so it is computed once per
* triangle here and shared by every pixel the triangle covers. */
export function draw(
fb: Framebuffer,
mesh: Mesh,
texture: Texture,
viewProj: Mat4,
config: RenderConfig,
): void {
const { vertices, indices } = mesh
for (let t = 0; t + 2 < indices.length; t += 3) {
const a = vertices[indices[t]]
const b = vertices[indices[t + 1]]
const c = vertices[indices[t + 2]]
const shade = config.lighting === "flat" ? flatShade(a, b, c) : 1
// Near-clipping can turn one triangle into a quad; fan it back to tris.
const poly = clipNear([project(viewProj, a), project(viewProj, b), project(viewProj, c)])
for (let k = 1; k + 1 < poly.length; k++) {
fillTriangle(fb, poly[0], poly[k], poly[k + 1], shade, texture, config)
}
}
}
function project(m: Mat4, vertex: Vertex): ClipVertex {
const p = Mat4.transform(m, vertex.pos)
return { x: p.x, y: p.y, w: p.w, u: vertex.uv.x, v: vertex.uv.y }
}
/** Flat (per-face) directional shade in 0..1: ambient plus diffuse from the
* face normal. `abs()` makes it two-sided so back-facing tris still light. */
function flatShade(a: Vertex, b: Vertex, c: Vertex): number {
const normal = Vec3.normalize(Vec3.cross(Vec3.sub(b.pos, a.pos), Vec3.sub(c.pos, a.pos)))
return Math.min(1, AMBIENT + DIFFUSE * Math.abs(Vec3.dot(normal, LIGHT)))
}
/**
* Clip a polygon against the camera plane (w = NEAR_W) with a single
* Sutherland-Hodgman pass, returning its vertices as a fan (0, 3, or 4).
*
* This matters even when standing inside the room: a wall to your side has
* vertices both in front of and behind the eye. Without clipping, the behind
* vertices have w <= 0 and invert under the perspective divide, smearing the
* triangle across the whole screen (and risking divide-by-zero). Clipping
* trims the triangle to just the visible part instead of dropping it.
*/
function clipNear(poly: ClipVertex[]): ClipVertex[] {
const out: ClipVertex[] = []
for (let i = 0; i < poly.length; i++) {
const cur = poly[i]
const prev = poly[(i + poly.length - 1) % poly.length]
const curIn = cur.w >= NEAR_W
const prevIn = prev.w >= NEAR_W
// Crossing the plane emits the intersection point before the inside one.
if (curIn !== prevIn) {
out.push(intersectNear(prev, cur))
}
if (curIn) {
out.push(cur)
}
}
return out
}
/** Point where edge a->b crosses w = NEAR_W, with every attribute lerped. */
function intersectNear(a: ClipVertex, b: ClipVertex): ClipVertex {
const t = (NEAR_W - a.w) / (b.w - a.w)
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
w: a.w + (b.w - a.w) * t,
u: a.u + (b.u - a.u) * t,
v: a.v + (b.v - a.v) * t,
}
}
/** Perspective-divide a clip vertex into framebuffer pixels.
*
* Vertex snap: real PS1 hardware transformed vertices in low-precision fixed
* point, so screen positions landed on a coarse grid and visibly popped
* between pixels as the camera moved — the trademark "vertex wobble". We
* emulate it by snapping to a `snap`-pixel grid. 0 disables it (smooth). */
function toScreen(fb: Framebuffer, c: ClipVertex, snap: number): ScreenVertex {
const invW = 1 / c.w
let sx = (c.x * invW * 0.5 + 0.5) * fb.width
let sy = (1 - (c.y * invW * 0.5 + 0.5)) * fb.height
if (snap > 0) {
sx = Math.round(sx / snap) * snap
sy = Math.round(sy / snap) * snap
}
return { sx, sy, invW, u: c.u, v: c.v }
}
/** Signed area of the triangle (a, b, point) times two. Its sign tells which
* side of edge a->b the point is on; the three edge values are the
* (unnormalized) barycentric weights. */
function edge(a: ScreenVertex, b: ScreenVertex, px: number, py: number): number {
return (b.sx - a.sx) * (py - a.sy) - (b.sy - a.sy) * (px - a.sx)
}
/** Scan-convert one clip-space triangle into the framebuffer. */
function fillTriangle(
fb: Framebuffer,
va: ClipVertex,
vb: ClipVertex,
vc: ClipVertex,
shade: number,
texture: Texture,
config: RenderConfig,
): void {
const a = toScreen(fb, va, config.vertexSnap)
const b = toScreen(fb, vb, config.vertexSnap)
const c = toScreen(fb, vc, config.vertexSnap)
const area = edge(a, b, c.sx, c.sy)
if (area === 0) {
return
}
const minX = Math.max(0, Math.floor(Math.min(a.sx, b.sx, c.sx)))
const maxX = Math.min(fb.width - 1, Math.ceil(Math.max(a.sx, b.sx, c.sx)))
const minY = Math.max(0, Math.floor(Math.min(a.sy, b.sy, c.sy)))
const maxY = Math.min(fb.height - 1, Math.ceil(Math.max(a.sy, b.sy, c.sy)))
const pc = config.perspectiveCorrect
const fog = config.fog
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const px = x + 0.5
const py = y + 0.5
// Barycentric weights, normalized by area so they sum to 1. Dividing by
// a signed area accepts either winding, which is why culling is unneeded.
const w0 = edge(b, c, px, py) / area
const w1 = edge(c, a, px, py) / area
const w2 = edge(a, b, px, py) / area
if (w0 < 0 || w1 < 0 || w2 < 0) {
continue
}
// 1/w interpolates linearly in screen space, so this is exact. Larger =
// nearer; the z-buffer keeps the max seen per pixel.
const invW = w0 * a.invW + w1 * b.invW + w2 * c.invW
const idx = y * fb.width + x
if (invW <= fb.depth[idx]) {
continue
}
// Two ways to interpolate texture coords across the triangle:
// affine - linear in screen space. This is what hardware without a
// perspective divide does. It is exact ONLY when the three vertices
// share a depth (a face viewed head-on). On a receding surface (the
// floor, or a wall turned into the periphery) the depth gradient
// makes it diverge, bending the texture along the triangle diagonal
// -- the signature PS1 "texture swim".
// persp - divide the interpolated u/w by the interpolated 1/w to
// undo foreshortening. Geometrically correct, no swim.
// perspectiveCorrect (0..1) lerps between them, so the look is a dial.
const uAff = w0 * a.u + w1 * b.u + w2 * c.u
const vAff = w0 * a.v + w1 * b.v + w2 * c.v
const uPer = (w0 * a.u * a.invW + w1 * b.u * b.invW + w2 * c.u * c.invW) / invW
const vPer = (w0 * a.v * a.invW + w1 * b.v * b.invW + w2 * c.v * c.invW) / invW
const u = uAff + (uPer - uAff) * pc
const v = vAff + (vPer - vAff) * pc
// Alpha cutout: discard transparent texels so sprites read as cutouts,
// not rectangles. Opaque world textures are alpha 255, so unaffected.
const texel = Texture.sample(texture, u, v, config.textureFilter)
if (Color.a(texel) < 128) {
continue
}
let color = Color.scale(texel, shade)
if (fog !== null) {
// dist == w (view-space depth); fade from full color to fog color.
const dist = 1 / invW
const f = Math.min(1, Math.max(0, (fog.far - dist) / (fog.far - fog.near)))
color = Color.lerp(fog.color, color, f)
}
fb.color[idx] = color
fb.depth[idx] = invW
}
}
}
}