feat: game/engine split refactor + skybox
This commit is contained in:
parent
581e5892b0
commit
eeedcb8e48
34 changed files with 610 additions and 218 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import type { Texture } from "../engine/render/Texture"
|
||||
import type { Textures } from "../game/textures"
|
||||
import barkUrl from "../assets/bark.png"
|
||||
import beeUrl from "../assets/bee.png"
|
||||
import birchUrl from "../assets/birch.png"
|
||||
|
|
@ -12,28 +13,12 @@ import needleUrl from "../assets/needle.png"
|
|||
import npcUrl from "../assets/npc.png"
|
||||
import robinUrl from "../assets/robin.png"
|
||||
import rockUrl from "../assets/rock.png"
|
||||
import skyboxUrl from "../assets/rockies.skybox.png"
|
||||
import wallUrl from "../assets/wall.png"
|
||||
|
||||
export type Textures = {
|
||||
floor: Texture
|
||||
grass: Texture
|
||||
bark: Texture
|
||||
birch: Texture
|
||||
leaf: Texture
|
||||
needle: Texture
|
||||
rock: Texture
|
||||
flower: Texture
|
||||
wall: Texture
|
||||
crate: Texture
|
||||
npc: Texture
|
||||
frog: Texture
|
||||
bee: Texture
|
||||
robin: Texture
|
||||
}
|
||||
|
||||
/** Load every game texture up front. Call once before starting the loop. */
|
||||
export async function loadTextures(): Promise<Textures> {
|
||||
const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin] = await Promise.all([
|
||||
const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin, skybox] = await Promise.all([
|
||||
loadTexture(floorUrl),
|
||||
loadTexture(grassUrl),
|
||||
loadTexture(barkUrl),
|
||||
|
|
@ -48,8 +33,9 @@ export async function loadTextures(): Promise<Textures> {
|
|||
loadTexture(frogUrl),
|
||||
loadTexture(beeUrl),
|
||||
loadTexture(robinUrl),
|
||||
loadTexture(skyboxUrl),
|
||||
])
|
||||
return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin }
|
||||
return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin, skybox }
|
||||
}
|
||||
|
||||
function loadTexture(url: string): Promise<Texture> {
|
||||
|
|
|
|||
548
app/level.ts
548
app/level.ts
|
|
@ -1,548 +0,0 @@
|
|||
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"
|
||||
import { Bush } from "../engine/scene/Bush"
|
||||
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]
|
||||
|
||||
/** Axis-aligned solid. Blocks the player horizontally while their feet are
|
||||
* below `top`; if `standable`, its `top` also counts as ground to land on. */
|
||||
export type Aabb = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
top: number
|
||||
standable: boolean
|
||||
}
|
||||
|
||||
/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders
|
||||
* 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
|
||||
minZ: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
maxZ: number
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */
|
||||
type MatKey = keyof ChunkMaterials
|
||||
|
||||
/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided
|
||||
* -- last), so the per-chunk draw sequence is deterministic and matches the pre-
|
||||
* registry order. Every material key must appear here. */
|
||||
const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]
|
||||
|
||||
/** The playground: a flat-floored room dropped into the center of a big open
|
||||
* landscape. The room (floor/walls/crate) is small and always drawn; the
|
||||
* outdoor world is split into `chunks` that are frustum-culled per frame. */
|
||||
export type Level = {
|
||||
floor: Mesh
|
||||
walls: Mesh
|
||||
crate: Mesh
|
||||
chunks: Chunk[]
|
||||
colliders: Aabb[]
|
||||
npcPosition: { x: number; y: number; z: number }
|
||||
/** Roaming mobs -- simulated on the main thread each frame (see main.ts), not
|
||||
* baked into the static culled chunks. */
|
||||
mobs: Mob[]
|
||||
terrain: Terrain
|
||||
sky: SkyConfig
|
||||
}
|
||||
|
||||
const ARENA = 12
|
||||
const WALL_HEIGHT = 4
|
||||
/** How deep the perimeter walls are. Thick enough to read as solid walls (and to
|
||||
* give the doorway real jambs); their outer faces sit flush with the room edge,
|
||||
* so they eat into the interior, not the terrain. */
|
||||
const WALL_THICKNESS = 1.5
|
||||
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
|
||||
|
||||
/** 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,
|
||||
}
|
||||
|
||||
/** Forest: how many trees to scatter on the grass, and the seed for their
|
||||
* placement/kind/growth. Trees ring the room out to `TREE_REACH` of the world;
|
||||
* each rolls oak-or-spruce and a growth 0..1 (sapling .. full grown). */
|
||||
const TREE_COUNT = 500
|
||||
const TREE_SEED = 0x5EED
|
||||
const TREE_REACH = 0.6
|
||||
|
||||
/** Boulders: how many to scatter, their seed, and how far out they reach
|
||||
* (fraction of the world). Sizes range small pebble .. big boulder. */
|
||||
const BOULDER_COUNT = 70
|
||||
const BOULDER_SEED = 0xB0142
|
||||
const BOULDER_REACH = 0.7
|
||||
|
||||
/** Bushes + flowers: ground detail, kept to the nearer band since they're small
|
||||
* and fog/size hides them far out. Flowers roll white/red/yellow. */
|
||||
const BUSH_COUNT = 140
|
||||
const BUSH_SEED = 0xB554
|
||||
const BUSH_REACH = 0.35
|
||||
const FLOWER_COUNT = 340
|
||||
const FLOWER_SEED = 0xF10E
|
||||
const FLOWER_REACH = 0.3
|
||||
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
|
||||
|
||||
/** Roaming mobs: how many frogs/bees to scatter, their seed, and how far out they
|
||||
* reach (fraction of the world). Kept modest -- roaming meshes are drawn every
|
||||
* frame (frustum-culled), not baked into the static chunks. */
|
||||
const FROG_COUNT = 40
|
||||
const BEE_COUNT = 30
|
||||
const ROBIN_COUNT = 30
|
||||
const MOB_SEED = 0x30B
|
||||
const MOB_REACH = 0.5
|
||||
|
||||
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
|
||||
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
|
||||
* (less drawn off-screen) but cost more per-cell tests + bounds; this is the
|
||||
* 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
|
||||
|
||||
/** The two cloud styles; swap which one the sky uses in `buildLevel`.
|
||||
* `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier
|
||||
* heightfield-shaded, domain-warped version with faked volume. */
|
||||
export const basicCumulus: CloudLayer = {
|
||||
kind: "basic",
|
||||
color: Color.rgb(248, 250, 255),
|
||||
coverage: 0.5,
|
||||
scale: 0.9,
|
||||
speed: 0.5,
|
||||
edge: 0.005,
|
||||
}
|
||||
|
||||
export const fancyCumulus: CloudLayer = {
|
||||
kind: "fancy",
|
||||
color: Color.rgb(250, 251, 255),
|
||||
coverage: 0.5,
|
||||
scale: 0.6,
|
||||
speed: 0.5,
|
||||
edge: 0.02,
|
||||
warp: 0.4,
|
||||
relief: 7,
|
||||
}
|
||||
|
||||
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
|
||||
// there instead of z-fighting the grass. The step is invisible at the doorway.
|
||||
const floor = mesh()
|
||||
const fy = FLOOR_LIFT
|
||||
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
|
||||
|
||||
const walls = mesh()
|
||||
const h = WALL_HEIGHT
|
||||
const t = WALL_THICKNESS
|
||||
// Three thick perimeter walls, outer faces flush with the room edge; the north
|
||||
// (-Z) side is left open onto the world. No ceiling, so the sky shows above.
|
||||
slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z)
|
||||
slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X)
|
||||
slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X)
|
||||
|
||||
// Crate on the flat room floor.
|
||||
const crate = mesh()
|
||||
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
|
||||
|
||||
const colliders: Aabb[] = [
|
||||
wall(-ARENA, ARENA, ARENA - t, ARENA),
|
||||
wall(ARENA - t, ARENA, -ARENA, ARENA - t),
|
||||
wall(-ARENA, -ARENA + t, -ARENA, ARENA - t),
|
||||
{
|
||||
minX: CRATE.x - CRATE.half,
|
||||
maxX: CRATE.x + CRATE.half,
|
||||
minZ: CRATE.z - CRATE.half,
|
||||
maxZ: CRATE.z + CRATE.half,
|
||||
top: CRATE.height,
|
||||
standable: true,
|
||||
},
|
||||
]
|
||||
|
||||
const sky: SkyConfig = {
|
||||
zenith: Color.rgb(58, 108, 196),
|
||||
horizon: Color.rgb(178, 198, 226),
|
||||
sun: Color.rgb(255, 246, 214),
|
||||
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
||||
sunSize: 0.04,
|
||||
clouds: basicCumulus,
|
||||
}
|
||||
|
||||
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(materials, trees, boulders, bushes, flowers)
|
||||
const mobs = placeMobs()
|
||||
|
||||
return { floor, walls, crate, chunks, colliders, npcPosition, mobs, 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.
|
||||
* Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
|
||||
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++) {
|
||||
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
|
||||
// Accumulate geometry into one mesh per material key, for the near (full) and
|
||||
// far (impostor) LOD sets. Props declare which material(s) they write, so the
|
||||
// baker never names a texture -- adding a species/material touches no code here.
|
||||
const near = new Map<string, Mesh>()
|
||||
const far = new Map<string, Mesh>()
|
||||
const grass = matMesh(near, "grass")
|
||||
far.set("grass", grass) // the ground is drawn in both LOD sets
|
||||
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)) {
|
||||
const s = Tree.species(tree.kind)
|
||||
Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage))
|
||||
Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor")
|
||||
}
|
||||
}
|
||||
for (const boulder of boulders) {
|
||||
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
||||
Boulder.build(boulder, matMesh(near, "rock"))
|
||||
Boulder.build(boulder, matMesh(far, "rock"), "impostor")
|
||||
}
|
||||
}
|
||||
// Bushes fold into the near leaf mesh; they just drop out past lodDistance.
|
||||
for (const bush of bushes) {
|
||||
if (inCell(bush.position, x0, z0, x1, z1)) {
|
||||
Bush.build(bush, matMesh(near, "leaf"))
|
||||
}
|
||||
}
|
||||
for (const flower of flowers) {
|
||||
if (inCell(flower.position, x0, z0, x1, z1)) {
|
||||
Flower.build(flower, matMesh(near, "flower"))
|
||||
}
|
||||
}
|
||||
const b = bounds([...near.values()])
|
||||
if (b === null) {
|
||||
continue
|
||||
}
|
||||
chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) })
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** Lazily get (creating on first use) the accumulation mesh for a material key in a
|
||||
* chunk's near/far map. Props write into these by key, so the baker stays generic. */
|
||||
function matMesh(map: Map<string, Mesh>, key: string): Mesh {
|
||||
let m = map.get(key)
|
||||
if (m === undefined) {
|
||||
m = mesh()
|
||||
map.set(key, m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/** Turn a chunk's per-material meshes into a draw-group list, in a fixed material
|
||||
* order (so the draw sequence is deterministic across bakes) and dropping any that
|
||||
* ended up empty (a cell rarely holds every prop kind). */
|
||||
function toGroups(map: Map<string, Mesh>, materials: ChunkMaterials): DrawGroup[] {
|
||||
const out: DrawGroup[] = []
|
||||
for (const key of MAT_ORDER) {
|
||||
const m = map.get(key)
|
||||
if (m !== undefined && m.indices.length > 0) {
|
||||
out.push({ mesh: m, material: materials[key] })
|
||||
}
|
||||
}
|
||||
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
|
||||
let minY = Infinity
|
||||
let minZ = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let maxZ = -Infinity
|
||||
for (const m of meshes) {
|
||||
const verts = m.verts
|
||||
for (let i = 0; i < verts.length; i += STRIDE) {
|
||||
const x = verts[i]
|
||||
const y = verts[i + 1]
|
||||
const z = verts[i + 2]
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
minZ = Math.min(minZ, z)
|
||||
maxX = Math.max(maxX, x)
|
||||
maxY = Math.max(maxY, y)
|
||||
maxZ = Math.max(maxZ, 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
|
||||
* terrain, rolls oak/spruce and a growth stage, and (once past sapling size)
|
||||
* drops a trunk collider so you can't walk through it. */
|
||||
function placeTrees(colliders: Aabb[]): Tree[] {
|
||||
const rand = mulberry(TREE_SEED)
|
||||
const maxDist = TERRAIN.outer * TREE_REACH
|
||||
const trees: Tree[] = []
|
||||
for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
|
||||
const angle = rand() * Math.PI * 2
|
||||
const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5)
|
||||
const x = Math.cos(angle) * dist
|
||||
const z = Math.sin(angle) * dist
|
||||
// Stay out of the room clearing and its flat rim.
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) {
|
||||
continue
|
||||
}
|
||||
const roll = rand()
|
||||
const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch"
|
||||
const growth = 0.08 + rand() * 0.92
|
||||
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
||||
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
|
||||
// Saplings are passable; grown trunks block. Square footprint, non-standable.
|
||||
if (growth > 0.35) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
return trees
|
||||
}
|
||||
|
||||
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
|
||||
* small. Each sits on the ground; big ones drop a blocking collider so you
|
||||
* can't walk through them (little rocks stay passable). */
|
||||
function placeBoulders(colliders: Aabb[]): Boulder[] {
|
||||
const rand = mulberry(BOULDER_SEED)
|
||||
const maxDist = TERRAIN.outer * BOULDER_REACH
|
||||
const boulders: Boulder[] = []
|
||||
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
|
||||
const angle = rand() * Math.PI * 2
|
||||
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
|
||||
const x = Math.cos(angle) * dist
|
||||
const z = Math.sin(angle) * dist
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
|
||||
continue
|
||||
}
|
||||
// Square the roll so most rocks are small, a few are big.
|
||||
const radius = 0.35 + rand() * rand() * 2.2
|
||||
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
||||
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
return boulders
|
||||
}
|
||||
|
||||
/** Scatter bushes on the grass near the play area (no colliders -- walk through). */
|
||||
function placeBushes(): Bush[] {
|
||||
const rand = mulberry(BUSH_SEED)
|
||||
const maxDist = TERRAIN.outer * BUSH_REACH
|
||||
const bushes: Bush[] = []
|
||||
for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) {
|
||||
const angle = rand() * Math.PI * 2
|
||||
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
|
||||
const x = Math.cos(angle) * dist
|
||||
const z = Math.sin(angle) * dist
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
|
||||
continue
|
||||
}
|
||||
bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 })
|
||||
}
|
||||
return bushes
|
||||
}
|
||||
|
||||
/** Scatter small flowers on the grass near the play area, colors rolled. */
|
||||
function placeFlowers(): Flower[] {
|
||||
const rand = mulberry(FLOWER_SEED)
|
||||
const maxDist = TERRAIN.outer * FLOWER_REACH
|
||||
const flowers: Flower[] = []
|
||||
for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) {
|
||||
const angle = rand() * Math.PI * 2
|
||||
const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2)
|
||||
const x = Math.cos(angle) * dist
|
||||
const z = Math.sin(angle) * dist
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) {
|
||||
continue
|
||||
}
|
||||
const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0]
|
||||
flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 })
|
||||
}
|
||||
return flowers
|
||||
}
|
||||
|
||||
/** Scatter frogs, bees + robins across the grass (like the boulders), each at its
|
||||
* home anchor with a random heading and size. No colliders here -- mobs move, so
|
||||
* their block/stand-on AABBs are rebuilt per frame in `main`. */
|
||||
function placeMobs(): Mob[] {
|
||||
const rand = mulberry(MOB_SEED)
|
||||
const maxDist = TERRAIN.outer * MOB_REACH
|
||||
const mobs: Mob[] = []
|
||||
const total = FROG_COUNT + BEE_COUNT + ROBIN_COUNT
|
||||
for (let guard = 0; mobs.length < total && guard < total * 20; guard++) {
|
||||
const angle = rand() * Math.PI * 2
|
||||
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
|
||||
const x = Math.cos(angle) * dist
|
||||
const z = Math.sin(angle) * dist
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
|
||||
continue
|
||||
}
|
||||
const n = mobs.length
|
||||
const kind: MobKind = n < FROG_COUNT ? "frog" : n < FROG_COUNT + BEE_COUNT ? "bee" : "robin"
|
||||
const y = Terrain.height(TERRAIN, x, z)
|
||||
const scale = kind === "frog" ? 0.5 + rand() * 0.35 : kind === "robin" ? 0.4 + rand() * 0.25 : 0.5 + rand() * 0.3
|
||||
mobs.push({
|
||||
kind,
|
||||
home: { x, y, z },
|
||||
position: { x, y, z },
|
||||
heading: rand() * Math.PI * 2,
|
||||
scale,
|
||||
seed: (rand() * 0xFFFFFFFF) | 0,
|
||||
vx: 0,
|
||||
vz: 0,
|
||||
vy: 0,
|
||||
timer: rand() * 1.5,
|
||||
// Bees hover (never grounded) and use phase for the bob; frogs/robins start
|
||||
// resting on the ground.
|
||||
phase: kind === "bee" ? rand() * 10 : 0,
|
||||
grounded: kind !== "bee",
|
||||
})
|
||||
}
|
||||
return mobs
|
||||
}
|
||||
|
||||
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
|
||||
function mulberry(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6D2B79F5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function mesh(): Mesh {
|
||||
return { verts: [], indices: [] }
|
||||
}
|
||||
|
||||
/** A perimeter wall collider: blocks from the sides, and `standable` so you can
|
||||
* jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */
|
||||
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
|
||||
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true }
|
||||
}
|
||||
|
||||
/** 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.verts.length / STRIDE
|
||||
m.verts.push(a[0], a[1], a[2], 0, 0, b[0], b[1], b[2], us, 0, c[0], c[1], c[2], us, vs, d[0], d[1], d[2], 0, vs)
|
||||
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||
}
|
||||
|
||||
/** An axis-aligned box from (x0,z0)-(x1,z1), y0..y1: four sides + top, no bottom
|
||||
* (never seen from below). `tpu` = texture tiles per world unit, so every face
|
||||
* tiles at the same density whatever its size. Used for the thick walls. */
|
||||
function slab(m: Mesh, x0: number, x1: number, z0: number, z1: number, y0: number, y1: number, tpu: number): void {
|
||||
const dx = (x1 - x0) * tpu
|
||||
const dz = (z1 - z0) * tpu
|
||||
const dy = (y1 - y0) * tpu
|
||||
quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz)
|
||||
quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy)
|
||||
quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy)
|
||||
quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy)
|
||||
quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy)
|
||||
}
|
||||
|
||||
/** 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
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { Camera } from "../engine/scene/Camera"
|
||||
import type { Mesh } from "../engine/scene/Mesh"
|
||||
import { Mob, MOB_KINDS, type MobKind } from "../engine/scene/Mob"
|
||||
import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob"
|
||||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import { loadTextures } from "./assets"
|
||||
import { buildLevel, type Level } from "./level"
|
||||
import { EYE_HEIGHT, Player } from "./player"
|
||||
import { buildLevel, type Level } from "../game/level"
|
||||
import { EYE_HEIGHT, Player } from "../game/player"
|
||||
import { createRenderer } from "./renderer"
|
||||
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "./renderScene"
|
||||
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene"
|
||||
|
||||
const FOV_DEGREES = 75
|
||||
const FOV = (FOV_DEGREES * Math.PI) / 180
|
||||
|
|
|
|||
169
app/player.ts
169
app/player.ts
|
|
@ -1,169 +0,0 @@
|
|||
import { Terrain } from "../engine/scene/Terrain"
|
||||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import type { Aabb, Level } from "./level"
|
||||
|
||||
/** The player as a vertical cylinder. `position` is at the feet; the camera
|
||||
* eye sits EYE_HEIGHT above it. */
|
||||
export type Player = {
|
||||
position: Vec3
|
||||
yaw: number
|
||||
pitch: number
|
||||
velocityY: number
|
||||
onGround: boolean
|
||||
}
|
||||
|
||||
export const EYE_HEIGHT = 1.6
|
||||
const RADIUS = 0.35
|
||||
const SPEED = 6
|
||||
/** Speed multiplier while a Run key (Shift) is held. Tweak to taste; set high to
|
||||
* blast across the big terrain -- move+collision is substepped, so walls stay
|
||||
* solid even at big multipliers. */
|
||||
const RUN_MULTIPLIER = 2
|
||||
const GRAVITY = 22
|
||||
const JUMP_SPEED = 14
|
||||
const NPC_RADIUS = 0.5
|
||||
|
||||
export namespace Player {
|
||||
/** Advance the player one frame: jump, horizontal move + collision, gravity. */
|
||||
export function update(player: Player, keys: Set<string>, dt: number, level: Level): void {
|
||||
if (keys.has("Space") && player.onGround) {
|
||||
player.velocityY = JUMP_SPEED
|
||||
player.onGround = false
|
||||
}
|
||||
// Move + collide in small substeps: collision is discrete (move, then push
|
||||
// out), so a single big running step could otherwise skip clean through a
|
||||
// wall. Substepping keeps each advance short enough to always hit it.
|
||||
const steps = moveSubsteps(keys, dt)
|
||||
for (let i = 0; i < steps; i++) {
|
||||
moveHorizontal(player, keys, dt / steps)
|
||||
collide(player, level)
|
||||
}
|
||||
fall(player, dt, level)
|
||||
}
|
||||
|
||||
/** Run-speed factor for the frame: RUN_MULTIPLIER while Shift is held, else 1. */
|
||||
function runFactor(keys: Set<string>): number {
|
||||
return keys.has("ShiftLeft") || keys.has("ShiftRight") ? RUN_MULTIPLIER : 1
|
||||
}
|
||||
|
||||
/** Number of move+collide substeps so each advances at most ~RADIUS, keeping
|
||||
* the player from tunneling walls however fast they run. */
|
||||
function moveSubsteps(keys: Set<string>, dt: number): number {
|
||||
const perFrame = SPEED * runFactor(keys) * dt * Math.SQRT2
|
||||
return Math.max(1, Math.ceil(perFrame / RADIUS))
|
||||
}
|
||||
|
||||
function moveHorizontal(player: Player, keys: Set<string>, dt: number): void {
|
||||
const speed = SPEED * runFactor(keys) * dt
|
||||
const fx = Math.sin(player.yaw)
|
||||
const fz = -Math.cos(player.yaw)
|
||||
const rx = Math.cos(player.yaw)
|
||||
const rz = Math.sin(player.yaw)
|
||||
const p = player.position
|
||||
if (keys.has("KeyW")) {
|
||||
p.x += fx * speed
|
||||
p.z += fz * speed
|
||||
}
|
||||
if (keys.has("KeyS")) {
|
||||
p.x -= fx * speed
|
||||
p.z -= fz * speed
|
||||
}
|
||||
if (keys.has("KeyD")) {
|
||||
p.x += rx * speed
|
||||
p.z += rz * speed
|
||||
}
|
||||
if (keys.has("KeyA")) {
|
||||
p.x -= rx * speed
|
||||
p.z -= rz * speed
|
||||
}
|
||||
}
|
||||
|
||||
/** Push the player's circle out of any solid it overlaps: level colliders it
|
||||
* is not standing above, and the NPC. This is what makes walls and the NPC
|
||||
* impassable while still letting you stand on the crate. */
|
||||
function collide(player: Player, level: Level): void {
|
||||
for (const aabb of level.colliders) {
|
||||
if (player.position.y < aabb.top - 0.01) {
|
||||
pushFromAabb(player.position, aabb)
|
||||
}
|
||||
}
|
||||
pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS)
|
||||
}
|
||||
|
||||
/** Apply gravity and land on the highest ground under the player. */
|
||||
function fall(player: Player, dt: number, level: Level): void {
|
||||
player.velocityY -= GRAVITY * dt
|
||||
player.position.y += player.velocityY * dt
|
||||
const ground = groundHeight(player.position, level)
|
||||
if (player.position.y <= ground) {
|
||||
player.position.y = ground
|
||||
player.velocityY = 0
|
||||
player.onGround = true
|
||||
} else {
|
||||
player.onGround = false
|
||||
}
|
||||
}
|
||||
|
||||
function groundHeight(position: Vec3, level: Level): number {
|
||||
let ground = Terrain.height(level.terrain, position.x, position.z)
|
||||
for (const aabb of level.colliders) {
|
||||
if (
|
||||
aabb.standable &&
|
||||
position.x >= aabb.minX &&
|
||||
position.x <= aabb.maxX &&
|
||||
position.z >= aabb.minZ &&
|
||||
position.z <= aabb.maxZ
|
||||
) {
|
||||
ground = Math.max(ground, aabb.top)
|
||||
}
|
||||
}
|
||||
return ground
|
||||
}
|
||||
|
||||
function pushFromAabb(position: Vec3, aabb: Aabb): void {
|
||||
const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x))
|
||||
const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z))
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const d2 = dx * dx + dz * dz
|
||||
if (d2 >= RADIUS * RADIUS) {
|
||||
return
|
||||
}
|
||||
if (d2 > 1e-6) {
|
||||
const d = Math.sqrt(d2)
|
||||
const push = (RADIUS - d) / d
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
return
|
||||
}
|
||||
// Center is inside the box: eject through the nearest face.
|
||||
const left = position.x - aabb.minX
|
||||
const rightSide = aabb.maxX - position.x
|
||||
const near = position.z - aabb.minZ
|
||||
const far = aabb.maxZ - position.z
|
||||
const m = Math.min(left, rightSide, near, far)
|
||||
if (m === left) {
|
||||
position.x = aabb.minX - RADIUS
|
||||
} else if (m === rightSide) {
|
||||
position.x = aabb.maxX + RADIUS
|
||||
} else if (m === near) {
|
||||
position.z = aabb.minZ - RADIUS
|
||||
} else {
|
||||
position.z = aabb.maxZ + RADIUS
|
||||
}
|
||||
}
|
||||
|
||||
function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void {
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const reach = RADIUS + otherRadius
|
||||
const d2 = dx * dx + dz * dz
|
||||
if (d2 >= reach * reach || d2 < 1e-6) {
|
||||
return
|
||||
}
|
||||
const d = Math.sqrt(d2)
|
||||
const push = (reach - d) / d
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { MOB_KINDS } from "../engine/scene/Mob"
|
||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
|
||||
import { MOB_KINDS } from "../game/actors/Mob"
|
||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
|
||||
|
||||
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
|
||||
* scene, this worker's row band, and its index into the per-worker times array. */
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import { Frustum } from "../engine/render/Frustum"
|
||||
import { Rasterizer } from "../engine/render/Rasterizer"
|
||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { Sky, type SkyConfig } from "../engine/render/Sky"
|
||||
import type { Camera } from "../engine/scene/Camera"
|
||||
import { Mat4 } from "../engine/math/Mat4"
|
||||
import type { Mesh } from "../engine/scene/Mesh"
|
||||
import { Mob, type MobKind } from "../engine/scene/Mob"
|
||||
import { Sprite } from "../engine/scene/Sprite"
|
||||
import type { Vec2 } from "../engine/math/Vec2"
|
||||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import type { Textures } from "./assets"
|
||||
import type { Chunk } from "./level"
|
||||
|
||||
/** Everything needed to render the world: the room, the cullable chunks, the NPC
|
||||
* billboard source, sky, and textures. Bundled so it can be handed to a worker
|
||||
* whole (it is plain data + typed arrays, structured-clone friendly). */
|
||||
export type Scene = {
|
||||
chunks: Chunk[]
|
||||
floor: Mesh
|
||||
walls: Mesh
|
||||
crate: Mesh
|
||||
npc: { position: Vec3; size: Vec2 }
|
||||
/** Canonical local-space mob meshes, one per kind, built once + shared by every
|
||||
* instance (each instance differs only by its per-frame model matrix). */
|
||||
mobMesh: Record<MobKind, Mesh>
|
||||
/** How many mobs the sim has -- sizes the worker's shared transform buffer. */
|
||||
mobCount: number
|
||||
sky: SkyConfig
|
||||
textures: Textures
|
||||
}
|
||||
|
||||
/** One mob's live transform for a frame: which mesh + where/how to place it.
|
||||
* Produced by `visibleMobs` on the main thread, then either passed straight to
|
||||
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
|
||||
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */
|
||||
export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number }
|
||||
export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale
|
||||
|
||||
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
|
||||
* the main thread and shared with every worker (so they don't each re-cull). */
|
||||
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
|
||||
const frustum = Frustum.fromViewProj(viewProj)
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const c = chunks[i]
|
||||
if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
|
||||
out.push(i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The `MobDraw`s for mobs whose world AABB is inside the view frustum. Mobs move,
|
||||
* so (unlike chunks) they can't be baked into the culled world -- they're culled
|
||||
* here per frame instead. Computed once on the main thread; the visible set is
|
||||
* what gets shipped to the workers. */
|
||||
export function visibleMobs(mobs: Mob[], viewProj: Mat4): MobDraw[] {
|
||||
const frustum = Frustum.fromViewProj(viewProj)
|
||||
const out: MobDraw[] = []
|
||||
for (const m of mobs) {
|
||||
const r = Mob.boundingRadius(m.kind) * m.scale
|
||||
const h = Mob.bodyHeight(m.kind) * m.scale
|
||||
const p = m.position
|
||||
if (Frustum.intersectsAabb(frustum, p.x - r, p.y - r, p.z - r, p.x + r, p.y + h + r, p.z + r)) {
|
||||
out.push({ kind: m.kind, x: p.x, y: p.y, z: p.z, heading: m.heading, scale: m.scale })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Render rows [y0, y1) of one frame into `fb`. This is the single source of
|
||||
* render truth: the single-thread path calls it with the full height, and each
|
||||
* worker calls it with its own disjoint band -- same output either way, and no
|
||||
* two bands touch the same pixel (so the shared framebuffer needs no locking).
|
||||
*/
|
||||
export function renderBand(
|
||||
fb: Framebuffer,
|
||||
scene: Scene,
|
||||
camera: Camera,
|
||||
viewProj: Mat4,
|
||||
visible: number[],
|
||||
mobDraws: MobDraw[],
|
||||
config: RenderConfig,
|
||||
skyStep: number,
|
||||
time: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
const tx = scene.textures
|
||||
Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1)
|
||||
// Room: small and always near, drawn unconditionally (double-sided).
|
||||
Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1)
|
||||
Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1)
|
||||
Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1)
|
||||
for (const i of visible) {
|
||||
const c = scene.chunks[i]
|
||||
// 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. 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 }
|
||||
Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1)
|
||||
// Roaming mobs: each is the shared local-space mesh for its kind, placed by its
|
||||
// own model matrix (viewProj x model). Drawn double-sided (cull off) -- they're
|
||||
// small and few, so the winding-correct backface cull isn't worth the fuss.
|
||||
for (const m of mobDraws) {
|
||||
const mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale))
|
||||
Rasterizer.draw(fb, scene.mobMesh[m.kind], tx[m.kind], mvp, config, false, y0, y1)
|
||||
}
|
||||
Framebuffer.quantize(fb, config, y0, y1)
|
||||
}
|
||||
|
||||
/** Whether a chunk is far enough to draw its impostor meshes: squared distance
|
||||
* from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on
|
||||
* camera, the chunk's baked bounds, and the config constant, all of which every
|
||||
* worker already holds, so the choice is identical across bands. */
|
||||
export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean {
|
||||
if (!(lodDistance < Infinity)) {
|
||||
return false
|
||||
}
|
||||
const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x))
|
||||
const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y))
|
||||
const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z))
|
||||
return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance
|
||||
}
|
||||
|
|
@ -2,10 +2,11 @@ import { Framebuffer } from "../engine/render/Framebuffer"
|
|||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import type { Mat4 } from "../engine/math/Mat4"
|
||||
import type { Camera } from "../engine/scene/Camera"
|
||||
import { MOB_KINDS } from "../engine/scene/Mob"
|
||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
|
||||
import { MOB_KINDS } from "../game/actors/Mob"
|
||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
|
||||
|
||||
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
|
||||
/** Clouds are drawn at 1/SKY_STEP resolution (the sky base + sun stay per-pixel);
|
||||
* band splits align to it so the cloud block grid stays seamless across workers. */
|
||||
const SKY_STEP = 2
|
||||
/** Use worker threads when the page can share memory (else single-thread). */
|
||||
const ENABLE_WORKERS = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue