Binary Space Partitioning
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
bsp-tree.ts
import type { Tracer } from "../trace";/** A wall of the map: a segment from (x1, y1) to (x2, y2), its front the left of the direction of travel. */export interface Wall { readonly x1: number; readonly y1: number; readonly x2: number; readonly y2: number;}/** Where a wall lies against a splitter's line. */export type WallSide = "front" | "back" | "on" | "spanning";/** How the splitter of a node is chosen from the walls that reach it. */export type SplitterChoice = "first" | "fewest";export interface BspTreeOptions { /** "first" takes the first wall in the list; "fewest" the wall whose line cuts the fewest others. */ choice: SplitterChoice;}/** A node of the tree: the splitter's line and every wall on it, and the subtrees in front of and behind it (a node id, or the negative id of an empty leaf). */export interface BspNode { readonly id: number; readonly depth: number; readonly parent: number; readonly walls: readonly Wall[]; readonly front: number; readonly back: number;}/** A finished tree: its nodes in build order and how many empty leaves, each a convex region, it has. */export interface BspBuild { readonly root: number; readonly nodes: readonly BspNode[]; readonly leaves: number;}/** A node in the order the painter visits it from an eye, with the walls it paints. */export interface PaintStep { readonly node: number; readonly walls: readonly Wall[];}/** Points nearer the line than this are on it; the maps are in whole tiles, so only a true crossing is ever closer. */const ON_LINE = 1e-9;/** * Binary space partitioning over a set of walls. One wall's line splits * the map in two; every other wall is in front of it, behind it, on it, * or crosses it and is cut at the crossing into a front part and a back * part. Each half is split again by one of its own walls, until a half * has no walls left: that half is an empty leaf, and every leaf is a * convex region. The tree is built once for a map. A view from any eye * then falls out of a walk: at each node, the half the eye is not in is * drawn first, then the node's walls, then the half the eye is in, so * walls come out back to front, the painter's order, with no sorting * and no depth buffer. Doom drew its levels this way. * * Time: O(walls x depth) to build, O(nodes) per view. */export class BspTree { private readonly nodes: BspNode[] = []; private leafCount = 0; constructor( readonly options: BspTreeOptions, private readonly tracer?: Tracer, ) { if (options.choice !== "first" && options.choice !== "fewest") throw new RangeError(`choice ${String(options.choice)} must be first or fewest`); } /** Which side of the wall's line a point is on: positive in front, negative behind, 0 on the line. */ static side(wall: Wall, x: number, y: number): number { const d = (wall.x2 - wall.x1) * (y - wall.y1) - (wall.y2 - wall.y1) * (x - wall.x1); return Math.abs(d) < ON_LINE ? 0 : d; } /** Where a wall lies against a splitter: both ends in front, both behind, both on the line, or one each side. */ static classify(wall: Wall, splitter: Wall): WallSide { const a = BspTree.side(splitter, wall.x1, wall.y1); const b = BspTree.side(splitter, wall.x2, wall.y2); if (a >= 0 && b >= 0) return a === 0 && b === 0 ? "on" : "front"; if (a <= 0 && b <= 0) return "back"; return "spanning"; } /** A spanning wall cut at the splitter's line: the part in front first, the part behind second. */ static split(wall: Wall, splitter: Wall): [Wall, Wall] { const a = BspTree.side(splitter, wall.x1, wall.y1); const b = BspTree.side(splitter, wall.x2, wall.y2); const t = a / (a - b); const crossingFraction = a / (a - b); const x = wall.x1 + (wall.x2 - wall.x1) * t; const x = wall.x1 + (wall.x2 - wall.x1) * crossingFraction; const y = wall.y1 + (wall.y2 - wall.y1) * t; const y = wall.y1 + (wall.y2 - wall.y1) * crossingFraction; const head: Wall = { x1: wall.x1, y1: wall.y1, x2: x, y2: y }; const tail: Wall = { x1: x, y1: y, x2: wall.x2, y2: wall.y2 }; return a > 0 ? [head, tail] : [tail, head]; } /** * A wall's whole line clipped to a width by height map, the splitting * line a node stands for: the two points where it leaves the box, or * null when it misses (it never does for a wall inside the map). */ static plane(wall: Wall, width: number, height: number): { from: { x: number; y: number }; to: { x: number; y: number } } | null { const dx = wall.x2 - wall.x1; const dy = wall.y2 - wall.y1; let t0 = -Infinity; let t1 = Infinity; for (const [p, q] of [ [-dx, wall.x1], [dx, width - wall.x1], [-dy, wall.y1], [dy, height - wall.y1], ] as const) { if (p === 0) { if (q < 0) return null; continue; } const at = q / p; if (p < 0) t0 = Math.max(t0, at); else t1 = Math.min(t1, at); } if (t0 > t1 || !Number.isFinite(t0) || !Number.isFinite(t1)) return null; return { from: { x: wall.x1 + dx * t0, y: wall.y1 + dy * t0 }, to: { x: wall.x1 + dx * t1, y: wall.y1 + dy * t1 } }; } /** How many of the walls the candidate's line would cut. */ static cuts(candidate: Wall, walls: readonly Wall[]): number { let n = 0; for (const wall of walls) if (wall !== candidate && BspTree.classify(wall, candidate) === "spanning") n++; return n; } /** Builds the tree over the walls and returns it; a map with no walls is one empty leaf. */ build(walls: readonly Wall[]): BspBuild { for (const wall of walls) { if (![wall.x1, wall.y1, wall.x2, wall.y2].every(Number.isFinite)) throw new RangeError("a wall has a coordinate that is not a number"); if (wall.x1 === wall.x2 && wall.y1 === wall.y2) throw new RangeError(`a wall at (${wall.x1}, ${wall.y1}) has no length`); } this.nodes.length = 0; this.leafCount = 0; const root = this.partition([...walls], 0, -1, "root"); return { root, nodes: this.nodes.map((n) => ({ ...n, walls: [...n.walls] })), leaves: this.leafCount }; } /** The nodes in painter's order from the eye: far side, node, near side, down the whole tree. */ paint(build: BspBuild, x: number, y: number): PaintStep[] { const order: PaintStep[] = []; const visit = (id: number): void => { if (id < 0) return; const node = build.nodes[id]!; const splitter = node.walls[0]!; const eyeInFront = BspTree.side(splitter, x, y) >= 0; // ▸ which side of the node's line is the eye on? draw the other first this.tracer?.emit("visit", { node: id, depth: node.depth, eye: eyeInFront ? "front" : "back", first: eyeInFront ? "back" : "front" }); visit(eyeInFront ? node.back : node.front); order.push({ node: id, walls: node.walls }); // ▸ the node's walls, after everything behind them from here this.tracer?.emit("paint", { node: id, order: order.length, walls: node.walls.length }); visit(eyeInFront ? node.front : node.back); }; visit(build.root); return order; } private choose(walls: readonly Wall[]): number { if (this.options.choice === "first") return 0; let best = 0; let fewest = Infinity; walls.forEach((candidate, i) => { const n = BspTree.cuts(candidate, walls); if (n < fewest) { fewest = n; best = i; } }); return best; } private partition(walls: Wall[], depth: number, parent: number, sideOfParent: "root" | "front" | "back"): number { if (walls.length === 0) { const leaf = this.leafCount++; // ▸ no walls left on this side: an empty, convex region this.tracer?.emit("leaf", { leaf, parent, depth, side: sideOfParent }); return -1 - leaf; } const chosen = this.choose(walls); const splitterIndex = this.choose(walls); const splitter = walls[chosen]!; const splitter = walls[splitterIndex]!; const id = this.nodes.length; const node = { id, depth, parent, walls: [splitter] as Wall[], front: -1, back: -1 }; this.nodes.push(node); // ▸ one wall's line splits this part of the map in two this.tracer?.emit("splitter", { node: id, depth, parent, side: sideOfParent, x1: splitter.x1, y1: splitter.y1, x2: splitter.x2, y2: splitter.y2, candidates: walls.length, cuts: BspTree.cuts(splitter, walls) }); const front: Wall[] = []; const back: Wall[] = []; walls.forEach((wall, i) => { if (i === chosen) return; if (i === splitterIndex) return; const where = BspTree.classify(wall, splitter); // ▸ every other wall is in front, behind, on the line, or cut by it this.tracer?.emit("classify", { node: id, x1: wall.x1, y1: wall.y1, x2: wall.x2, y2: wall.y2, side: where }); if (where === "front") front.push(wall); else if (where === "back") back.push(wall); else if (where === "on") node.walls.push(wall); else { const [ahead, behind] = BspTree.split(wall, splitter); front.push(ahead); back.push(behind); } }); const frontChild = this.partition(front, depth + 1, id, "front"); const backChild = this.partition(back, depth + 1, id, "back"); this.nodes[id] = { ...node, front: frontChild, back: backChild }; return id; }}10 nodes, 11 leafs (regions), depth 4, 11 wall parts; 10 nodes painted back to front with no sorting.