From cbe0710c3743c2d0780057fabae435de22cc9928 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 5 May 2024 16:42:45 +0200 Subject: [PATCH] Sparse set for entity records --- lib/init.lua | 114 ++++++++++++++++++++++++++--------- tests/world.lua | 154 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 27 deletions(-) create mode 100644 tests/world.lua diff --git a/lib/init.lua b/lib/init.lua index 8c0b5bd..1cb378d 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -29,9 +29,10 @@ type Archetype = { type Record = { archetype: Archetype, row: number, + dense: i24, } -type EntityIndex = {[i24]: Record} +type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}} type ComponentIndex = {[i24]: ArchetypeMap} type ArchetypeRecord = number @@ -81,10 +82,12 @@ local function transitionArchetype( column[last] = nil end + local dense, sparse = entityIndex.dense, entityIndex.sparse -- Move the entity from the source to the destination archetype. local atSourceRow = sourceEntities[sourceRow] 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 -- corresponding to the entities' rows that were swapped. @@ -92,7 +95,7 @@ local function transitionArchetype( if sourceRow ~= movedAway then local atMovedAway = sourceEntities[movedAway] sourceEntities[sourceRow] = atMovedAway - entityIndex[atMovedAway].row = sourceRow + sparse[atMovedAway].row = sourceRow end sourceEntities[movedAway] = nil @@ -178,10 +181,13 @@ local World = {} World.__index = World function World.new() local self = setmetatable({ - archetypeIndex = {}; + archetypeIndex = {}; archetypes = {}; componentIndex = {}; - entityIndex = {}; + entityIndex = { + dense = {}, + sparse = {} + } :: EntityIndex; hooks = { [ON_ADD] = {}; }; @@ -294,15 +300,18 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet return add end -local function ensureRecord(entityIndex, entityId: i53): Record - local record = entityIndex[entityId] - - if not record then - record = {} - entityIndex[entityId] = record +local function ensureRecord(entityIndex: EntityIndex, entityId: i53): Record + local sparse = entityIndex.sparse + local dense = entityIndex.dense + local page = sparse[entityId] + if not page then + local i = #dense + 1 + page = { dense = i } :: Record + sparse[entityId] = page + dense[i] = entityId end - return record :: Record + return page end 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?) local id = entityId - local record = world.entityIndex[id] + local record = world.entityIndex.sparse[id] if not record then return nil end @@ -587,15 +596,64 @@ function World.entity(world: World) return nextEntityId + REST 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 record = entityIndex[entityId] - 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 + archetypeDelete(entityIndex, entityId, true) end function World.observer(world: World, ...) @@ -652,21 +710,23 @@ function World.observer(world: World, ...) end function World.__iter(world: World): () -> (number?, unknown?) - local entityIndex = world.entityIndex + local dense = world.entityIndex.dense + local sparse = world.entityIndex.sparse local last return function() - local entity, record = next(entityIndex, last) - if not entity then + local lastEntity, entityId = next(dense, last) + if not lastEntity then return end - last = entity + last = lastEntity + local record = sparse[entityId] local archetype = record.archetype if not archetype then -- 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. - return entity + return entityId end local row = record.row @@ -678,7 +738,7 @@ function World.__iter(world: World): () -> (number?, unknown?) entityData[types[i]] = column[row] end - return entity, entityData + return entityId, entityData end end diff --git a/tests/world.lua b/tests/world.lua new file mode 100644 index 0000000..a917f65 --- /dev/null +++ b/tests/world.lua @@ -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() \ No newline at end of file