feat: 1995

This commit is contained in:
Dan Finch 2026-08-04 02:09:54 +02:00
commit fb89263930
69 changed files with 3359 additions and 0 deletions

72
engine/math/Mat4.ts Normal file
View 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
View 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
View 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
View 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,
)
}
}

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

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