From 6a51e00eea9d7091f594677c64d9b6e14c486cce Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 17 Oct 2025 18:29:31 +0900 Subject: [PATCH] feat(spatial): add bvh builder --- ROADMAP.md | 2 +- docs/index.d.ts | 50 ++++++ examples/bvh.ts | 66 ++++++++ src/index.ts | 18 +++ src/spatial/bvh.ts | 364 ++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 11 ++ tests/bvh.test.ts | 84 ++++++++++ tests/index.test.ts | 3 + 8 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 examples/bvh.ts create mode 100644 src/spatial/bvh.ts create mode 100644 tests/bvh.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 920ce00..559b9e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,7 +100,7 @@ - [x] Octree partitioning for 3D space - [x] Circle collision helpers - [x] Raycasting utilities - - [ ] Bounding volume hierarchy (BVH) builder + - [x] Bounding volume hierarchy (BVH) builder **Data structures** - [x] Binary heap priority queue - [x] Disjoint set union (union-find) diff --git a/docs/index.d.ts b/docs/index.d.ts index 8806088..66d3269 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -71,6 +71,9 @@ export const examples: { readonly circleAabbCollision: 'examples/circle.ts'; readonly circleSegmentIntersection: 'examples/circle.ts'; readonly sweptAABB: 'examples/sweptAabb.ts'; + readonly buildBvh: 'examples/bvh.ts'; + readonly queryBvh: 'examples/bvh.ts'; + readonly raycastBvh: 'examples/bvh.ts'; }; readonly search: { readonly fuzzySearch: 'examples/search.ts'; @@ -793,6 +796,42 @@ export class Octree { querySphere(center: Point3D, radius: number): Array; } +/** + * Bounding volume hierarchy for accelerating 3D spatial queries. + * Use for: ray picking, collision broad-phase, visibility culling. + * Performance: O(log n) traversal for balanced trees. + * Import: spatial/bvh.ts + */ +export type BvhAxis = 'x' | 'y' | 'z'; +export interface BvhEntry { item: T; bounds: Box3 } +export interface BvhLeaf { type: 'leaf'; bounds: Box3; entries: ReadonlyArray> } +export interface BvhBranch { + type: 'branch'; + bounds: Box3; + axis: BvhAxis; + left: BvhNode; + right: BvhNode; +} +export type BvhNode = BvhLeaf | BvhBranch; +export interface BuildBvhOptions { + getBounds(item: T): Box3; + maxLeafSize?: number; + maxDepth?: number; +} +export interface BvhRaycastHit { entry: BvhEntry; distance: number } +export function buildBvh(items: ReadonlyArray, options: BuildBvhOptions): BvhNode | null; +export function queryBvh( + node: BvhNode | null, + query: Box3, + results?: Array> +): Array>; +export function raycastBvh( + node: BvhNode | null, + ray: Ray3D, + intersect: (entry: BvhEntry, ray: Ray3D) => number | null, + maxDistance?: number +): BvhRaycastHit | null; + /** * Axis-aligned bounding box helpers. * Use for: broad collisions, viewport culling, layout math. @@ -3636,6 +3675,12 @@ export interface Vector2D { y: number; } +export interface Vector3D { + x: number; + y: number; + z: number; +} + export interface Rect { x: number; y: number; @@ -3652,6 +3697,11 @@ export interface Box3 { depth: number; } +export interface Ray3D { + origin: Point3D; + direction: Vector3D; +} + export interface Graph { [key: string]: Array<{ node: string; weight?: number }>; } diff --git a/examples/bvh.ts b/examples/bvh.ts new file mode 100644 index 0000000..28b8a6e --- /dev/null +++ b/examples/bvh.ts @@ -0,0 +1,66 @@ +import { buildBvh, queryBvh, raycastBvh } from '../src/index.js'; + +const objects = [ + { id: 'crate', bounds: { x: 0, y: 0, z: 0, width: 2, height: 2, depth: 2 } }, + { id: 'barrel', bounds: { x: 4, y: 0.5, z: 1, width: 1.5, height: 3, depth: 1.5 } }, + { id: 'pillar', bounds: { x: 8, y: 0, z: 0, width: 1, height: 6, depth: 1 } }, +]; + +const bvh = buildBvh(objects, { + getBounds: (item) => item.bounds, +}); + +const queryResult = queryBvh(bvh, { + x: -1, + y: -1, + z: -1, + width: 5, + height: 5, + depth: 5, +}); +console.log('intersecting ids', queryResult.map((entry) => entry.item.id)); + +const hit = raycastBvh( + bvh, + { origin: { x: -5, y: 1, z: 1 }, direction: { x: 1, y: 0, z: 0 } }, + (entry, ray) => rayAabb(ray, entry.bounds) +); +console.log('first hit', hit?.entry.item.id, 'at distance', hit?.distance); + +function rayAabb( + ray: { origin: { x: number; y: number; z: number }; direction: { x: number; y: number; z: number } }, + box: { x: number; y: number; z: number; width: number; height: number; depth: number } +): number | null { + const invDirX = 1 / ray.direction.x; + const invDirY = 1 / ray.direction.y; + const invDirZ = 1 / ray.direction.z; + + let tMin = ((ray.direction.x >= 0 ? box.x : box.x + box.width) - ray.origin.x) * invDirX; + let tMax = ((ray.direction.x >= 0 ? box.x + box.width : box.x) - ray.origin.x) * invDirX; + + const tyMin = ((ray.direction.y >= 0 ? box.y : box.y + box.height) - ray.origin.y) * invDirY; + const tyMax = ((ray.direction.y >= 0 ? box.y + box.height : box.y) - ray.origin.y) * invDirY; + + if (tMin > tyMax || tyMin > tMax) { + return null; + } + + if (tyMin > tMin) tMin = tyMin; + if (tyMax < tMax) tMax = tyMax; + + const tzMin = ((ray.direction.z >= 0 ? box.z : box.z + box.depth) - ray.origin.z) * invDirZ; + const tzMax = ((ray.direction.z >= 0 ? box.z + box.depth : box.z) - ray.origin.z) * invDirZ; + + if (tMin > tzMax || tzMin > tMax) { + return null; + } + + if (tzMin > tMin) tMin = tzMin; + if (tzMax < tMax) tMax = tzMax; + + if (tMax < 0) { + return null; + } + + return tMin >= 0 ? tMin : tMax; +} diff --git a/src/index.ts b/src/index.ts index 600d819..84cbf45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,9 @@ export const examples = { raycastSegment: 'examples/raycast.ts', raycastAabb: 'examples/raycast.ts', sweptAABB: 'examples/sweptAabb.ts', + buildBvh: 'examples/bvh.ts', + queryBvh: 'examples/bvh.ts', + raycastBvh: 'examples/bvh.ts', }, search: { fuzzySearch: 'examples/search.ts', @@ -408,6 +411,21 @@ export { Quadtree } from './spatial/quadtree.js'; */ export { Octree } from './spatial/octree.js'; +/** + * Bounding volume hierarchy builder for accelerating spatial queries. + * + * Example file: examples/bvh.ts + */ +export { buildBvh, queryBvh, raycastBvh } from './spatial/bvh.js'; +export type { + BvhNode, + BvhLeaf, + BvhBranch, + BvhEntry, + BvhAxis, + BvhRaycastHit, +} from './spatial/bvh.js'; + /** * Axis-aligned bounding box collision detection helpers. * diff --git a/src/spatial/bvh.ts b/src/spatial/bvh.ts new file mode 100644 index 0000000..5547062 --- /dev/null +++ b/src/spatial/bvh.ts @@ -0,0 +1,364 @@ +import type { Box3, Ray3D } from '../types.js'; + +export type BvhAxis = 'x' | 'y' | 'z'; + +export interface BvhEntry { + item: T; + bounds: Box3; +} + +export interface BvhLeaf { + type: 'leaf'; + bounds: Box3; + entries: Array>; +} + +export interface BvhBranch { + type: 'branch'; + bounds: Box3; + axis: BvhAxis; + left: BvhNode; + right: BvhNode; +} + +export type BvhNode = BvhLeaf | BvhBranch; + +export interface BuildBvhOptions { + getBounds(item: T): Box3; + maxLeafSize?: number; + maxDepth?: number; +} + +export function buildBvh( + items: ReadonlyArray, + options: BuildBvhOptions +): BvhNode | null { + if (!options || typeof options.getBounds !== 'function') { + throw new TypeError('options.getBounds must be a function returning Box3 bounds.'); + } + if (!Array.isArray(items) || items.length === 0) { + return null; + } + + const maxLeafSize = Math.max(1, Math.floor(options.maxLeafSize ?? 4)); + const maxDepth = Math.max(1, Math.floor(options.maxDepth ?? 24)); + + const entries = items.map>((item: T) => { + const bounds = cloneBox(options.getBounds(item)); + validateBox(bounds); + return { item, bounds }; + }); + + return build(entries.slice(), 0); + + function build(currentEntries: Array>, depth: number): BvhNode { + const bounds = computeBounds(currentEntries); + if ( + currentEntries.length <= maxLeafSize || + depth >= maxDepth || + isDegenerate(bounds) + ) { + return { + type: 'leaf', + bounds, + entries: currentEntries.slice(), + }; + } + + const axis = chooseSplitAxis(bounds); + currentEntries.sort( + (a, b) => centerAlongAxis(a.bounds, axis) - centerAlongAxis(b.bounds, axis) + ); + const mid = Math.floor(currentEntries.length / 2); + const leftEntries = currentEntries.slice(0, mid); + const rightEntries = currentEntries.slice(mid); + + if (leftEntries.length === 0 || rightEntries.length === 0) { + return { + type: 'leaf', + bounds, + entries: currentEntries.slice(), + }; + } + + return { + type: 'branch', + bounds, + axis, + left: build(leftEntries, depth + 1), + right: build(rightEntries, depth + 1), + }; + } +} + +export function queryBvh( + node: BvhNode | null, + query: Box3, + results: Array> = [] +): Array> { + if (!node) { + return results; + } + validateBox(query); + collect(node, query, results); + return results; +} + +export interface BvhRaycastHit { + entry: BvhEntry; + distance: number; +} + +export function raycastBvh( + node: BvhNode | null, + ray: Ray3D, + intersect: (entry: BvhEntry, ray: Ray3D) => number | null, + maxDistance = Infinity +): BvhRaycastHit | null { + if (!node) { + return null; + } + validateRay(ray); + let upperBound = Number.isFinite(maxDistance) ? maxDistance : Infinity; + const rootHit = rayAabb(ray, node.bounds, upperBound); + if (rootHit == null) { + return null; + } + + let closest: BvhRaycastHit | null = null; + const stack: Array<{ node: BvhNode; distance: number }> = [ + { node, distance: rootHit }, + ]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (closest && current.distance > closest.distance) { + continue; + } + + if (current.node.type === 'leaf') { + for (const entry of current.node.entries) { + const hitDistance = intersect(entry, ray); + if (hitDistance == null || hitDistance < 0) { + continue; + } + if (!Number.isFinite(hitDistance)) { + throw new Error('intersect callback must return a finite distance.'); + } + if (hitDistance <= (closest?.distance ?? upperBound)) { + closest = { entry, distance: hitDistance }; + upperBound = hitDistance; + } + } + } else { + const leftDistance = rayAabb(ray, current.node.left.bounds, upperBound); + const rightDistance = rayAabb(ray, current.node.right.bounds, upperBound); + + if (leftDistance != null && rightDistance != null) { + if (leftDistance > rightDistance) { + stack.push({ node: current.node.left, distance: leftDistance }); + stack.push({ node: current.node.right, distance: rightDistance }); + } else { + stack.push({ node: current.node.right, distance: rightDistance }); + stack.push({ node: current.node.left, distance: leftDistance }); + } + } else if (leftDistance != null) { + stack.push({ node: current.node.left, distance: leftDistance }); + } else if (rightDistance != null) { + stack.push({ node: current.node.right, distance: rightDistance }); + } + } + } + + return closest; +} + +function collect( + node: BvhNode, + query: Box3, + results: Array> +): void { + if (!boxesIntersect(node.bounds, query)) { + return; + } + + if (node.type === 'leaf') { + for (const entry of node.entries) { + if (boxesIntersect(entry.bounds, query)) { + results.push(entry); + } + } + return; + } + + collect(node.left, query, results); + collect(node.right, query, results); +} + +function computeBounds(entries: Array>): Box3 { + const first = entries[0]?.bounds; + if (!first) { + throw new Error('Cannot compute BVH bounds for empty entry list.'); + } + let minX = first.x; + let minY = first.y; + let minZ = first.z; + let maxX = first.x + first.width; + let maxY = first.y + first.height; + let maxZ = first.z + first.depth; + + for (let i = 1; i < entries.length; i += 1) { + const bounds = entries[i].bounds; + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + minZ = Math.min(minZ, bounds.z); + maxX = Math.max(maxX, bounds.x + bounds.width); + maxY = Math.max(maxY, bounds.y + bounds.height); + maxZ = Math.max(maxZ, bounds.z + bounds.depth); + } + + return { + x: minX, + y: minY, + z: minZ, + width: maxX - minX, + height: maxY - minY, + depth: maxZ - minZ, + }; +} + +function chooseSplitAxis(bounds: Box3): BvhAxis { + const { width, height, depth } = bounds; + if (width >= height && width >= depth) { + return 'x'; + } + if (height >= depth) { + return 'y'; + } + return 'z'; +} + +function centerAlongAxis(bounds: Box3, axis: BvhAxis): number { + if (axis === 'x') { + return bounds.x + bounds.width / 2; + } + if (axis === 'y') { + return bounds.y + bounds.height / 2; + } + return bounds.z + bounds.depth / 2; +} + +function isDegenerate(bounds: Box3): boolean { + return bounds.width <= 0 || bounds.height <= 0 || bounds.depth <= 0; +} + +function cloneBox(box: Box3): Box3 { + return { + x: box.x, + y: box.y, + z: box.z, + width: box.width, + height: box.height, + depth: box.depth, + }; +} + +function validateBox(box: Box3): void { + if ( + typeof box?.x !== 'number' || + typeof box?.y !== 'number' || + typeof box?.z !== 'number' || + typeof box?.width !== 'number' || + typeof box?.height !== 'number' || + typeof box?.depth !== 'number' + ) { + throw new TypeError( + 'Box must contain numeric x, y, z, width, height, and depth.' + ); + } + if ( + !Number.isFinite(box.x) || + !Number.isFinite(box.y) || + !Number.isFinite(box.z) || + !Number.isFinite(box.width) || + !Number.isFinite(box.height) || + !Number.isFinite(box.depth) + ) { + throw new TypeError('Box values must be finite numbers.'); + } + if (box.width < 0 || box.height < 0 || box.depth < 0) { + throw new Error('Box width, height, and depth must be non-negative.'); + } +} + +function validateRay(ray: Ray3D): void { + if ( + typeof ray?.origin?.x !== 'number' || + typeof ray.origin?.y !== 'number' || + typeof ray.origin?.z !== 'number' + ) { + throw new TypeError('Ray origin must contain numeric x, y, and z values.'); + } + if ( + typeof ray?.direction?.x !== 'number' || + typeof ray.direction?.y !== 'number' || + typeof ray.direction?.z !== 'number' + ) { + throw new TypeError('Ray direction must contain numeric x, y, and z values.'); + } + if ( + (ray.direction.x === 0 && ray.direction.y === 0 && ray.direction.z === 0) || + !Number.isFinite(ray.direction.x) || + !Number.isFinite(ray.direction.y) || + !Number.isFinite(ray.direction.z) + ) { + throw new Error('Ray direction must be a finite, non-zero vector.'); + } +} + +function boxesIntersect(a: Box3, b: Box3): boolean { + return !( + a.x + a.width < b.x || + b.x + b.width < a.x || + a.y + a.height < b.y || + b.y + b.height < a.y || + a.z + a.depth < b.z || + b.z + b.depth < a.z + ); +} + +function rayAabb(ray: Ray3D, box: Box3, maxDistance: number): number | null { + const invDirX = 1 / ray.direction.x; + const invDirY = 1 / ray.direction.y; + const invDirZ = 1 / ray.direction.z; + + let tMin = ((ray.direction.x >= 0 ? box.x : box.x + box.width) - ray.origin.x) * invDirX; + let tMax = ((ray.direction.x >= 0 ? box.x + box.width : box.x) - ray.origin.x) * invDirX; + + const tyMin = ((ray.direction.y >= 0 ? box.y : box.y + box.height) - ray.origin.y) * invDirY; + const tyMax = ((ray.direction.y >= 0 ? box.y + box.height : box.y) - ray.origin.y) * invDirY; + + if (tMin > tyMax || tyMin > tMax) { + return null; + } + + if (tyMin > tMin) tMin = tyMin; + if (tyMax < tMax) tMax = tyMax; + + const tzMin = ((ray.direction.z >= 0 ? box.z : box.z + box.depth) - ray.origin.z) * invDirZ; + const tzMax = ((ray.direction.z >= 0 ? box.z + box.depth : box.z) - ray.origin.z) * invDirZ; + + if (tMin > tzMax || tzMin > tMax) { + return null; + } + + if (tzMin > tMin) tMin = tzMin; + if (tzMax < tMax) tMax = tzMax; + + if (tMax < 0 || tMin > maxDistance) { + return null; + } + + const hitDistance = tMin >= 0 ? tMin : tMax; + return hitDistance <= maxDistance ? hitDistance : null; +} diff --git a/src/types.ts b/src/types.ts index 219cd5c..553fbbb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,12 @@ export interface Vector2D { y: number; } +export interface Vector3D { + x: number; + y: number; + z: number; +} + export interface Rect { x: number; y: number; @@ -39,6 +45,11 @@ export interface Ray { direction: Vector2D; } +export interface Ray3D { + origin: Point3D; + direction: Vector3D; +} + export interface GraphEdge { node: string; weight?: number; diff --git a/tests/bvh.test.ts b/tests/bvh.test.ts new file mode 100644 index 0000000..b41e82b --- /dev/null +++ b/tests/bvh.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; + +import { buildBvh, queryBvh, raycastBvh } from '../src/spatial/bvh.js'; + +describe('BVH', () => { + const items = [ + { id: 'a', bounds: { x: 0, y: 0, z: 0, width: 2, height: 2, depth: 2 } }, + { id: 'b', bounds: { x: 5, y: 0, z: 0, width: 1, height: 1, depth: 1 } }, + { id: 'c', bounds: { x: -3, y: -1, z: 0, width: 1.5, height: 1.5, depth: 1 } }, + { id: 'd', bounds: { x: 2, y: 2, z: 2, width: 2, height: 2, depth: 2 } }, + ]; + + it('builds a hierarchy and returns balanced nodes', () => { + const tree = buildBvh(items, { getBounds: (entry) => entry.bounds }); + expect(tree).not.toBeNull(); + expect(tree?.bounds.width).toBeGreaterThan(0); + if (tree?.type === 'branch') { + expect(tree.left.bounds.width).toBeGreaterThan(0); + expect(tree.right.bounds.width).toBeGreaterThan(0); + } + }); + + it('queries entries intersecting an AABB', () => { + const tree = buildBvh(items, { getBounds: (entry) => entry.bounds }); + const results = queryBvh(tree, { x: -1, y: -1, z: -1, width: 4, height: 4, depth: 4 }); + expect(results.map((entry) => entry.item.id).sort()).toEqual(['a', 'd']); + }); + + it('performs ray intersection against nearest entry', () => { + const tree = buildBvh(items, { getBounds: (entry) => entry.bounds }); + const ray = { origin: { x: -5, y: 1, z: 1 }, direction: { x: 1, y: 0, z: 0 } }; + const hit = raycastBvh(tree, ray, (entry) => rayAabb(ray, entry.bounds)); + expect(hit?.entry.item.id).toBe('a'); + expect(hit?.distance).toBeCloseTo(5, 5); + }); + + it('validates build inputs and ray queries', () => { + expect(() => buildBvh(items, { getBounds: () => ({ x: 0, y: 0, z: 0, width: -1, height: 1, depth: 1 }) })).toThrow(); + expect(() => buildBvh(items, {} as never)).toThrow('options.getBounds'); + + const tree = buildBvh(items, { getBounds: (entry) => entry.bounds }); + expect(() => raycastBvh(tree, { origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: 0, z: 0 } }, () => 0)).toThrow( + 'non-zero vector' + ); + }); +}); + +function rayAabb( + ray: { origin: { x: number; y: number; z: number }; direction: { x: number; y: number; z: number } }, + box: { x: number; y: number; z: number; width: number; height: number; depth: number } +): number | null { + const invDirX = 1 / ray.direction.x; + const invDirY = 1 / ray.direction.y; + const invDirZ = 1 / ray.direction.z; + + let tMin = ((ray.direction.x >= 0 ? box.x : box.x + box.width) - ray.origin.x) * invDirX; + let tMax = ((ray.direction.x >= 0 ? box.x + box.width : box.x) - ray.origin.x) * invDirX; + + const tyMin = ((ray.direction.y >= 0 ? box.y : box.y + box.height) - ray.origin.y) * invDirY; + const tyMax = ((ray.direction.y >= 0 ? box.y + box.height : box.y) - ray.origin.y) * invDirY; + + if (tMin > tyMax || tyMin > tMax) { + return null; + } + + if (tyMin > tMin) tMin = tyMin; + if (tyMax < tMax) tMax = tyMax; + + const tzMin = ((ray.direction.z >= 0 ? box.z : box.z + box.depth) - ray.origin.z) * invDirZ; + const tzMax = ((ray.direction.z >= 0 ? box.z + box.depth : box.z) - ray.origin.z) * invDirZ; + + if (tMin > tzMax || tzMin > tMax) { + return null; + } + + if (tzMin > tMin) tMin = tzMin; + if (tzMax < tMax) tMax = tzMax; + + if (tMax < 0) { + return null; + } + + return tMin >= 0 ? tMin : tMax; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 71a009c..7a71f32 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -48,6 +48,9 @@ describe('package entry point', () => { expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); expect(examples.spatial.Octree).toBe('examples/octree.ts'); + expect(examples.spatial.buildBvh).toBe('examples/bvh.ts'); + expect(examples.spatial.queryBvh).toBe('examples/bvh.ts'); + expect(examples.spatial.raycastBvh).toBe('examples/bvh.ts'); }); it('provides strong typing for example categories and names', () => {