Sparse set for entity records

This commit is contained in:
Ukendio 2024-05-05 16:42:45 +02:00
parent d5414f1bc4
commit cbe0710c37
2 changed files with 241 additions and 27 deletions

View file

@ -29,9 +29,10 @@ type Archetype = {
type Record = { type Record = {
archetype: Archetype, archetype: Archetype,
row: number, row: number,
dense: i24,
} }
type EntityIndex = {[i24]: Record} type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}}
type ComponentIndex = {[i24]: ArchetypeMap} type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number type ArchetypeRecord = number
@ -81,10 +82,12 @@ local function transitionArchetype(
column[last] = nil column[last] = nil
end end
local dense, sparse = entityIndex.dense, entityIndex.sparse
-- Move the entity from the source to the destination archetype. -- Move the entity from the source to the destination archetype.
local atSourceRow = sourceEntities[sourceRow] local atSourceRow = sourceEntities[sourceRow]
destinationEntities[destinationRow] = atSourceRow destinationEntities[destinationRow] = atSourceRow
entityIndex[atSourceRow].row = destinationRow local record = sparse[atSourceRow]
record.row = destinationRow
-- Because we have swapped columns we now have to update the records -- Because we have swapped columns we now have to update the records
-- corresponding to the entities' rows that were swapped. -- corresponding to the entities' rows that were swapped.
@ -92,7 +95,7 @@ local function transitionArchetype(
if sourceRow ~= movedAway then if sourceRow ~= movedAway then
local atMovedAway = sourceEntities[movedAway] local atMovedAway = sourceEntities[movedAway]
sourceEntities[sourceRow] = atMovedAway sourceEntities[sourceRow] = atMovedAway
entityIndex[atMovedAway].row = sourceRow sparse[atMovedAway].row = sourceRow
end end
sourceEntities[movedAway] = nil sourceEntities[movedAway] = nil
@ -178,10 +181,13 @@ local World = {}
World.__index = World World.__index = World
function World.new() function World.new()
local self = setmetatable({ local self = setmetatable({
archetypeIndex = {}; archetypeIndex = {};
archetypes = {}; archetypes = {};
componentIndex = {}; componentIndex = {};
entityIndex = {}; entityIndex = {
dense = {},
sparse = {}
} :: EntityIndex;
hooks = { hooks = {
[ON_ADD] = {}; [ON_ADD] = {};
}; };
@ -294,15 +300,18 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet
return add return add
end end
local function ensureRecord(entityIndex, entityId: i53): Record local function ensureRecord(entityIndex: EntityIndex, entityId: i53): Record
local record = entityIndex[entityId] local sparse = entityIndex.sparse
local dense = entityIndex.dense
if not record then local page = sparse[entityId]
record = {} if not page then
entityIndex[entityId] = record local i = #dense + 1
page = { dense = i } :: Record
sparse[entityId] = page
dense[i] = entityId
end end
return record :: Record return page
end end
function World.set(world: World, entityId: i53, componentId: i53, data: unknown) function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
@ -374,7 +383,7 @@ end
function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?) function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?)
local id = entityId local id = entityId
local record = world.entityIndex[id] local record = world.entityIndex.sparse[id]
if not record then if not record then
return nil return nil
end end
@ -587,15 +596,64 @@ function World.entity(world: World)
return nextEntityId + REST return nextEntityId + REST
end end
function World.delete(world: World, entityId: i53) -- should reuse this logic in World.set instead of swap removing in transition archetype
local function destructColumns(columns, count, row)
if row == count then
for _, column in columns do
column[count] = nil
end
else
for _, column in columns do
column[row] = column[count]
column[count] = nil
end
end
end
local function archetypeDelete(entityIndex, entityId: i53, destruct: boolean)
local sparse = entityIndex.sparse
local dense = entityIndex.dense
local record = sparse[entityId]
local archetype = record.archetype
local row = record.row
local denseIndex = record.dense
local entities = archetype.entities
local last = #entities
local entityToMove = entities[last]
--local entityToDelete = entities[row]
entities[row] = entityToMove
entities[last] = nil
if row ~= last then
local recordToMove = sparse[entityToMove]
if recordToMove then
recordToMove.row = row
record.dense = denseIndex
dense[denseIndex] = entityToMove
end
end
record.archetype = nil
record.row = nil
entityIndex.sparse[entityId] = nil
local atDense = record.dense
entityIndex[atDense] = nil
local columns = archetype.columns
if not destruct then
return
end
destructColumns(columns, last, row)
end
function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex local entityIndex = world.entityIndex
local record = entityIndex[entityId] archetypeDelete(entityIndex, entityId, true)
moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE)
-- Since we just appended an entity to the ROOT_ARCHETYPE we have to remove it from
-- the entities array and delete the record. We know there won't be the hole since
-- we are always removing the last row.
--world.ROOT_ARCHETYPE.entities[record.row] = nil
--entityIndex[entityId] = nil
end end
function World.observer(world: World, ...) function World.observer(world: World, ...)
@ -652,21 +710,23 @@ function World.observer(world: World, ...)
end end
function World.__iter(world: World): () -> (number?, unknown?) function World.__iter(world: World): () -> (number?, unknown?)
local entityIndex = world.entityIndex local dense = world.entityIndex.dense
local sparse = world.entityIndex.sparse
local last local last
return function() return function()
local entity, record = next(entityIndex, last) local lastEntity, entityId = next(dense, last)
if not entity then if not lastEntity then
return return
end end
last = entity last = lastEntity
local record = sparse[entityId]
local archetype = record.archetype local archetype = record.archetype
if not archetype then if not archetype then
-- Returns only the entity id as an entity without data should not return -- Returns only the entity id as an entity without data should not return
-- data and allow the user to get an error if they don't handle the case. -- data and allow the user to get an error if they don't handle the case.
return entity return entityId
end end
local row = record.row local row = record.row
@ -678,7 +738,7 @@ function World.__iter(world: World): () -> (number?, unknown?)
entityData[types[i]] = column[row] entityData[types[i]] = column[row]
end end
return entity, entityData return entityId, entityData
end end
end end

154
tests/world.lua Normal file
View file

@ -0,0 +1,154 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
local N = 10
TEST("world", function()
do CASE "should be iterable"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local eA = world:entity()
world:set(eA, A, true)
local eB = world:entity()
world:set(eB, B, true)
local eAB = world:entity()
world:set(eAB, A, true)
world:set(eAB, B, true)
local count = 0
for id, data in world do
count += 1
if id == eA then
CHECK(data[A] == true)
CHECK(data[B] == nil)
elseif id == eB then
CHECK(data[A] == nil)
CHECK(data[B] == true)
elseif id == eAB then
CHECK(data[A] == true)
CHECK(data[B] == true)
else
error("unknown entity", id)
end
end
CHECK(count == 3)
end
do CASE "should query all matching entities"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i > 5 then world:set(id, B, true) end
entities[i] = id
end
for id in world:query(A) do
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do CASE "should query all matching entities when irrelevant component is removed"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
world:set(id, B, true)
if i > 5 then world:remove(id, B, true) end
entities[i] = id
end
local added = 0
for id in world:query(A) do
added += 1
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(added == N)
end
do CASE "should query all entities without B"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i < 5 then
entities[i] = id
else
world:set(id, B, true)
end
end
for id in world:query(A):without(B) do
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do CASE "should allow setting components in arbitrary order"
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
CHECK(world:get(id, Poison) == 5)
end
do CASE "Should allow deleting components"
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
local id1 = world:entity()
world:set(id1, Poison, 500)
world:set(id1, Health, 50)
world:delete(id)
CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == nil)
CHECK(world:get(id1, Poison) == 500)
CHECK(world:get(id1, Health) == 50)
end
end)
FINISH()