From 81474b6bcaae51a285bc85af7ffcb598e9a6835d Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 12:51:07 +0200 Subject: [PATCH] feat: terrain --- AGENTS.md | 68 ++++++++++------ LICENSE.md | 137 -------------------------------- app/assets.ts | 7 +- app/level.ts | 143 ++++++++++++++++++---------------- app/main.ts | 1 + app/player.ts | 3 +- assets/grass.png | Bin 0 -> 9248 bytes engine/render/Rasterizer.ts | 33 +++----- engine/render/RenderConfig.ts | 25 ++---- engine/scene/Camera.ts | 5 +- engine/scene/Terrain.ts | 94 ++++++++++++++++++++++ regime.config.json | 3 +- scripts/gen-assets.ts | 11 +++ 13 files changed, 251 insertions(+), 279 deletions(-) delete mode 100644 LICENSE.md create mode 100644 assets/grass.png create mode 100644 engine/scene/Terrain.ts diff --git a/AGENTS.md b/AGENTS.md index 6b5164a..867c155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `RenderConfig`. Nothing about the era is hardcoded into the renderer. -- **Software rasterizer on purpose** — the PS1 look *is* rasterizer artifacts - (affine texture swim, vertex snap, no mipmaps). Raytracing was considered and - cut (it removes the very artifacts we want). +- **Software rasterizer on purpose** — the PS1 look leans on rasterizer traits + (vertex snap, low-res + dither/banding, no mipmaps). Raytracing was considered + 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. Deliberately **not** ECS or any "Big Game Architecture." Prefer the smallest 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`, `Texture` (nearest/bilinear, wrapping, no mipmaps), `Sky` (gradient + sun + procedural clouds). - - `scene/` — `Camera` (fps yaw/pitch), `Mesh` (indexed tris), `Sprite` - (Y-axis billboard). + - `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks), + `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. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are already the `Color` layout). - - `level.ts` — builds the playground: per-texture meshes, `Aabb` colliders, - NPC position, sky/cloud config. Geometry density knob `DIVISIONS_PER_UNIT`. + - `level.ts` — builds the playground: a flat stone-floored room (three walls, + 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 - collision. + collision; ground height comes from `Terrain.height` (plus standable AABBs). - `index.html` — Vite entry at repo root; holds the `#screen` canvas and the `#fps` meter div (styled inline). - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and 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. - `server/` — Bun server stub. `shared/` — isomorphic slot. @@ -72,15 +85,15 @@ rules live in `.agents/rules/*.md`. `Player.update` → build `Camera` → `Camera.viewProjection` → `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` → `Framebuffer.quantize` → `present` (integer-scale, letterboxed blit; `imageSmoothingEnabled` follows `upscaleFilter`). Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**, -affine↔perspective-correct UV blend (`perspectiveCorrect`), screen-space -vertex snap, flat directional lighting, distance fog, **alpha cutout** (discard -texel alpha < 128, for sprites), **double-sided** (no backface culling). +perspective-correct UVs, screen-space vertex snap, flat directional lighting, +distance fog, **alpha cutout** (discard texel alpha < 128, for sprites), +**double-sided** (no backface culling). ## 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 `ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live. Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`, - `vertexSnap`, `perspectiveCorrect` (0 = full affine swim, 1 = correct), - `textureFilter`, `lighting`, `fog`. -- **`app/level.ts` `DIVISIONS_PER_UNIT`** (1.3) — geometry triangle density per - world unit. Higher = smaller triangles = less affine swim; **below ~0.5 the - floor degenerates into a black wedge** (affine sampling collapses onto the - texture's dark grout lines). Works with `perspectiveCorrect` — both fight the - same affine error from different sides. + `vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always + perspective-correct — the affine-swim dial was removed.) +- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world + unit. Lower = the stone tiles bigger and less busy = less far-distance moire + (there are no mipmaps); higher = finer but shimmerier. +- **`app/level.ts` `GROUND_DIVISIONS`** (56) — outdoor ground grid resolution and + 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 @@ -112,7 +131,8 @@ branching in the cloud shader. Cost scales with sky resolution — fine at ## Controls 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) @@ -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 `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 -it back. Sampling individual pixels this way diagnosed the affine black-wedge -(affine sampled grout, perspective-correct sampled stone) and tuned the clouds. -Put temp scripts in the job tmp dir, not the repo. +it back. Sampling individual pixels this way tuned the clouds, framed the terrain +peaks, and confirmed the ground textures flat (no swim). Put temp scripts in the +job tmp dir, not the repo. ## Roadmap / not yet built diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 650bf30..0000000 --- a/LICENSE.md +++ /dev/null @@ -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 Organization’s 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 Organization’s 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 Organization’s 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 Organization’s 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 User’s 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 User’s 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. diff --git a/app/assets.ts b/app/assets.ts index c3f0a24..ca5c580 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -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 { - 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 { diff --git a/app/level.ts b/app/level.ts index 9ca4a10..17df18b 100644 --- a/app/level.ts +++ b/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) } diff --git a/app/main.ts b/app/main.ts index 2306876..fef890b 100644 --- a/app/main.ts +++ b/app/main.ts @@ -110,6 +110,7 @@ async function main(): Promise { 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) diff --git a/app/player.ts b/app/player.ts index 2c7ee86..7281d5d 100644 --- a/app/player.ts +++ b/app/player.ts @@ -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 && diff --git a/assets/grass.png b/assets/grass.png new file mode 100644 index 0000000000000000000000000000000000000000..f59a5ea78088be29bad80ec3978bb782761fe75f GIT binary patch literal 9248 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 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, diff --git a/engine/scene/Camera.ts b/engine/scene/Camera.ts index 496ebf0..4fe024b 100644 --- a/engine/scene/Camera.ts +++ b/engine/scene/Camera.ts @@ -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) } } diff --git a/engine/scene/Terrain.ts b/engine/scene/Terrain.ts new file mode 100644 index 0000000..d42754d --- /dev/null +++ b/engine/scene/Terrain.ts @@ -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) + } +} diff --git a/regime.config.json b/regime.config.json index 4401aa7..0041093 100644 --- a/regime.config.json +++ b/regime.config.json @@ -9,7 +9,8 @@ "sigitex:tool/oxc", "sigitex:tool/commitlint", "sigitex:tool/husky", - "sigitex:tool/vibes" + "sigitex:tool/vibes", + "sigitex:license/mit" ], "vars": { "repo": "meat", diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index 7916387..09c3230 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -108,6 +108,16 @@ const floor: Shade = (x, y) => { 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 row = Math.floor(y / 16) const bx = (x + (row % 2) * 16) % 32 @@ -161,6 +171,7 @@ const npc: Shade = (x, y) => { const assets: Array<[string, number, number, Shade]> = [ ["floor", 64, 64, floor], + ["grass", 64, 64, grass], ["wall", 64, 64, wall], ["crate", 64, 64, crate], ["npc", 48, 64, npc],