feat: culling
This commit is contained in:
parent
680e08aadc
commit
ef5029da1d
7 changed files with 309 additions and 103 deletions
83
AGENTS.md
83
AGENTS.md
|
|
@ -20,6 +20,11 @@ rules live in `.agents/rules/*.md`.
|
||||||
- **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.
|
||||||
|
- **Culling beats batching here.** A "draw call" is just a JS loop (no GPU state),
|
||||||
|
so merging the world into big meshes only defeats visibility skipping. Instead
|
||||||
|
the outdoor world is stored as spatial **chunks** that are frustum-culled per
|
||||||
|
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
||||||
|
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
||||||
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
||||||
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
|
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
|
||||||
all browser glue (canvas, input, image decode) lives in `app/`.
|
all browser glue (canvas, input, image decode) lives in `app/`.
|
||||||
|
|
@ -49,13 +54,15 @@ rules live in `.agents/rules/*.md`.
|
||||||
- `math/` — `Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
|
- `math/` — `Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
|
||||||
- `render/` — `Color` (packed RGBA, little-endian = canvas ImageData order),
|
- `render/` — `Color` (packed RGBA, little-endian = canvas ImageData order),
|
||||||
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
|
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
|
||||||
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 +
|
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
||||||
procedural clouds).
|
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
||||||
|
mipmaps), `Sky` (gradient + sun + procedural clouds; renders at 1/`step` res).
|
||||||
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
||||||
`Mesh` (indexed tris), `Sprite` (Y-axis billboard), `Terrain` (procedural
|
`Mesh` (indexed tris), `Sprite` (Y-axis billboard), `Terrain` (procedural
|
||||||
heightfield around the room: flat clearing in the center, rolling hills, tall
|
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;
|
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
|
||||||
|
per chunk, aligned so patches weld crack-free, with a hole for the room;
|
||||||
`Terrain.height` is the shared ground-height sampler for the player), `Tree`
|
`Terrain.height` is the shared ground-height sampler for the player), `Tree`
|
||||||
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
|
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
|
||||||
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
|
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
|
||||||
|
|
@ -66,18 +73,17 @@ rules live in `.agents/rules/*.md`.
|
||||||
- `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: a flat stone-floored room (three thick
|
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
||||||
walls via `slab`, north side open) in the center of a big grassy `Terrain`
|
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
||||||
world (~20x across).
|
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
||||||
Per-texture meshes incl. the outdoor grass `ground`, a scattered forest
|
`placeBoulders` → instance lists + colliders; `TREE_/BOULDER_COUNT`/`_SEED`/
|
||||||
(`scatterTrees` → `trunks`/`oakFoliage`/`spruceFoliage`, `TREE_COUNT`/`_SEED`/
|
`_REACH`), then `buildChunks` bakes terrain + props into a `CHUNK_GRID` x
|
||||||
`_REACH`) and `boulders` (`scatterBoulders`, `BOULDER_COUNT`/`_SEED`/`_REACH`),
|
`CHUNK_GRID` grid of `Chunk`s (each = per-texture meshes grass/bark/leaf/needle/
|
||||||
`Aabb` colliders (incl. grown-tree trunks + big boulders), NPC position,
|
rock + a tight AABB) that `main` frustum-culls. `Aabb` colliders (walls, crate,
|
||||||
`Terrain` config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config.
|
grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
|
||||||
The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the
|
sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
|
||||||
terrain skirt that laps under the room edges. Room surfaces are single flat
|
stays clean over the terrain skirt that laps under the room edges. Room surfaces
|
||||||
quads -- no subdivision needed since texturing is perspective-correct; ground
|
are single flat quads -- no subdivision needed since texturing is
|
||||||
triangle count is `GROUND_DIVISIONS` (fixed grid, so world size sets cell
|
perspective-correct.
|
||||||
chunkiness, not tri count).
|
|
||||||
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run
|
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run
|
||||||
(`RUN_MULTIPLIER`) + circle-vs-AABB/-circle collision, substepped so fast
|
(`RUN_MULTIPLIER`) + circle-vs-AABB/-circle collision, substepped so fast
|
||||||
running can't tunnel walls; ground height from `Terrain.height` (plus
|
running can't tunnel walls; ground height from `Terrain.height` (plus
|
||||||
|
|
@ -95,17 +101,21 @@ rules live in `.agents/rules/*.md`.
|
||||||
## Frame pipeline (`app/main.ts` `frame`)
|
## Frame pipeline (`app/main.ts` `frame`)
|
||||||
|
|
||||||
`Player.update` → build `Camera` → `Camera.viewProjection` →
|
`Player.update` → build `Camera` → `Camera.viewProjection` →
|
||||||
`Sky.render` (fills color + resets depth, replaces a clear) →
|
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
|
||||||
`Rasterizer.draw` ground, floor, walls, crate, boulders, tree trunks, oak
|
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
|
||||||
foliage, spruce foliage (one call per texture) →
|
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
|
||||||
`Sprite.billboard(npc)` drawn via `Rasterizer.draw` →
|
bark, leaf, needle (backface-culled) → `Sprite.billboard(npc)` (double-sided) →
|
||||||
`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**,
|
||||||
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
|
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
|
||||||
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
|
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites).
|
||||||
**double-sided** (no backface culling).
|
**Backface culling is opt-in** (`draw(..., cull)`, default off = double-sided):
|
||||||
|
on for solid world chunks, off for sprites and the room. It relies on winding, so
|
||||||
|
generators feeding culled draws (terrain patch, tree/boulder builders) must wind
|
||||||
|
front-out — a culled mesh that renders inside-out has its index order flipped
|
||||||
|
(see `Terrain.patch`). The cull sign: back-facing == positive screen area here.
|
||||||
|
|
||||||
## The look — where to tune
|
## The look — where to tune
|
||||||
|
|
||||||
|
|
@ -118,14 +128,27 @@ distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
|
||||||
- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
|
- **`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
|
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
||||||
(there are no mipmaps); higher = finer but shimmerier.
|
(there are no mipmaps); higher = finer but shimmerier.
|
||||||
- **`app/level.ts` `GROUND_DIVISIONS`** (56) — outdoor ground grid resolution and
|
- **`app/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
|
||||||
the **main outdoor FPS lever**. The open vista is **transform-bound** on the
|
granularity and terrain resolution. World terrain divisions = `CHUNK_GRID *
|
||||||
ground's triangles (no frustum culling — every tri is projected each frame), so
|
TERRAIN_SUBDIV`. Smaller cells cull tighter (draw less off-screen) but cost more
|
||||||
cost is ~linear in this: measured ~45 fps at 48, ~28 fps at 64, ~24 fps at 96
|
per-cell tests/bounds. This is the lever if a dense world still lags.
|
||||||
(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
|
## Performance / where the frame goes
|
||||||
comparatively cheap since far ground is a thin horizon band. Cranking
|
|
||||||
`TERRAIN.peakHeight`/`outer` costs almost nothing (same tri count).
|
The world is dense (hundreds of trees + boulders, ~50k tris) but most of it is
|
||||||
|
off-screen or fogged each frame, so three things keep it cheap:
|
||||||
|
- **Frustum culling** (`Frustum` + per-`Chunk` AABB test in `main`) — skips whole
|
||||||
|
chunks that fall outside the view. Behind you + off to the sides = free.
|
||||||
|
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
|
||||||
|
(terrain, foliage, rock). See the Rasterizer note re winding.
|
||||||
|
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
|
||||||
|
pixel and dominated the frame; sampling once per 2×2 block quarters it.
|
||||||
|
|
||||||
|
Together ~1.5–2x over drawing everything full-res every frame, and the win grows
|
||||||
|
with content since culled chunks cost ~nothing. Next levers if needed: LOD /
|
||||||
|
impostors for far trees, flat typed-array geometry (kill per-tri allocation),
|
||||||
|
Web-Worker banded rasterization. `TREE_COUNT`/`BOULDER_COUNT` are the blunt
|
||||||
|
content dials.
|
||||||
|
|
||||||
## Clouds
|
## Clouds
|
||||||
|
|
||||||
|
|
|
||||||
145
app/level.ts
145
app/level.ts
|
|
@ -18,22 +18,33 @@ export type Aabb = {
|
||||||
standable: boolean
|
standable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders
|
||||||
|
* standing in it, split by texture, plus an axis-aligned bounding box (tight to
|
||||||
|
* the actual geometry, so overhanging canopies aren't clipped). The renderer
|
||||||
|
* frustum-tests the box and skips the whole cell when it is off-screen -- this
|
||||||
|
* is what keeps a big, dense world affordable. Empty cells are never created. */
|
||||||
|
export type Chunk = {
|
||||||
|
minX: number
|
||||||
|
minY: number
|
||||||
|
minZ: number
|
||||||
|
maxX: number
|
||||||
|
maxY: number
|
||||||
|
maxZ: number
|
||||||
|
grass: Mesh
|
||||||
|
bark: Mesh
|
||||||
|
leaf: Mesh
|
||||||
|
needle: Mesh
|
||||||
|
rock: Mesh
|
||||||
|
}
|
||||||
|
|
||||||
/** The playground: a flat-floored room dropped into the center of a big open
|
/** 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
|
* landscape. The room (floor/walls/crate) is small and always drawn; the
|
||||||
* NPC stands, the heightfield the outdoor ground + player share, and the sky. */
|
* outdoor world is split into `chunks` that are frustum-culled per frame. */
|
||||||
export type Level = {
|
export type Level = {
|
||||||
floor: Mesh
|
floor: Mesh
|
||||||
walls: Mesh
|
walls: Mesh
|
||||||
crate: Mesh
|
crate: Mesh
|
||||||
ground: Mesh
|
chunks: Chunk[]
|
||||||
/** All tree trunks + branches (bark texture). */
|
|
||||||
trunks: Mesh
|
|
||||||
/** Oak canopies (leaf texture) and spruce foliage (needle texture), split so
|
|
||||||
* each takes its own texture in one draw call. */
|
|
||||||
oakFoliage: Mesh
|
|
||||||
spruceFoliage: Mesh
|
|
||||||
/** Scattered boulders (rock texture). */
|
|
||||||
boulders: Mesh
|
|
||||||
colliders: Aabb[]
|
colliders: Aabb[]
|
||||||
npcPosition: { x: number; y: number; z: number }
|
npcPosition: { x: number; y: number; z: number }
|
||||||
terrain: Terrain
|
terrain: Terrain
|
||||||
|
|
@ -80,10 +91,14 @@ const BOULDER_COUNT = 70
|
||||||
const BOULDER_SEED = 0xB0142
|
const BOULDER_SEED = 0xB0142
|
||||||
const BOULDER_REACH = 0.7
|
const BOULDER_REACH = 0.7
|
||||||
|
|
||||||
/** Outdoor ground mesh resolution. A fixed grid over the whole world, so cell
|
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
|
||||||
* size (and cost) is set here, not by the world's size: bigger `outer` gives
|
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
|
||||||
* chunkier terrain, not more triangles. `GROUND_UV` sets texture tiles/unit. */
|
* (less drawn off-screen) but cost more per-cell tests + bounds; this is the
|
||||||
const GROUND_DIVISIONS = 56
|
* granularity knob. `TERRAIN_SUBDIV` is the terrain quads per cell edge, so the
|
||||||
|
* world's terrain resolution is `CHUNK_GRID * TERRAIN_SUBDIV`. `GROUND_UV` sets
|
||||||
|
* texture tiles/unit. */
|
||||||
|
const CHUNK_GRID = 12
|
||||||
|
const TERRAIN_SUBDIV = 5
|
||||||
const GROUND_UV = 0.25
|
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`.
|
||||||
|
|
@ -118,9 +133,6 @@ export function buildLevel(): Level {
|
||||||
const fy = FLOOR_LIFT
|
const fy = FLOOR_LIFT
|
||||||
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
|
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
|
||||||
const t = WALL_THICKNESS
|
const t = WALL_THICKNESS
|
||||||
|
|
@ -159,27 +171,86 @@ export function buildLevel(): Level {
|
||||||
|
|
||||||
const npcPosition = { x: 2, y: 0, z: -1 }
|
const npcPosition = { x: 2, y: 0, z: -1 }
|
||||||
|
|
||||||
// Scatter a forest on the grass; appends into the tree meshes + trunk colliders.
|
// Place the props (also pushes their colliders), then bake everything into
|
||||||
const trunks = mesh()
|
// frustum-cullable spatial chunks.
|
||||||
const oakFoliage = mesh()
|
const trees = placeTrees(colliders)
|
||||||
const spruceFoliage = mesh()
|
const boulders = placeBoulders(colliders)
|
||||||
scatterTrees(trunks, oakFoliage, spruceFoliage, colliders)
|
const chunks = buildChunks(trees, boulders)
|
||||||
|
|
||||||
// Scatter boulders across the terrain; big ones get colliders.
|
return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky }
|
||||||
const boulders = mesh()
|
}
|
||||||
scatterBoulders(boulders, colliders)
|
|
||||||
|
|
||||||
return { floor, walls, crate, ground, trunks, oakFoliage, spruceFoliage, boulders, colliders, npcPosition, terrain: TERRAIN, sky }
|
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
|
||||||
|
* chunks. Each prop lands in the cell holding its base; the cell's bounds are
|
||||||
|
* grown to the real geometry so overhanging canopies never get culled early. */
|
||||||
|
function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
|
||||||
|
const cell = (TERRAIN.outer * 2) / CHUNK_GRID
|
||||||
|
const chunks: Chunk[] = []
|
||||||
|
for (let ci = 0; ci < CHUNK_GRID; ci++) {
|
||||||
|
const x0 = -TERRAIN.outer + ci * cell
|
||||||
|
const x1 = x0 + cell
|
||||||
|
for (let cj = 0; cj < CHUNK_GRID; cj++) {
|
||||||
|
const z0 = -TERRAIN.outer + cj * cell
|
||||||
|
const z1 = z0 + cell
|
||||||
|
const grass = mesh()
|
||||||
|
const bark = mesh()
|
||||||
|
const leaf = mesh()
|
||||||
|
const needle = mesh()
|
||||||
|
const rock = mesh()
|
||||||
|
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
||||||
|
for (const tree of trees) {
|
||||||
|
if (inCell(tree.position, x0, z0, x1, z1)) {
|
||||||
|
Tree.build(tree, bark, tree.kind === "oak" ? leaf : needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const boulder of boulders) {
|
||||||
|
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
||||||
|
Boulder.build(boulder, rock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const b = bounds([grass, bark, leaf, needle, rock])
|
||||||
|
if (b === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chunks.push({ ...b, grass, bark, leaf, needle, rock })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, z1: number): boolean {
|
||||||
|
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tight AABB over several meshes' vertices, or null if they are all empty. */
|
||||||
|
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let minZ = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
let maxZ = -Infinity
|
||||||
|
for (const m of meshes) {
|
||||||
|
for (const v of m.vertices) {
|
||||||
|
minX = Math.min(minX, v.pos.x)
|
||||||
|
minY = Math.min(minY, v.pos.y)
|
||||||
|
minZ = Math.min(minZ, v.pos.z)
|
||||||
|
maxX = Math.max(maxX, v.pos.x)
|
||||||
|
maxY = Math.max(maxY, v.pos.y)
|
||||||
|
maxZ = Math.max(maxZ, v.pos.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the
|
/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the
|
||||||
* terrain, rolls oak/spruce and a growth stage, and (once past sapling size)
|
* terrain, rolls oak/spruce and a growth stage, and (once past sapling size)
|
||||||
* drops a trunk collider so you can't walk through it. */
|
* drops a trunk collider so you can't walk through it. */
|
||||||
function scatterTrees(trunks: Mesh, oakFoliage: Mesh, spruceFoliage: Mesh, colliders: Aabb[]): void {
|
function placeTrees(colliders: Aabb[]): Tree[] {
|
||||||
const rand = mulberry(TREE_SEED)
|
const rand = mulberry(TREE_SEED)
|
||||||
const maxDist = TERRAIN.outer * TREE_REACH
|
const maxDist = TERRAIN.outer * TREE_REACH
|
||||||
let placed = 0
|
const trees: Tree[] = []
|
||||||
for (let guard = 0; placed < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
|
for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
|
||||||
const angle = rand() * Math.PI * 2
|
const angle = rand() * Math.PI * 2
|
||||||
const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5)
|
const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5)
|
||||||
const x = Math.cos(angle) * dist
|
const x = Math.cos(angle) * dist
|
||||||
|
|
@ -191,24 +262,24 @@ function scatterTrees(trunks: Mesh, oakFoliage: Mesh, spruceFoliage: Mesh, colli
|
||||||
const kind = rand() < 0.5 ? "oak" : "spruce"
|
const kind = rand() < 0.5 ? "oak" : "spruce"
|
||||||
const growth = 0.08 + rand() * 0.92
|
const growth = 0.08 + rand() * 0.92
|
||||||
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
||||||
Tree.build({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }, trunks, kind === "oak" ? oakFoliage : spruceFoliage)
|
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
|
||||||
// Saplings are passable; grown trunks block. Square footprint, non-standable.
|
// Saplings are passable; grown trunks block. Square footprint, non-standable.
|
||||||
if (growth > 0.35) {
|
if (growth > 0.35) {
|
||||||
const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15
|
const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15
|
||||||
colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false })
|
colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false })
|
||||||
}
|
}
|
||||||
placed++
|
|
||||||
}
|
}
|
||||||
|
return trees
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
|
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
|
||||||
* small. Each sits on the ground; big ones drop a blocking collider so you
|
* small. Each sits on the ground; big ones drop a blocking collider so you
|
||||||
* can't walk through them (little rocks stay passable). */
|
* can't walk through them (little rocks stay passable). */
|
||||||
function scatterBoulders(boulders: Mesh, colliders: Aabb[]): void {
|
function placeBoulders(colliders: Aabb[]): Boulder[] {
|
||||||
const rand = mulberry(BOULDER_SEED)
|
const rand = mulberry(BOULDER_SEED)
|
||||||
const maxDist = TERRAIN.outer * BOULDER_REACH
|
const maxDist = TERRAIN.outer * BOULDER_REACH
|
||||||
let placed = 0
|
const boulders: Boulder[] = []
|
||||||
for (let guard = 0; placed < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
|
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
|
||||||
const angle = rand() * Math.PI * 2
|
const angle = rand() * Math.PI * 2
|
||||||
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
|
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
|
||||||
const x = Math.cos(angle) * dist
|
const x = Math.cos(angle) * dist
|
||||||
|
|
@ -219,12 +290,12 @@ function scatterBoulders(boulders: Mesh, colliders: Aabb[]): void {
|
||||||
// Square the roll so most rocks are small, a few are big.
|
// Square the roll so most rocks are small, a few are big.
|
||||||
const radius = 0.35 + rand() * rand() * 2.2
|
const radius = 0.35 + rand() * rand() * 2.2
|
||||||
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
||||||
Boulder.build({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 }, boulders)
|
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
|
||||||
if (radius > 0.7) {
|
if (radius > 0.7) {
|
||||||
colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false })
|
colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false })
|
||||||
}
|
}
|
||||||
placed++
|
|
||||||
}
|
}
|
||||||
|
return boulders
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
|
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
|
||||||
|
|
|
||||||
23
app/main.ts
23
app/main.ts
|
|
@ -1,4 +1,5 @@
|
||||||
import { Framebuffer } from "../engine/render/Framebuffer"
|
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
|
import { Frustum } from "../engine/render/Frustum"
|
||||||
import { Rasterizer } from "../engine/render/Rasterizer"
|
import { Rasterizer } from "../engine/render/Rasterizer"
|
||||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { Sky } from "../engine/render/Sky"
|
import { Sky } from "../engine/render/Sky"
|
||||||
|
|
@ -9,6 +10,8 @@ import { buildLevel } from "./level"
|
||||||
import { EYE_HEIGHT, Player } from "./player"
|
import { EYE_HEIGHT, Player } from "./player"
|
||||||
|
|
||||||
const FOV = Math.PI / 3
|
const FOV = Math.PI / 3
|
||||||
|
/** Sky is drawn at 1/SKY_STEP resolution (the cloud fbm is the costly part). */
|
||||||
|
const SKY_STEP = 2
|
||||||
|
|
||||||
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||||
const ctx = screen.getContext("2d")!
|
const ctx = screen.getContext("2d")!
|
||||||
|
|
@ -109,15 +112,23 @@ 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, SKY_STEP)
|
||||||
Rasterizer.draw(fb, level.ground, textures.grass, viewProj, config)
|
// Room is small and always near where you play; draw it unconditionally.
|
||||||
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)
|
||||||
Rasterizer.draw(fb, level.boulders, textures.rock, viewProj, config)
|
// Outdoor world: skip whole chunks that fall outside the view frustum.
|
||||||
Rasterizer.draw(fb, level.trunks, textures.bark, viewProj, config)
|
const frustum = Frustum.fromViewProj(viewProj)
|
||||||
Rasterizer.draw(fb, level.oakFoliage, textures.leaf, viewProj, config)
|
for (const c of level.chunks) {
|
||||||
Rasterizer.draw(fb, level.spruceFoliage, textures.needle, viewProj, config)
|
if (!Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Rasterizer.draw(fb, c.grass, textures.grass, viewProj, config, true)
|
||||||
|
Rasterizer.draw(fb, c.rock, textures.rock, viewProj, config, true)
|
||||||
|
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true)
|
||||||
|
Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true)
|
||||||
|
Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true)
|
||||||
|
}
|
||||||
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
|
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
|
||||||
Framebuffer.quantize(fb, config)
|
Framebuffer.quantize(fb, config)
|
||||||
present()
|
present()
|
||||||
|
|
|
||||||
66
engine/render/Frustum.ts
Normal file
66
engine/render/Frustum.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { Mat4 } from "../math/Mat4"
|
||||||
|
|
||||||
|
/** The six view-frustum planes packed as (a, b, c, d) each, normal pointing
|
||||||
|
* inward: a point is inside a plane when a*x + b*y + c*z + d >= 0. */
|
||||||
|
export type Frustum = Float32Array
|
||||||
|
|
||||||
|
export namespace Frustum {
|
||||||
|
/** Extract the planes from a view-projection matrix (Gribb-Hartmann). Our Mat4
|
||||||
|
* is column-major (`m[col*4 + row]`), so a clip-space row `i` gathers the
|
||||||
|
* `i`-th entry of every column. Left/right/bottom/top/near/far are the row
|
||||||
|
* sums/differences with the w-row. */
|
||||||
|
export function fromViewProj(m: Mat4): Frustum {
|
||||||
|
const rx = [m[0], m[1], m[2], m[3]]
|
||||||
|
const ry = [m[4], m[5], m[6], m[7]]
|
||||||
|
const rz = [m[8], m[9], m[10], m[11]]
|
||||||
|
const rw = [m[12], m[13], m[14], m[15]]
|
||||||
|
// Row i of the clip matrix = (rx[i], ry[i], rz[i], rw[i]).
|
||||||
|
const row = (i: number): [number, number, number, number] => [rx[i], ry[i], rz[i], rw[i]]
|
||||||
|
const [x0, y0, z0, w0] = row(0)
|
||||||
|
const [x1, y1, z1, w1] = row(1)
|
||||||
|
const [x2, y2, z2, w2] = row(2)
|
||||||
|
const [x3, y3, z3, w3] = row(3)
|
||||||
|
const f = new Float32Array(24)
|
||||||
|
plane(f, 0, x3 + x0, y3 + y0, z3 + z0, w3 + w0) // left
|
||||||
|
plane(f, 1, x3 - x0, y3 - y0, z3 - z0, w3 - w0) // right
|
||||||
|
plane(f, 2, x3 + x1, y3 + y1, z3 + z1, w3 + w1) // bottom
|
||||||
|
plane(f, 3, x3 - x1, y3 - y1, z3 - z1, w3 - w1) // top
|
||||||
|
plane(f, 4, x3 + x2, y3 + y2, z3 + z2, w3 + w2) // near
|
||||||
|
plane(f, 5, x3 - x2, y3 - y2, z3 - z2, w3 - w2) // far
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the axis-aligned box might be visible. Conservative: tests the box
|
||||||
|
* corner farthest along each plane normal; the box is culled only if that
|
||||||
|
* corner is still outside some plane, so nothing visible is ever dropped. */
|
||||||
|
export function intersectsAabb(
|
||||||
|
f: Frustum,
|
||||||
|
minX: number,
|
||||||
|
minY: number,
|
||||||
|
minZ: number,
|
||||||
|
maxX: number,
|
||||||
|
maxY: number,
|
||||||
|
maxZ: number,
|
||||||
|
): boolean {
|
||||||
|
for (let p = 0; p < 24; p += 4) {
|
||||||
|
const a = f[p]
|
||||||
|
const b = f[p + 1]
|
||||||
|
const c = f[p + 2]
|
||||||
|
const px = a >= 0 ? maxX : minX
|
||||||
|
const py = b >= 0 ? maxY : minY
|
||||||
|
const pz = c >= 0 ? maxZ : minZ
|
||||||
|
if (a * px + b * py + c * pz + f[p + 3] < 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function plane(f: Frustum, i: number, a: number, b: number, c: number, d: number): void {
|
||||||
|
const inv = 1 / Math.hypot(a, b, c)
|
||||||
|
f[i * 4] = a * inv
|
||||||
|
f[i * 4 + 1] = b * inv
|
||||||
|
f[i * 4 + 2] = c * inv
|
||||||
|
f[i * 4 + 3] = d * inv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ export namespace Rasterizer {
|
||||||
texture: Texture,
|
texture: Texture,
|
||||||
viewProj: Mat4,
|
viewProj: Mat4,
|
||||||
config: RenderConfig,
|
config: RenderConfig,
|
||||||
|
cull = false,
|
||||||
): void {
|
): void {
|
||||||
const { vertices, indices } = mesh
|
const { vertices, indices } = mesh
|
||||||
for (let t = 0; t + 2 < indices.length; t += 3) {
|
for (let t = 0; t + 2 < indices.length; t += 3) {
|
||||||
|
|
@ -56,7 +57,7 @@ export namespace Rasterizer {
|
||||||
// Near-clipping can turn one triangle into a quad; fan it back to tris.
|
// Near-clipping can turn one triangle into a quad; fan it back to tris.
|
||||||
const poly = clipNear([project(viewProj, a), project(viewProj, b), project(viewProj, c)])
|
const poly = clipNear([project(viewProj, a), project(viewProj, b), project(viewProj, c)])
|
||||||
for (let k = 1; k + 1 < poly.length; k++) {
|
for (let k = 1; k + 1 < poly.length; k++) {
|
||||||
fillTriangle(fb, poly[0], poly[k], poly[k + 1], shade, texture, config)
|
fillTriangle(fb, poly[0], poly[k], poly[k + 1], shade, texture, config, cull)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +147,7 @@ export namespace Rasterizer {
|
||||||
shade: number,
|
shade: number,
|
||||||
texture: Texture,
|
texture: Texture,
|
||||||
config: RenderConfig,
|
config: RenderConfig,
|
||||||
|
cull: boolean,
|
||||||
): void {
|
): void {
|
||||||
const a = toScreen(fb, va, config.vertexSnap)
|
const a = toScreen(fb, va, config.vertexSnap)
|
||||||
const b = toScreen(fb, vb, config.vertexSnap)
|
const b = toScreen(fb, vb, config.vertexSnap)
|
||||||
|
|
@ -154,6 +156,12 @@ export namespace Rasterizer {
|
||||||
if (area === 0) {
|
if (area === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Backface cull: a back-facing triangle has the opposite screen winding
|
||||||
|
// (positive area here). Only enabled for solid, consistently-wound meshes;
|
||||||
|
// sprites and the room stay double-sided (cull = false).
|
||||||
|
if (cull && area > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const minX = Math.max(0, Math.floor(Math.min(a.sx, b.sx, c.sx)))
|
const minX = Math.max(0, Math.floor(Math.min(a.sx, b.sx, c.sx)))
|
||||||
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)))
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,13 @@ export namespace Sky {
|
||||||
* Per pixel it reconstructs the view ray from the camera basis, shades a
|
* Per pixel it reconstructs the view ray from the camera basis, shades a
|
||||||
* horizon->zenith gradient by the ray's elevation, brightens toward `sun` near
|
* horizon->zenith gradient by the ray's elevation, brightens toward `sun` near
|
||||||
* `sunDir`, then lays crisp-edged cumulus over the top.
|
* `sunDir`, then lays crisp-edged cumulus over the top.
|
||||||
|
*
|
||||||
|
* `step` (>= 1) renders the sky at 1/step resolution: the expensive shading
|
||||||
|
* (the per-pixel cloud fbm dominates the frame) runs once per step x step
|
||||||
|
* block and is copied across it. The sky is low-frequency, so 2 is nearly free
|
||||||
|
* visually and quarters the cloud cost; 1 is full resolution.
|
||||||
*/
|
*/
|
||||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number): void {
|
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1): void {
|
||||||
const { width, height, color, depth } = fb
|
const { width, height, color, depth } = fb
|
||||||
const forward = Camera.forward(camera)
|
const forward = Camera.forward(camera)
|
||||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||||
|
|
@ -70,10 +75,15 @@ export namespace Sky {
|
||||||
const cosSun = Math.cos(sky.sunSize)
|
const cosSun = Math.cos(sky.sunSize)
|
||||||
const clouds = sky.clouds
|
const clouds = sky.clouds
|
||||||
const cloud: CloudSample = { cover: 0, shade: 1 }
|
const cloud: CloudSample = { cover: 0, shade: 1 }
|
||||||
for (let y = 0; y < height; y++) {
|
const s = Math.max(1, step | 0)
|
||||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
for (let by = 0; by < height; by += s) {
|
||||||
for (let x = 0; x < width; x++) {
|
// Shade at the block center, then flood the whole block with that color.
|
||||||
const ndcX = ((x + 0.5) / width) * 2 - 1
|
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||||
|
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
|
||||||
|
const yEnd = Math.min(height, by + s)
|
||||||
|
for (let bx = 0; bx < width; bx += s) {
|
||||||
|
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||||
|
const ndcX = ((sampleX + 0.5) / width) * 2 - 1
|
||||||
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
||||||
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
||||||
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
||||||
|
|
@ -100,9 +110,14 @@ export namespace Sky {
|
||||||
c = Color.lerp(c, Color.scale(clouds.color, cloud.shade), cloud.cover)
|
c = Color.lerp(c, Color.scale(clouds.color, cloud.shade), cloud.cover)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const i = y * width + x
|
const xEnd = Math.min(width, bx + s)
|
||||||
color[i] = c
|
for (let y = by; y < yEnd; y++) {
|
||||||
depth[i] = 0
|
const o = y * width
|
||||||
|
for (let x = bx; x < xEnd; x++) {
|
||||||
|
color[o + x] = c
|
||||||
|
depth[o + x] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,34 +42,46 @@ export namespace Terrain {
|
||||||
return rise * (hills + peaks)
|
return rise * (hills + peaks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the outdoor ground as a `divisions`x`divisions` grid over the whole
|
/** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle
|
||||||
* world, each vertex lifted onto the heightfield. Cells inside the clearing
|
* [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose
|
||||||
* are skipped so the mesh has a hole where the flat room floor goes (no
|
* center is inside the clearing are skipped (the room floor's hole). UVs use
|
||||||
* z-fighting). `uvScale` sets texture tiles per world unit. */
|
* world position * `uvScale`, so neighboring patches tile seamlessly. Callers
|
||||||
export function ground(t: Terrain, divisions: number, uvScale: number): Mesh {
|
* keep the spacing uniform and cell edges aligned, so shared edges weld with
|
||||||
const vertices: Mesh["vertices"] = []
|
* no cracks. Used to build the terrain per spatial chunk. */
|
||||||
const indices: number[] = []
|
export function patch(
|
||||||
const step = (t.outer * 2) / divisions
|
t: Terrain,
|
||||||
const row = divisions + 1
|
mesh: Mesh,
|
||||||
for (let i = 0; i <= divisions; i++) {
|
x0: number,
|
||||||
const z = -t.outer + i * step
|
z0: number,
|
||||||
for (let j = 0; j <= divisions; j++) {
|
x1: number,
|
||||||
const x = -t.outer + j * step
|
z1: number,
|
||||||
vertices.push({ pos: { x, y: height(t, x, z), z }, uv: { x: x * uvScale, y: z * uvScale } })
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
uvScale: number,
|
||||||
|
): void {
|
||||||
|
const base = mesh.vertices.length
|
||||||
|
const dx = (x1 - x0) / cols
|
||||||
|
const dz = (z1 - z0) / rows
|
||||||
|
const stride = cols + 1
|
||||||
|
for (let i = 0; i <= rows; i++) {
|
||||||
|
const z = z0 + i * dz
|
||||||
|
for (let j = 0; j <= cols; j++) {
|
||||||
|
const x = x0 + j * dx
|
||||||
|
mesh.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 i = 0; i < rows; i++) {
|
||||||
for (let j = 0; j < divisions; j++) {
|
for (let j = 0; j < cols; j++) {
|
||||||
const cx = -t.outer + (j + 0.5) * step
|
const cx = x0 + (j + 0.5) * dx
|
||||||
const cz = -t.outer + (i + 0.5) * step
|
const cz = z0 + (i + 0.5) * dz
|
||||||
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
|
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const p = i * row + j
|
const p = base + i * stride + j
|
||||||
indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
// Wound so the surface faces up/out, matching the backface-cull sign.
|
||||||
|
mesh.indices.push(p, p + stride + 1, p + 1, p, p + stride, p + stride + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { vertices, indices }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rolling hills in 0..1, always non-negative so the ground never dips below
|
/** Rolling hills in 0..1, always non-negative so the ground never dips below
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue