use bounding boxes to optimize collision detection

This commit is contained in:
2025-01-19 14:24:19 +09:00
parent 0a4853aff9
commit e75c0d2944
10 changed files with 275 additions and 94 deletions
+183 -23
View File
@@ -8,6 +8,7 @@ import Buff from './buff.js'
export default class Entity {
id = crypto.randomUUID()
abilities = {}
bbox = new Float32Array(4)
buffs = []
casting = null
cooldowns = {}
@@ -35,6 +36,82 @@ export default class Entity {
return new SAT.Circle(new SAT.Vector(x, y), radius)
}
// deliberate code duplication for performance
static tunnelCollider(fromX, fromY, toX, toY, radius) {
if (radius <= 0) {
return SATX.line(fromX, fromY, toX, toY)
}
const sides = new Float32Array(5)
sides[0] = toX - fromX
sides[1] = toY - fromY
sides[4] = Math.hypot(sides[0], sides[1])
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
sides[3] = (sides[0] / sides[4]) * radius
return new SAT.Polygon(new SAT.Vector(fromX - sides[2], fromY - sides[3]), [
new SAT.Vector(),
new SAT.Vector(sides[0], sides[1]),
new SAT.Vector(sides[0] + (2 * sides[2]), sides[1] + (2 * sides[3])),
new SAT.Vector(2 * sides[2], 2 * sides[3]),
])
}
// deliberate code duplication for performance
static tunnelVertices(fromX, fromY, toX, toY, radius) {
const sides = new Float32Array(5)
sides[0] = toX - fromX
sides[1] = toY - fromY
sides[4] = Math.hypot(sides[0], sides[1])
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
sides[3] = (sides[0] / sides[4]) * radius
return [
new Vector2(fromX - sides[2], fromY - sides[3]),
new Vector2(fromX - sides[2] + sides[0], fromY - sides[3] + sides[1]),
new Vector2(fromX + sides[2] + sides[0], fromY + sides[3] + sides[1]),
new Vector2(fromX + sides[2], fromY + sides[3]),
]
}
// deliberate code duplication for performance
static tunnelBbox(fromX, fromY, toX, toY, radius) {
if (radius <= 0) {
return new Float32Array([
Math.max(fromY, toY),
Math.max(fromX, toX),
Math.min(fromY, toY),
Math.min(fromX, toX),
])
}
const sides = new Float32Array(5)
sides[0] = toX - fromX
sides[1] = toY - fromY
sides[4] = Math.hypot(sides[0], sides[1])
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
sides[3] = (sides[0] / sides[4]) * radius
const offsetX = fromX + sides[0]
const x1 = fromX - sides[2]
const x2 = fromX + sides[2]
const x3 = offsetX - sides[2]
const x4 = offsetX + sides[2]
const offsetY = fromY + sides[1]
const y1 = fromY - sides[3]
const y2 = fromY + sides[3]
const y3 = offsetY - sides[3]
const y4 = offsetY + sides[3]
return new Float32Array([
Math.max(y1, y2, y3, y4),
Math.max(x1, x2, x3, x4),
Math.min(y1, y2, y3, y4),
Math.min(x1, x2, x3, x4),
])
}
constructor(options = {}) {
Object.entries(options).forEach(([key, value]) => this[key] = value)
if (this.position == null) {
@@ -48,6 +125,7 @@ export default class Entity {
}
}
get attacking() { return this.#attacking }
get destination() { return this.#dest }
get logic() { return this.#logic }
get game() { return this.#game }
@@ -133,7 +211,7 @@ export default class Entity {
this.#attacking = attack
this.#moving = true
this.#dest = SATX.fixCollisions(cursor, this.collidables(), this.radius, this.game?.width, this.game?.height)
this.#dest = cursor.clone()
}
stopAction() {
@@ -174,14 +252,11 @@ export default class Entity {
}
collidables() {
const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider())
const terrainColliders = (this.game?.terrains ?? []).map((t) => t.colliders()).flat()
return entityColliders.concat(terrainColliders)
return this.customBboxCollidables(this.bbox)
}
collider() {
return new SAT.Circle(new SAT.Vector(this.position.x, this.position.y), this.radius)
return Entity.collider(this.position.x, this.position.y, this.radius)
}
colliders() {
@@ -221,6 +296,20 @@ export default class Entity {
return this.position.distanceTo(cursor)
}
futureCollidables(futurePosition) {
return this.customBboxCollidables(new Float32Array([
futurePosition.y + this.radius,
futurePosition.x + this.radius,
futurePosition.y - this.radius,
futurePosition.x - this.radius,
]))
}
customBboxCollidables(bbox) {
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
return entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
}
getBuff(id) {
const entityBuff = this.buffs.find((it) => it.id == id)
if (entityBuff == null) { return }
@@ -240,11 +329,70 @@ export default class Entity {
}
fixPosition() {
this.position = SATX.fixCollisions(this.position, this.collidables(), this.radius, this.game?.width, this.game?.height).clone()
this.position = this.fixFuturePosition(this.position.clone()).clone()
}
isColliding(...colliders) {
return SATX.collideObjects(this.collider(), colliders)
fixFuturePosition(futurePosition) {
if (!this.willCollide(futurePosition)) {
return futurePosition
}
let direction = new Vector2(0, 5)
let multiplier = 1
const rotationSlices = 16
const origin = new Vector2()
const maxX = this.game?.width ?? Infinity
const maxY = this.game?.height ?? Infinity
const radius = this.radius
for (let limit = 1; limit <= 10000; limit++) {
const rads = (limit % rotationSlices) * 2 * Math.PI / rotationSlices
const offset = direction.clone().rotateAround(origin, rads).multiplyScalar(multiplier)
const position = SATX.clamp(futurePosition.clone().add(offset), maxX, maxY, radius)
if (!this.willCollide(position)) {
return position
}
if (limit % rotationSlices == 0) {
multiplier++
}
}
console.error(`Can't fix position ([${futurePosition.x}, ${futurePosition.y}]) of entity ID: ${this.id}`)
}
isColliding() {
const collidables = this.collidables()
if (collidables.length < 1) {
return false
}
const colliders = collidables.map((it) => it.colliders()).flat()
const collider = this.collider()
return colliders.some((it) => SATX.collideObject(collider, it))
}
obstaclesInStraightPath(destination, position = this.position) {
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return [] }
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it)))
}
isInLineOfSight(destination, position = this.position) {
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true }
const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat()
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
return !colliders.some((it) => SATX.collideObject(collider, it))
}
removeBuff(id) {
@@ -277,6 +425,8 @@ export default class Entity {
if (this.#logic != null) {
this.#logic()
}
this.#calculateBbox()
}
waypoints() {
@@ -287,6 +437,25 @@ export default class Entity {
return unadjustedWaypoints.map(([waypoint, direction]) => this.adjustWaypoint(waypoint, direction)) ?? []
}
willCollide(futurePosition) {
const collidables = this.futureCollidables(futurePosition)
if (collidables.length < 1) {
return false
}
const colliders = collidables.map((it) => it.colliders()).flat()
const collider = Entity.collider(futurePosition.x, futurePosition.y, this.radius)
return colliders.some((it) => SATX.collideObject(collider, it))
}
#calculateBbox() {
this.bbox[0] = this.position.y + this.radius
this.bbox[1] = this.position.x + this.radius
this.bbox[2] = this.position.y - this.radius
this.bbox[3] = this.position.x - this.radius
}
#cast() {
if (this.casting == null) {
return false
@@ -333,22 +502,18 @@ export default class Entity {
if (!this.#moving || this.#dest == null) { return false }
// TODO: bounding boxes to early discard a lot of collidables
const collidables = this.collidables()
const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius)
const fixedDest = this.fixFuturePosition(this.#dest)
if (this.#path.length > 0) {
const sectionDest = this.#path.at(0)
const sectionTunnel = SATX.entityTunnel(this.position.x, this.position.y, sectionDest.x, sectionDest.y, this.radius)
const lineOfSight = !SATX.collideObjects(sectionTunnel, collidables)
const lineOfSight = this.isInLineOfSight(sectionDest)
if (!lineOfSight) {
this.#path = []
}
}
if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) {
const tunnel = SATX.entityTunnel(this.position.x, this.position.y, fixedDest.x, fixedDest.y, this.radius)
const lineOfSight = !SATX.collideObjects(tunnel, collidables)
const lineOfSight = this.isInLineOfSight(fixedDest)
if (lineOfSight) {
this.#path = [fixedDest]
}
@@ -375,9 +540,7 @@ export default class Entity {
let obstacleInPath = false
let lastSection = this.position
for (const section of path) {
const tunnel = SATX.entityTunnel(lastSection.x, lastSection.y, section.x, section.y, this.radius)
const globalObstacles = this.game.terrains.concat(this.game.entities.filter((e) => e.id != this.id))
const sectionObstacles = SATX.collideObstacles(tunnel, globalObstacles)
const sectionObstacles = this.obstaclesInStraightPath(section, lastSection)
if (sectionObstacles.length > 0) {
obstacleInPath = true
const obstacleIds = obstacles.map((o) => o.id)
@@ -408,12 +571,9 @@ export default class Entity {
const position = distance <= speed ? destination : stepTaken
const rotation = direction.angle()
const collider = Entity.collider(position.x, position.y, this.radius)
const isColliding = SATX.collideObjects(collider, this.collidables())
this.rotation = rotation
if (!isColliding) {
if (!this.willCollide(position)) {
this.position.copy(position)
}