UNPKG

pixi-cull

Version:

a library to visibly cull objects designed to work with pixi.js

575 lines (533 loc) 20.2 kB
import * as PIXI from 'pixi.js' import { DisplayObjectWithCulling, AABB } from './types' export interface SpatialHashOptions { size?: number xSize?: number ySize?: number simpleTest?: boolean dirtyTest?: boolean } export interface ContainerCullObject { static?: boolean added?: (object: DisplayObjectWithCulling) => void removed?: (object: DisplayObjectWithCulling) => void } export interface SpatialHashStats { buckets: number total: number visible: number culled: number } interface SpatialHashBounds { xStart: number yStart: number xEnd: number yEnd: number } export interface ContainerWithCulling extends PIXI.Container { cull?: ContainerCullObject } const SpatialHashDefaultOptions: SpatialHashOptions = { xSize: 1000, ySize: 1000, simpleTest: true, dirtyTest: true, } export class SpatialHash { protected xSize: number = 1000 protected ySize: number = 1000 /** simpleTest toggle */ public simpleTest: boolean = true /** dirtyTest toggle */ public dirtyTest: boolean = true protected width: number protected height: number protected hash: object /** array of PIXI.Containers added using addContainer() */ protected containers: ContainerWithCulling[] /** array of DisplayObjects added using add() */ protected elements: DisplayObjectWithCulling[] protected objects: DisplayObjectWithCulling[] // protected hash: <string, protected lastBuckets: number /** * creates a spatial-hash cull * Note, options.dirtyTest defaults to false. To greatly improve performance set to true and set * displayObject.dirty=true when the displayObject changes) * * @param {object} [options] * @param {number} [options.size=1000] - cell size used to create hash (xSize = ySize) * @param {number} [options.xSize] - horizontal cell size (leave undefined if size is set) * @param {number} [options.ySize] - vertical cell size (leave undefined if size is set) * @param {boolean} [options.simpleTest=true] - after finding visible buckets, iterates through items and tests individual bounds * @param {string} [options.dirtyTest=false] - only update spatial hash for objects with object.dirty=true; this has a HUGE impact on performance */ constructor(options?: SpatialHashOptions) { options = { ...SpatialHashDefaultOptions, ...options } if (options && typeof options.size !== 'undefined') { this.xSize = this.ySize = options.size } else { this.xSize = options.xSize this.ySize = options.ySize } this.simpleTest = options.simpleTest this.dirtyTest = options.dirtyTest this.width = this.height = 0 this.hash = {} this.containers = [] this.elements = [] } /** * add an object to be culled * side effect: adds object.spatialHashes to track existing hashes * @param {DisplayObjectWithCulling} object * @param {boolean} [staticObject] - set to true if the object's position/size does not change * @return {DisplayObjectWithCulling} object */ add(object: DisplayObjectWithCulling, staticObject?: boolean): DisplayObjectWithCulling { object.spatial = { hashes: [] } if (this.dirtyTest) { object.dirty = true } if (staticObject) { object.staticObject = true } this.updateObject(object) this.elements.push(object) return object } /** * remove an object added by add() * @param {DisplayObjectWithCulling} object * @return {DisplayObjectWithCulling} object */ remove(object: DisplayObjectWithCulling): DisplayObjectWithCulling { this.elements.splice(this.elements.indexOf(object), 1) this.removeFromHash(object) return object } /** * add an array of objects to be culled * @param {PIXI.Container} container * @param {boolean} [staticObject] - set to true if the objects in the container's position/size do not change */ addContainer(container: ContainerWithCulling, staticObject?: boolean) { const added = (object: DisplayObjectWithCulling) => { object.spatial = { hashes: [] } this.updateObject(object) } const removed = (object: DisplayObjectWithCulling) => { this.removeFromHash(object) } const length = container.children.length for (let i = 0; i < length; i++) { const object = container.children[i] as DisplayObjectWithCulling object.spatial = { hashes: [] } this.updateObject(object) } container.cull = {} this.containers.push(container) container.on('childAdded', added) container.on('childRemoved', removed) container.cull.added = added container.cull.removed = removed if (staticObject) { container.cull.static = true } } /** * remove an array added by addContainer() * @param {PIXI.Container} container * @return {PIXI.Container} container */ removeContainer(container: ContainerWithCulling): ContainerWithCulling { this.containers.splice(this.containers.indexOf(container), 1) container.children.forEach(object => this.removeFromHash(object)) container.off('childAdded', container.cull.added) container.off('removedFrom', container.cull.removed) delete container.cull return container } /** * update the hashes and cull the items in the list * @param {AABB} AABB * @param {boolean} [skipUpdate] - skip updating the hashes of all objects * @param {Function} [callback] - callback for each item that is not culled - note, this function is called before setting `object.visible=true` * @return {number} number of buckets in results */ cull(AABB: AABB, skipUpdate?: boolean, callback?: (object: DisplayObjectWithCulling) => boolean): number { if (!skipUpdate) { this.updateObjects() } this.invisible() let objects: DisplayObjectWithCulling[] if (callback) { objects = this.queryCallbackAll(AABB, this.simpleTest, callback) } else { objects = this.query(AABB, this.simpleTest) } objects.forEach(object => object.visible = true) return this.lastBuckets } /** * set all objects in hash to visible=false */ invisible() { const length = this.elements.length for (let i = 0; i < length; i++) { this.elements[i].visible = false } for (const container of this.containers) { const length = container.children.length for (let i = 0; i < length; i++) { container.children[i].visible = false } } } /** * update the hashes for all objects * automatically called from update() when skipUpdate=false */ updateObjects() { if (this.dirtyTest) { const length = this.elements.length for (let i = 0; i < length; i++) { const object = this.elements[i] if (object.dirty) { this.updateObject(object) object.dirty = false } } for (const container of this.containers) { if (!container.cull.static) { const length = container.children.length for (let i = 0; i < length; i++) { const object = container.children[i] as DisplayObjectWithCulling if (object.dirty) { this.updateObject(object) object.dirty = false } } } } } else { const length = this.elements.length for (let i = 0; i < length; i++) { const object = this.elements[i] if (!object.staticObject) { this.updateObject(object) } } for (let container of this.containers) { if (!container.cull.static) { const length = container.children.length for (let i = 0; i < length; i++) { this.updateObject(container.children[i]) } } } } } /** * update the has of an object * automatically called from updateObjects() * @param {DisplayObjectWithCulling} object */ updateObject(object: DisplayObjectWithCulling) { let AABB: AABB const box = object.getLocalBounds() AABB = object.AABB = { x: object.x + (box.x - object.pivot.x) * object.scale.x, y: object.y + (box.y - object.pivot.y) * object.scale.y, width: box.width * object.scale.x, height: box.height * object.scale.y } let spatial = object.spatial if (!spatial) { spatial = object.spatial = { hashes: [] } } const { xStart, yStart, xEnd, yEnd } = this.getBounds(AABB) // only remove and insert if mapping has changed if (spatial.xStart !== xStart || spatial.yStart !== yStart || spatial.xEnd !== xEnd || spatial.yEnd !== yEnd) { if (spatial.hashes.length) { this.removeFromHash(object) } for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { const key = x + ',' + y this.insert(object, key) spatial.hashes.push(key) } } spatial.xStart = xStart spatial.yStart = yStart spatial.xEnd = xEnd spatial.yEnd = yEnd } } /** * returns an array of buckets with >= minimum of objects in each bucket * @param {number} [minimum=1] * @return {array} array of buckets */ getBuckets(minimum: number = 1): string[] { const hashes = [] for (const key in this.hash) { const hash = this.hash[key] if (hash.length >= minimum) { hashes.push(hash) } } return hashes } /** * gets hash bounds * @param {AABB} AABB * @return {SpatialHashBounds} */ protected getBounds(AABB: AABB): SpatialHashBounds { const xStart = Math.floor(AABB.x / this.xSize) const yStart = Math.floor(AABB.y / this.ySize) const xEnd = Math.floor((AABB.x + AABB.width) / this.xSize) const yEnd = Math.floor((AABB.y + AABB.height) / this.ySize) return { xStart, yStart, xEnd, yEnd } } /** * insert object into the spatial hash * automatically called from updateObject() * @param {DisplayObjectWithCulling} object * @param {string} key */ insert(object: DisplayObjectWithCulling, key: string) { if (!this.hash[key]) { this.hash[key] = [object] } else { this.hash[key].push(object) } } /** * removes object from the hash table * should be called when removing an object * automatically called from updateObject() * @param {object} object */ removeFromHash(object: DisplayObjectWithCulling) { const spatial = object.spatial while (spatial.hashes.length) { const key = spatial.hashes.pop() const list = this.hash[key] list.splice(list.indexOf(object), 1) } } /** * get all neighbors that share the same hash as object * @param {DisplayObjectWithCulling} object - in the spatial hash * @return {Array} - of objects that are in the same hash as object */ neighbors(object: DisplayObjectWithCulling): DisplayObjectWithCulling[] { let results = [] object.spatial.hashes.forEach(key => results = results.concat(this.hash[key])) return results } /** * returns an array of objects contained within bounding box * @param {AABB} AABB - bounding box to search * @param {boolean} [simpleTest=true] - perform a simple bounds check of all items in the buckets * @return {object[]} - search results */ query(AABB: AABB, simpleTest: boolean = true): DisplayObjectWithCulling[] { let buckets = 0 let results = [] const { xStart, yStart, xEnd, yEnd } = this.getBounds(AABB) for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { const entry = this.hash[x + ',' + y] if (entry) { if (simpleTest) { const length = entry.length for (let i = 0; i < length; i++) { const object = entry[i] const box = object.AABB if (box.x + box.width > AABB.x && box.x < AABB.x + AABB.width && box.y + box.height > AABB.y && box.y < AABB.y + AABB.height) { results.push(object) } } } else { results = results.concat(entry) } buckets++ } } } this.lastBuckets = buckets return results } /** * returns an array of objects contained within bounding box with a callback on each non-culled object * this function is different from queryCallback, which cancels the query when a callback returns true * * @param {AABB} AABB - bounding box to search * @param {boolean} [simpleTest=true] - perform a simple bounds check of all items in the buckets * @param {Function} callback - function to run for each non-culled object * @return {object[]} - search results */ queryCallbackAll(AABB: AABB, simpleTest: boolean = true, callback: (object: DisplayObjectWithCulling) => boolean): DisplayObjectWithCulling[] { let buckets = 0 let results = [] const { xStart, yStart, xEnd, yEnd } = this.getBounds(AABB) for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { const entry = this.hash[x + ',' + y] if (entry) { if (simpleTest) { const length = entry.length for (let i = 0; i < length; i++) { const object = entry[i] const box = object.AABB if (box.x + box.width > AABB.x && box.x < AABB.x + AABB.width && box.y + box.height > AABB.y && box.y < AABB.y + AABB.height) { results.push(object) callback(object) } } } else { results = results.concat(entry) for (const object of entry) { callback(object) } } buckets++ } } } this.lastBuckets = buckets return results } /** * iterates through objects contained within bounding box * stops iterating if the callback returns true * @param {AABB} AABB - bounding box to search * @param {function} callback * @param {boolean} [simpleTest=true] - perform a simple bounds check of all items in the buckets * @return {boolean} - true if callback returned early */ queryCallback(AABB: AABB, callback: (object: DisplayObjectWithCulling) => boolean, simpleTest: boolean = true): boolean { const { xStart, yStart, xEnd, yEnd } = this.getBounds(AABB) for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { const entry = this.hash[x + ',' + y] if (entry) { for (let i = 0; i < entry.length; i++) { const object = entry[i] if (simpleTest) { const AABB = object.AABB if (AABB.x + AABB.width > AABB.x && AABB.x < AABB.x + AABB.width && AABB.y + AABB.height > AABB.y && AABB.y < AABB.y + AABB.height) { if (callback(object)) { return true } } } else { if (callback(object)) { return true } } } } } } return false } /** * Get stats * @return {SpatialHashStats} */ stats(): SpatialHashStats { let visible = 0, count = 0 const length = this.elements.length for (let i = 0; i < length; i++) { const object = this.elements[i] visible += object.visible ? 1 : 0 count++ } for (const list of this.containers) { const length = list.children.length for (let i = 0; i < length; i++) { const object = list.children[i] visible += object.visible ? 1 : 0 count++ } } return { buckets: this.lastBuckets, total: count, visible, culled: count - visible } } /** * helper function to evaluate hash table * @return {number} - the number of buckets in the hash table * */ getNumberOfBuckets(): number { return Object.keys(this.hash).length } /** * helper function to evaluate hash table * @return {number} - the average number of entries in each bucket */ getAverageSize(): number { let total = 0 for (let key in this.hash) { total += this.hash[key].length } return total / this.getBuckets().length } /** * helper function to evaluate the hash table * @return {number} - the largest sized bucket */ getLargest(): number { let largest = 0 for (let key in this.hash) { if (this.hash[key].length > largest) { largest = this.hash[key].length } } return largest } /** * gets quadrant bounds * @return {SpatialHashBounds} */ getWorldBounds(): SpatialHashBounds { let xStart = Infinity, yStart = Infinity, xEnd = 0, yEnd = 0 for (let key in this.hash) { const split = key.split(',') let x = parseInt(split[0]) let y = parseInt(split[1]) xStart = x < xStart ? x : xStart yStart = y < yStart ? y : yStart xEnd = x > xEnd ? x : xEnd yEnd = y > yEnd ? y : yEnd } return { xStart, yStart, xEnd, yEnd } } /** * helper function to evaluate the hash table * @param {AABB} [AABB] - bounding box to search or entire world * @return {number} - sparseness percentage (i.e., buckets with at least 1 element divided by total possible buckets) */ getSparseness(AABB?: AABB): number { let count = 0, total = 0 const { xStart, yStart, xEnd, yEnd } = AABB ? this.getBounds(AABB) : this.getWorldBounds() for (let y = yStart; y < yEnd; y++) { for (let x = xStart; x < xEnd; x++) { count += (this.hash[x + ',' + y] ? 1 : 0) total++ } } return count / total } }