feat: 1995
This commit is contained in:
commit
fb89263930
69 changed files with 3359 additions and 0 deletions
72
engine/math/Mat4.ts
Normal file
72
engine/math/Mat4.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Vec3 } from "./Vec3"
|
||||
|
||||
/** 4x4 matrix in column-major storage: index = col * 4 + row, matching OpenGL
|
||||
* conventions so the standard perspective/lookAt formulas apply directly. */
|
||||
export type Mat4 = Float32Array
|
||||
|
||||
export namespace Mat4 {
|
||||
/** Matrix product A * B (apply B first, then A). */
|
||||
export function multiply(a: Mat4, b: Mat4): Mat4 {
|
||||
const out = new Float32Array(16)
|
||||
for (let col = 0; col < 4; col++) {
|
||||
for (let row = 0; row < 4; row++) {
|
||||
let sum = 0
|
||||
for (let k = 0; k < 4; k++) {
|
||||
sum += a[k * 4 + row] * b[col * 4 + k]
|
||||
}
|
||||
out[col * 4 + row] = sum
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Right-handed perspective projection (camera looks down -Z). Maps the view
|
||||
* frustum to clip space; the -1 in row 3 copies -z into w, so the later
|
||||
* divide by w is what produces foreshortening. */
|
||||
export function perspective(fovY: number, aspect: number, near: number, far: number): Mat4 {
|
||||
const f = 1 / Math.tan(fovY / 2)
|
||||
const out = new Float32Array(16)
|
||||
out[0] = f / aspect
|
||||
out[5] = f
|
||||
out[10] = (far + near) / (near - far)
|
||||
out[11] = -1
|
||||
out[14] = (2 * far * near) / (near - far)
|
||||
return out
|
||||
}
|
||||
|
||||
/** View matrix looking from `eye` toward `center`, with `up` roughly up.
|
||||
* Builds an orthonormal camera basis (s = right, u = up, f = forward) and
|
||||
* packs it as the inverse camera transform. */
|
||||
export function lookAt(eye: Vec3, center: Vec3, up: Vec3): Mat4 {
|
||||
const f = Vec3.normalize(Vec3.sub(center, eye))
|
||||
const s = Vec3.normalize(Vec3.cross(f, up))
|
||||
const u = Vec3.cross(s, f)
|
||||
const out = new Float32Array(16)
|
||||
out[0] = s.x
|
||||
out[1] = u.x
|
||||
out[2] = -f.x
|
||||
out[4] = s.y
|
||||
out[5] = u.y
|
||||
out[6] = -f.y
|
||||
out[8] = s.z
|
||||
out[9] = u.z
|
||||
out[10] = -f.z
|
||||
out[12] = -Vec3.dot(s, eye)
|
||||
out[13] = -Vec3.dot(u, eye)
|
||||
out[14] = Vec3.dot(f, eye)
|
||||
out[15] = 1
|
||||
return out
|
||||
}
|
||||
|
||||
/** Transform a point, returning homogeneous coords. `w` is kept (not divided
|
||||
* out) because the rasterizer needs it for near-clipping and the perspective
|
||||
* divide/depth; for a perspective matrix w equals the view-space distance. */
|
||||
export function transform(m: Mat4, v: Vec3): { x: number; y: number; z: number; w: number } {
|
||||
return {
|
||||
x: m[0] * v.x + m[4] * v.y + m[8] * v.z + m[12],
|
||||
y: m[1] * v.x + m[5] * v.y + m[9] * v.z + m[13],
|
||||
z: m[2] * v.x + m[6] * v.y + m[10] * v.z + m[14],
|
||||
w: m[3] * v.x + m[7] * v.y + m[11] * v.z + m[15],
|
||||
}
|
||||
}
|
||||
}
|
||||
2
engine/math/Vec2.ts
Normal file
2
engine/math/Vec2.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** 2D vector, used for texture coordinates. */
|
||||
export type Vec2 = { x: number; y: number }
|
||||
38
engine/math/Vec3.ts
Normal file
38
engine/math/Vec3.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export type Vec3 = { x: number; y: number; z: number }
|
||||
|
||||
/** Plain 3D vector math. Every operation returns a fresh object (no in-place
|
||||
* mutation) to keep call sites easy to reason about. */
|
||||
export namespace Vec3 {
|
||||
export function add(a: Vec3, b: Vec3): Vec3 {
|
||||
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }
|
||||
}
|
||||
|
||||
export function sub(a: Vec3, b: Vec3): Vec3 {
|
||||
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }
|
||||
}
|
||||
|
||||
export function scale(v: Vec3, s: number): Vec3 {
|
||||
return { x: v.x * s, y: v.y * s, z: v.z * s }
|
||||
}
|
||||
|
||||
export function dot(a: Vec3, b: Vec3): number {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z
|
||||
}
|
||||
|
||||
export function cross(a: Vec3, b: Vec3): Vec3 {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x,
|
||||
}
|
||||
}
|
||||
|
||||
export function length(v: Vec3): number {
|
||||
return Math.sqrt(dot(v, v))
|
||||
}
|
||||
|
||||
export function normalize(v: Vec3): Vec3 {
|
||||
const len = length(v)
|
||||
return len === 0 ? v : scale(v, 1 / len)
|
||||
}
|
||||
}
|
||||
45
engine/render/Color.ts
Normal file
45
engine/render/Color.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* A color packed into 32 bits as RGBA in little-endian byte order, i.e. the
|
||||
* bytes in memory run R, G, B, A. That is exactly the layout a canvas
|
||||
* ImageData expects, so the `Uint32Array` framebuffer can be reinterpreted as
|
||||
* an ImageData with zero per-pixel conversion at blit time.
|
||||
*/
|
||||
export type Color = number
|
||||
|
||||
export namespace Color {
|
||||
export function rgb(r: number, g: number, b: number, a = 255): Color {
|
||||
// The shifts coerce the (possibly fractional) inputs to int32 and pack the
|
||||
// channels; `>>> 0` forces an unsigned result so it stays a valid Color.
|
||||
return ((a << 24) | (b << 16) | (g << 8) | r) >>> 0
|
||||
}
|
||||
|
||||
export function r(c: Color): number {
|
||||
return c & 0xFF
|
||||
}
|
||||
|
||||
export function g(c: Color): number {
|
||||
return (c >>> 8) & 0xFF
|
||||
}
|
||||
|
||||
export function b(c: Color): number {
|
||||
return (c >>> 16) & 0xFF
|
||||
}
|
||||
|
||||
export function a(c: Color): number {
|
||||
return (c >>> 24) & 0xFF
|
||||
}
|
||||
|
||||
/** Multiply RGB by a scalar (for shading), keeping alpha. */
|
||||
export function scale(c: Color, s: number): Color {
|
||||
return rgb(r(c) * s, g(c) * s, b(c) * s, a(c))
|
||||
}
|
||||
|
||||
/** Linear blend between two colors, t in 0..1. Used for fog and bilinear. */
|
||||
export function lerp(from: Color, to: Color, t: number): Color {
|
||||
return rgb(
|
||||
r(from) + (r(to) - r(from)) * t,
|
||||
g(from) + (g(to) - g(from)) * t,
|
||||
b(from) + (b(to) - b(from)) * t,
|
||||
)
|
||||
}
|
||||
}
|
||||
75
engine/render/Framebuffer.ts
Normal file
75
engine/render/Framebuffer.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
217
engine/render/Rasterizer.ts
Normal file
217
engine/render/Rasterizer.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
engine/render/RenderConfig.ts
Normal file
116
engine/render/RenderConfig.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Color } from "./Color"
|
||||
|
||||
/** Linear distance fog: pixels are untouched at/before `near`, fully `color`
|
||||
* at/after `far`, and blended in between. */
|
||||
export type Fog = {
|
||||
color: Color
|
||||
near: number
|
||||
far: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Every PS1-look trait as a live dial. Nothing here is baked into the renderer;
|
||||
* the same scene rendered with two configs gives two eras of hardware, so this
|
||||
* is the object you tweak to experiment. Presets live in the namespace below.
|
||||
*/
|
||||
export type RenderConfig = {
|
||||
/** Internal render resolution before upscaling. The core chunkiness dial:
|
||||
* the whole frame is drawn at this size then scaled up to the display, so
|
||||
* lower numbers mean bigger, blockier pixels. PS1 output was ~320x240. */
|
||||
internalWidth: number
|
||||
internalHeight: number
|
||||
/** How the low-res buffer is scaled to the screen. `nearest` keeps crisp,
|
||||
* blocky pixels (authentic); `linear` smooths them into a soft blur. */
|
||||
upscaleFilter: "nearest" | "linear"
|
||||
/** Bits per color channel. The PS1 framebuffer was 15-bit (5 bits each),
|
||||
* which steps smooth gradients into visible bands. 8 = full 24-bit color,
|
||||
* no banding. */
|
||||
colorDepth: number
|
||||
/** Ordered (Bayer 4x4) dither strength, 0..1. Trades color banding for a
|
||||
* fixed crosshatch of alternating pixels, exactly how the PS1 masked its
|
||||
* 15-bit output. 0 = no dithering. */
|
||||
dither: number
|
||||
/** Screen-space vertex snap grid in pixels. The PS1 transformed vertices in
|
||||
* low-precision fixed point, so they popped between pixels and models
|
||||
* jittered as the camera moved. 0 = off (smooth), 1 = one-pixel snap,
|
||||
* higher = coarser and more pronounced wobble. */
|
||||
vertexSnap: number
|
||||
/**
|
||||
* Texture-mapping correction, 0..1. At 0, texture coords interpolate linearly
|
||||
* in screen space (affine): geometrically wrong on any receding surface, so
|
||||
* the texture bends and swims along triangle diagonals -- the classic PS1
|
||||
* artifact. Faces viewed head-on still look perfect because their depth is
|
||||
* constant. At 1, coords are perspective-correct and everything is straight.
|
||||
* Values in between soften the swim; subdividing geometry reduces it too,
|
||||
* because each smaller triangle spans less depth.
|
||||
*/
|
||||
perspectiveCorrect: number
|
||||
/** Texture sampling. `nearest` point-samples for crunchy PS1 texels;
|
||||
* `linear` does bilinear smoothing (cleaner, but not period-accurate).
|
||||
* Neither uses mipmaps, so distant textures shimmer regardless. */
|
||||
textureFilter: "nearest" | "linear"
|
||||
/** `flat` gives one directional shade per face (the PS1 used cheap flat /
|
||||
* per-vertex lighting); `none` draws the texture unlit at full brightness. */
|
||||
lighting: "none" | "flat"
|
||||
/** Distance fog, or null to disable. PS1 games leaned on fog to hide the
|
||||
* short draw distance and the shimmer of far geometry. It also colors pixels
|
||||
* no triangle covers, so the frame's clear color should match `fog.color`. */
|
||||
fog: Fog | null
|
||||
}
|
||||
|
||||
/** Ready-made looks. The demo binds keys 1/2/3 to these, and they intentionally
|
||||
* sweep `perspectiveCorrect` 0 -> 0.5 -> 1 so you can watch the texture swim
|
||||
* straighten out as you press through them. */
|
||||
export namespace RenderConfig {
|
||||
export const psxish: RenderConfig = {
|
||||
internalWidth: 384,
|
||||
internalHeight: 216,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 5,
|
||||
dither: 1,
|
||||
vertexSnap: 1,
|
||||
perspectiveCorrect: 0.25,
|
||||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(150, 170, 200), near: 6, far: 22 },
|
||||
}
|
||||
|
||||
export const ps1: RenderConfig = {
|
||||
internalWidth: 320,
|
||||
internalHeight: 240,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 5,
|
||||
dither: 1,
|
||||
vertexSnap: 1,
|
||||
perspectiveCorrect: 0.25,
|
||||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(150, 170, 200), near: 6, far: 22 },
|
||||
}
|
||||
|
||||
export const soft: RenderConfig = {
|
||||
internalWidth: 480,
|
||||
internalHeight: 270,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 6,
|
||||
dither: 0.5,
|
||||
vertexSnap: 0.5,
|
||||
perspectiveCorrect: 0.5,
|
||||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(170, 190, 215), near: 10, far: 40 },
|
||||
}
|
||||
|
||||
export const clean: RenderConfig = {
|
||||
internalWidth: 960,
|
||||
internalHeight: 540,
|
||||
upscaleFilter: "linear",
|
||||
colorDepth: 8,
|
||||
dither: 0,
|
||||
vertexSnap: 0,
|
||||
perspectiveCorrect: 1,
|
||||
textureFilter: "linear",
|
||||
lighting: "flat",
|
||||
fog: null,
|
||||
}
|
||||
}
|
||||
64
engine/render/Sky.ts
Normal file
64
engine/render/Sky.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Color } from "./Color"
|
||||
import type { Framebuffer } from "./Framebuffer"
|
||||
import { Camera } from "../scene/Camera"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** Procedural sky: a vertical gradient plus a sun disc. No texture needed. */
|
||||
export type SkyConfig = {
|
||||
zenith: Color
|
||||
horizon: Color
|
||||
sun: Color
|
||||
/** World-space direction toward the sun (need not be normalized). */
|
||||
sunDir: Vec3
|
||||
/** Angular radius of the sun's core, in radians. */
|
||||
sunSize: number
|
||||
}
|
||||
|
||||
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
||||
|
||||
export namespace Sky {
|
||||
/**
|
||||
* Fill the whole framebuffer with the sky and reset depth to 0. Run first each
|
||||
* frame in place of Framebuffer.clear; opaque geometry then overwrites the sky
|
||||
* wherever it is nearer.
|
||||
*
|
||||
* Per pixel it reconstructs the view ray from the camera basis, shades a
|
||||
* horizon->zenith gradient by the ray's elevation (so it pans with pitch and
|
||||
* yaw), and brightens toward `sun` where the ray points near `sunDir`.
|
||||
*/
|
||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig): void {
|
||||
const { width, height, color, depth } = fb
|
||||
const forward = Camera.forward(camera)
|
||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||
const up = Vec3.cross(right, forward)
|
||||
const tanY = Math.tan(camera.fov / 2)
|
||||
const tanX = tanY * (width / height)
|
||||
const sun = Vec3.normalize(sky.sunDir)
|
||||
const cosSun = Math.cos(sky.sunSize)
|
||||
for (let y = 0; y < height; y++) {
|
||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||
for (let x = 0; x < width; x++) {
|
||||
const ndcX = ((x + 0.5) / width) * 2 - 1
|
||||
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
||||
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
||||
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
||||
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
||||
const inv = 1 / Math.hypot(dx, dy, dz)
|
||||
dx *= inv
|
||||
dy *= inv
|
||||
dz *= inv
|
||||
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
||||
const t = Math.max(0, Math.min(1, dy))
|
||||
let c = Color.lerp(sky.horizon, sky.zenith, t)
|
||||
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
||||
if (facing > cosSun) {
|
||||
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
||||
c = Color.lerp(c, sky.sun, glow)
|
||||
}
|
||||
const i = y * width + x
|
||||
color[i] = c
|
||||
depth[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
engine/render/Texture.ts
Normal file
72
engine/render/Texture.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Color } from "./Color"
|
||||
|
||||
/** A square-or-rectangular image of packed RGBA texels, row-major. */
|
||||
export type Texture = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly data: Uint32Array
|
||||
}
|
||||
|
||||
export namespace Texture {
|
||||
/** Generate a `size`x`size` checkerboard of `cells` squares per axis, using
|
||||
* colors `a` (top-left) and `b`. A stand-in until real textures load. */
|
||||
export function checker(size: number, cells: number, a: Color, b: Color): Texture {
|
||||
const data = new Uint32Array(size * size)
|
||||
const cell = size / cells
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const on = (Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0
|
||||
data[y * size + x] = on ? a : b
|
||||
}
|
||||
}
|
||||
return { width: size, height: size, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a texel. Coordinates wrap (tile) outside 0..1. `nearest` point-
|
||||
* samples for crunchy PS1 texels; `linear` bilinearly blends the four
|
||||
* neighbors for a smooth (non-period) result.
|
||||
*
|
||||
* Note there are no mipmaps: when a textured surface is minified in the
|
||||
* distance, many texels fall inside one pixel and point sampling picks an
|
||||
* essentially random one, so the pattern aliases into a crawling moire as the
|
||||
* camera moves. That shimmer is itself part of the PS1 look here; the usual
|
||||
* cure (mipmaps) is a deliberate future step, not a bug.
|
||||
*/
|
||||
export function sample(tex: Texture, u: number, v: number, filter: "nearest" | "linear"): Color {
|
||||
return filter === "linear" ? bilinear(tex, u, v) : nearest(tex, u, v)
|
||||
}
|
||||
|
||||
function nearest(tex: Texture, u: number, v: number): Color {
|
||||
const x = wrap(Math.floor(frac(u) * tex.width), tex.width)
|
||||
const y = wrap(Math.floor(frac(v) * tex.height), tex.height)
|
||||
return tex.data[y * tex.width + x]
|
||||
}
|
||||
|
||||
function bilinear(tex: Texture, u: number, v: number): Color {
|
||||
// -0.5 aligns the sample grid to texel centers before blending.
|
||||
const fx = frac(u) * tex.width - 0.5
|
||||
const fy = frac(v) * tex.height - 0.5
|
||||
const x0 = Math.floor(fx)
|
||||
const y0 = Math.floor(fy)
|
||||
const tx = fx - x0
|
||||
const ty = fy - y0
|
||||
const top = Color.lerp(texel(tex, x0, y0), texel(tex, x0 + 1, y0), tx)
|
||||
const bottom = Color.lerp(texel(tex, x0, y0 + 1), texel(tex, x0 + 1, y0 + 1), tx)
|
||||
return Color.lerp(top, bottom, ty)
|
||||
}
|
||||
|
||||
function texel(tex: Texture, x: number, y: number): Color {
|
||||
return tex.data[wrap(y, tex.height) * tex.width + wrap(x, tex.width)]
|
||||
}
|
||||
|
||||
/** Fractional part in 0..1 (handles negatives), for uv tiling. */
|
||||
function frac(n: number): number {
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/** Wrap an index into 0..size-1, staying non-negative for negative inputs. */
|
||||
function wrap(n: number, size: number): number {
|
||||
return ((n % size) + size) % size
|
||||
}
|
||||
}
|
||||
35
engine/scene/Camera.ts
Normal file
35
engine/scene/Camera.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Mat4 } from "../math/Mat4"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** First-person camera. Orientation is Euler yaw/pitch (no roll), which is all
|
||||
* an FPS needs and avoids gimbal bookkeeping. */
|
||||
export type Camera = {
|
||||
position: Vec3
|
||||
/** Rotation around +Y, radians. 0 looks toward -Z; increasing turns right. */
|
||||
yaw: number
|
||||
/** Look up/down, radians. Positive looks up. Clamp near +-pi/2 to avoid flip. */
|
||||
pitch: number
|
||||
/** Vertical field of view, radians. */
|
||||
fov: number
|
||||
}
|
||||
|
||||
export namespace Camera {
|
||||
/** Unit forward direction implied by yaw/pitch. */
|
||||
export function forward(cam: Camera): Vec3 {
|
||||
const cp = Math.cos(cam.pitch)
|
||||
return {
|
||||
x: cp * Math.sin(cam.yaw),
|
||||
y: Math.sin(cam.pitch),
|
||||
z: -cp * Math.cos(cam.yaw),
|
||||
}
|
||||
}
|
||||
|
||||
/** Combined projection * view matrix for the given viewport aspect ratio.
|
||||
* Near/far are fixed for now; far only needs to exceed the fog distance. */
|
||||
export function viewProjection(cam: Camera, aspect: number): Mat4 {
|
||||
const eye = cam.position
|
||||
const view = Mat4.lookAt(eye, Vec3.add(eye, forward(cam)), { x: 0, y: 1, z: 0 })
|
||||
const proj = Mat4.perspective(cam.fov, aspect, 0.05, 100)
|
||||
return Mat4.multiply(proj, view)
|
||||
}
|
||||
}
|
||||
11
engine/scene/Mesh.ts
Normal file
11
engine/scene/Mesh.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Vec2 } from "../math/Vec2"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** One mesh vertex: a world-space position and its texture coordinate. uv is in
|
||||
* tile units, not 0..1, so values >1 repeat the texture (see Texture.sample). */
|
||||
export type Vertex = { pos: Vec3; uv: Vec2 }
|
||||
|
||||
/** Indexed triangle mesh in world space. `indices` holds three entries per
|
||||
* triangle, each indexing into `vertices`; sharing vertices between triangles
|
||||
* keeps seams welded and shrinks the data. */
|
||||
export type Mesh = { vertices: Vertex[]; indices: number[] }
|
||||
45
engine/scene/Sprite.ts
Normal file
45
engine/scene/Sprite.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Vec2 } from "../math/Vec2"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
import type { Texture } from "../render/Texture"
|
||||
import { Camera } from "./Camera"
|
||||
import type { Mesh } from "./Mesh"
|
||||
|
||||
/** A flat image standing in the world, always turned to face the camera --
|
||||
* how the PS1 drew most enemies and props instead of 3D models. */
|
||||
export type Sprite = {
|
||||
/** World anchor at the base (feet) center. */
|
||||
position: Vec3
|
||||
/** World-space width and height. */
|
||||
size: Vec2
|
||||
texture: Texture
|
||||
}
|
||||
|
||||
export namespace Sprite {
|
||||
/**
|
||||
* Build the sprite's quad as a Y-axis billboard: it spins around vertical to
|
||||
* face the camera but stays upright, so characters never tilt. Draw the
|
||||
* result with Rasterizer.draw (its alpha cutout hides transparent texels).
|
||||
*/
|
||||
export function billboard(sprite: Sprite, camera: Camera): Mesh {
|
||||
const forward = Camera.forward(camera)
|
||||
// Camera right projected onto the ground plane (== normalize(-fz, 0, fx)).
|
||||
const len = Math.hypot(forward.x, forward.z) || 1
|
||||
const rx = -forward.z / len
|
||||
const rz = forward.x / len
|
||||
const hw = sprite.size.x / 2
|
||||
const p = sprite.position
|
||||
const y0 = p.y
|
||||
const y1 = p.y + sprite.size.y
|
||||
const lx = p.x - rx * hw
|
||||
const lz = p.z - rz * hw
|
||||
const gx = p.x + rx * hw
|
||||
const gz = p.z + rz * hw
|
||||
const vertices = [
|
||||
{ pos: { x: lx, y: y0, z: lz }, uv: { x: 0, y: 1 } },
|
||||
{ pos: { x: gx, y: y0, z: gz }, uv: { x: 1, y: 1 } },
|
||||
{ pos: { x: gx, y: y1, z: gz }, uv: { x: 1, y: 0 } },
|
||||
{ pos: { x: lx, y: y1, z: lz }, uv: { x: 0, y: 0 } },
|
||||
]
|
||||
return { vertices, indices: [0, 1, 2, 0, 2, 3] }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue