103 lines
4.5 KiB
TypeScript
103 lines
4.5 KiB
TypeScript
|
|
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 type { Mat4 } from "../engine/math/Mat4"
|
||
|
|
import type { Mesh } from "../engine/scene/Mesh"
|
||
|
|
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 }
|
||
|
|
sky: SkyConfig
|
||
|
|
textures: Textures
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 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
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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[],
|
||
|
|
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]
|
||
|
|
Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1)
|
||
|
|
// Past lodDistance, swap full tree/boulder geometry for cheap impostors.
|
||
|
|
// `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.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.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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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)
|
||
|
|
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
|
||
|
|
}
|