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 } } } }