increase collision performance _heavily_

This commit is contained in:
Elizabeth Hunt 2023-08-17 22:42:09 -06:00
parent 1c28e10b86
commit 432ce5428f
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
9 changed files with 213 additions and 68 deletions

View File

@ -1,5 +1,7 @@
import { Floor, Player } from "@engine/entities"; import { Floor, Player } from "@engine/entities";
import { Game } from "@engine/Game"; import { Game } from "@engine/Game";
import { Grid } from "@engine/structures";
import { Miscellaneous } from "@engine/config";
import { import {
WallBounds, WallBounds,
FacingDirection, FacingDirection,
@ -65,11 +67,16 @@ export class JumpStorm {
socket, socket,
); );
const grid = new Grid(
{ width: Miscellaneous.WIDTH, height: Miscellaneous.HEIGHT },
{ width: 30, height: 30 },
);
[ [
this.createInputSystem(), this.createInputSystem(),
new FacingDirection(), new FacingDirection(),
new Physics(), new Physics(),
new Collision(), new Collision(grid),
new WallBounds(ctx.canvas.width), new WallBounds(ctx.canvas.width),
new NetworkUpdate( new NetworkUpdate(
clientSocketMessageQueueProvider, clientSocketMessageQueueProvider,

View File

@ -17,6 +17,25 @@ export class BoundingBox extends Component {
// https://en.wikipedia.org/wiki/Hyperplane_separation_theorem // https://en.wikipedia.org/wiki/Hyperplane_separation_theorem
public isCollidingWith(box: BoundingBox): boolean { public isCollidingWith(box: BoundingBox): boolean {
if (this.rotation == 0 && box.rotation == 0) {
const thisTopLeft = this.getTopLeft();
const thisBottomRight = this.getBottomRight();
const thatTopLeft = box.getTopLeft();
const thatBottomRight = box.getBottomRight();
if (
thisBottomRight.x <= thatTopLeft.x ||
thisTopLeft.x >= thatBottomRight.x ||
thisBottomRight.y <= thatTopLeft.y ||
thisTopLeft.y >= thatBottomRight.y
) {
return false;
}
return true;
}
const boxes = [this.getVertices(), box.getVertices()]; const boxes = [this.getVertices(), box.getVertices()];
for (const poly of boxes) { for (const poly of boxes) {
for (let i = 0; i < poly.length; i++) { for (let i = 0; i < poly.length; i++) {
@ -83,4 +102,18 @@ export class BoundingBox extends Component {
height: Math.abs(width * Math.cos(rads) + height * Math.sin(rads)), height: Math.abs(width * Math.cos(rads) + height * Math.sin(rads)),
}; };
} }
public getTopLeft(): Coord2D {
return {
x: this.center.x - this.dimension.width / 2,
y: this.center.y - this.dimension.height / 2,
};
}
public getBottomRight(): Coord2D {
return {
x: this.center.x + this.dimension.width / 2,
y: this.center.y + this.dimension.height / 2,
};
}
} }

View File

@ -23,7 +23,10 @@ export class Floor extends Entity {
this.addComponent( this.addComponent(
new BoundingBox( new BoundingBox(
{ x: 300, y: 300 }, {
x: 300,
y: 300,
},
{ width, height: Floor.spriteSpec.height }, { width, height: Floor.spriteSpec.height },
), ),
); );

View File

@ -18,7 +18,7 @@ import { Direction } from "../interfaces";
export class Player extends Entity { export class Player extends Entity {
private static MASS: number = 10; private static MASS: number = 10;
private static MOI: number = 1000; private static MOI: number = 100;
private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(
Sprites.COFFEE, Sprites.COFFEE,
@ -29,7 +29,10 @@ export class Player extends Entity {
this.addComponent( this.addComponent(
new BoundingBox( new BoundingBox(
{ x: 300, y: 100 }, {
x: 300,
y: 100,
},
{ width: Player.spriteSpec.width, height: Player.spriteSpec.height }, { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
0, 0,
), ),

97
engine/structures/Grid.ts Normal file
View File

@ -0,0 +1,97 @@
import type { Coord2D, Dimension2D } from "../interfaces";
import type { RefreshingCollisionFinderBehavior } from ".";
export class Grid implements RefreshingCollisionFinderBehavior {
private cellEntities: Map<number, string[]>;
private gridDimension: Dimension2D;
private cellDimension: Dimension2D;
private topLeft: Coord2D;
constructor(
gridDimension: Dimension2D,
cellDimension: Dimension2D,
topLeft = { x: 0, y: 0 },
) {
this.gridDimension = gridDimension;
this.cellDimension = cellDimension;
this.topLeft = topLeft;
this.cellEntities = new Map();
}
public insert(boxedEntry: BoxedEntry) {
this.getOverlappingCells(boxedEntry).forEach((gridIdx) => {
if (!this.cellEntities.has(gridIdx)) {
this.cellEntities.set(gridIdx, []);
}
this.cellEntities.get(gridIdx).push(boxedEntry.id);
});
}
public getNeighborIds(boxedEntry: BoxedEntry): Set<string> {
const neighborIds: Set<string> = new Set();
this.getOverlappingCells(boxedEntry).forEach((gridIdx) => {
if (this.cellEntities.has(gridIdx)) {
this.cellEntities.get(gridIdx).forEach((id) => neighborIds.add(id));
}
});
return neighborIds;
}
public clear() {
this.cellEntities.clear();
}
public setTopLeft(topLeft: Coord2D) {
this.topLeft = topLeft;
}
public setDimension(dimension: Dimension2D) {
this.gridDimension = dimension;
}
public setCellDimension(cellDimension: Dimension2D) {
this.cellDimension = cellDimension;
}
private getOverlappingCells(boxedEntry: BoxedEntry): number[] {
const { center, dimension } = boxedEntry;
const yBoxes = Math.ceil(
this.gridDimension.height / this.cellDimension.height,
);
const xBoxes = Math.ceil(
this.gridDimension.width / this.cellDimension.width,
);
const translated: Coord2D = {
y: center.y - this.topLeft.y,
x: center.x - this.topLeft.x,
};
const topLeftBox = {
x: Math.floor(
(translated.x - dimension.width / 2) / this.cellDimension.width,
),
y: Math.floor(
(translated.y - dimension.height / 2) / this.cellDimension.height,
),
};
const bottomRightBox = {
x: Math.floor(
(translated.x + dimension.width / 2) / this.cellDimension.width,
),
y: Math.floor(
(translated.y + dimension.height / 2) / this.cellDimension.height,
),
};
const cells: number[] = [];
for (let y = topLeftBox.y; y <= bottomRightBox.y; ++y)
for (let x = topLeftBox.x; x <= bottomRightBox.x; ++x)
cells.push(yBoxes * y + x);
return cells;
}
}

View File

@ -1,10 +1,5 @@
import type { Coord2D, Dimension2D } from "../interfaces"; import type { Coord2D, Dimension2D } from "../interfaces";
import type { BoxedEntry, RefreshingCollisionFinderBehavior } from ".";
export interface BoxedEntry {
id: string;
dimension: Dimension2D;
center: Coord2D;
}
enum Quadrant { enum Quadrant {
I, I,
@ -13,7 +8,14 @@ enum Quadrant {
IV, IV,
} }
export class QuadTree { /*
unused due to performance problems. here anyways, in case it _really_ is necessary at some point
(and to justify the amount of time i spent here).
*/
export class QuadTree implements RefreshingCollisionFinderBehavior {
private static readonly QUADTREE_MAX_LEVELS = 3;
private static readonly QUADTREE_SPLIT_THRESHOLD = 2000;
private maxLevels: number; private maxLevels: number;
private splitThreshold: number; private splitThreshold: number;
private level: number; private level: number;
@ -24,18 +26,18 @@ export class QuadTree {
private objects: BoxedEntry[]; private objects: BoxedEntry[];
constructor( constructor(
topLeft: Coord2D, topLeft: Coord2D = { x: 0, y: 0 },
dimension: Dimension2D, dimension: Dimension2D,
maxLevels: number, maxLevels: number = QuadTree.QUADTREE_MAX_LEVELS,
splitThreshold: number, splitThreshold: number = QuadTree.QUADTREE_SPLIT_THRESHOLD,
level?: number, level: number = 0,
) { ) {
this.children = new Map<Quadrant, QuadTree>(); this.children = new Map<Quadrant, QuadTree>();
this.objects = []; this.objects = [];
this.maxLevels = maxLevels; this.maxLevels = maxLevels;
this.splitThreshold = splitThreshold; this.splitThreshold = splitThreshold;
this.level = level ?? 0; this.level = level;
this.topLeft = topLeft; this.topLeft = topLeft;
this.dimension = dimension; this.dimension = dimension;
@ -45,7 +47,7 @@ export class QuadTree {
if (this.hasChildren()) { if (this.hasChildren()) {
this.getQuadrants(boxedEntry).forEach((quadrant) => { this.getQuadrants(boxedEntry).forEach((quadrant) => {
const quadrantBox = this.children.get(quadrant); const quadrantBox = this.children.get(quadrant);
quadrantBox?.insert(boxedEntry); quadrantBox!.insert(boxedEntry);
}); });
return; return;
} }
@ -73,15 +75,16 @@ export class QuadTree {
} }
public getNeighborIds(boxedEntry: BoxedEntry): string[] { public getNeighborIds(boxedEntry: BoxedEntry): string[] {
const neighbors: string[] = this.objects.map(({ id }) => id); const neighbors = new Set<string>(
this.objects.map(({ id }) => id).filter((id) => id != boxedEntry.id),
);
if (this.hasChildren()) { if (this.hasChildren()) {
this.getQuadrants(boxedEntry).forEach((quadrant) => { this.getQuadrants(boxedEntry).forEach((quadrant) => {
const quadrantBox = this.children.get(quadrant); const quadrantBox = this.children.get(quadrant);
quadrantBox quadrantBox
?.getNeighborIds(boxedEntry) ?.getNeighborIds(boxedEntry)
.forEach((id) => neighbors.push(id)); .forEach((id) => neighbors.add(id));
}); });
} }
@ -158,9 +161,9 @@ export class QuadTree {
private realignObjects(): void { private realignObjects(): void {
this.objects.forEach((boxedEntry) => { this.objects.forEach((boxedEntry) => {
this.getQuadrants(boxedEntry).forEach((direction) => { this.getQuadrants(boxedEntry).forEach((quadrant) => {
const quadrant = this.children.get(direction); const quadrantBox = this.children.get(quadrant);
quadrant?.insert(boxedEntry); quadrantBox!.insert(boxedEntry);
}); });
}); });

View File

@ -0,0 +1,14 @@
import type { Coord2D, Dimension2D } from "../interfaces";
export interface BoxedEntry {
id: string;
dimension: Dimension2D;
center: Coord2D;
}
export interface RefreshingCollisionFinderBehavior {
public clear(): void;
public insert(boxedEntry: BoxedEntry): void;
public getNeighborIds(boxedEntry: BoxedEntry): Set<string>;
public setTopLeft(topLeft: Coord2d): void;
}

View File

@ -1 +1,3 @@
export * from "./RefreshingCollisionFinderBehavior";
export * from "./QuadTree"; export * from "./QuadTree";
export * from "./Grid";

View File

@ -8,58 +8,49 @@ import {
Forces, Forces,
} from "../components"; } from "../components";
import { Game } from "../Game"; import { Game } from "../Game";
import { PhysicsConstants } from "../config"; import { Miscellaneous, PhysicsConstants } from "../config";
import { Entity } from "../entities"; import { Entity } from "../entities";
import type { Coord2D, Dimension2D, Velocity2D } from "../interfaces"; import type { Coord2D, Dimension2D, Velocity2D } from "../interfaces";
import { QuadTree, BoxedEntry } from "../structures"; import { BoxedEntry, RefreshingCollisionFinderBehavior } from "../structures";
export class Collision extends System { export class Collision extends System {
private static readonly COLLIDABLE_COMPONENT_NAMES = [ private static readonly COLLIDABLE_COMPONENT_NAMES = [
ComponentNames.Collide, ComponentNames.Collide,
ComponentNames.TopCollidable, ComponentNames.TopCollidable,
]; ];
private static readonly QUADTREE_MAX_LEVELS = 10;
private static readonly QUADTREE_SPLIT_THRESHOLD = 10;
private quadTree: QuadTree; private collisionFinder: RefreshingCollisionFinderBehavior;
constructor(screenDimensions: Dimension2D) { constructor(refreshingCollisionFinder: RefreshingCollisionFinderBehavior) {
super(SystemNames.Collision); super(SystemNames.Collision);
this.quadTree = new QuadTree( this.collisionFinder = refreshingCollisionFinder;
{ x: 0, y: 0 },
screenDimensions,
Collision.QUADTREE_MAX_LEVELS,
Collision.QUADTREE_SPLIT_THRESHOLD,
);
} }
public update(_dt: number, game: Game) { public update(_dt: number, game: Game) {
// rebuild the quadtree this.collisionFinder.clear();
this.quadTree.clear();
const entitiesToAddToQuadtree: Entity[] = []; const entitiesToAddToCollisionFinder: Entity[] = [];
Collision.COLLIDABLE_COMPONENT_NAMES.map((componentName) => Collision.COLLIDABLE_COMPONENT_NAMES.map((componentName) =>
game.forEachEntityWithComponent(componentName, (entity) => { game.forEachEntityWithComponent(componentName, (entity) => {
if (!entity.hasComponent(ComponentNames.BoundingBox)) { if (!entity.hasComponent(ComponentNames.BoundingBox)) {
return; return;
} }
entitiesToAddToQuadtree.push(entity); entitiesToAddToCollisionFinder.push(entity);
}), }),
); );
this.insertEntitiesInQuadTreeAndUpdateBounds(entitiesToAddToQuadtree); this.insertEntitiesAndUpdateBounds(entitiesToAddToCollisionFinder);
this.findCollidingEntitiesAndCollide(entitiesToAddToCollisionFinder, game);
this.findCollidingEntitiesAndCollide(entitiesToAddToQuadtree, game);
} }
private insertEntitiesInQuadTreeAndUpdateBounds(entities: Entity[]) { private insertEntitiesAndUpdateBounds(entities: Entity[]) {
const collisionFinderInsertions: BoxedEntry[] = [];
const topLeft: Coord2D = { x: Infinity, y: Infinity }; const topLeft: Coord2D = { x: Infinity, y: Infinity };
const bottomRight: Coord2D = { x: -Infinity, y: -Infinity }; const bottomRight: Coord2D = { x: -Infinity, y: -Infinity };
const quadTreeInsertions: BoxedEntry[] = [];
entities.forEach((entity) => { entities.forEach((entity) => {
const boundingBox = entity.getComponent<BoundingBox>( const boundingBox = entity.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox,
@ -71,21 +62,15 @@ export class Collision extends System {
} }
const { center } = boundingBox; const { center } = boundingBox;
const topLeftBoundingBox = { const topLeftBoundingBox = boundingBox.getTopLeft();
x: center.x - dimension.width / 2, const bottomRightBoundingBox = boundingBox.getBottomRight();
y: center.y - dimension.height / 2,
};
const bottomRightBoundingBox = {
x: center.x + dimension.width / 2,
y: center.y + dimension.height / 2,
};
topLeft.x = Math.min(topLeftBoundingBox.x, topLeft.x); topLeft.x = Math.min(topLeftBoundingBox.x, topLeft.x);
topLeft.y = Math.min(topLeftBoundingBox.y, topLeft.y); topLeft.y = Math.min(topLeftBoundingBox.y, topLeft.y);
bottomRight.x = Math.max(bottomRightBoundingBox.x, bottomRight.x); bottomRight.x = Math.max(bottomRightBoundingBox.x, bottomRight.x);
bottomRight.y = Math.min(bottomRightBoundingBox.y, bottomRight.y); bottomRight.y = Math.max(bottomRightBoundingBox.y, bottomRight.y);
quadTreeInsertions.push({ collisionFinderInsertions.push({
id: entity.id, id: entity.id,
dimension, dimension,
center, center,
@ -94,16 +79,16 @@ export class Collision extends System {
// set bounds first // set bounds first
if (entities.length > 0) { if (entities.length > 0) {
this.quadTree.setTopLeft(topLeft); this.collisionFinder.setTopLeft(topLeft);
this.quadTree.setDimension({ this.collisionFinder.setDimension({
width: bottomRight.x - topLeft.x, width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y, height: bottomRight.y - topLeft.y,
}); });
} }
// then, begin insertions // then, begin insertions
quadTreeInsertions.forEach((boxedEntry: BoxedEntry) => collisionFinderInsertions.forEach((boxedEntry: BoxedEntry) =>
this.quadTree.insert(boxedEntry), this.collisionFinder.insert(boxedEntry),
); );
} }
@ -181,15 +166,13 @@ export class Collision extends System {
ComponentNames.BoundingBox, ComponentNames.BoundingBox,
); );
const neighborIds = this.quadTree const neighborIds = this.collisionFinder.getNeighborIds({
.getNeighborIds({
id: entity.id, id: entity.id,
dimension: boundingBox.dimension, dimension: boundingBox.dimension,
center: boundingBox.center, center: boundingBox.center,
}) });
.filter((neighborId) => neighborId != entity.id);
neighborIds.forEach((neighborId) => { for (const neighborId of neighborIds) {
const neighbor = game.getEntity(neighborId); const neighbor = game.getEntity(neighborId);
if (!neighbor) return; if (!neighbor) return;
@ -200,7 +183,7 @@ export class Collision extends System {
if (boundingBox.isCollidingWith(neighborBoundingBox)) { if (boundingBox.isCollidingWith(neighborBoundingBox)) {
collidingEntityIds.push([entity.id, neighborId]); collidingEntityIds.push([entity.id, neighborId]);
} }
}); }
} }
return collidingEntityIds; return collidingEntityIds;