feat: actors stage 1
This commit is contained in:
parent
bd88d3cbd6
commit
4869db01e5
5 changed files with 112 additions and 60 deletions
100
app/level.ts
100
app/level.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { Color } from "../engine/render/Color"
|
||||
import type { DrawGroup, Material } from "../engine/render/Material"
|
||||
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
||||
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
||||
import { Boulder } from "../engine/scene/Boulder"
|
||||
|
|
@ -7,6 +8,7 @@ import { Flower, type FlowerColor } from "../engine/scene/Flower"
|
|||
import type { Mob, MobKind } from "../engine/scene/Mob"
|
||||
import { Terrain } from "../engine/scene/Terrain"
|
||||
import { Tree } from "../engine/scene/Tree"
|
||||
import type { Textures } from "./assets"
|
||||
|
||||
type Corner = [number, number, number]
|
||||
|
||||
|
|
@ -22,10 +24,11 @@ export type Aabb = {
|
|||
}
|
||||
|
||||
/** 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. */
|
||||
* standing in it, baked into `DrawGroup`s (mesh + material), 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; empty groups are pruned at bake time. */
|
||||
export type Chunk = {
|
||||
minX: number
|
||||
minY: number
|
||||
|
|
@ -33,24 +36,23 @@ export type Chunk = {
|
|||
maxX: number
|
||||
maxY: number
|
||||
maxZ: number
|
||||
grass: Mesh
|
||||
bark: Mesh
|
||||
/** Birch trunks (white bark texture) -- separate mesh since a draw is one mesh +
|
||||
* one texture, and silver birch bark can't share the brown oak/spruce bark. */
|
||||
birchBark: Mesh
|
||||
leaf: Mesh
|
||||
needle: Mesh
|
||||
rock: Mesh
|
||||
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
|
||||
flowers: Mesh
|
||||
/** Cheap low-poly impostors of the same trees/boulders, drawn instead of the
|
||||
* full meshes once the chunk is past `config.lodDistance` (renderScene). Same
|
||||
* textures as bark/birch/leaf/needle/rock. Bushes/flowers have no far version. */
|
||||
barkFar: Mesh
|
||||
birchBarkFar: Mesh
|
||||
leafFar: Mesh
|
||||
needleFar: Mesh
|
||||
rockFar: Mesh
|
||||
/** Full-detail draw groups (grass + full trees/boulders), used up close. */
|
||||
near: DrawGroup[]
|
||||
/** LOD draw groups (grass + cheap tree/boulder impostors, no bushes/flowers),
|
||||
* used once the chunk is past `config.lodDistance` (see `chunkFar`). */
|
||||
far: DrawGroup[]
|
||||
}
|
||||
|
||||
/** The materials the chunk baker binds its meshes to -- one per ground/prop
|
||||
* texture. Built once from the loaded `Textures`, shared across every chunk. */
|
||||
type ChunkMaterials = {
|
||||
grass: Material
|
||||
bark: Material
|
||||
birch: Material
|
||||
leaf: Material
|
||||
needle: Material
|
||||
rock: Material
|
||||
flower: Material
|
||||
}
|
||||
|
||||
/** The playground: a flat-floored room dropped into the center of a big open
|
||||
|
|
@ -162,7 +164,7 @@ export const fancyCumulus: CloudLayer = {
|
|||
relief: 7,
|
||||
}
|
||||
|
||||
export function buildLevel(): Level {
|
||||
export function buildLevel(textures: Textures): 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
|
||||
|
|
@ -209,13 +211,26 @@ export function buildLevel(): Level {
|
|||
|
||||
const npcPosition = { x: 2, y: 0, z: -1 }
|
||||
|
||||
// The ground/prop materials the chunk baker draws with (grass + trees + rocks +
|
||||
// flowers). Solid surfaces backface-cull; flowers are double-sided. Shared by
|
||||
// every chunk, so cloning to a worker dedups them.
|
||||
const materials: ChunkMaterials = {
|
||||
grass: { texture: textures.grass, cull: true },
|
||||
bark: { texture: textures.bark, cull: true },
|
||||
birch: { texture: textures.birch, cull: true },
|
||||
leaf: { texture: textures.leaf, cull: true },
|
||||
needle: { texture: textures.needle, cull: true },
|
||||
rock: { texture: textures.rock, cull: true },
|
||||
flower: { texture: textures.flower, cull: false },
|
||||
}
|
||||
|
||||
// Place the props (also pushes their colliders), then bake everything into
|
||||
// frustum-cullable spatial chunks.
|
||||
const trees = placeTrees(colliders)
|
||||
const boulders = placeBoulders(colliders)
|
||||
const bushes = placeBushes()
|
||||
const flowers = placeFlowers()
|
||||
const chunks = buildChunks(trees, boulders, bushes, flowers)
|
||||
const chunks = buildChunks(materials, trees, boulders, bushes, flowers)
|
||||
const mobs = placeMobs()
|
||||
|
||||
return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky }
|
||||
|
|
@ -225,7 +240,7 @@ export function buildLevel(): Level {
|
|||
* 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.
|
||||
* Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
|
||||
function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
|
||||
function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
|
||||
const cell = (TERRAIN.outer * 2) / CHUNK_GRID
|
||||
const chunks: Chunk[] = []
|
||||
for (let ci = 0; ci < CHUNK_GRID; ci++) {
|
||||
|
|
@ -280,7 +295,28 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
|
|||
if (b === null) {
|
||||
continue
|
||||
}
|
||||
chunks.push({ ...b, grass, bark, birchBark, leaf, needle, rock, flowers: flowerMesh, barkFar, birchBarkFar, leafFar, needleFar, rockFar })
|
||||
// Same draws as before, just described as data. Order is preserved (it
|
||||
// matches the old fixed sequence): grass, then the solid props, then the
|
||||
// double-sided flowers. Empty meshes are pruned so a chunk only carries the
|
||||
// groups it actually has.
|
||||
const near = drawGroups([
|
||||
[grass, m.grass],
|
||||
[rock, m.rock],
|
||||
[bark, m.bark],
|
||||
[birchBark, m.birch],
|
||||
[leaf, m.leaf],
|
||||
[needle, m.needle],
|
||||
[flowerMesh, m.flower],
|
||||
])
|
||||
const far = drawGroups([
|
||||
[grass, m.grass],
|
||||
[rockFar, m.rock],
|
||||
[barkFar, m.bark],
|
||||
[birchBarkFar, m.birch],
|
||||
[leafFar, m.leaf],
|
||||
[needleFar, m.needle],
|
||||
])
|
||||
chunks.push({ ...b, near, far })
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
|
|
@ -290,6 +326,18 @@ function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number,
|
|||
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
|
||||
}
|
||||
|
||||
/** Pair meshes with their materials into a draw-group list, dropping any mesh
|
||||
* that ended up empty (a cell rarely holds every prop kind). */
|
||||
function drawGroups(pairs: [Mesh, Material][]): DrawGroup[] {
|
||||
const out: DrawGroup[] = []
|
||||
for (const [m, material] of pairs) {
|
||||
if (m.indices.length > 0) {
|
||||
out.push({ mesh: m, material })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
|
|
|||
11
app/main.ts
11
app/main.ts
|
|
@ -47,7 +47,7 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me
|
|||
|
||||
async function main(): Promise<void> {
|
||||
const textures = await loadTextures()
|
||||
const level = buildLevel()
|
||||
const level = buildLevel(textures)
|
||||
// Two canonical mob meshes, built once and shared by every instance (the sim
|
||||
// supplies each mob's per-frame transform).
|
||||
const frogMesh: Mesh = { verts: [], indices: [] }
|
||||
|
|
@ -161,11 +161,10 @@ async function main(): Promise<void> {
|
|||
let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length
|
||||
for (const i of visible) {
|
||||
const c = level.chunks[i]
|
||||
t += c.grass.indices.length + c.flowers.indices.length
|
||||
const far = chunkFar(c, cam.position, config.lodDistance)
|
||||
t += far
|
||||
? c.barkFar.indices.length + c.birchBarkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length
|
||||
: c.bark.indices.length + c.birchBark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length
|
||||
const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
|
||||
for (const g of groups) {
|
||||
t += g.mesh.indices.length
|
||||
}
|
||||
}
|
||||
return (t / 3) | 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,23 +101,13 @@ export function renderBand(
|
|||
Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1)
|
||||
for (const i of visible) {
|
||||
const c = scene.chunks[i]
|
||||
Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1)
|
||||
// Past lodDistance, swap full tree/boulder geometry for cheap impostors.
|
||||
// Past lodDistance, draw the cheap impostor group set instead of full detail.
|
||||
// `chunkFar` is pure (camera + chunk bounds + config), so every worker band
|
||||
// makes the identical choice -- no full/impostor seam across bands.
|
||||
if (chunkFar(c, camera.position, config.lodDistance)) {
|
||||
Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.birchBarkFar, tx.birch, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1)
|
||||
} else {
|
||||
Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.birchBark, tx.birch, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1)
|
||||
// makes the identical choice -- no full/impostor seam across bands. The loop
|
||||
// is content-agnostic: each group carries its own mesh + material.
|
||||
const groups = chunkFar(c, camera.position, config.lodDistance) ? c.far : c.near
|
||||
for (const g of groups) {
|
||||
Rasterizer.draw(fb, g.mesh, g.material.texture, viewProj, config, g.material.cull, y0, y1)
|
||||
}
|
||||
}
|
||||
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue