217 lines
9.3 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|
|
}
|