perf: faster still
This commit is contained in:
parent
ef5029da1d
commit
67cd54fe33
8 changed files with 280 additions and 203 deletions
|
|
@ -1,27 +1,29 @@
|
|||
import { Color } from "./Color"
|
||||
import type { Framebuffer } from "./Framebuffer"
|
||||
import type { RenderConfig } from "./RenderConfig"
|
||||
import type { Fog, 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 }
|
||||
import type { Mat4 } from "../math/Mat4"
|
||||
import { STRIDE, type Mesh } from "../scene/Mesh"
|
||||
|
||||
/** 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 })
|
||||
/** Fixed world-space directional light (normalized components). */
|
||||
const LIGHT_LEN = Math.hypot(0.4, 1, 0.35)
|
||||
const LIGHT_X = 0.4 / LIGHT_LEN
|
||||
const LIGHT_Y = 1 / LIGHT_LEN
|
||||
const LIGHT_Z = 0.35 / LIGHT_LEN
|
||||
const AMBIENT = 0.4
|
||||
const DIFFUSE = 0.6
|
||||
|
||||
/** Floats per clip-space vertex in the scratch buffers: x, y, w, u, v (clip z is
|
||||
* unused, so it is dropped). */
|
||||
const CLIP = 5
|
||||
/** Reused per-triangle scratch: the 3 projected verts (`src`) and the near-clip
|
||||
* result (`dst`, up to 4 verts). Module-level so the hot path never allocates.
|
||||
* Safe because a triangle is fully processed before the next one starts. */
|
||||
const src = new Float64Array(3 * CLIP)
|
||||
const dst = new Float64Array(4 * CLIP)
|
||||
|
||||
/**
|
||||
* Software triangle rasterizer — the heart of the PS1 look.
|
||||
*
|
||||
|
|
@ -31,15 +33,17 @@ const DIFFUSE = 0.6
|
|||
* interpolates depth as 1/w, perspective-correct texture coords, and applies
|
||||
* flat shading plus distance fog.
|
||||
*
|
||||
* Meshes are stored flat (see `Mesh`) and the whole per-triangle path works in
|
||||
* reused scratch buffers, so drawing allocates nothing — no GC churn, no frame
|
||||
* spikes. `cull` enables backface culling for solid, consistently-wound meshes.
|
||||
*
|
||||
* The period-accurate rough edges are deliberate, not unfinished: no mipmaps
|
||||
* (so distant textures shimmer/moire) and no antialiasing (jagged silhouettes).
|
||||
* 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. */
|
||||
/** Draw an indexed mesh into the framebuffer through a view-projection matrix.
|
||||
* Shading is flat (one normal per face), computed once per triangle. `cull`
|
||||
* drops back-facing triangles (default off = double-sided). */
|
||||
export function draw(
|
||||
fb: Framebuffer,
|
||||
mesh: Mesh,
|
||||
|
|
@ -48,165 +52,221 @@ export namespace Rasterizer {
|
|||
config: RenderConfig,
|
||||
cull = false,
|
||||
): void {
|
||||
const { vertices, indices } = mesh
|
||||
const { verts, indices } = mesh
|
||||
const flat = config.lighting === "flat"
|
||||
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
|
||||
const o0 = indices[t] * STRIDE
|
||||
const o1 = indices[t + 1] * STRIDE
|
||||
const o2 = indices[t + 2] * STRIDE
|
||||
const shade = flat ? flatShade(verts, o0, o1, o2) : 1
|
||||
project(viewProj, verts, o0, 0)
|
||||
project(viewProj, verts, o1, CLIP)
|
||||
project(viewProj, verts, o2, CLIP * 2)
|
||||
// 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, cull)
|
||||
const n = clipNear(3)
|
||||
for (let k = 1; k + 1 < n; k++) {
|
||||
fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
/** Transform vertex `o` of `verts` by `m` into clip space, written to `src` at
|
||||
* `out`. Only x, y, w are needed (z is unused); the matrix multiply is inlined
|
||||
* to avoid allocating a result object. */
|
||||
function project(m: Mat4, verts: number[], o: number, out: number): void {
|
||||
const x = verts[o]
|
||||
const y = verts[o + 1]
|
||||
const z = verts[o + 2]
|
||||
src[out] = m[0] * x + m[4] * y + m[8] * z + m[12]
|
||||
src[out + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
|
||||
src[out + 2] = m[3] * x + m[7] * y + m[11] * z + m[15]
|
||||
src[out + 3] = verts[o + 3]
|
||||
src[out + 4] = verts[o + 4]
|
||||
}
|
||||
|
||||
/** 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)))
|
||||
* face normal (cross of two edges). `abs()` makes it two-sided so back-facing
|
||||
* tris still light. Reads positions straight from the flat vertex array. */
|
||||
function flatShade(verts: number[], o0: number, o1: number, o2: number): number {
|
||||
const ax = verts[o0]
|
||||
const ay = verts[o0 + 1]
|
||||
const az = verts[o0 + 2]
|
||||
const e1x = verts[o1] - ax
|
||||
const e1y = verts[o1 + 1] - ay
|
||||
const e1z = verts[o1 + 2] - az
|
||||
const e2x = verts[o2] - ax
|
||||
const e2y = verts[o2 + 1] - ay
|
||||
const e2z = verts[o2 + 2] - az
|
||||
const nx = e1y * e2z - e1z * e2y
|
||||
const ny = e1z * e2x - e1x * e2z
|
||||
const nz = e1x * e2y - e1y * e2x
|
||||
const len = Math.hypot(nx, ny, nz)
|
||||
if (len === 0) {
|
||||
return AMBIENT
|
||||
}
|
||||
const d = Math.abs((nx * LIGHT_X + ny * LIGHT_Y + nz * LIGHT_Z) / len)
|
||||
return Math.min(1, AMBIENT + DIFFUSE * d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Clip the `count`-vertex polygon in `src` against the camera plane (w =
|
||||
* NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
|
||||
* 4 verts) to `dst` and returning its vertex count.
|
||||
*
|
||||
* 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.
|
||||
* triangle across the whole screen (and risking divide-by-zero).
|
||||
*/
|
||||
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
|
||||
function clipNear(count: number): number {
|
||||
let out = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ci = i * CLIP
|
||||
const pi = ((i + count - 1) % count) * CLIP
|
||||
const curW = src[ci + 2]
|
||||
const prevW = src[pi + 2]
|
||||
const curIn = curW >= NEAR_W
|
||||
const prevIn = prevW >= NEAR_W
|
||||
// Crossing the plane emits the intersection point before the inside one.
|
||||
if (curIn !== prevIn) {
|
||||
out.push(intersectNear(prev, cur))
|
||||
const t = (NEAR_W - prevW) / (curW - prevW)
|
||||
const o = out * CLIP
|
||||
dst[o] = src[pi] + (src[ci] - src[pi]) * t
|
||||
dst[o + 1] = src[pi + 1] + (src[ci + 1] - src[pi + 1]) * t
|
||||
dst[o + 2] = prevW + (curW - prevW) * t
|
||||
dst[o + 3] = src[pi + 3] + (src[ci + 3] - src[pi + 3]) * t
|
||||
dst[o + 4] = src[pi + 4] + (src[ci + 4] - src[pi + 4]) * t
|
||||
out++
|
||||
}
|
||||
if (curIn) {
|
||||
out.push(cur)
|
||||
const o = out * CLIP
|
||||
dst[o] = src[ci]
|
||||
dst[o + 1] = src[ci + 1]
|
||||
dst[o + 2] = curW
|
||||
dst[o + 3] = src[ci + 3]
|
||||
dst[o + 4] = src[ci + 4]
|
||||
out++
|
||||
}
|
||||
}
|
||||
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.
|
||||
/**
|
||||
* Scan-convert one clip-space triangle (verts `ia`, `ib`, `ic` in `dst`).
|
||||
*
|
||||
* 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. */
|
||||
* Vertex snap: real PS1 hardware transformed vertices in low-precision fixed
|
||||
* point, so screen positions popped between pixels as the camera moved (the
|
||||
* trademark "vertex wobble"). We emulate it by snapping to a `snap`-pixel grid.
|
||||
*/
|
||||
function fillTriangle(
|
||||
fb: Framebuffer,
|
||||
va: ClipVertex,
|
||||
vb: ClipVertex,
|
||||
vc: ClipVertex,
|
||||
ia: number,
|
||||
ib: number,
|
||||
ic: number,
|
||||
shade: number,
|
||||
texture: Texture,
|
||||
config: RenderConfig,
|
||||
cull: boolean,
|
||||
): 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)
|
||||
const oa = ia * CLIP
|
||||
const ob = ib * CLIP
|
||||
const oc = ic * CLIP
|
||||
const width = fb.width
|
||||
const height = fb.height
|
||||
const snap = config.vertexSnap
|
||||
const invWa = 1 / dst[oa + 2]
|
||||
const invWb = 1 / dst[ob + 2]
|
||||
const invWc = 1 / dst[oc + 2]
|
||||
let sxA = (dst[oa] * invWa * 0.5 + 0.5) * width
|
||||
let syA = (1 - (dst[oa + 1] * invWa * 0.5 + 0.5)) * height
|
||||
let sxB = (dst[ob] * invWb * 0.5 + 0.5) * width
|
||||
let syB = (1 - (dst[ob + 1] * invWb * 0.5 + 0.5)) * height
|
||||
let sxC = (dst[oc] * invWc * 0.5 + 0.5) * width
|
||||
let syC = (1 - (dst[oc + 1] * invWc * 0.5 + 0.5)) * height
|
||||
if (snap > 0) {
|
||||
sxA = Math.round(sxA / snap) * snap
|
||||
syA = Math.round(syA / snap) * snap
|
||||
sxB = Math.round(sxB / snap) * snap
|
||||
syB = Math.round(syB / snap) * snap
|
||||
sxC = Math.round(sxC / snap) * snap
|
||||
syC = Math.round(syC / snap) * snap
|
||||
}
|
||||
// Signed area x2; its sign is the screen winding.
|
||||
const area = (sxB - sxA) * (syC - syA) - (syB - syA) * (sxC - sxA)
|
||||
if (area === 0) {
|
||||
return
|
||||
}
|
||||
// Backface cull: a back-facing triangle has the opposite screen winding
|
||||
// (positive area here). Only enabled for solid, consistently-wound meshes;
|
||||
// sprites and the room stay double-sided (cull = false).
|
||||
// Backface cull: a back-facing triangle has positive area here. Only for
|
||||
// solid, consistently-wound meshes; sprites/room stay double-sided.
|
||||
if (cull && 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 invArea = 1 / area
|
||||
const uA = dst[oa + 3]
|
||||
const vA = dst[oa + 4]
|
||||
const uB = dst[ob + 3]
|
||||
const vB = dst[ob + 4]
|
||||
const uC = dst[oc + 3]
|
||||
const vC = dst[oc + 4]
|
||||
const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC)))
|
||||
const maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC)))
|
||||
const minY = Math.max(0, Math.floor(Math.min(syA, syB, syC)))
|
||||
const maxY = Math.min(height - 1, Math.ceil(Math.max(syA, syB, syC)))
|
||||
// Edge deltas for the three barycentric edge functions (b->c, c->a, a->b).
|
||||
const dx0 = sxC - sxB
|
||||
const dy0 = syC - syB
|
||||
const dx1 = sxA - sxC
|
||||
const dy1 = syA - syC
|
||||
const dx2 = sxB - sxA
|
||||
const dy2 = syB - syA
|
||||
const fog = config.fog
|
||||
const filter = config.textureFilter
|
||||
const color = fb.color
|
||||
const depth = fb.depth
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
const py = y + 0.5
|
||||
const rowStart = y * width
|
||||
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) {
|
||||
// Barycentric weights, normalized so they sum to 1. Dividing by a signed
|
||||
// area accepts either winding.
|
||||
const w0 = (dx0 * (py - syB) - dy0 * (px - sxB)) * invArea
|
||||
if (w0 < 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]) {
|
||||
const w1 = (dx1 * (py - syC) - dy1 * (px - sxC)) * invArea
|
||||
if (w1 < 0) {
|
||||
continue
|
||||
}
|
||||
// Perspective-correct texture coords: divide the interpolated u/w and
|
||||
// v/w by the interpolated 1/w to undo foreshortening, so textures sit
|
||||
// flat on receding surfaces with no affine "swim".
|
||||
const u = (w0 * a.u * a.invW + w1 * b.u * b.invW + w2 * c.u * c.invW) / invW
|
||||
const v = (w0 * a.v * a.invW + w1 * b.v * b.invW + w2 * c.v * c.invW) / invW
|
||||
// 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)
|
||||
const w2 = (dx2 * (py - syA) - dy2 * (px - sxA)) * invArea
|
||||
if (w2 < 0) {
|
||||
continue
|
||||
}
|
||||
// 1/w interpolates linearly in screen space. Larger = nearer.
|
||||
const invW = w0 * invWa + w1 * invWb + w2 * invWc
|
||||
const idx = rowStart + x
|
||||
if (invW <= depth[idx]) {
|
||||
continue
|
||||
}
|
||||
// Perspective-correct texture coords: divide interpolated u/w, v/w by 1/w
|
||||
// to undo foreshortening, so textures sit flat on receding surfaces.
|
||||
const u = (w0 * uA * invWa + w1 * uB * invWb + w2 * uC * invWc) / invW
|
||||
const v = (w0 * vA * invWa + w1 * vB * invWb + w2 * vC * invWc) / invW
|
||||
// Alpha cutout: discard transparent texels so sprites read as cutouts.
|
||||
const texel = Texture.sample(texture, u, v, filter)
|
||||
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
|
||||
color[idx] = fog === null ? Color.scale(texel, shade) : shadeFog(texel, shade, fog, invW)
|
||||
depth[idx] = invW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shade a texel then fade it toward the fog color by view-space distance. */
|
||||
function shadeFog(texel: Color, shade: number, fog: Fog, invW: number): Color {
|
||||
const dist = 1 / invW
|
||||
const f = Math.min(1, Math.max(0, (fog.far - dist) / (fog.far - fog.near)))
|
||||
return Color.lerp(fog.color, Color.scale(texel, shade), f)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue