feat: terrain
This commit is contained in:
parent
f86b4ada13
commit
81474b6bca
13 changed files with 251 additions and 279 deletions
|
|
@ -1,11 +1,13 @@
|
|||
import type { Texture } from "../engine/render/Texture"
|
||||
import crateUrl from "../assets/crate.png"
|
||||
import floorUrl from "../assets/floor.png"
|
||||
import grassUrl from "../assets/grass.png"
|
||||
import npcUrl from "../assets/npc.png"
|
||||
import wallUrl from "../assets/wall.png"
|
||||
|
||||
export type Textures = {
|
||||
floor: Texture
|
||||
grass: Texture
|
||||
wall: Texture
|
||||
crate: Texture
|
||||
npc: Texture
|
||||
|
|
@ -13,13 +15,14 @@ export type Textures = {
|
|||
|
||||
/** Load every game texture up front. Call once before starting the loop. */
|
||||
export async function loadTextures(): Promise<Textures> {
|
||||
const [floor, wall, crate, npc] = await Promise.all([
|
||||
const [floor, grass, wall, crate, npc] = await Promise.all([
|
||||
loadTexture(floorUrl),
|
||||
loadTexture(grassUrl),
|
||||
loadTexture(wallUrl),
|
||||
loadTexture(crateUrl),
|
||||
loadTexture(npcUrl),
|
||||
])
|
||||
return { floor, wall, crate, npc }
|
||||
return { floor, grass, wall, crate, npc }
|
||||
}
|
||||
|
||||
function loadTexture(url: string): Promise<Texture> {
|
||||
|
|
|
|||
143
app/level.ts
143
app/level.ts
|
|
@ -1,6 +1,7 @@
|
|||
import { Color } from "../engine/render/Color"
|
||||
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
||||
import type { Mesh } from "../engine/scene/Mesh"
|
||||
import { Terrain } from "../engine/scene/Terrain"
|
||||
|
||||
type Corner = [number, number, number]
|
||||
|
||||
|
|
@ -15,30 +16,48 @@ export type Aabb = {
|
|||
standable: boolean
|
||||
}
|
||||
|
||||
/** The playground: geometry split by texture, its collision solids, where the
|
||||
* NPC stands, and the sky to draw behind it. */
|
||||
/** The playground: a flat-floored room dropped into the center of a big open
|
||||
* landscape. Geometry is split by texture, plus the collision solids, where the
|
||||
* NPC stands, the heightfield the outdoor ground + player share, and the sky. */
|
||||
export type Level = {
|
||||
floor: Mesh
|
||||
walls: Mesh
|
||||
crate: Mesh
|
||||
ground: Mesh
|
||||
colliders: Aabb[]
|
||||
npcPosition: { x: number; y: number; z: number }
|
||||
terrain: Terrain
|
||||
sky: SkyConfig
|
||||
}
|
||||
|
||||
const ARENA = 12
|
||||
const WALL_HEIGHT = 4
|
||||
const CRATE = { x: -2, z: -2, half: 1, top: 1 }
|
||||
const CRATE = { x: -2, z: -2, half: 1, height: 1 }
|
||||
/** Z-bias lifting the stone floor above the terrain skirt that laps under the
|
||||
* room edge (see `buildLevel`). Big enough to beat depth precision, too small
|
||||
* to see. */
|
||||
const FLOOR_LIFT = 0.02
|
||||
|
||||
/** Triangle density: grid divisions per world unit, applied to every textured
|
||||
* surface (floor, walls, crate). Higher = smaller triangles = each spans less
|
||||
* depth = less affine texture swim, at the cost of more geometry; lower =
|
||||
* chunkier, wilder PS1 warp (below ~0.5 the floor tips into the black-wedge
|
||||
* degeneration). Because it scales with surface size, one value keeps the big
|
||||
* floor and the little crate equally warp-free. Edit and Vite reloads. Pairs
|
||||
* with the `ps1` preset's `perspectiveCorrect`, which fights the same error
|
||||
* from the render side. */
|
||||
const DIVISIONS_PER_UNIT = 1.3
|
||||
/** The world around the room: a flat clearing the size of the room (`inner`),
|
||||
* rolling hills beyond, ramping into very high peaks at the `outer` rim ~20x
|
||||
* the room across. Tune freely -- crank `peakHeight` for taller mountains,
|
||||
* `outer` for a bigger world. */
|
||||
const TERRAIN: Terrain = {
|
||||
inner: ARENA,
|
||||
outer: ARENA * 20,
|
||||
blend: 12,
|
||||
amplitude: 5,
|
||||
frequency: 0.14,
|
||||
peakHeight: 90,
|
||||
peakFrequency: 0.05,
|
||||
peakStart: 0.45,
|
||||
}
|
||||
|
||||
/** Outdoor ground mesh resolution. A fixed grid over the whole world, so cell
|
||||
* size (and cost) is set here, not by the world's size: bigger `outer` gives
|
||||
* chunkier terrain, not more triangles. `GROUND_UV` sets texture tiles/unit. */
|
||||
const GROUND_DIVISIONS = 56
|
||||
const GROUND_UV = 0.25
|
||||
|
||||
/** The two cloud styles; swap which one the sky uses in `buildLevel`.
|
||||
* `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier
|
||||
|
|
@ -49,7 +68,7 @@ export const basicCumulus: CloudLayer = {
|
|||
coverage: 0.5,
|
||||
scale: 0.9,
|
||||
speed: 0.5,
|
||||
edge: 0.02,
|
||||
edge: 0.005,
|
||||
}
|
||||
|
||||
export const fancyCumulus: CloudLayer = {
|
||||
|
|
@ -64,22 +83,30 @@ export const fancyCumulus: CloudLayer = {
|
|||
}
|
||||
|
||||
export function buildLevel(): Level {
|
||||
// Flat room floor, lifted a hair above the terrain's clearing (y 0). The
|
||||
// outdoor grid's cells straddle the room boundary and lap under the floor's
|
||||
// edges; this small z-bias keeps the flat stone floor winning the depth test
|
||||
// there instead of z-fighting the grass. The step is invisible at the doorway.
|
||||
const floor = mesh()
|
||||
quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12)
|
||||
const fy = FLOOR_LIFT
|
||||
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
|
||||
|
||||
// The big surrounding landscape, with a hole where the room sits.
|
||||
const ground = Terrain.ground(TERRAIN, GROUND_DIVISIONS, GROUND_UV)
|
||||
|
||||
const walls = mesh()
|
||||
const h = WALL_HEIGHT
|
||||
// Inward-facing perimeter, no ceiling so the sky shows above.
|
||||
quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5)
|
||||
quadGrid(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5)
|
||||
quadGrid(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5)
|
||||
quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5)
|
||||
// Three inward-facing walls; the north (-Z) side is left open onto the world.
|
||||
// No ceiling, so the sky shows above.
|
||||
quad(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5)
|
||||
quad(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5)
|
||||
quad(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5)
|
||||
|
||||
// Crate on the flat room floor.
|
||||
const crate = mesh()
|
||||
box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top)
|
||||
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
|
||||
|
||||
const colliders: Aabb[] = [
|
||||
wall(-ARENA, ARENA, -ARENA, -ARENA + 1),
|
||||
wall(-ARENA, ARENA, ARENA - 1, ARENA),
|
||||
wall(ARENA - 1, ARENA, -ARENA, ARENA),
|
||||
wall(-ARENA, -ARENA + 1, -ARENA, ARENA),
|
||||
|
|
@ -88,7 +115,7 @@ export function buildLevel(): Level {
|
|||
maxX: CRATE.x + CRATE.half,
|
||||
minZ: CRATE.z - CRATE.half,
|
||||
maxZ: CRATE.z + CRATE.half,
|
||||
top: CRATE.top,
|
||||
top: CRATE.height,
|
||||
standable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -102,7 +129,9 @@ export function buildLevel(): Level {
|
|||
clouds: basicCumulus,
|
||||
}
|
||||
|
||||
return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky }
|
||||
const npcPosition = { x: 2, y: 0, z: -1 }
|
||||
|
||||
return { floor, walls, crate, ground, colliders, npcPosition, terrain: TERRAIN, sky }
|
||||
}
|
||||
|
||||
function mesh(): Mesh {
|
||||
|
|
@ -113,57 +142,33 @@ function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
|
|||
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false }
|
||||
}
|
||||
|
||||
/** Tessellate a quad into a grid sized by DIVISIONS_PER_UNIT, so triangle size
|
||||
* (and thus affine warp) is consistent whatever the surface's scale. Corners
|
||||
* run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */
|
||||
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void {
|
||||
const nu = divisions(a, b)
|
||||
const nv = divisions(a, d)
|
||||
/** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) ->
|
||||
* d (0,vs); `us`/`vs` set how many texture tiles span it. No subdivision is
|
||||
* needed -- texturing is perspective-correct, so a single quad looks right at
|
||||
* any size. */
|
||||
function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void {
|
||||
const base = m.vertices.length
|
||||
const row = nu + 1
|
||||
for (let i = 0; i <= nv; i++) {
|
||||
const t = i / nv
|
||||
for (let j = 0; j <= nu; j++) {
|
||||
const s = j / nu
|
||||
const wa = (1 - s) * (1 - t)
|
||||
const wb = s * (1 - t)
|
||||
const wc = s * t
|
||||
const wd = (1 - s) * t
|
||||
m.vertices.push({
|
||||
pos: {
|
||||
x: a[0] * wa + b[0] * wb + c[0] * wc + d[0] * wd,
|
||||
y: a[1] * wa + b[1] * wb + c[1] * wc + d[1] * wd,
|
||||
z: a[2] * wa + b[2] * wb + c[2] * wc + d[2] * wd,
|
||||
},
|
||||
uv: { x: us * s, y: vs * t },
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < nv; i++) {
|
||||
for (let j = 0; j < nu; j++) {
|
||||
const p = base + i * row + j
|
||||
m.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
||||
}
|
||||
}
|
||||
m.vertices.push(
|
||||
{ pos: { x: a[0], y: a[1], z: a[2] }, uv: { x: 0, y: 0 } },
|
||||
{ pos: { x: b[0], y: b[1], z: b[2] }, uv: { x: us, y: 0 } },
|
||||
{ pos: { x: c[0], y: c[1], z: c[2] }, uv: { x: us, y: vs } },
|
||||
{ pos: { x: d[0], y: d[1], z: d[2] }, uv: { x: 0, y: vs } },
|
||||
)
|
||||
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||
}
|
||||
|
||||
/** Grid divisions along edge a->b, from its world length and the density. */
|
||||
function divisions(a: Corner, b: Corner): number {
|
||||
const length = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||||
return Math.max(1, Math.round(length * DIVISIONS_PER_UNIT))
|
||||
}
|
||||
|
||||
/** A box centered at (cx, cz) on the floor: top face plus four sides, one uv
|
||||
* tile per face. No bottom (never seen). Tessellated like every other surface,
|
||||
* so it no longer warps up close. */
|
||||
function box(m: Mesh, cx: number, cz: number, half: number, top: number): void {
|
||||
/** A box centered at (cx, cz), rising `height` units from `base`: top face plus
|
||||
* four sides, one uv tile per face. No bottom (never seen). */
|
||||
function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void {
|
||||
const x0 = cx - half
|
||||
const x1 = cx + half
|
||||
const z0 = cz - half
|
||||
const z1 = cz + half
|
||||
quadGrid(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1], 1, 1)
|
||||
quadGrid(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0], 1, 1)
|
||||
quadGrid(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1], 1, 1)
|
||||
quadGrid(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0], 1, 1)
|
||||
quadGrid(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1], 1, 1)
|
||||
const y0 = base
|
||||
const y1 = base + height
|
||||
quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1)
|
||||
quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], 1, 1)
|
||||
quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], 1, 1)
|
||||
quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], 1, 1)
|
||||
quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], 1, 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ async function main(): Promise<void> {
|
|||
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
|
||||
|
||||
Sky.render(fb, camera, level.sky, now / 1000)
|
||||
Rasterizer.draw(fb, level.ground, textures.grass, viewProj, config)
|
||||
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
|
||||
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
||||
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Terrain } from "../engine/scene/Terrain"
|
||||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import type { Aabb, Level } from "./level"
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ export namespace Player {
|
|||
}
|
||||
|
||||
function groundHeight(position: Vec3, level: Level): number {
|
||||
let ground = 0
|
||||
let ground = Terrain.height(level.terrain, position.x, position.z)
|
||||
for (const aabb of level.colliders) {
|
||||
if (
|
||||
aabb.standable &&
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue