feat: culling
This commit is contained in:
parent
680e08aadc
commit
ef5029da1d
7 changed files with 309 additions and 103 deletions
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,
|
||||
viewProj: Mat4,
|
||||
config: RenderConfig,
|
||||
cull = false,
|
||||
): void {
|
||||
const { vertices, indices } = mesh
|
||||
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.
|
||||
const poly = clipNear([project(viewProj, a), project(viewProj, b), project(viewProj, c)])
|
||||
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,
|
||||
texture: Texture,
|
||||
config: RenderConfig,
|
||||
cull: boolean,
|
||||
): void {
|
||||
const a = toScreen(fb, va, config.vertexSnap)
|
||||
const b = toScreen(fb, vb, config.vertexSnap)
|
||||
|
|
@ -154,6 +156,12 @@ export namespace Rasterizer {
|
|||
if (area === 0) {
|
||||
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 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)))
|
||||
|
|
|
|||
|
|
@ -58,8 +58,13 @@ export namespace Sky {
|
|||
* 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
|
||||
* `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 forward = Camera.forward(camera)
|
||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||
|
|
@ -70,10 +75,15 @@ export namespace Sky {
|
|||
const cosSun = Math.cos(sky.sunSize)
|
||||
const clouds = sky.clouds
|
||||
const cloud: CloudSample = { cover: 0, shade: 1 }
|
||||
for (let y = 0; y < height; y++) {
|
||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||
for (let x = 0; x < width; x++) {
|
||||
const ndcX = ((x + 0.5) / width) * 2 - 1
|
||||
const s = Math.max(1, step | 0)
|
||||
for (let by = 0; by < height; by += s) {
|
||||
// Shade at the block center, then flood the whole block with that color.
|
||||
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.
|
||||
let dx = forward.x + right.x * ndcX * tanX + up.x * 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)
|
||||
}
|
||||
}
|
||||
const i = y * width + x
|
||||
color[i] = c
|
||||
depth[i] = 0
|
||||
const xEnd = Math.min(width, bx + s)
|
||||
for (let y = by; y < yEnd; y++) {
|
||||
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)
|
||||
}
|
||||
|
||||
/** Build the outdoor ground as a `divisions`x`divisions` grid over the whole
|
||||
* world, each vertex lifted onto the heightfield. Cells inside the clearing
|
||||
* are skipped so the mesh has a hole where the flat room floor goes (no
|
||||
* z-fighting). `uvScale` sets texture tiles per world unit. */
|
||||
export function ground(t: Terrain, divisions: number, uvScale: number): Mesh {
|
||||
const vertices: Mesh["vertices"] = []
|
||||
const indices: number[] = []
|
||||
const step = (t.outer * 2) / divisions
|
||||
const row = divisions + 1
|
||||
for (let i = 0; i <= divisions; i++) {
|
||||
const z = -t.outer + i * step
|
||||
for (let j = 0; j <= divisions; j++) {
|
||||
const x = -t.outer + j * step
|
||||
vertices.push({ pos: { x, y: height(t, x, z), z }, uv: { x: x * uvScale, y: z * uvScale } })
|
||||
/** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle
|
||||
* [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose
|
||||
* center is inside the clearing are skipped (the room floor's hole). UVs use
|
||||
* world position * `uvScale`, so neighboring patches tile seamlessly. Callers
|
||||
* keep the spacing uniform and cell edges aligned, so shared edges weld with
|
||||
* no cracks. Used to build the terrain per spatial chunk. */
|
||||
export function patch(
|
||||
t: Terrain,
|
||||
mesh: Mesh,
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
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 j = 0; j < divisions; j++) {
|
||||
const cx = -t.outer + (j + 0.5) * step
|
||||
const cz = -t.outer + (i + 0.5) * step
|
||||
for (let i = 0; i < rows; i++) {
|
||||
for (let j = 0; j < cols; j++) {
|
||||
const cx = x0 + (j + 0.5) * dx
|
||||
const cz = z0 + (i + 0.5) * dz
|
||||
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
|
||||
continue
|
||||
}
|
||||
const p = i * row + j
|
||||
indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
||||
const p = base + i * stride + j
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue