feat: terrain
This commit is contained in:
parent
f86b4ada13
commit
81474b6bca
13 changed files with 251 additions and 279 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ export namespace Camera {
|
|||
}
|
||||
|
||||
/** Combined projection * view matrix for the given viewport aspect ratio.
|
||||
* Near/far are fixed for now; far only needs to exceed the fog distance. */
|
||||
* Near/far are fixed; far only needs to exceed the fog distance, and is set
|
||||
* wide enough to reach the outdoor world's distant peaks. */
|
||||
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)
|
||||
const proj = Mat4.perspective(cam.fov, aspect, 0.05, 260)
|
||||
return Mat4.multiply(proj, view)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
engine/scene/Terrain.ts
Normal file
94
engine/scene/Terrain.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { Mesh } from "./Mesh"
|
||||
|
||||
/** A procedural heightfield surrounding the room. It is the single source of
|
||||
* ground height: the outdoor mesh is built from it and the player stands on the
|
||||
* same `height` samples, so what you see and what you collide with agree. The
|
||||
* center (out to `inner`) is a flat clearing where the room sits; from there the
|
||||
* land rolls outward and ramps up into tall peaks at the far edge. Every field
|
||||
* is a live knob -- edit them in the level to reshape the world. */
|
||||
export type Terrain = {
|
||||
/** Half-extent of the flat central clearing (the room lives here); height 0. */
|
||||
inner: number
|
||||
/** World half-extent. Peaks ramp up toward this outer rim. */
|
||||
outer: number
|
||||
/** Ease-up distance just outside `inner`, so the clearing meets the hills with
|
||||
* a slope instead of a wall. */
|
||||
blend: number
|
||||
/** Rolling-hill height across the open ground. */
|
||||
amplitude: number
|
||||
/** Rolling-hill frequency (low = broad hills over the big world). */
|
||||
frequency: number
|
||||
/** Extra height of the mountains near the edge -- make this big for peaks. */
|
||||
peakHeight: number
|
||||
/** Mountain frequency (low = few, massive ridges). */
|
||||
peakFrequency: number
|
||||
/** Fraction of the way out (0..1) where the peaks begin rising. */
|
||||
peakStart: number
|
||||
}
|
||||
|
||||
export namespace Terrain {
|
||||
/** Ground height at world (x, z). 0 inside the clearing, rolling hills beyond,
|
||||
* ramping into peaks toward the edge. Uses a square (Chebyshev) radius so the
|
||||
* clearing is a square that lines up with the square room. */
|
||||
export function height(t: Terrain, x: number, z: number): number {
|
||||
const r = Math.max(Math.abs(x), Math.abs(z))
|
||||
if (r <= t.inner) {
|
||||
return 0
|
||||
}
|
||||
const rise = smoothstep(t.inner, t.inner + t.blend, r)
|
||||
const hills = t.amplitude * bumps(x, z, t.frequency)
|
||||
const k = Math.min(1, (r - t.inner) / (t.outer - t.inner))
|
||||
const peaks = t.peakHeight * ridges(x, z, t.peakFrequency) * smoothstep(t.peakStart, 1, k)
|
||||
return rise * (hills + peaks)
|
||||
}
|
||||
|
||||
/** Build the outdoor ground as a `divisions`x`divisions` grid over the whole
|
||||
* world, each vertex lifted onto the heightfield. Cells inside the clearing
|
||||
* are skipped so the mesh has a hole where the flat room floor goes (no
|
||||
* z-fighting). `uvScale` sets texture tiles per world unit. */
|
||||
export function ground(t: Terrain, divisions: number, uvScale: number): Mesh {
|
||||
const vertices: Mesh["vertices"] = []
|
||||
const indices: number[] = []
|
||||
const step = (t.outer * 2) / divisions
|
||||
const row = divisions + 1
|
||||
for (let i = 0; i <= divisions; i++) {
|
||||
const z = -t.outer + i * step
|
||||
for (let j = 0; j <= divisions; j++) {
|
||||
const x = -t.outer + j * step
|
||||
vertices.push({ pos: { x, y: height(t, x, z), z }, uv: { x: x * uvScale, y: z * uvScale } })
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < divisions; i++) {
|
||||
for (let j = 0; j < divisions; j++) {
|
||||
const cx = -t.outer + (j + 0.5) * step
|
||||
const cz = -t.outer + (i + 0.5) * step
|
||||
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
|
||||
continue
|
||||
}
|
||||
const p = i * row + j
|
||||
indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
||||
}
|
||||
}
|
||||
return { vertices, indices }
|
||||
}
|
||||
|
||||
/** Rolling hills in 0..1, always non-negative so the ground never dips below
|
||||
* the clearing. */
|
||||
function bumps(x: number, z: number, f: number): number {
|
||||
const a = Math.sin(x * f) * Math.cos(z * f)
|
||||
const b = Math.sin((x + z) * f * 0.5 + 1.7) * 0.5
|
||||
return (a + b + 1.5) / 3
|
||||
}
|
||||
|
||||
/** Ridged noise in 0..1: crests where the field crosses zero give sharp
|
||||
* mountain ridgelines rather than round blobs. */
|
||||
function ridges(x: number, z: number, f: number): number {
|
||||
const n = Math.sin(x * f + 1.3) * Math.cos(z * f - 0.7) * 0.7 + Math.sin((x + z) * f * 0.6 + 2.5) * 0.3
|
||||
return 1 - Math.abs(n)
|
||||
}
|
||||
|
||||
function smoothstep(a: number, b: number, x: number): number {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue