mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
[feat] Add QuadTree spatial data structure for node indexing
- Implements efficient O(log n) spatial queries for large node graphs - Supports dynamic insertion, removal, and updates - Configurable depth and capacity parameters - Comprehensive test suite covering performance scenarios - Benchmark tools for performance validation - Designed for viewport culling optimization in 100+ node workflows 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
195
src/utils/spatial/QuadTree.test.ts
Normal file
195
src/utils/spatial/QuadTree.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { QuadTree, type Bounds } from './QuadTree'
|
||||||
|
|
||||||
|
describe('QuadTree', () => {
|
||||||
|
let quadTree: QuadTree<string>
|
||||||
|
const worldBounds: Bounds = { x: 0, y: 0, width: 1000, height: 1000 }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
quadTree = new QuadTree<string>(worldBounds, {
|
||||||
|
maxDepth: 4,
|
||||||
|
maxItemsPerNode: 4
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('insertion', () => {
|
||||||
|
it('should insert items within bounds', () => {
|
||||||
|
const success = quadTree.insert('node1',
|
||||||
|
{ x: 100, y: 100, width: 50, height: 50 },
|
||||||
|
'node1'
|
||||||
|
)
|
||||||
|
expect(success).toBe(true)
|
||||||
|
expect(quadTree.size).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject items outside bounds', () => {
|
||||||
|
const success = quadTree.insert('node1',
|
||||||
|
{ x: -100, y: -100, width: 50, height: 50 },
|
||||||
|
'node1'
|
||||||
|
)
|
||||||
|
expect(success).toBe(false)
|
||||||
|
expect(quadTree.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle duplicate IDs by replacing', () => {
|
||||||
|
quadTree.insert('node1', { x: 100, y: 100, width: 50, height: 50 }, 'data1')
|
||||||
|
quadTree.insert('node1', { x: 200, y: 200, width: 50, height: 50 }, 'data2')
|
||||||
|
|
||||||
|
expect(quadTree.size).toBe(1)
|
||||||
|
const results = quadTree.query({ x: 150, y: 150, width: 100, height: 100 })
|
||||||
|
expect(results).toContain('data2')
|
||||||
|
expect(results).not.toContain('data1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('querying', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Insert test nodes in a grid pattern
|
||||||
|
for (let x = 0; x < 10; x++) {
|
||||||
|
for (let y = 0; y < 10; y++) {
|
||||||
|
const id = `node_${x}_${y}`
|
||||||
|
quadTree.insert(id, {
|
||||||
|
x: x * 100,
|
||||||
|
y: y * 100,
|
||||||
|
width: 50,
|
||||||
|
height: 50
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find nodes within query bounds', () => {
|
||||||
|
const results = quadTree.query({ x: 0, y: 0, width: 250, height: 250 })
|
||||||
|
expect(results.length).toBe(9) // 3x3 grid
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array for out-of-bounds query', () => {
|
||||||
|
const results = quadTree.query({ x: 2000, y: 2000, width: 100, height: 100 })
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle partial overlaps', () => {
|
||||||
|
const results = quadTree.query({ x: 25, y: 25, width: 100, height: 100 })
|
||||||
|
expect(results.length).toBe(4) // 2x2 grid due to overlap
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle large query areas efficiently', () => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const results = quadTree.query({ x: 0, y: 0, width: 1000, height: 1000 })
|
||||||
|
const queryTime = performance.now() - startTime
|
||||||
|
|
||||||
|
expect(results.length).toBe(100) // All nodes
|
||||||
|
expect(queryTime).toBeLessThan(5) // Should be fast
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removal', () => {
|
||||||
|
it('should remove existing items', () => {
|
||||||
|
quadTree.insert('node1', { x: 100, y: 100, width: 50, height: 50 }, 'node1')
|
||||||
|
expect(quadTree.size).toBe(1)
|
||||||
|
|
||||||
|
const success = quadTree.remove('node1')
|
||||||
|
expect(success).toBe(true)
|
||||||
|
expect(quadTree.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle removal of non-existent items', () => {
|
||||||
|
const success = quadTree.remove('nonexistent')
|
||||||
|
expect(success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updating', () => {
|
||||||
|
it('should update item position', () => {
|
||||||
|
quadTree.insert('node1', { x: 100, y: 100, width: 50, height: 50 }, 'node1')
|
||||||
|
|
||||||
|
const success = quadTree.update('node1', { x: 200, y: 200, width: 50, height: 50 })
|
||||||
|
expect(success).toBe(true)
|
||||||
|
|
||||||
|
// Should not find at old position
|
||||||
|
const oldResults = quadTree.query({ x: 75, y: 75, width: 100, height: 100 })
|
||||||
|
expect(oldResults).not.toContain('node1')
|
||||||
|
|
||||||
|
// Should find at new position
|
||||||
|
const newResults = quadTree.query({ x: 175, y: 175, width: 100, height: 100 })
|
||||||
|
expect(newResults).toContain('node1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subdivision', () => {
|
||||||
|
it('should subdivide when exceeding max items', () => {
|
||||||
|
// Insert 5 items (max is 4) to trigger subdivision
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
quadTree.insert(`node${i}`, {
|
||||||
|
x: i * 10,
|
||||||
|
y: i * 10,
|
||||||
|
width: 5,
|
||||||
|
height: 5
|
||||||
|
}, `node${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(quadTree.size).toBe(5)
|
||||||
|
|
||||||
|
// Verify all items can still be found
|
||||||
|
const allResults = quadTree.query(worldBounds)
|
||||||
|
expect(allResults.length).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('performance', () => {
|
||||||
|
it('should handle 1000 nodes efficiently', () => {
|
||||||
|
const insertStart = performance.now()
|
||||||
|
|
||||||
|
// Insert 1000 nodes
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const x = Math.random() * 900
|
||||||
|
const y = Math.random() * 900
|
||||||
|
quadTree.insert(`node${i}`, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: 50,
|
||||||
|
height: 50
|
||||||
|
}, `node${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTime = performance.now() - insertStart
|
||||||
|
expect(insertTime).toBeLessThan(50) // Should be fast
|
||||||
|
|
||||||
|
// Query performance
|
||||||
|
const queryStart = performance.now()
|
||||||
|
const results = quadTree.query({ x: 400, y: 400, width: 200, height: 200 })
|
||||||
|
const queryTime = performance.now() - queryStart
|
||||||
|
|
||||||
|
expect(queryTime).toBeLessThan(2) // Queries should be very fast
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.length).toBeLessThan(1000) // Should cull most nodes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle zero-sized bounds', () => {
|
||||||
|
const success = quadTree.insert('point', { x: 100, y: 100, width: 0, height: 0 }, 'point')
|
||||||
|
expect(success).toBe(true)
|
||||||
|
|
||||||
|
const results = quadTree.query({ x: 99, y: 99, width: 2, height: 2 })
|
||||||
|
expect(results).toContain('point')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle items spanning multiple quadrants', () => {
|
||||||
|
const success = quadTree.insert('large', {
|
||||||
|
x: 400,
|
||||||
|
y: 400,
|
||||||
|
width: 200,
|
||||||
|
height: 200
|
||||||
|
}, 'large')
|
||||||
|
expect(success).toBe(true)
|
||||||
|
|
||||||
|
// Should be found when querying any overlapping quadrant
|
||||||
|
const topLeft = quadTree.query({ x: 0, y: 0, width: 500, height: 500 })
|
||||||
|
const bottomRight = quadTree.query({ x: 500, y: 500, width: 500, height: 500 })
|
||||||
|
|
||||||
|
expect(topLeft).toContain('large')
|
||||||
|
expect(bottomRight).toContain('large')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
290
src/utils/spatial/QuadTree.ts
Normal file
290
src/utils/spatial/QuadTree.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* QuadTree implementation for spatial indexing of nodes
|
||||||
|
* Optimized for viewport culling in large node graphs
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuadTreeItem<T> {
|
||||||
|
id: string
|
||||||
|
bounds: Bounds
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuadTreeOptions {
|
||||||
|
maxDepth?: number
|
||||||
|
maxItemsPerNode?: number
|
||||||
|
minNodeSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuadNode<T> {
|
||||||
|
private bounds: Bounds
|
||||||
|
private depth: number
|
||||||
|
private maxDepth: number
|
||||||
|
private maxItems: number
|
||||||
|
private items: QuadTreeItem<T>[] = []
|
||||||
|
private children: QuadNode<T>[] | null = null
|
||||||
|
private divided = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
bounds: Bounds,
|
||||||
|
depth: number = 0,
|
||||||
|
maxDepth: number = 5,
|
||||||
|
maxItems: number = 4
|
||||||
|
) {
|
||||||
|
this.bounds = bounds
|
||||||
|
this.depth = depth
|
||||||
|
this.maxDepth = maxDepth
|
||||||
|
this.maxItems = maxItems
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(item: QuadTreeItem<T>): boolean {
|
||||||
|
// Check if item is within bounds
|
||||||
|
if (!this.contains(item.bounds)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have space and haven't divided, add to this node
|
||||||
|
if (this.items.length < this.maxItems && !this.divided) {
|
||||||
|
this.items.push(item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't reached max depth, subdivide
|
||||||
|
if (!this.divided && this.depth < this.maxDepth) {
|
||||||
|
this.subdivide()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If divided, insert into children
|
||||||
|
if (this.divided && this.children) {
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.insert(item)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't subdivide further, add to this node anyway
|
||||||
|
this.items.push(item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(item: QuadTreeItem<T>): boolean {
|
||||||
|
const index = this.items.findIndex(i => i.id === item.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
this.items.splice(index, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.divided && this.children) {
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.remove(item)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
query(searchBounds: Bounds, found: QuadTreeItem<T>[] = []): QuadTreeItem<T>[] {
|
||||||
|
// Check if search area intersects with this node
|
||||||
|
if (!this.intersects(searchBounds)) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add items in this node that intersect with search bounds
|
||||||
|
for (const item of this.items) {
|
||||||
|
if (this.boundsIntersect(item.bounds, searchBounds)) {
|
||||||
|
found.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively search children
|
||||||
|
if (this.divided && this.children) {
|
||||||
|
for (const child of this.children) {
|
||||||
|
child.query(searchBounds, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private subdivide() {
|
||||||
|
const { x, y, width, height } = this.bounds
|
||||||
|
const halfWidth = width / 2
|
||||||
|
const halfHeight = height / 2
|
||||||
|
|
||||||
|
this.children = [
|
||||||
|
// Top-left
|
||||||
|
new QuadNode<T>(
|
||||||
|
{ x, y, width: halfWidth, height: halfHeight },
|
||||||
|
this.depth + 1,
|
||||||
|
this.maxDepth,
|
||||||
|
this.maxItems
|
||||||
|
),
|
||||||
|
// Top-right
|
||||||
|
new QuadNode<T>(
|
||||||
|
{ x: x + halfWidth, y, width: halfWidth, height: halfHeight },
|
||||||
|
this.depth + 1,
|
||||||
|
this.maxDepth,
|
||||||
|
this.maxItems
|
||||||
|
),
|
||||||
|
// Bottom-left
|
||||||
|
new QuadNode<T>(
|
||||||
|
{ x, y: y + halfHeight, width: halfWidth, height: halfHeight },
|
||||||
|
this.depth + 1,
|
||||||
|
this.maxDepth,
|
||||||
|
this.maxItems
|
||||||
|
),
|
||||||
|
// Bottom-right
|
||||||
|
new QuadNode<T>(
|
||||||
|
{ x: x + halfWidth, y: y + halfHeight, width: halfWidth, height: halfHeight },
|
||||||
|
this.depth + 1,
|
||||||
|
this.maxDepth,
|
||||||
|
this.maxItems
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
this.divided = true
|
||||||
|
|
||||||
|
// Redistribute existing items to children
|
||||||
|
const itemsToRedistribute = [...this.items]
|
||||||
|
this.items = []
|
||||||
|
|
||||||
|
for (const item of itemsToRedistribute) {
|
||||||
|
let inserted = false
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.insert(item)) {
|
||||||
|
inserted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep in parent if it doesn't fit in any child
|
||||||
|
if (!inserted) {
|
||||||
|
this.items.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private contains(itemBounds: Bounds): boolean {
|
||||||
|
return (
|
||||||
|
itemBounds.x >= this.bounds.x &&
|
||||||
|
itemBounds.y >= this.bounds.y &&
|
||||||
|
itemBounds.x + itemBounds.width <= this.bounds.x + this.bounds.width &&
|
||||||
|
itemBounds.y + itemBounds.height <= this.bounds.y + this.bounds.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private intersects(searchBounds: Bounds): boolean {
|
||||||
|
return this.boundsIntersect(this.bounds, searchBounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private boundsIntersect(a: Bounds, b: Bounds): 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug helper to get tree structure
|
||||||
|
getDebugInfo(): any {
|
||||||
|
return {
|
||||||
|
bounds: this.bounds,
|
||||||
|
depth: this.depth,
|
||||||
|
itemCount: this.items.length,
|
||||||
|
divided: this.divided,
|
||||||
|
children: this.children?.map(child => child.getDebugInfo())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuadTree<T> {
|
||||||
|
private root: QuadNode<T>
|
||||||
|
private itemMap: Map<string, QuadTreeItem<T>> = new Map()
|
||||||
|
private options: Required<QuadTreeOptions>
|
||||||
|
|
||||||
|
constructor(bounds: Bounds, options: QuadTreeOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
maxDepth: options.maxDepth ?? 5,
|
||||||
|
maxItemsPerNode: options.maxItemsPerNode ?? 4,
|
||||||
|
minNodeSize: options.minNodeSize ?? 50
|
||||||
|
}
|
||||||
|
|
||||||
|
this.root = new QuadNode<T>(
|
||||||
|
bounds,
|
||||||
|
0,
|
||||||
|
this.options.maxDepth,
|
||||||
|
this.options.maxItemsPerNode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(id: string, bounds: Bounds, data: T): boolean {
|
||||||
|
const item: QuadTreeItem<T> = { id, bounds, data }
|
||||||
|
|
||||||
|
// Remove old item if it exists
|
||||||
|
if (this.itemMap.has(id)) {
|
||||||
|
this.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = this.root.insert(item)
|
||||||
|
if (success) {
|
||||||
|
this.itemMap.set(id, item)
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): boolean {
|
||||||
|
const item = this.itemMap.get(id)
|
||||||
|
if (!item) return false
|
||||||
|
|
||||||
|
const success = this.root.remove(item)
|
||||||
|
if (success) {
|
||||||
|
this.itemMap.delete(id)
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, newBounds: Bounds): boolean {
|
||||||
|
const item = this.itemMap.get(id)
|
||||||
|
if (!item) return false
|
||||||
|
|
||||||
|
// Remove and re-insert with new bounds
|
||||||
|
const data = item.data
|
||||||
|
this.remove(id)
|
||||||
|
return this.insert(id, newBounds, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
query(searchBounds: Bounds): T[] {
|
||||||
|
const items = this.root.query(searchBounds)
|
||||||
|
return items.map(item => item.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.root = new QuadNode<T>(
|
||||||
|
this.root['bounds'],
|
||||||
|
0,
|
||||||
|
this.options.maxDepth,
|
||||||
|
this.options.maxItemsPerNode
|
||||||
|
)
|
||||||
|
this.itemMap.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.itemMap.size
|
||||||
|
}
|
||||||
|
|
||||||
|
getDebugInfo() {
|
||||||
|
return {
|
||||||
|
size: this.size,
|
||||||
|
tree: this.root.getDebugInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/utils/spatial/QuadTreeBenchmark.ts
Normal file
218
src/utils/spatial/QuadTreeBenchmark.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Performance benchmark for QuadTree vs linear culling
|
||||||
|
* Measures query performance at different node counts and zoom levels
|
||||||
|
*/
|
||||||
|
import { QuadTree, type Bounds } from './QuadTree'
|
||||||
|
|
||||||
|
export interface BenchmarkResult {
|
||||||
|
nodeCount: number
|
||||||
|
queryCount: number
|
||||||
|
linearTime: number
|
||||||
|
quadTreeTime: number
|
||||||
|
speedup: number
|
||||||
|
culledPercentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeData {
|
||||||
|
id: string
|
||||||
|
bounds: Bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuadTreeBenchmark {
|
||||||
|
private worldBounds: Bounds = { x: -5000, y: -5000, width: 10000, height: 10000 }
|
||||||
|
|
||||||
|
// Generate random nodes with realistic clustering
|
||||||
|
generateNodes(count: number): NodeData[] {
|
||||||
|
const nodes: NodeData[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// 70% clustered, 30% scattered
|
||||||
|
const isClustered = Math.random() < 0.7
|
||||||
|
|
||||||
|
let x: number, y: number
|
||||||
|
|
||||||
|
if (isClustered) {
|
||||||
|
// Pick a cluster center
|
||||||
|
const clusterX = (Math.random() - 0.5) * 8000
|
||||||
|
const clusterY = (Math.random() - 0.5) * 8000
|
||||||
|
|
||||||
|
// Add node near cluster with gaussian distribution
|
||||||
|
x = clusterX + (Math.random() - 0.5) * 500
|
||||||
|
y = clusterY + (Math.random() - 0.5) * 500
|
||||||
|
} else {
|
||||||
|
// Scattered randomly
|
||||||
|
x = (Math.random() - 0.5) * 9000
|
||||||
|
y = (Math.random() - 0.5) * 9000
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: `node_${i}`,
|
||||||
|
bounds: {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: 200 + Math.random() * 100,
|
||||||
|
height: 100 + Math.random() * 50
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear viewport culling (baseline)
|
||||||
|
linearCulling(nodes: NodeData[], viewport: Bounds): string[] {
|
||||||
|
const visible: string[] = []
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (this.boundsIntersect(node.bounds, viewport)) {
|
||||||
|
visible.push(node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuadTree viewport culling
|
||||||
|
quadTreeCulling(quadTree: QuadTree<string>, viewport: Bounds): string[] {
|
||||||
|
return quadTree.query(viewport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if two bounds intersect
|
||||||
|
private boundsIntersect(a: Bounds, b: Bounds): 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run benchmark for specific configuration
|
||||||
|
runBenchmark(
|
||||||
|
nodeCount: number,
|
||||||
|
viewportSize: { width: number; height: number },
|
||||||
|
queryCount: number = 100
|
||||||
|
): BenchmarkResult {
|
||||||
|
// Generate nodes
|
||||||
|
const nodes = this.generateNodes(nodeCount)
|
||||||
|
|
||||||
|
// Build QuadTree
|
||||||
|
const quadTree = new QuadTree<string>(this.worldBounds, {
|
||||||
|
maxDepth: Math.ceil(Math.log2(nodeCount / 4)),
|
||||||
|
maxItemsPerNode: 4
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
quadTree.insert(node.id, node.bounds, node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random viewports for testing
|
||||||
|
const viewports: Bounds[] = []
|
||||||
|
for (let i = 0; i < queryCount; i++) {
|
||||||
|
const x = (Math.random() - 0.5) * (this.worldBounds.width - viewportSize.width)
|
||||||
|
const y = (Math.random() - 0.5) * (this.worldBounds.height - viewportSize.height)
|
||||||
|
viewports.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: viewportSize.width,
|
||||||
|
height: viewportSize.height
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark linear culling
|
||||||
|
const linearStart = performance.now()
|
||||||
|
let linearVisibleTotal = 0
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
const visible = this.linearCulling(nodes, viewport)
|
||||||
|
linearVisibleTotal += visible.length
|
||||||
|
}
|
||||||
|
const linearTime = performance.now() - linearStart
|
||||||
|
|
||||||
|
// Benchmark QuadTree culling
|
||||||
|
const quadTreeStart = performance.now()
|
||||||
|
let quadTreeVisibleTotal = 0
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
const visible = this.quadTreeCulling(quadTree, viewport)
|
||||||
|
quadTreeVisibleTotal += visible.length
|
||||||
|
}
|
||||||
|
const quadTreeTime = performance.now() - quadTreeStart
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const avgVisible = linearVisibleTotal / queryCount
|
||||||
|
const culledPercentage = ((nodeCount - avgVisible) / nodeCount) * 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeCount,
|
||||||
|
queryCount,
|
||||||
|
linearTime,
|
||||||
|
quadTreeTime,
|
||||||
|
speedup: linearTime / quadTreeTime,
|
||||||
|
culledPercentage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run comprehensive benchmark suite
|
||||||
|
runBenchmarkSuite(): BenchmarkResult[] {
|
||||||
|
const nodeCounts = [50, 100, 200, 500, 1000, 2000, 5000]
|
||||||
|
const viewportSizes = [
|
||||||
|
{ width: 1920, height: 1080 }, // Full HD
|
||||||
|
{ width: 800, height: 600 }, // Zoomed in
|
||||||
|
{ width: 4000, height: 3000 } // Zoomed out
|
||||||
|
]
|
||||||
|
|
||||||
|
const results: BenchmarkResult[] = []
|
||||||
|
|
||||||
|
for (const nodeCount of nodeCounts) {
|
||||||
|
for (const viewportSize of viewportSizes) {
|
||||||
|
const result = this.runBenchmark(nodeCount, viewportSize)
|
||||||
|
results.push(result)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Nodes: ${nodeCount}, ` +
|
||||||
|
`Viewport: ${viewportSize.width}x${viewportSize.height}, ` +
|
||||||
|
`Linear: ${result.linearTime.toFixed(2)}ms, ` +
|
||||||
|
`QuadTree: ${result.quadTreeTime.toFixed(2)}ms, ` +
|
||||||
|
`Speedup: ${result.speedup.toFixed(2)}x, ` +
|
||||||
|
`Culled: ${result.culledPercentage.toFixed(1)}%`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find optimal maxDepth for given node count
|
||||||
|
findOptimalDepth(nodeCount: number): number {
|
||||||
|
const nodes = this.generateNodes(nodeCount)
|
||||||
|
const viewport = { x: 0, y: 0, width: 1920, height: 1080 }
|
||||||
|
|
||||||
|
let bestDepth = 1
|
||||||
|
let bestTime = Infinity
|
||||||
|
|
||||||
|
for (let depth = 1; depth <= 10; depth++) {
|
||||||
|
const quadTree = new QuadTree<string>(this.worldBounds, {
|
||||||
|
maxDepth: depth,
|
||||||
|
maxItemsPerNode: 4
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
for (const node of nodes) {
|
||||||
|
quadTree.insert(node.id, node.bounds, node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure query time
|
||||||
|
const start = performance.now()
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
quadTree.query(viewport)
|
||||||
|
}
|
||||||
|
const time = performance.now() - start
|
||||||
|
|
||||||
|
if (time < bestTime) {
|
||||||
|
bestTime = time
|
||||||
|
bestDepth = depth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestDepth
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user