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

@ -12,9 +12,11 @@ rules live in `.agents/rules/*.md`.
- **PS1 look is a set of live knobs**, dial-able from full-PS1 to clean. See - **PS1 look is a set of live knobs**, dial-able from full-PS1 to clean. See
`RenderConfig`. Nothing about the era is hardcoded into the renderer. `RenderConfig`. Nothing about the era is hardcoded into the renderer.
- **Software rasterizer on purpose** — the PS1 look *is* rasterizer artifacts - **Software rasterizer on purpose** — the PS1 look leans on rasterizer traits
(affine texture swim, vertex snap, no mipmaps). Raytracing was considered and (vertex snap, low-res + dither/banding, no mipmaps). Raytracing was considered
cut (it removes the very artifacts we want). and cut (it removes the very artifacts we want). Note: **affine texture "swim"
was dropped** — texturing is always perspective-correct now (it warped badly on
the big outdoor terrain); the other era knobs stay.
- **Minimal architecture.** Scene-graph-lite / plain data + functions. - **Minimal architecture.** Scene-graph-lite / plain data + functions.
Deliberately **not** ECS or any "Big Game Architecture." Prefer the smallest Deliberately **not** ECS or any "Big Game Architecture." Prefer the smallest
clear structure; add knobs to experiment rather than abstractions. clear structure; add knobs to experiment rather than abstractions.
@ -50,21 +52,32 @@ rules live in `.agents/rules/*.md`.
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`, Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`,
`Texture` (nearest/bilinear, wrapping, no mipmaps), `Sky` (gradient + sun + `Texture` (nearest/bilinear, wrapping, no mipmaps), `Sky` (gradient + sun +
procedural clouds). procedural clouds).
- `scene/``Camera` (fps yaw/pitch), `Mesh` (indexed tris), `Sprite` - `scene/``Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
(Y-axis billboard). `Mesh` (indexed tris), `Sprite` (Y-axis billboard), `Terrain` (procedural
heightfield around the room: flat clearing in the center, rolling hills, tall
edge peaks. `Terrain.ground` builds the outdoor mesh with a hole for the room;
`Terrain.height` is the shared ground-height sampler for the player).
- `app/` — browser glue. - `app/` — browser glue.
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes - `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes
are already the `Color` layout). are already the `Color` layout).
- `level.ts` — builds the playground: per-texture meshes, `Aabb` colliders, - `level.ts` — builds the playground: a flat stone-floored room (three walls,
NPC position, sky/cloud config. Geometry density knob `DIVISIONS_PER_UNIT`. north side open) in the center of a big grassy `Terrain` world (~20x across).
Per-texture meshes incl. the outdoor grass `ground`, `Aabb` colliders, NPC
position, `Terrain` config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config.
The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the
terrain skirt that laps under the room edges. Room surfaces are single flat
quads -- no subdivision needed since texturing is perspective-correct; ground
triangle count is `GROUND_DIVISIONS` (fixed grid, so world size sets cell
chunkiness, not tri count).
- `player.ts` — feet-cylinder player: gravity/jump + circle-vs-AABB/-circle - `player.ts` — feet-cylinder player: gravity/jump + circle-vs-AABB/-circle
collision. collision; ground height comes from `Terrain.height` (plus standable AABBs).
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the - `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
`#fps` meter div (styled inline). `#fps` meter div (styled inline).
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
- `assets/` — generated `floor/wall/crate/npc` PNGs. Swap for real art anytime; - `assets/` — generated `floor/grass/wall/crate/npc` PNGs (`floor` = room stone,
`grass` = outdoor ground). Swap for real art anytime;
filenames are the contract. filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot. - `server/` — Bun server stub. `shared/` — isomorphic slot.
@ -72,15 +85,15 @@ rules live in `.agents/rules/*.md`.
`Player.update` → build `Camera``Camera.viewProjection` `Player.update` → build `Camera``Camera.viewProjection`
`Sky.render` (fills color + resets depth, replaces a clear) → `Sky.render` (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` floor, walls, crate (one call per texture) → `Rasterizer.draw` ground, floor, walls, crate (one call per texture) →
`Sprite.billboard(npc)` drawn via `Rasterizer.draw` `Sprite.billboard(npc)` drawn via `Rasterizer.draw`
`Framebuffer.quantize``present` (integer-scale, letterboxed blit; `Framebuffer.quantize``present` (integer-scale, letterboxed blit;
`imageSmoothingEnabled` follows `upscaleFilter`). `imageSmoothingEnabled` follows `upscaleFilter`).
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**, Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
affine↔perspective-correct UV blend (`perspectiveCorrect`), screen-space perspective-correct UVs, screen-space vertex snap, flat directional lighting,
vertex snap, flat directional lighting, distance fog, **alpha cutout** (discard distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
texel alpha < 128, for sprites), **double-sided** (no backface culling). **double-sided** (no backface culling).
## The look — where to tune ## The look — where to tune
@ -88,13 +101,19 @@ texel alpha < 128, for sprites), **double-sided** (no backface culling).
`standard` (384×216, the startup default), `soft`, `clean`, plus an unbound `standard` (384×216, the startup default), `soft`, `clean`, plus an unbound
`ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live. `ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live.
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`, Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
`vertexSnap`, `perspectiveCorrect` (0 = full affine swim, 1 = correct), `vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always
`textureFilter`, `lighting`, `fog`. perspective-correct — the affine-swim dial was removed.)
- **`app/level.ts` `DIVISIONS_PER_UNIT`** (1.3) — geometry triangle density per - **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
world unit. Higher = smaller triangles = less affine swim; **below ~0.5 the unit. Lower = the stone tiles bigger and less busy = less far-distance moire
floor degenerates into a black wedge** (affine sampling collapses onto the (there are no mipmaps); higher = finer but shimmerier.
texture's dark grout lines). Works with `perspectiveCorrect` — both fight the - **`app/level.ts` `GROUND_DIVISIONS`** (56) — outdoor ground grid resolution and
same affine error from different sides. the **main outdoor FPS lever**. The open vista is **transform-bound** on the
ground's triangles (no frustum culling — every tri is projected each frame), so
cost is ~linear in this: measured ~45 fps at 48, ~28 fps at 64, ~24 fps at 96
(headless, `standard`). Lower it for FPS, raise for finer terrain. Draw distance
(`fog.far` + `Camera` far plane, pushed out to ~200/260 for this scene) is
comparatively cheap since far ground is a thin horizon band. Cranking
`TERRAIN.peakHeight`/`outer` costs almost nothing (same tri count).
## Clouds ## Clouds
@ -112,7 +131,8 @@ branching in the cloud shader. Cost scales with sky resolution — fine at
## Controls ## Controls
WASD move · mouse look (click canvas to pointer-lock) · **Space** jump · WASD move · mouse look (click canvas to pointer-lock) · **Space** jump ·
**1/2/3** switch look presets. FPS shown bottom-right. **1/2/3** switch look presets. FPS shown bottom-right. The room's north wall is
open — walk out onto the terrain and toward the peaks.
## Code style (oxlint-enforced — match it) ## Code style (oxlint-enforced — match it)
@ -134,9 +154,9 @@ throwaway `bun` script can: `buildLevel()`, decode `assets/*.png` (they're
filter-0 RGBA — trivial to inflate), set a `Camera` pose, run filter-0 RGBA — trivial to inflate), set a `Camera` pose, run
`Sky.render` + `Rasterizer.draw` into a `Framebuffer`, encode the color buffer `Sky.render` + `Rasterizer.draw` into a `Framebuffer`, encode the color buffer
to PNG (same hand-rolled encoder as `scripts/gen-assets.ts`), write it, and read to PNG (same hand-rolled encoder as `scripts/gen-assets.ts`), write it, and read
it back. Sampling individual pixels this way diagnosed the affine black-wedge it back. Sampling individual pixels this way tuned the clouds, framed the terrain
(affine sampled grout, perspective-correct sampled stone) and tuned the clouds. peaks, and confirmed the ground textures flat (no swim). Put temp scripts in the
Put temp scripts in the job tmp dir, not the repo. job tmp dir, not the repo.
## Roadmap / not yet built ## Roadmap / not yet built

View file

@ -1,137 +0,0 @@
# Proprietary Software License
Copyright © 2026 SIGITEX. All rights reserved.
## 1. Proprietary and Confidential Material
This software, including all source code, object code, documentation, configuration files, data models, specifications, designs, interfaces, scripts, assets, and related materials (collectively, the **“Software”**), is the proprietary and confidential property of SIGITEX (**“Organization”**).
The Software contains confidential information and trade secrets belonging to the Organization. It is provided solely for authorized internal use and must be handled in accordance with the Organizations information-security, confidentiality, data-protection, and acceptable-use policies.
## 2. Authorized Users
Access to and use of the Software are limited to:
1. employees of the Organization;
2. contractors, consultants, and service providers who have been expressly authorized by the Organization; and
3. other persons who have received prior written authorization from the Organization.
Authorization is limited to the scope, purpose, systems, and duration approved by the Organization.
## 3. Limited Internal-Use License
Subject to continued authorization and compliance with this license, the Organization grants Authorized Users a limited, revocable, non-exclusive, non-transferable, and non-sublicensable right to access, execute, reproduce, and modify the Software solely:
1. for the Organizations internal business purposes;
2. within systems, environments, accounts, and repositories approved by the Organization; and
3. as necessary to perform work for the Organization.
No ownership rights are transferred under this license.
## 4. Restrictions
Except where expressly authorized in writing by the Organization, no person may:
1. disclose, publish, distribute, transmit, sell, license, sublicense, lease, assign, transfer, or otherwise make the Software available to any external person or entity;
2. upload or copy the Software to any public repository, personal repository, public file-sharing service, unapproved cloud service, or externally accessible system;
3. use the Software for personal purposes or for the benefit of any third party;
4. copy the Software except as reasonably necessary for an authorized internal purpose;
5. remove, obscure, or alter any copyright, confidentiality, attribution, ownership, or proprietary-rights notice;
6. reverse engineer, decompile, disassemble, or otherwise attempt to derive components of the Software, except to the extent required for authorized development or expressly permitted by applicable law;
7. incorporate the Software into any externally distributed product, service, deliverable, or open-source project;
8. use the Software to create, train, improve, evaluate, or supply an external artificial-intelligence or machine-learning system without prior written approval;
9. provide the Software to an external vendor or automated service unless the Organization has approved both the vendor and the specific use; or
10. use the Software in violation of applicable law, contractual obligations, or Organization policy.
## 5. Confidentiality
Authorized Users must:
1. protect the Software using at least the same degree of care used to protect the Organizations other confidential information, and no less than reasonable care;
2. disclose the Software only to persons who are authorized and have a legitimate need to know;
3. promptly report any suspected loss, unauthorized access, disclosure, copying, or distribution; and
4. comply with all applicable confidentiality agreements and Organization policies.
The confidentiality obligations in this license survive termination of access, employment, engagement, or authorization.
## 6. Third-Party Components
The Software may include third-party materials governed by separate licenses. Those licenses apply only to the relevant third-party materials.
Nothing in this license restricts rights granted directly under an applicable third-party license. All original portions of the Software created or owned by the Organization remain subject to this proprietary license.
Authorized Users must not introduce third-party code, data, models, assets, or dependencies into the Software unless their use has been reviewed and approved under the Organizations applicable policies.
## 7. Ownership
The Organization retains all rights, title, and interest in and to the Software, including all copyrights, patent rights, trade-secret rights, trademarks, database rights, and other intellectual-property rights.
To the extent permitted by applicable law and any governing employment or contractor agreement, all modifications, enhancements, derivative works, fixes, documentation, and other contributions made in connection with authorized work for the Organization are owned exclusively by the Organization.
## 8. Security and Access Control
Authorized Users must not:
1. share credentials or access tokens;
2. circumvent access controls, monitoring systems, technical restrictions, or security measures;
3. retain unauthorized local copies, backups, exports, credentials, secrets, or production data; or
4. access the Software after authorization has expired or been revoked.
The Organization may monitor, audit, limit, suspend, or revoke access to the Software at any time.
## 9. Termination and Return of Materials
Authorization under this license terminates immediately when:
1. the Organization revokes access;
2. the Authorized Users employment, engagement, or approved role ends;
3. the authorized purpose ends; or
4. the Authorized User breaches this license or an applicable Organization policy.
Upon termination, the Authorized User must immediately stop using the Software and, as directed by the Organization, return or permanently delete all copies in the Authorized Users possession or control, subject to applicable legal-retention requirements.
## 10. No External Rights
Possession of or access to the Software does not grant any right to use, copy, modify, disclose, or distribute it beyond the limited authorization expressly stated in this license.
No rights are granted by implication, estoppel, exhaustion, or otherwise.
Any external use, disclosure, licensing, distribution, or commercialization requires a separate written agreement signed by an authorized representative of the Organization.
## 11. Disclaimer
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE SOFTWARE IS PROVIDED **“AS IS”** AND **“AS AVAILABLE,”** WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE.
THE ORGANIZATION DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, ACCURACY, SECURITY, AND NON-INFRINGEMENT.
This disclaimer does not limit obligations that the Organization cannot lawfully exclude.
## 12. Limitation of Liability
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE ORGANIZATION AND ITS AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, AND AGENTS WILL NOT BE LIABLE UNDER THIS LICENSE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS, REVENUE, BUSINESS, OR GOODWILL.
Nothing in this section excludes or limits liability that cannot lawfully be excluded or limited.
## 13. Remedies
Unauthorized use or disclosure of the Software may cause irreparable harm for which monetary damages may be inadequate.
The Organization may seek injunctive or equitable relief, in addition to any other remedies available under contract, statute, common law, or Organization policy.
## 14. Governing Law
This license is governed by the laws of The United States, without regard to its conflict-of-laws rules.
## 15. General Terms
If any provision of this license is held unenforceable, that provision will be enforced to the maximum extent permitted, and the remaining provisions will remain in effect.
A failure by the Organization to enforce any provision is not a waiver of that provision or any other right.
In the event of a conflict between this license and a separately executed written agreement, the separately executed agreement controls to the extent of the conflict.
---
**NOTICE: PROPRIETARY AND CONFIDENTIAL**
Unauthorized access, use, copying, modification, disclosure, or distribution of this Software is prohibited.

View file

@ -1,11 +1,13 @@
import type { Texture } from "../engine/render/Texture" import type { Texture } from "../engine/render/Texture"
import crateUrl from "../assets/crate.png" import crateUrl from "../assets/crate.png"
import floorUrl from "../assets/floor.png" import floorUrl from "../assets/floor.png"
import grassUrl from "../assets/grass.png"
import npcUrl from "../assets/npc.png" import npcUrl from "../assets/npc.png"
import wallUrl from "../assets/wall.png" import wallUrl from "../assets/wall.png"
export type Textures = { export type Textures = {
floor: Texture floor: Texture
grass: Texture
wall: Texture wall: Texture
crate: Texture crate: Texture
npc: Texture npc: Texture
@ -13,13 +15,14 @@ export type Textures = {
/** Load every game texture up front. Call once before starting the loop. */ /** Load every game texture up front. Call once before starting the loop. */
export async function loadTextures(): Promise<Textures> { 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(floorUrl),
loadTexture(grassUrl),
loadTexture(wallUrl), loadTexture(wallUrl),
loadTexture(crateUrl), loadTexture(crateUrl),
loadTexture(npcUrl), loadTexture(npcUrl),
]) ])
return { floor, wall, crate, npc } return { floor, grass, wall, crate, npc }
} }
function loadTexture(url: string): Promise<Texture> { function loadTexture(url: string): Promise<Texture> {

View file

@ -1,6 +1,7 @@
import { Color } from "../engine/render/Color" import { Color } from "../engine/render/Color"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import type { Mesh } from "../engine/scene/Mesh" import type { Mesh } from "../engine/scene/Mesh"
import { Terrain } from "../engine/scene/Terrain"
type Corner = [number, number, number] type Corner = [number, number, number]
@ -15,30 +16,48 @@ export type Aabb = {
standable: boolean standable: boolean
} }
/** The playground: geometry split by texture, its collision solids, where the /** The playground: a flat-floored room dropped into the center of a big open
* NPC stands, and the sky to draw behind it. */ * 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 = { export type Level = {
floor: Mesh floor: Mesh
walls: Mesh walls: Mesh
crate: Mesh crate: Mesh
ground: Mesh
colliders: Aabb[] colliders: Aabb[]
npcPosition: { x: number; y: number; z: number } npcPosition: { x: number; y: number; z: number }
terrain: Terrain
sky: SkyConfig sky: SkyConfig
} }
const ARENA = 12 const ARENA = 12
const WALL_HEIGHT = 4 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 /** The world around the room: a flat clearing the size of the room (`inner`),
* surface (floor, walls, crate). Higher = smaller triangles = each spans less * rolling hills beyond, ramping into very high peaks at the `outer` rim ~20x
* depth = less affine texture swim, at the cost of more geometry; lower = * the room across. Tune freely -- crank `peakHeight` for taller mountains,
* chunkier, wilder PS1 warp (below ~0.5 the floor tips into the black-wedge * `outer` for a bigger world. */
* degeneration). Because it scales with surface size, one value keeps the big const TERRAIN: Terrain = {
* floor and the little crate equally warp-free. Edit and Vite reloads. Pairs inner: ARENA,
* with the `ps1` preset's `perspectiveCorrect`, which fights the same error outer: ARENA * 20,
* from the render side. */ blend: 12,
const DIVISIONS_PER_UNIT = 1.3 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`. /** The two cloud styles; swap which one the sky uses in `buildLevel`.
* `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier * `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier
@ -49,7 +68,7 @@ export const basicCumulus: CloudLayer = {
coverage: 0.5, coverage: 0.5,
scale: 0.9, scale: 0.9,
speed: 0.5, speed: 0.5,
edge: 0.02, edge: 0.005,
} }
export const fancyCumulus: CloudLayer = { export const fancyCumulus: CloudLayer = {
@ -64,22 +83,30 @@ export const fancyCumulus: CloudLayer = {
} }
export function buildLevel(): Level { 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() 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 walls = mesh()
const h = WALL_HEIGHT const h = WALL_HEIGHT
// Inward-facing perimeter, no ceiling so the sky shows above. // Three inward-facing walls; the north (-Z) side is left open onto the world.
quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5) // No ceiling, so the sky shows above.
quadGrid(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)
quadGrid(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)
quadGrid(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() 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[] = [ const colliders: Aabb[] = [
wall(-ARENA, ARENA, -ARENA, -ARENA + 1),
wall(-ARENA, ARENA, ARENA - 1, ARENA), wall(-ARENA, ARENA, ARENA - 1, ARENA),
wall(ARENA - 1, ARENA, -ARENA, ARENA), wall(ARENA - 1, ARENA, -ARENA, ARENA),
wall(-ARENA, -ARENA + 1, -ARENA, ARENA), wall(-ARENA, -ARENA + 1, -ARENA, ARENA),
@ -88,7 +115,7 @@ export function buildLevel(): Level {
maxX: CRATE.x + CRATE.half, maxX: CRATE.x + CRATE.half,
minZ: CRATE.z - CRATE.half, minZ: CRATE.z - CRATE.half,
maxZ: CRATE.z + CRATE.half, maxZ: CRATE.z + CRATE.half,
top: CRATE.top, top: CRATE.height,
standable: true, standable: true,
}, },
] ]
@ -102,7 +129,9 @@ export function buildLevel(): Level {
clouds: basicCumulus, 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 { 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 } return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false }
} }
/** Tessellate a quad into a grid sized by DIVISIONS_PER_UNIT, so triangle size /** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) ->
* (and thus affine warp) is consistent whatever the surface's scale. Corners * d (0,vs); `us`/`vs` set how many texture tiles span it. No subdivision is
* run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */ * needed -- texturing is perspective-correct, so a single quad looks right at
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void { * any size. */
const nu = divisions(a, b) function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void {
const nv = divisions(a, d)
const base = m.vertices.length const base = m.vertices.length
const row = nu + 1 m.vertices.push(
for (let i = 0; i <= nv; i++) { { pos: { x: a[0], y: a[1], z: a[2] }, uv: { x: 0, y: 0 } },
const t = i / nv { pos: { x: b[0], y: b[1], z: b[2] }, uv: { x: us, y: 0 } },
for (let j = 0; j <= nu; j++) { { pos: { x: c[0], y: c[1], z: c[2] }, uv: { x: us, y: vs } },
const s = j / nu { pos: { x: d[0], y: d[1], z: d[2] }, uv: { x: 0, y: vs } },
const wa = (1 - s) * (1 - t) )
const wb = s * (1 - t) m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
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)
}
}
} }
/** Grid divisions along edge a->b, from its world length and the density. */ /** A box centered at (cx, cz), rising `height` units from `base`: top face plus
function divisions(a: Corner, b: Corner): number { * four sides, one uv tile per face. No bottom (never seen). */
const length = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void {
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 {
const x0 = cx - half const x0 = cx - half
const x1 = cx + half const x1 = cx + half
const z0 = cz - half const z0 = cz - half
const z1 = cz + half const z1 = cz + half
quadGrid(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1], 1, 1) const y0 = base
quadGrid(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0], 1, 1) const y1 = base + height
quadGrid(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1], 1, 1) quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1)
quadGrid(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0], 1, 1) quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], 1, 1)
quadGrid(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1], 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)
} }

View file

@ -110,6 +110,7 @@ async function main(): Promise<void> {
const viewProj = Camera.viewProjection(camera, fb.width / fb.height) const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
Sky.render(fb, camera, level.sky, now / 1000) 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.floor, textures.floor, viewProj, config)
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config) Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config) Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)

View file

@ -1,3 +1,4 @@
import { Terrain } from "../engine/scene/Terrain"
import type { Vec3 } from "../engine/math/Vec3" import type { Vec3 } from "../engine/math/Vec3"
import type { Aabb, Level } from "./level" import type { Aabb, Level } from "./level"
@ -82,7 +83,7 @@ export namespace Player {
} }
function groundHeight(position: Vec3, level: Level): number { 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) { for (const aabb of level.colliders) {
if ( if (
aabb.standable && aabb.standable &&

BIN
assets/grass.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -28,14 +28,13 @@ const DIFFUSE = 0.6
* Per triangle the pipeline is: transform to clip space, clip against the near * Per triangle the pipeline is: transform to clip space, clip against the near
* plane, perspective-divide to screen pixels (optionally snapping vertices to a * plane, perspective-divide to screen pixels (optionally snapping vertices to a
* grid), then fill with an edge-function / barycentric scan. Per pixel it * grid), then fill with an edge-function / barycentric scan. Per pixel it
* interpolates depth as 1/w, texture coords (affine or perspective-correct, see * interpolates depth as 1/w, perspective-correct texture coords, and applies
* `fillTriangle`), and applies flat shading plus distance fog. * flat shading plus distance fog.
* *
* The period-accurate rough edges are deliberate, not unfinished: no mipmaps * The period-accurate rough edges are deliberate, not unfinished: no mipmaps
* (so distant textures shimmer/moire), no antialiasing (jagged silhouettes), * (so distant textures shimmer/moire) and no antialiasing (jagged silhouettes).
* and affine texturing by default (the texture "swim"). Depth is a plain 1/w * Depth is a plain 1/w z-buffer and triangles are drawn double-sided (no
* z-buffer and triangles are drawn double-sided (no backface culling), so mesh * backface culling), so mesh winding can never cause surfaces to drop out.
* winding can never cause surfaces to drop out.
*/ */
export namespace Rasterizer { export namespace Rasterizer {
/** Draw an indexed mesh into the framebuffer through a view-projection /** 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 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 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 maxY = Math.min(fb.height - 1, Math.ceil(Math.max(a.sy, b.sy, c.sy)))
const pc = config.perspectiveCorrect
const fog = config.fog const fog = config.fog
for (let y = minY; y <= maxY; y++) { for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) { for (let x = minX; x <= maxX; x++) {
@ -180,22 +178,11 @@ export namespace Rasterizer {
if (invW <= fb.depth[idx]) { if (invW <= fb.depth[idx]) {
continue continue
} }
// Two ways to interpolate texture coords across the triangle: // Perspective-correct texture coords: divide the interpolated u/w and
// affine - linear in screen space. This is what hardware without a // v/w by the interpolated 1/w to undo foreshortening, so textures sit
// perspective divide does. It is exact ONLY when the three vertices // flat on receding surfaces with no affine "swim".
// share a depth (a face viewed head-on). On a receding surface (the const u = (w0 * a.u * a.invW + w1 * b.u * b.invW + w2 * c.u * c.invW) / invW
// floor, or a wall turned into the periphery) the depth gradient const v = (w0 * a.v * a.invW + w1 * b.v * b.invW + w2 * c.v * c.invW) / invW
// 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, // Alpha cutout: discard transparent texels so sprites read as cutouts,
// not rectangles. Opaque world textures are alpha 255, so unaffected. // not rectangles. Opaque world textures are alpha 255, so unaffected.
const texel = Texture.sample(texture, u, v, config.textureFilter) 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, * jittered as the camera moved. 0 = off (smooth), 1 = one-pixel snap,
* higher = coarser and more pronounced wobble. */ * higher = coarser and more pronounced wobble. */
vertexSnap: number 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; /** Texture sampling. `nearest` point-samples for crunchy PS1 texels;
* `linear` does bilinear smoothing (cleaner, but not period-accurate). * `linear` does bilinear smoothing (cleaner, but not period-accurate).
* Neither uses mipmaps, so distant textures shimmer regardless. */ * Neither uses mipmaps, so distant textures shimmer regardless. */
@ -58,9 +48,8 @@ export type RenderConfig = {
fog: Fog | null fog: Fog | null
} }
/** Ready-made looks. The demo binds keys 1/2/3 to these, and they intentionally /** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution,
* sweep `perspectiveCorrect` 0 -> 0.5 -> 1 so you can watch the texture swim * color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */
* straighten out as you press through them. */
export namespace RenderConfig { export namespace RenderConfig {
export const standard: RenderConfig = { export const standard: RenderConfig = {
internalWidth: 384, internalWidth: 384,
@ -69,10 +58,9 @@ export namespace RenderConfig {
colorDepth: 5, colorDepth: 5,
dither: 1, dither: 1,
vertexSnap: 1, vertexSnap: 1,
perspectiveCorrect: 0.25,
textureFilter: "nearest", textureFilter: "nearest",
lighting: "flat", 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 = { export const ps1: RenderConfig = {
@ -82,10 +70,9 @@ export namespace RenderConfig {
colorDepth: 5, colorDepth: 5,
dither: 1, dither: 1,
vertexSnap: 1, vertexSnap: 1,
perspectiveCorrect: 0.25,
textureFilter: "nearest", textureFilter: "nearest",
lighting: "flat", 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 = { export const soft: RenderConfig = {
@ -95,10 +82,9 @@ export namespace RenderConfig {
colorDepth: 6, colorDepth: 6,
dither: 0.5, dither: 0.5,
vertexSnap: 0.5, vertexSnap: 0.5,
perspectiveCorrect: 0.5,
textureFilter: "nearest", textureFilter: "nearest",
lighting: "flat", 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 = { export const clean: RenderConfig = {
@ -108,7 +94,6 @@ export namespace RenderConfig {
colorDepth: 8, colorDepth: 8,
dither: 0, dither: 0,
vertexSnap: 0, vertexSnap: 0,
perspectiveCorrect: 1,
textureFilter: "linear", textureFilter: "linear",
lighting: "flat", lighting: "flat",
fog: null, fog: null,

View file

@ -25,11 +25,12 @@ export namespace Camera {
} }
/** Combined projection * view matrix for the given viewport aspect ratio. /** 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 { export function viewProjection(cam: Camera, aspect: number): Mat4 {
const eye = cam.position const eye = cam.position
const view = Mat4.lookAt(eye, Vec3.add(eye, forward(cam)), { x: 0, y: 1, z: 0 }) 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) return Mat4.multiply(proj, view)
} }
} }

94
engine/scene/Terrain.ts Normal file
View 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)
}
}

View file

@ -9,7 +9,8 @@
"sigitex:tool/oxc", "sigitex:tool/oxc",
"sigitex:tool/commitlint", "sigitex:tool/commitlint",
"sigitex:tool/husky", "sigitex:tool/husky",
"sigitex:tool/vibes" "sigitex:tool/vibes",
"sigitex:license/mit"
], ],
"vars": { "vars": {
"repo": "meat", "repo": "meat",

View file

@ -108,6 +108,16 @@ const floor: Shade = (x, y) => {
return [132 + n, 130 + n, 120 + n, 255] return [132 + n, 130 + n, 120 + n, 255]
} }
// Outdoor ground. Green, busy, with faux vertical blades and soft patches, kept
// free of strong low-frequency features so it tiles across the terrain without an
// obvious repeating grid.
const grass: Shade = (x, y) => {
const n = noise(x, y) * 18
const blade = noise(x, y * 3) * 8
const patch = noise(Math.floor(x / 8), Math.floor(y / 8)) * 14
return [56 + n * 0.7 + patch * 0.5, 116 + n + blade + patch, 50 + n * 0.5 + patch * 0.4, 255]
}
const wall: Shade = (x, y) => { const wall: Shade = (x, y) => {
const row = Math.floor(y / 16) const row = Math.floor(y / 16)
const bx = (x + (row % 2) * 16) % 32 const bx = (x + (row % 2) * 16) % 32
@ -161,6 +171,7 @@ const npc: Shade = (x, y) => {
const assets: Array<[string, number, number, Shade]> = [ const assets: Array<[string, number, number, Shade]> = [
["floor", 64, 64, floor], ["floor", 64, 64, floor],
["grass", 64, 64, grass],
["wall", 64, 64, wall], ["wall", 64, 64, wall],
["crate", 64, 64, crate], ["crate", 64, 64, crate],
["npc", 48, 64, npc], ["npc", 48, 64, npc],