refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

29
engine/render/Chunk.ts Normal file
View file

@ -0,0 +1,29 @@
import type { DrawGroup } from "./Material"
import type { Vec3 } from "../math/Vec3"
export type Bounds3 = {
minX: number
minY: number
minZ: number
maxX: number
maxY: number
maxZ: number
}
/** One cullable section of static world geometry with two engine-supported LODs. */
export type Chunk = Bounds3 & {
readonly near: readonly DrawGroup[]
readonly far: readonly DrawGroup[]
}
export namespace Chunk {
export function isFar(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
}
}

View file

@ -0,0 +1,123 @@
import { STRIDE, type Mesh } from "../scene/Mesh"
import type { Chunk, Bounds3 } from "./Chunk"
import type { DrawGroup, Material } from "./Material"
export type MeshBatch = {
mesh: (material: Material) => Mesh
use: (material: Material, mesh: Mesh) => void
}
export type ChunkItem = {
position: { x: number; z: number }
bakeNear: (batch: MeshBatch) => void
bakeFar?: (batch: MeshBatch) => void
}
export type ChunkCell = {
x0: number
z0: number
x1: number
z1: number
}
export type ChunkBuilder = {
minX: number
minZ: number
maxX: number
maxZ: number
columns: number
rows: number
bakeCell: (near: MeshBatch, far: MeshBatch, cell: ChunkCell) => void
}
export namespace ChunkBuilder {
export function build(config: ChunkBuilder, items: ChunkItem[]): Chunk[] {
const width = (config.maxX - config.minX) / config.columns
const depth = (config.maxZ - config.minZ) / config.rows
const chunks: Chunk[] = []
for (let column = 0; column < config.columns; column++) {
const x0 = config.minX + column * width
const x1 = x0 + width
for (let row = 0; row < config.rows; row++) {
const z0 = config.minZ + row * depth
const z1 = z0 + depth
const nearMeshes = new Map<Material, Mesh>()
const farMeshes = new Map<Material, Mesh>()
const near = batch(nearMeshes)
const far = batch(farMeshes)
config.bakeCell(near, far, { x0, z0, x1, z1 })
for (const item of items) {
if (inCell(item.position, x0, z0, x1, z1)) {
item.bakeNear(near)
item.bakeFar?.(far)
}
}
const nearGroups = groups(nearMeshes)
const farGroups = groups(farMeshes)
const box = bounds([...nearMeshes.values(), ...farMeshes.values()])
if (box !== null) {
chunks.push({ ...box, near: nearGroups, far: farGroups })
}
}
}
return chunks
}
function batch(meshes: Map<Material, Mesh>): MeshBatch {
return {
mesh(material) {
let mesh = meshes.get(material)
if (mesh === undefined) {
mesh = { verts: [], indices: [] }
meshes.set(material, mesh)
}
return mesh
},
use(material, mesh) {
meshes.set(material, mesh)
},
}
}
function groups(meshes: Map<Material, Mesh>): DrawGroup[] {
const result: DrawGroup[] = []
for (const [material, mesh] of meshes) {
if (mesh.indices.length > 0) {
result.push({ mesh, material })
}
}
return result
}
function inCell(
position: { x: number; z: number },
x0: number,
z0: number,
x1: number,
z1: number,
): boolean {
return (
position.x >= x0 && position.x < x1 && position.z >= z0 && position.z < z1
)
}
function bounds(meshes: Mesh[]): Bounds3 | null {
let minX = Infinity
let minY = Infinity
let minZ = Infinity
let maxX = -Infinity
let maxY = -Infinity
let maxZ = -Infinity
for (const mesh of meshes) {
for (let i = 0; i < mesh.verts.length; i += STRIDE) {
minX = Math.min(minX, mesh.verts[i])
minY = Math.min(minY, mesh.verts[i + 1])
minZ = Math.min(minZ, mesh.verts[i + 2])
maxX = Math.max(maxX, mesh.verts[i])
maxY = Math.max(maxY, mesh.verts[i + 1])
maxZ = Math.max(maxZ, mesh.verts[i + 2])
}
}
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
}
}

View file

@ -115,8 +115,8 @@ export namespace Rasterizer {
* NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
* 4 verts) to `dst` and returning its vertex count.
*
* This matters even when standing inside the room: a wall to your side has
* vertices both in front of and behind the eye. Without clipping, the behind
* Geometry intersecting the camera plane has vertices both in front of and
* behind the eye. Without clipping, the behind
* vertices have w <= 0 and invert under the perspective divide, smearing the
* triangle across the whole screen (and risking divide-by-zero).
*/
@ -201,7 +201,7 @@ export namespace Rasterizer {
return
}
// Backface cull: a back-facing triangle has positive area here. Only for
// solid, consistently-wound meshes; sprites/room stay double-sided.
// solid, consistently-wound meshes; other materials may stay double-sided.
if (cull && area > 0) {
return
}

View file

@ -46,10 +46,8 @@ export type RenderConfig = {
* short draw distance and the shimmer of far geometry. It also colors pixels
* no triangle covers, so the frame's clear color should match `fog.color`. */
fog: Fog | null
/** Beyond this distance (world units) trees + boulders draw as cheap low-poly
* impostors instead of full geometry, cutting per-triangle work in dense
* views. Kept inside `fog.far` so far detail is already fog-dimmed at the
* switch; `Infinity` disables LOD. */
/** Beyond this distance (world units), chunks draw their cheaper far groups
* instead of near geometry. `Infinity` disables LOD. */
lodDistance: number
}

View file

@ -0,0 +1,123 @@
import type { Mat4 } from "../math/Mat4"
import type { Camera } from "../scene/Camera"
import type { RenderConfig } from "./RenderConfig"
import type { RenderInstance, RenderScene } from "./RenderScene"
export type RenderWorkerInit = {
colorSAB: SharedArrayBuffer
depthSAB: SharedArrayBuffer
width: number
height: number
scene: RenderScene
band: [number, number]
config: RenderConfig
skyStep: number
ctrlSAB: SharedArrayBuffer
cameraSAB: SharedArrayBuffer
viewProjectionSAB: SharedArrayBuffer
visibleChunkSAB: SharedArrayBuffer
instanceIdSAB: SharedArrayBuffer
instanceTransformSAB: SharedArrayBuffer
timesSAB: SharedArrayBuffer
workerIndex: number
}
export type RenderFrameCamera = {
camera: Camera
time: number
}
/** Shared frame-buffer layout used by browser driver and render workers. */
export namespace RenderProtocol {
export const FRAME = 0
export const DONE = 1
export const VISIBLE_CHUNKS = 2
export const VISIBLE_INSTANCES = 3
export const CONTROL_LENGTH = 4
export const CAMERA_X = 0
export const CAMERA_Y = 1
export const CAMERA_Z = 2
export const CAMERA_YAW = 3
export const CAMERA_PITCH = 4
export const CAMERA_FOV = 5
export const CAMERA_TIME = 6
export const CAMERA_LENGTH = 7
export const VIEW_PROJECTION_LENGTH = 16
export const TRANSFORM_FLOATS = 5
export function writeCamera(
output: Float64Array<ArrayBufferLike>,
camera: Camera,
time: number,
): void {
output[CAMERA_X] = camera.position.x
output[CAMERA_Y] = camera.position.y
output[CAMERA_Z] = camera.position.z
output[CAMERA_YAW] = camera.yaw
output[CAMERA_PITCH] = camera.pitch
output[CAMERA_FOV] = camera.fov
output[CAMERA_TIME] = time
}
export function readCamera(input: Float64Array<ArrayBufferLike>): RenderFrameCamera {
return {
camera: {
position: {
x: input[CAMERA_X],
y: input[CAMERA_Y],
z: input[CAMERA_Z],
},
yaw: input[CAMERA_YAW],
pitch: input[CAMERA_PITCH],
fov: input[CAMERA_FOV],
},
time: input[CAMERA_TIME],
}
}
export function writeViewProjection(
output: Float32Array<ArrayBufferLike>,
viewProjection: Mat4,
): void {
output.set(viewProjection)
}
export function writeInstances(
ids: Int32Array<ArrayBufferLike>,
transforms: Float32Array<ArrayBufferLike>,
instances: RenderInstance[],
): number {
const count = Math.min(instances.length, ids.length)
for (let i = 0; i < count; i++) {
const instance = instances[i]
const offset = i * TRANSFORM_FLOATS
ids[i] = instance.prototype
transforms[offset] = instance.x
transforms[offset + 1] = instance.y
transforms[offset + 2] = instance.z
transforms[offset + 3] = instance.heading
transforms[offset + 4] = instance.scale
}
return count
}
export function readInstances(
ids: Int32Array<ArrayBufferLike>,
transforms: Float32Array<ArrayBufferLike>,
count: number,
output: RenderInstance[],
): void {
output.length = 0
for (let i = 0; i < count; i++) {
const offset = i * TRANSFORM_FLOATS
output.push({
prototype: ids[i],
x: transforms[offset],
y: transforms[offset + 1],
z: transforms[offset + 2],
heading: transforms[offset + 3],
scale: transforms[offset + 4],
})
}
}
}

View file

@ -0,0 +1,224 @@
import { Mat4 as Matrix, type Mat4 } from "../math/Mat4"
import type { Vec2 } from "../math/Vec2"
import type { Vec3 } from "../math/Vec3"
import type { Camera } from "../scene/Camera"
import { Sprite } from "../scene/Sprite"
import { Framebuffer, type Framebuffer as Frame } from "./Framebuffer"
import { Frustum } from "./Frustum"
import type { DrawGroup, Material } from "./Material"
import { Rasterizer } from "./Rasterizer"
import type { RenderConfig } from "./RenderConfig"
import { Sky, type SkyConfig } from "./Sky"
import { Chunk, type Chunk as RenderChunk } from "./Chunk"
export type Billboard = {
position: Vec3
size: Vec2
material: Material
}
export type RenderPrototype = {
readonly groups: readonly DrawGroup[]
readonly radius: number
readonly minY: number
readonly maxY: number
}
export type RenderTransform = {
x: number
y: number
z: number
heading: number
scale: number
}
export type RenderInstance = RenderTransform & {
prototype: number
}
/** Clone-safe render projection of a live level. Contains no behavior callbacks or
* game-content registries, so workers need only engine code. */
export type RenderScene = {
readonly chunks: readonly RenderChunk[]
readonly staticGroups: readonly DrawGroup[]
readonly billboards: readonly Billboard[]
readonly prototypes: readonly RenderPrototype[]
readonly maxInstances: number
readonly sky: SkyConfig
}
export namespace RenderScene {
export function visibleChunks(
scene: RenderScene,
viewProjection: Mat4,
): number[] {
const frustum = Frustum.fromViewProj(viewProjection)
const visible: number[] = []
for (let i = 0; i < scene.chunks.length; i++) {
const chunk = scene.chunks[i]
if (
Frustum.intersectsAabb(
frustum,
chunk.minX,
chunk.minY,
chunk.minZ,
chunk.maxX,
chunk.maxY,
chunk.maxZ,
)
) {
visible.push(i)
}
}
return visible
}
export function visibleInstances(
scene: RenderScene,
instances: RenderInstance[],
viewProjection: Mat4,
): RenderInstance[] {
const frustum = Frustum.fromViewProj(viewProjection)
const visible: RenderInstance[] = []
for (const instance of instances) {
const prototype = scene.prototypes[instance.prototype]
if (prototype === undefined) {
continue
}
const radius = prototype.radius * instance.scale
if (
Frustum.intersectsAabb(
frustum,
instance.x - radius,
instance.y + prototype.minY * instance.scale,
instance.z - radius,
instance.x + radius,
instance.y + prototype.maxY * instance.scale,
instance.z + radius,
)
) {
visible.push(instance)
}
}
return visible
}
export function renderBand(
framebuffer: Frame,
scene: RenderScene,
camera: Camera,
viewProjection: Mat4,
visible: number[],
instances: RenderInstance[],
config: RenderConfig,
skyStep: number,
time: number,
y0: number,
y1: number,
): void {
Sky.render(framebuffer, camera, scene.sky, time, skyStep, y0, y1)
drawGroups(framebuffer, scene.staticGroups, viewProjection, config, y0, y1)
for (const index of visible) {
const chunk = scene.chunks[index]
const groups = Chunk.isFar(chunk, camera.position, config.lodDistance)
? chunk.far
: chunk.near
drawGroups(framebuffer, groups, viewProjection, config, y0, y1)
}
for (const billboard of scene.billboards) {
const sprite = {
position: billboard.position,
size: billboard.size,
texture: billboard.material.texture,
}
Rasterizer.draw(
framebuffer,
Sprite.billboard(sprite, camera),
billboard.material.texture,
viewProjection,
config,
billboard.material.cull,
y0,
y1,
)
}
for (const instance of instances) {
const prototype = scene.prototypes[instance.prototype]
if (prototype === undefined) {
continue
}
const modelViewProjection = Matrix.multiply(
viewProjection,
Matrix.compose(
instance.x,
instance.y,
instance.z,
instance.heading,
instance.scale,
),
)
drawGroups(
framebuffer,
prototype.groups,
modelViewProjection,
config,
y0,
y1,
)
}
Framebuffer.quantize(framebuffer, config, y0, y1)
}
export function triangleCount(
scene: RenderScene,
visible: number[],
instances: RenderInstance[],
eye: Vec3,
lodDistance: number,
): number {
let indices = groupIndices(scene.staticGroups) + scene.billboards.length * 6
for (const index of visible) {
const chunk = scene.chunks[index]
indices += groupIndices(
Chunk.isFar(chunk, eye, lodDistance) ? chunk.far : chunk.near,
)
}
for (const instance of instances) {
const prototype = scene.prototypes[instance.prototype]
if (prototype !== undefined) {
indices += groupIndices(prototype.groups)
}
}
return (indices / 3) | 0
}
function drawGroups(
framebuffer: Frame,
groups: readonly DrawGroup[],
matrix: Mat4,
config: RenderConfig,
y0: number,
y1: number,
): void {
for (const group of groups) {
Rasterizer.draw(
framebuffer,
group.mesh,
group.material.texture,
matrix,
config,
group.material.cull,
y0,
y1,
)
}
}
function groupIndices(groups: readonly DrawGroup[]): number {
let count = 0
for (const group of groups) {
count += group.mesh.indices.length
}
return count
}
}