Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 107 additions & 50 deletions bump.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ local bump = {
]]
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the first change here is to add a table pool so that we can reuse temporary tables instead of throwing them away. This is used for a few operations:

  • The table used to keep track of visited items in World:project().
  • The table used to store the results of getDictItemsInCellRect
  • A new table used to keep track of visited items during World:projectMove() (formerly World:check()). More on this later.

------------------------------------------
-- Table Pool
------------------------------------------
local Pool = {}
do
local ok, tabelClear = pcall(require, 'table.clear')
if not ok then
tabelClear = function (t)
for k, _ in pairs(t) do
t[k] = nil
end
end
end

local pool = {}
local len = 0

function Pool.fetch()
if len == 0 then
Pool.free({})
end
local t = table.remove(pool, len)
len = len - 1
return t
end

function Pool.free(t)
tabelClear(t)
len = len + 1
pool[len] = t
end
end

------------------------------------------
-- Auxiliary functions
------------------------------------------
Expand Down Expand Up @@ -189,13 +222,15 @@ local function rect_detectCollision(x1,y1,w1,h1, x2,y2,w2,h2, goalX, goalY)
end

return {
overlaps = overlaps,
ti = ti,
move = {x = dx, y = dy},
normal = {x = nx, y = ny},
touch = {x = tx, y = ty},
itemRect = {x = x1, y = y1, w = w1, h = h1},
otherRect = {x = x2, y = y2, w = w2, h = h2}
overlaps = overlaps,
ti = ti,
distance = rect_getSquareDistance(x1,y1,w1,h1, x2,y2,w2,h2),
moveX = dx,
moveY = dy,
normalX = nx,
normalY = ny,
touchX = tx,
touchY = ty,
}
end

Expand Down Expand Up @@ -266,55 +301,57 @@ end
-- Responses
------------------------------------------

Copy link
Contributor Author

@oniietzschan oniietzschan Sep 28, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding alreadyVisited is part of a change which I'll explain below by World:check() / World:projectMove()

local touch = function(world, col, x,y,w,h, goalX, goalY, filter)
return col.touch.x, col.touch.y, {}, 0
local touch = function(world, col, x,y,w,h, goalX, goalY, filter, alreadyVisited)
return col.touchX, col.touchY, {}, 0
end

local cross = function(world, col, x,y,w,h, goalX, goalY, filter)
local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter)
local cross = function(world, col, x,y,w,h, goalX, goalY, filter, alreadyVisited)
local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter, alreadyVisited)
return goalX, goalY, cols, len
end

local slide = function(world, col, x,y,w,h, goalX, goalY, filter)
local slide = function(world, col, x,y,w,h, goalX, goalY, filter, alreadyVisited)
goalX = goalX or x
goalY = goalY or y

local tch, move = col.touch, col.move
if move.x ~= 0 or move.y ~= 0 then
if col.normal.x ~= 0 then
goalX = tch.x
if col.moveX ~= 0 or col.moveY ~= 0 then
if col.normalX ~= 0 then
goalX = col.touchX
else
goalY = tch.y
goalY = col.touchY
end
end

col.slide = {x = goalX, y = goalY}
col.slideX, col.slideY = goalX, goalY

x,y = tch.x, tch.y
local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter)
x, y = col.touchX, col.touchY
local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter, alreadyVisited)
return goalX, goalY, cols, len
end

local bounce = function(world, col, x,y,w,h, goalX, goalY, filter)
local bounce = function(world, col, x,y,w,h, goalX, goalY, filter, alreadyVisited)
goalX = goalX or x
goalY = goalY or y

local tch, move = col.touch, col.move
local tx, ty = tch.x, tch.y
local tx, ty = col.touchX, col.touchY

local bx, by = tx, ty

if move.x ~= 0 or move.y ~= 0 then
if col.moveX ~= 0 or col.moveY ~= 0 then
local bnx, bny = goalX - tx, goalY - ty
if col.normal.x == 0 then bny = -bny else bnx = -bnx end
if col.normalX == 0 then
bny = -bny
else
bnx = -bnx
end
bx, by = tx + bnx, ty + bny
end

col.bounce = {x = bx, y = by}
x,y = tch.x, tch.y
col.bounceX, col.bounceY = bx, by
x, y = col.touchX, col.touchY
goalX, goalY = bx, by

local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter)
local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter, alreadyVisited)
return goalX, goalY, cols, len
end

Expand All @@ -331,10 +368,7 @@ local function sortByWeight(a,b) return a.weight < b.weight end

local function sortByTiAndDistance(a,b)
if a.ti == b.ti then
local ir, ar, br = a.itemRect, a.otherRect, b.otherRect
local ad = rect_getSquareDistance(ir.x,ir.y,ir.w,ir.h, ar.x,ar.y,ar.w,ar.h)
local bd = rect_getSquareDistance(ir.x,ir.y,ir.w,ir.h, br.x,br.y,br.w,br.h)
return ad < bd
return a.distance < b.distance
end
return a.ti < b.ti
end
Expand Down Expand Up @@ -365,7 +399,8 @@ local function removeItemFromCell(self, item, cx, cy)
end

local function getDictItemsInCellRect(self, cl,ct,cw,ch)
local items_dict = {}
local items_dict = Pool.fetch()

for cy=ct,ct+ch-1 do
local row = self.rows[cy]
if row then
Expand Down Expand Up @@ -444,17 +479,21 @@ function World:addResponse(name, response)
self.responses[name] = response
end

function World:project(item, x,y,w,h, goalX, goalY, filter)
local EMPTY_TABLE = {}

function World:project(item, x,y,w,h, goalX, goalY, filter, alreadyVisited)
assertIsRect(x,y,w,h)

goalX = goalX or x
goalY = goalY or y
filter = filter or defaultFilter
filter = filter or defaultFilter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't create the table for collisions until we've actually encountered a collision. This definitely cuts down on garbage collection, but also adds a branch to the logic. I believe it's worthwhile, but maybe you'll disagree.

local collisions, len = {}, 0
local collisions, len = nil, 0

local visited = {}
if item ~= nil then visited[item] = true end
local visited = Pool.fetch()
if item ~= nil then
visited[item] = true
end

-- This could probably be done with less cells using a polygon raster over the cells instead of a
-- bounding rect of the whole movement. Conditional to building a queryPolygon method
Expand All @@ -467,7 +506,7 @@ function World:project(item, x,y,w,h, goalX, goalY, filter)
local dictItemsInCellRect = getDictItemsInCellRect(self, cl,ct,cw,ch)

for other,_ in pairs(dictItemsInCellRect) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, see World:check() / World:projectMove() for more details on how alreadyVisited works.

if not visited[other] then
if not visited[other] and (alreadyVisited == nil or not alreadyVisited[other]) then
visited[other] = true

local responseName = filter(item, other)
Expand All @@ -481,15 +520,23 @@ function World:project(item, x,y,w,h, goalX, goalY, filter)
col.type = responseName

len = len + 1
if collisions == nil then
collisions = {}
end
collisions[len] = col
end
end
end
end

table.sort(collisions, sortByTiAndDistance)
Pool.free(visited)
Pool.free(dictItemsInCellRect)

return collisions, len
if collisions ~= nil then
table.sort(collisions, sortByTiAndDistance)
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EMPTY_TABLE is by far the newest change here, everything else I changed back in march. The idea is that we reuse one static {} whenever we return results with no collisions. Formerly, for my own use, I was just returning nil, 0. But I think this is a little nicer because there's no API change.

It's ever so slightly dangerous though, because if the user does something crazy and inserts into EMPTY_TABLE, that could cause some incredibly strange behavior.

return collisions or EMPTY_TABLE, len
end

function World:countCells()
Expand Down Expand Up @@ -560,6 +607,8 @@ function World:queryRect(x,y,w,h, filter)
end
end

Pool.free(dictItemsInCellRect)

return items, len
end

Expand All @@ -580,6 +629,8 @@ function World:queryPoint(x,y, filter)
end
end

Pool.free(dictItemsInCellRect)

return items, len
end

Expand Down Expand Up @@ -696,19 +747,23 @@ function World:move(item, goalX, goalY, filter)
end

Copy link
Contributor Author

@oniietzschan oniietzschan Sep 28, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a new method World:projectMove(). This is essentially a combination of World:check() and World:project(). You can use it to check collisions for items which aren't actually stored inside the world. Just like :project(), you can use items which aren't actually inside the Bump world. Just like :check(), it simulates collision responses as the item "moves" totwards its goal.

I've found this incredibly practical for optimization, because the fewer things which are stored in the world, the faster collision detection is.

An example of how you could use this: Bullets need to collide with Actors; however, Actors don't necessarily need to collide with Bullets. So, we insert actors into the bump world, but we keep track of the bullets some other way. We can still have the bullets interact with the actors by using World:projectMove(bullet, ...).

Again, I'll document this in README if you're okay with this change.

function World:check(item, goalX, goalY, filter)
local x,y,w,h = self:getRect(item)
return self:projectMove(item, x, y, w, h, goalX,goalY, filter)
end

function World:projectMove(item, x, y, w, h, goalX, goalY, filter)
filter = filter or defaultFilter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so here's the big alreadyVisited change I forecasted a couple times earlier. Really, the behavior here hasn't changed, but the implementation has.

Before: filter is wrapped with another function which keeps track of the "visited" items during collision response resolution. This creates a single use function every single time World:check() is invoked, a big source of garbage!

Now: Instead of wrapping filter, we just pass a visited table into the collision response functions, it still keeps track of "visited" items the same way, but without the waste.

local visited = {[item] = true}
local visitedFilter = function(itm, other)
if visited[other] then return false end
return filter(itm, other)
local projected_cols, projected_len = self:project(item, x,y,w,h, goalX,goalY, filter)

if projected_len == 0 then
return goalX, goalY, EMPTY_TABLE, 0
end

local cols, len = {}, 0

local x,y,w,h = self:getRect(item)

local projected_cols, projected_len = self:project(item, x,y,w,h, goalX,goalY, visitedFilter)
local visited = Pool.fetch()
visited[item] = true

while projected_len > 0 do
local col = projected_cols[1]
Expand All @@ -724,14 +779,16 @@ function World:check(item, goalX, goalY, filter)
col,
x, y, w, h,
goalX, goalY,
visitedFilter
filter,
visited
)
end

Pool.free(visited)

return goalX, goalY, cols, len
end


-- Public library functions

bump.newWorld = function(cellSize)
Expand Down
74 changes: 74 additions & 0 deletions performance_test.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
local RNG_SEED = 69420731
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a the benchmark I was using during development. Not sure if it makes sense to merge it in. Let me know if you want this removed or moved to a subfolder or something.

local WORLD_SIZE = 10
local OBJECT_COUNT = 10
local MOVEMENT_GENERATIONS = 250
local MOVE_RANGE = 1
local TEST_COUNT = 10

local function clamp(lower, val, upper)
if lower > upper then lower, upper = upper, lower end
return math.max(lower, math.min(upper, val))
end

local function doTest(world)
-- seed RNG
math.randomseed(RNG_SEED)

-- create OBJECT_COUNT random entities
local entities = {}
for i = 1, OBJECT_COUNT do
local x = math.random() * WORLD_SIZE
local y = math.random() * WORLD_SIZE
local w = (math.random() * 1.5) + 0.5
local h = (math.random() * 1.5) + 0.5
local entity = {name = i}
table.insert(entities, entity)
world:add(entity, x, y, w, h)
end

-- Collect garbage and stop GC.
collectgarbage('collect')
collectgarbage('stop')

-- move all entities for MOVEMENT_GENERATIONS generations.
local collisions = 0
for i = 1, MOVEMENT_GENERATIONS do
for _, entity in ipairs(entities) do
local x, y = world:getRect(entity)
local goalX = clamp(0, x - MOVE_RANGE + (math.random() * MOVE_RANGE * 2), WORLD_SIZE)
local goalY = clamp(0, y - MOVE_RANGE + (math.random() * MOVE_RANGE * 2), WORLD_SIZE)
local _, _, _, len = world:move(entity, goalX, goalY)
collisions = collisions + len
end
end

-- restart GC and measure memory difference before and after.
local kbBefore = collectgarbage('count')
collectgarbage('restart')
collectgarbage('collect')
local kbAfter = collectgarbage('count')
local kbGarbage = kbBefore - kbAfter

-- -- Print stats.
-- print(("Collisions: %d"):format(collisions))
-- print(("Garbage: %.2fkB"):format(kbGarbage))

return kbGarbage
end

local function doTests(label, bump)
print(("============= %s ============="):format(label))
local totalGarbage = 0
for i = 1, TEST_COUNT do
local world = bump.newWorld(1)
local garbage = doTest(world)
totalGarbage = totalGarbage + garbage
end
local averageGarbage = totalGarbage / TEST_COUNT
print(("Garbage: %.2f kB"):format(averageGarbage))
print(("(Average after %d tests.)"):format(TEST_COUNT))
end

doTests('Original', require 'bump-original')
print('')
doTests('Modded', require 'bump')
Loading