feat: terrain

This commit is contained in:
Dan Finch 2026-08-04 12:51:07 +02:00
parent f86b4ada13
commit 81474b6bca
13 changed files with 251 additions and 279 deletions

View file

@ -28,14 +28,13 @@ const DIFFUSE = 0.6
* 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.
* interpolates depth as 1/w, perspective-correct texture coords, 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.
* (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
@ -159,7 +158,6 @@ export namespace Rasterizer {
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++) {
@ -180,22 +178,11 @@ export namespace Rasterizer {
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
// 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)

View file

@ -35,16 +35,6 @@ export type RenderConfig = {
* 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. */
@ -58,9 +48,8 @@ export type RenderConfig = {
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. */
/** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution,
* color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */
export namespace RenderConfig {
export const standard: RenderConfig = {
internalWidth: 384,
@ -69,10 +58,9 @@ export namespace RenderConfig {
colorDepth: 5,
dither: 1,
vertexSnap: 1,
perspectiveCorrect: 0.25,
textureFilter: "nearest",
lighting: "flat",
fog: { color: Color.rgb(150, 170, 200), near: 6, far: 22 },
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
}
export const ps1: RenderConfig = {
@ -82,10 +70,9 @@ export namespace RenderConfig {
colorDepth: 5,
dither: 1,
vertexSnap: 1,
perspectiveCorrect: 0.25,
textureFilter: "nearest",
lighting: "flat",
fog: { color: Color.rgb(150, 170, 200), near: 6, far: 22 },
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
}
export const soft: RenderConfig = {
@ -95,10 +82,9 @@ export namespace RenderConfig {
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 },
fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 },
}
export const clean: RenderConfig = {
@ -108,7 +94,6 @@ export namespace RenderConfig {
colorDepth: 8,
dither: 0,
vertexSnap: 0,
perspectiveCorrect: 1,
textureFilter: "linear",
lighting: "flat",
fog: null,