From 5476491c5d6d62cf80f69927f744d2611140315e Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 5 May 2024 03:25:34 +0200 Subject: [PATCH 01/25] Should index into column --- lib/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/init.lua b/lib/init.lua index b3e31bc..6d9c1fe 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -560,7 +560,7 @@ function World.query(world: World, ...: i53): Query end for i in components do - queryOutput[i] = tr[i][row] + queryOutput[i] = columns[tr[i]][row] end return entityId, unpack(queryOutput, 1, queryLength) From 7ad1ef37f0823879e7da95a1f856547cda3b701d Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 5 May 2024 03:45:38 +0200 Subject: [PATCH 02/25] Add exhaustive benchmarks for operations --- benches/exhaustive.lua | 372 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 benches/exhaustive.lua diff --git a/benches/exhaustive.lua b/benches/exhaustive.lua new file mode 100644 index 0000000..3095c70 --- /dev/null +++ b/benches/exhaustive.lua @@ -0,0 +1,372 @@ +local testkit = require("../testkit") +local jecs = require("../lib/init") +local ecr = require("../DevPackages/_Index/centau_ecr@0.8.0/ecr/src/ecr") + + +local BENCH, START = testkit.benchmark() + +local function TITLE(title: string) + print() + print(testkit.color.white(title)) +end + +local N = 2^16-2 + +type i53 = number + +do TITLE "create" + BENCH("entity", function() + local world = jecs.World.new() + for i = 1, START(N) do + world:entity() + end + end) +end + +--- component benchmarks + +--todo: perform the same benchmarks for multiple components.? +-- these kind of operations only support 1 component at a time, which is +-- a shame, especially for archetypes where moving components is expensive. + +do TITLE "set" + BENCH("add 1 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + + for i = 1, N do + entities[i] = world:entity() + end + + for i = 1, START(N) do + world:set(entities[i], A, i) + end + end) + + BENCH("change 1 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + local e = world:entity() + world:set(e, A, 1) + + for i = 1, START(N) do + world:set(e, A, 2) + end + end) + +end + +do TITLE "remove" + BENCH("1 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + + for i = 1, N do + local id = world:entity() + entities[i] = id + world:set(id, A, true) + end + + for i = 1, START(N) do + world:remove(entities[i], A) + end + + end) +end + +do TITLE "get" + BENCH("1 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + + for i = 1, N do + local id = world:entity() + entities[i] = id + world:set(id, A, true) + end + + for i = 1, START(N) do + -- ? curious why the overhead is roughly 80 ns. + world:get(entities[i], A) + end + + end) + + BENCH("2 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + local B = world:component() + + for i = 1, N do + local id = world:entity() + entities[i] = id + world:set(id, A, true) + world:set(id, B, true) + end + + for i = 1, START(N) do + world:get(entities[i], A, B) + end + + end) + + BENCH("3 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + local B = world:component() + local C = world:component() + + for i = 1, N do + local id = world:entity() + entities[i] = id + world:set(id, A, true) + world:set(id, B, true) + world:set(id, C, true) + end + + for i = 1, START(N) do + world:get(entities[i], A, B, C) + end + + end) + + BENCH("4 component", function() + local world = jecs.World.new() + local entities = {} + + local A = world:component() + local B = world:component() + local C = world:component() + local D = world:component() + + for i = 1, N do + local id = world:entity() + entities[i] = id + world:set(id, A, true) + world:set(id, B, true) + world:set(id, C, true) + world:set(id, D, true) + end + + for i = 1, START(N) do + world:get(entities[i], A, B, C, D) + end + + end) +end + +do TITLE (testkit.color.white_underline("Jecs query")) + + local function count(query: () -> ()) + local n = 0 + for _ in query do + n += 1 + end + return n + end + + local function flip() + return math.random() > 0.5 + end + + local function view_bench( + world: jecs.World, + A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53, I: i53 + ) + + BENCH("1 component", function() + START(count(world:query(A))) + for _ in world:query(A) do end + end) + + BENCH("2 component", function() + START(count(world:query(A, B))) + for _ in world:query(A, B) do end + end) + + BENCH("4 component", function() + START(count(world:query(A, B, C, D))) + for _ in world:query(A, B, C, D) do end + end) + + BENCH("8 component", function() + START(count(world:query(A, B, C, D, E, F, G, H))) + for _ in world:query(A, B, C, D, E, F, G, H) do end + end) + end + + do TITLE "random components" + + local world = jecs.World.new() + + local A = world:component() + local B = world:component() + local C = world:component() + local D = world:component() + local E = world:component() + local F = world:component() + local G = world:component() + local H = world:component() + local I = world:component() + + for i = 1, N do + local id = world:entity() + if flip() then world:set(id, A, true) end + if flip() then world:set(id, B, true) end + if flip() then world:set(id, C, true) end + if flip() then world:set(id, D, true) end + if flip() then world:set(id, E, true) end + if flip() then world:set(id, F, true) end + if flip() then world:set(id, G, true) end + if flip() then world:set(id, H, true) end + if flip() then world:set(id, I, true) end + + end + + view_bench(world, A, B, C, D, E, F, G, H, I) + + end + + do TITLE "one component in common" + + local world = jecs.World.new() + + local A = world:component() + local B = world:component() + local C = world:component() + local D = world:component() + local E = world:component() + local F = world:component() + local G = world:component() + local H = world:component() + local I = world:component() + + for i = 1, N do + local id = world:entity() + local a = true + if flip() then world:set(id, B, true) else a = false end + if flip() then world:set(id, C, true) else a = false end + if flip() then world:set(id, D, true) else a = false end + if flip() then world:set(id, E, true) else a = false end + if flip() then world:set(id, F, true) else a = false end + if flip() then world:set(id, G, true) else a = false end + if flip() then world:set(id, H, true) else a = false end + if flip() then world:set(id, I, true) else a = false end + if a then world:set(id, A, true) end + + end + + view_bench(world, A, B, C, D, E, F, G, H, I) + + end + +end + +do TITLE (testkit.color.white_underline("ECR query")) + + local A = ecr.component() + local B = ecr.component() + local C = ecr.component() + local D = ecr.component() + local E = ecr.component() + local F = ecr.component() + local G = ecr.component() + local H = ecr.component() + local I = ecr.component() + + local function count(query: () -> ()) + local n = 0 + for _ in query do + n += 1 + end + return n + end + + local function flip() + return math.random() > 0.5 + end + + local function view_bench( + world: ecr.Registry, + A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53, I: i53 + ) + + BENCH("1 component", function() + START(count(world:view(A))) + for _ in world:view(A) do end + end) + + BENCH("2 component", function() + START(count(world:view(A, B))) + for _ in world:view(A, B) do end + end) + + BENCH("4 component", function() + START(count(world:view(A, B, C, D))) + for _ in world:view(A, B, C, D) do end + end) + + BENCH("8 component", function() + START(count(world:view(A, B, C, D, E, F, G, H))) + for _ in world:view(A, B, C, D, E, F, G, H) do end + end) + end + + + do TITLE "random components" + local world = ecr.registry() + + for i = 1, N do + local id = world.create() + if flip() then world:set(id, A, true) end + if flip() then world:set(id, B, true) end + if flip() then world:set(id, C, true) end + if flip() then world:set(id, D, true) end + if flip() then world:set(id, E, true) end + if flip() then world:set(id, F, true) end + if flip() then world:set(id, G, true) end + if flip() then world:set(id, H, true) end + if flip() then world:set(id, I, true) end + + end + + view_bench(world, A, B, C, D, E, F, G, H, I) + + end + + do TITLE "one component in common" + + local world = ecr.registry() + + for i = 1, N do + local id = world.create() + local a = true + if flip() then world:set(id, B, true) else a = false end + if flip() then world:set(id, C, true) else a = false end + if flip() then world:set(id, D, true) else a = false end + if flip() then world:set(id, E, true) else a = false end + if flip() then world:set(id, F, true) else a = false end + if flip() then world:set(id, G, true) else a = false end + if flip() then world:set(id, H, true) else a = false end + if flip() then world:set(id, I, true) else a = false end + if a then world:set(id, A, true) end + + end + + view_bench(world, A, B, C, D, E, F, G, H, I) + + end + +end \ No newline at end of file From 7cb610b097fde21e7eef56dba041016ff3d5e3ee Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 5 May 2024 15:06:57 +0200 Subject: [PATCH 03/25] Update mirror --- benches/query.lua | 96 +++++++++- mirror/init.lua | 435 ++++++++++++++++++++++++---------------------- 2 files changed, 322 insertions(+), 209 deletions(-) diff --git a/benches/query.lua b/benches/query.lua index 8f54596..195e9c6 100644 --- a/benches/query.lua +++ b/benches/query.lua @@ -8,7 +8,8 @@ local function TITLE(title: string) print(testkit.color.white(title)) end -local jecs = require("../mirror/init") +local jecs = require("../lib/init") +local mirror = require("../mirror/init") type i53 = number @@ -101,6 +102,99 @@ do a += 1 end + view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) + end +end + +do + TITLE(testkit.color.white_underline("Mirror query")) + local ecs = mirror.World.new() + do + TITLE("one component in common") + + local function view_bench(world: jecs.World, A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53) + BENCH("1 component", function() + for _ in world:query(A) do + end + end) + + BENCH("2 component", function() + for _ in world:query(A, B) do + end + end) + + BENCH("4 component", function() + for _ in world:query(A, B, C, D) do + end + end) + + BENCH("8 component", function() + for _ in world:query(A, B, C, D, E, F, G, H) do + end + end) + end + + local D1 = ecs:component() + local D2 = ecs:component() + local D3 = ecs:component() + local D4 = ecs:component() + local D5 = ecs:component() + local D6 = ecs:component() + local D7 = ecs:component() + local D8 = ecs:component() + + local function flip() + return math.random() >= 0.15 + end + + local added = 0 + local archetypes = {} + for i = 1, 2 ^ 16 - 2 do + local entity = ecs:entity() + + local combination = "" + + if flip() then + combination ..= "B" + ecs:set(entity, D2, {value = true}) + end + if flip() then + combination ..= "C" + ecs:set(entity, D3, {value = true}) + end + if flip() then + combination ..= "D" + ecs:set(entity, D4, {value = true}) + end + if flip() then + combination ..= "E" + ecs:set(entity, D5, {value = true}) + end + if flip() then + combination ..= "F" + ecs:set(entity, D6, {value = true}) + end + if flip() then + combination ..= "G" + ecs:set(entity, D7, {value = true}) + end + if flip() then + combination ..= "H" + ecs:set(entity, D8, {value = true}) + end + + if #combination == 7 then + added += 1 + ecs:set(entity, D1, {value = true}) + end + archetypes[combination] = true + end + + local a = 0 + for _ in archetypes do + a += 1 + end + view_bench(ecs, D1, D2, D3, D4, D5, D6, D7, D8) end end \ No newline at end of file diff --git a/mirror/init.lua b/mirror/init.lua index 35a9b9c..6d9c1fe 100644 --- a/mirror/init.lua +++ b/mirror/init.lua @@ -6,10 +6,10 @@ type i53 = number type i24 = number -type Ty = { i53 } +type Ty = {i53} type ArchetypeId = number -type Column = { any } +type Column = {any} type Archetype = { id: number, @@ -20,9 +20,9 @@ type Archetype = { }, }, types: Ty, - type: string | number, - entities: { number }, - columns: { Column }, + type: string | number, + entities: {number}, + columns: {Column}, records: {}, } @@ -31,13 +31,13 @@ type Record = { row: number, } -type EntityIndex = { [i24]: Record } -type ComponentIndex = { [i24]: ArchetypeMap} +type EntityIndex = {[i24]: Record} +type ComponentIndex = {[i24]: ArchetypeMap} type ArchetypeRecord = number -type ArchetypeMap = { sparse: { [ArchetypeId]: ArchetypeRecord } , size: number } -type Archetypes = { [ArchetypeId]: Archetype } - +type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number} +type Archetypes = {[ArchetypeId]: Archetype} + type ArchetypeDiff = { added: Ty, removed: Ty, @@ -64,17 +64,17 @@ local function transitionArchetype( local types = from.types for i, column in columns do - -- Retrieves the new column index from the source archetype's record from each component + -- Retrieves the new column index from the source archetype's record from each component -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. local targetColumn = destinationColumns[tr[types[i]]] -- Sometimes target column may not exist, e.g. when you remove a component. - if targetColumn then + if targetColumn then targetColumn[destinationRow] = column[sourceRow] end -- If the entity is the last row in the archetype then swapping it would be meaningless. local last = #column - if sourceRow ~= last then + if sourceRow ~= last then -- Swap rempves columns to ensure there are no holes in the archetype. column[sourceRow] = column[last] end @@ -82,24 +82,27 @@ local function transitionArchetype( end -- Move the entity from the source to the destination archetype. - destinationEntities[destinationRow] = sourceEntities[sourceRow] - entityIndex[sourceEntities[sourceRow]].row = destinationRow + local atSourceRow = sourceEntities[sourceRow] + destinationEntities[destinationRow] = atSourceRow + entityIndex[atSourceRow].row = destinationRow -- Because we have swapped columns we now have to update the records -- corresponding to the entities' rows that were swapped. local movedAway = #sourceEntities - if sourceRow ~= movedAway then - sourceEntities[sourceRow] = sourceEntities[movedAway] - entityIndex[sourceEntities[movedAway]].row = sourceRow + if sourceRow ~= movedAway then + local atMovedAway = sourceEntities[movedAway] + sourceEntities[sourceRow] = atMovedAway + entityIndex[atMovedAway].row = sourceRow end - + sourceEntities[movedAway] = nil end -local function archetypeAppend(entity: i53, archetype: Archetype): i24 +local function archetypeAppend(entity: number, archetype: Archetype): number local entities = archetype.entities - table.insert(entities, entity) - return #entities + local length = #entities + 1 + entities[length] = entity + return length end local function newEntity(entityId: i53, record: Record, archetype: Archetype) @@ -122,47 +125,49 @@ local function hash(arr): string | number return table.concat(arr, "_") end -local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, from: Archetype?) - local destinationCount = #to.types +local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, _from: Archetype?) local destinationIds = to.types + local records = to.records + local id = to.id - for i = 1, destinationCount do - local destinationId = destinationIds[i] + for i, destinationId in destinationIds do + local archetypesMap = componentIndex[destinationId] - if not componentIndex[destinationId] then - componentIndex[destinationId] = { size = 0, sparse = {} } + if not archetypesMap then + archetypesMap = {size = 0, sparse = {}} + componentIndex[destinationId] = archetypesMap end - local archetypesMap = componentIndex[destinationId] - archetypesMap.sparse[to.id] = i - to.records[destinationId] = i + archetypesMap.sparse[id] = i + records[destinationId] = i end end -local function archetypeOf(world: World, types: { i24 }, prev: Archetype?): Archetype +local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype local ty = hash(types) - world.nextArchetypeId = (world.nextArchetypeId::number)+ 1 - local id = world.nextArchetypeId + local id = world.nextArchetypeId + 1 + world.nextArchetypeId = id - local columns = {} :: { any } + local length = #types + local columns = table.create(length) :: {any} - for _ in types do - table.insert(columns, {}) + for index in types do + columns[index] = {} end local archetype = { - id = id, - types = types, - type = ty, - columns = columns, - entities = {}, - edges = {}, - records = {}, + columns = columns; + edges = {}; + entities = {}; + id = id; + records = {}; + type = ty; + types = types; } world.archetypeIndex[ty] = archetype world.archetypes[id] = archetype - if #types > 0 then + if length > 0 then createArchetypeRecords(world.componentIndex, archetype, prev) end @@ -171,42 +176,42 @@ end local World = {} World.__index = World -function World.new() +function World.new() local self = setmetatable({ - entityIndex = {}, - componentIndex = {}, - archetypes = {}, - archetypeIndex = {}, - ROOT_ARCHETYPE = (nil :: any) :: Archetype, - nextEntityId = 0, - nextComponentId = 0, - nextArchetypeId = 0, + archetypeIndex = {}; + archetypes = {}; + componentIndex = {}; + entityIndex = {}; hooks = { - [ON_ADD] = {} - } + [ON_ADD] = {}; + }; + nextArchetypeId = 0; + nextComponentId = 0; + nextEntityId = 0; + ROOT_ARCHETYPE = (nil :: any) :: Archetype; }, World) - return self + return self end -local function emit(world, eventDescription) +local function emit(world, eventDescription) local event = eventDescription.event table.insert(world.hooks[event], { - ids = eventDescription.ids, - archetype = eventDescription.archetype, - otherArchetype = eventDescription.otherArchetype, - offset = eventDescription.offset + archetype = eventDescription.archetype; + ids = eventDescription.ids; + offset = eventDescription.offset; + otherArchetype = eventDescription.otherArchetype; }) end -local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) - if #added > 0 then +local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) + if #added > 0 then emit(world, { - event = ON_ADD, - ids = added, - archetype = archetype, - otherArchetype = otherArchetype, - offset = row, + archetype = archetype; + event = ON_ADD; + ids = added; + offset = row; + otherArchetype = otherArchetype; }) end end @@ -217,7 +222,7 @@ local function ensureArchetype(world: World, types, prev) if #types < 1 then return world.ROOT_ARCHETYPE end - + local ty = hash(types) local archetype = world.archetypeIndex[ty] if archetype then @@ -227,10 +232,8 @@ local function ensureArchetype(world: World, types, prev) return archetypeOf(world, types, prev) end -local function findInsert(types: { i53 }, toAdd: i53) - local count = #types - for i = 1, count do - local id = types[i] +local function findInsert(types: {i53}, toAdd: i53) + for i, id in types do if id == toAdd then return -1 end @@ -238,13 +241,13 @@ local function findInsert(types: { i53 }, toAdd: i53) return i end end - return count + 1 + return #types + 1 end local function findArchetypeWith(world: World, node: Archetype, componentId: i53) local types = node.types -- Component IDs are added incrementally, so inserting and sorting - -- them each time would be expensive. Instead this insertion sort can find the insertion + -- them each time would be expensive. Instead this insertion sort can find the insertion -- point in the types array. local at = findInsert(types, componentId) if at == -1 then @@ -259,48 +262,57 @@ local function findArchetypeWith(world: World, node: Archetype, componentId: i53 end local function ensureEdge(archetype: Archetype, componentId: i53) - if not archetype.edges[componentId] then - archetype.edges[componentId] = {} :: any + local edges = archetype.edges + local edge = edges[componentId] + if not edge then + edge = {} :: any + edges[componentId] = edge end - return archetype.edges[componentId] + return edge end local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype - if not from then + if not from then -- If there was no source archetype then it should return the ROOT_ARCHETYPE - if not world.ROOT_ARCHETYPE then - local ROOT_ARCHETYPE = archetypeOf(world, {}, nil) - world.ROOT_ARCHETYPE = ROOT_ARCHETYPE - end - from = world.ROOT_ARCHETYPE + local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE + if not ROOT_ARCHETYPE then + ROOT_ARCHETYPE = archetypeOf(world, {}, nil) + world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never + end + from = ROOT_ARCHETYPE end + local edge = ensureEdge(from, componentId) - - if not edge.add then - -- Save an edge using the component ID to the archetype to allow + local add = edge.add + if not add then + -- Save an edge using the component ID to the archetype to allow -- faster traversals to adjacent archetypes. - edge.add = findArchetypeWith(world, from, componentId) + add = findArchetypeWith(world, from, componentId) + edge.add = add :: never end - return edge.add + return add end local function ensureRecord(entityIndex, entityId: i53): Record - local id = entityId - if not entityIndex[id] then - entityIndex[id] = {} + local record = entityIndex[entityId] + + if not record then + record = {} + entityIndex[entityId] = record end - return entityIndex[id] :: Record + + return record :: Record end -function World.set(world: World, entityId: i53, componentId: i53, data: unknown) +function World.set(world: World, entityId: i53, componentId: i53, data: unknown) local record = ensureRecord(world.entityIndex, entityId) local from = record.archetype local to = archetypeTraverseAdd(world, componentId, from) - if from == to then - -- If the archetypes are the same it can avoid moving the entity - -- and just set the data directly. + if from == to then + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. local archetypeRecord = to.records[componentId] from.columns[archetypeRecord][record.row] = data -- Should fire an OnSet event here. @@ -308,13 +320,13 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown) end if from then - -- If there was a previous archetype, then the entity needs to move the archetype + -- If there was a previous archetype, then the entity needs to move the archetype moveEntity(world.entityIndex, entityId, record, to) else if #to.types > 0 then -- When there is no previous archetype it should create the archetype newEntity(entityId, record, to) - onNotifyAdd(world, to, from, record.row, { componentId }) + onNotifyAdd(world, to, from, record.row, {componentId}) end end @@ -326,28 +338,30 @@ local function archetypeTraverseRemove(world: World, componentId: i53, archetype local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype local edge = ensureEdge(from, componentId) - - if not edge.remove then - local to = table.clone(from.types) + local remove = edge.remove + if not remove then + local to = table.clone(from.types) table.remove(to, table.find(to, componentId)) - edge.remove = ensureArchetype(world, to, from) + remove = ensureArchetype(world, to, from) + edge.remove = remove :: never end - return edge.remove + return remove end -function World.remove(world: World, entityId: i53, componentId: i53) - local record = ensureRecord(world.entityIndex, entityId) +function World.remove(world: World, entityId: i53, componentId: i53) + local entityIndex = world.entityIndex + local record = ensureRecord(entityIndex, entityId) local sourceArchetype = record.archetype local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) - if sourceArchetype and not (sourceArchetype == destinationArchetype) then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) + if sourceArchetype and not (sourceArchetype == destinationArchetype) then + moveEntity(entityIndex, entityId, record, destinationArchetype) end end -- Keeping the function as small as possible to enable inlining -local function get(componentIndex: { [i24]: ArchetypeMap }, record: Record, componentId: i24) +local function get(record: Record, componentId: i24) local archetype = record.archetype local archetypeRecord = archetype.records[componentId] @@ -360,35 +374,35 @@ end function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?) local id = entityId - local componentIndex = world.componentIndex local record = world.entityIndex[id] if not record then return nil end - local va = get(componentIndex, record, a) + local va = get(record, a) if b == nil then return va elseif c == nil then - return va, get(componentIndex, record, b) + return va, get(record, b) elseif d == nil then - return va, get(componentIndex, record, b), get(componentIndex, record, c) + return va, get(record, b), get(record, c) elseif e == nil then - return va, get(componentIndex, record, b), get(componentIndex, record, c), get(componentIndex, record, d) + return va, get(record, b), get(record, c), get(record, d) else error("args exceeded") end end -local function noop(self: Query, ...: i53): () -> (number, ...any) - return function() - end :: any +-- the less creation the better +local function actualNoOperation() end +local function noop(_self: Query, ...: i53): () -> (number, ...any) + return actualNoOperation :: any end local EmptyQuery = { - __iter = noop, - without = noop + __iter = noop; + without = noop; } EmptyQuery.__index = EmptyQuery setmetatable(EmptyQuery, EmptyQuery) @@ -396,25 +410,28 @@ setmetatable(EmptyQuery, EmptyQuery) export type Query = typeof(EmptyQuery) function World.query(world: World, ...: i53): Query - local compatibleArchetypes = {} - local components = { ... } - local archetypes = world.archetypes - local queryLength = #components - - if queryLength == 0 then + -- breaking? + if (...) == nil then error("Missing components") end + local compatibleArchetypes = {} + local length = 0 + + local components = {...} + local archetypes = world.archetypes + local queryLength = #components + local firstArchetypeMap local componentIndex = world.componentIndex - for i, componentId in components do + for _, componentId in components do local map = componentIndex[componentId] if not map then return EmptyQuery end - if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then + if firstArchetypeMap == nil or map.size < firstArchetypeMap.size then firstArchetypeMap = map end end @@ -422,110 +439,107 @@ function World.query(world: World, ...: i53): Query for id in firstArchetypeMap.sparse do local archetype = archetypes[id] local archetypeRecords = archetype.records - local indices = {} + local indices = {} local skip = false - - for i, componentId in components do + + for i, componentId in components do local index = archetypeRecords[componentId] - if not index then + if not index then skip = true break end - indices[i] = archetypeRecords[componentId] + indices[i] = index end - if skip then + if skip then continue end - table.insert(compatibleArchetypes, { archetype, indices }) + + length += 1 + compatibleArchetypes[length] = {archetype, indices} end local lastArchetype, compatibleArchetype = next(compatibleArchetypes) - if not lastArchetype then + if not lastArchetype then return EmptyQuery end - + local preparedQuery = {} preparedQuery.__index = preparedQuery - function preparedQuery:without(...) - local components = { ... } - for i = #compatibleArchetypes, 1, -1 do + function preparedQuery:without(...) + local withoutComponents = {...} + for i = #compatibleArchetypes, 1, -1 do local archetype = compatibleArchetypes[i][1] + local records = archetype.records local shouldRemove = false - for _, componentId in components do - if archetype.records[componentId] then + + for _, componentId in withoutComponents do + if records[componentId] then shouldRemove = true break end end - if shouldRemove then + + if shouldRemove then table.remove(compatibleArchetypes, i) end - end + end lastArchetype, compatibleArchetype = next(compatibleArchetypes) - if not lastArchetype then + if not lastArchetype then return EmptyQuery end - + return self end local lastRow local queryOutput = {} - - function preparedQuery:__iter() - return function() + function preparedQuery:__iter() + return function() local archetype = compatibleArchetype[1] local row = next(archetype.entities, lastRow) - while row == nil do + while row == nil do lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype) - if lastArchetype == nil then - return + if lastArchetype == nil then + return end archetype = compatibleArchetype[1] row = next(archetype.entities, row) end lastRow = row - + local entityId = archetype.entities[row :: number] local columns = archetype.columns local tr = compatibleArchetype[2] - - if queryLength == 1 then + + if queryLength == 1 then return entityId, columns[tr[1]][row] - elseif queryLength == 2 then + elseif queryLength == 2 then return entityId, columns[tr[1]][row], columns[tr[2]][row] - elseif queryLength == 3 then - return entityId, - columns[tr[1]][row], - columns[tr[2]][row], - columns[tr[3]][row] - elseif queryLength == 4 then - return entityId, - columns[tr[1]][row], - columns[tr[2]][row], - columns[tr[3]][row], - columns[tr[4]][row] - elseif queryLength == 5 then - return entityId, + elseif queryLength == 3 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row] + elseif queryLength == 4 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row] + elseif queryLength == 5 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row], columns[tr[5]][row] - elseif queryLength == 6 then - return entityId, + elseif queryLength == 6 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row], columns[tr[5]][row], columns[tr[6]][row] - elseif queryLength == 7 then - return entityId, + elseif queryLength == 7 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], @@ -533,8 +547,8 @@ function World.query(world: World, ...: i53): Query columns[tr[5]][row], columns[tr[6]][row], columns[tr[7]][row] - elseif queryLength == 8 then - return entityId, + elseif queryLength == 8 then + return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], @@ -545,8 +559,8 @@ function World.query(world: World, ...: i53): Query columns[tr[8]][row] end - for i in components do - queryOutput[i] = tr[i][row] + for i in components do + queryOutput[i] = columns[tr[i]][row] end return entityId, unpack(queryOutput, 1, queryLength) @@ -556,23 +570,24 @@ function World.query(world: World, ...: i53): Query return setmetatable({}, preparedQuery) :: any end -function World.component(world: World) - local componentId = world.nextComponentId + 1 - if componentId > HI_COMPONENT_ID then - -- IDs are partitioned into ranges because component IDs are not nominal, +function World.component(world: World) + local componentId = world.nextComponentId + 1 + if componentId > HI_COMPONENT_ID then + -- IDs are partitioned into ranges because component IDs are not nominal, -- so it needs to error when IDs intersect into the entity range. - error("Too many components, consider using world:entity() instead to create components.") + error("Too many components, consider using world:entity() instead to create components.") end world.nextComponentId = componentId return componentId end function World.entity(world: World) - world.nextEntityId += 1 - return world.nextEntityId + REST + local nextEntityId = world.nextEntityId + 1 + world.nextEntityId = nextEntityId + return nextEntityId + REST end -function World.delete(world: World, entityId: i53) +function World.delete(world: World, entityId: i53) local entityIndex = world.entityIndex local record = entityIndex[entityId] moveEntity(entityIndex, entityId, record, world.ROOT_ARCHETYPE) @@ -584,57 +599,61 @@ function World.delete(world: World, entityId: i53) end function World.observer(world: World, ...) - local componentIds = { ... } - + local componentIds = {...} + local idsCount = #componentIds + local hooks = world.hooks + return { - event = function(event) - local hook = world.hooks[event] - world.hooks[event] = nil + event = function(event) + local hook = hooks[event] + hooks[event] = nil local last, change - return function() + return function() last, change = next(hook, last) - if not last then + if not last then return end local matched = false - - while not matched do + local ids = change.ids + + while not matched do local skip = false - for _, id in change.ids do - if not table.find(componentIds, id) then + for _, id in ids do + if not table.find(componentIds, id) then skip = true break end end - - if skip then + + if skip then last, change = next(hook, last) + ids = change.ids continue end matched = true end - - local queryOutput = {} + + local queryOutput = table.create(idsCount) local row = change.offset local archetype = change.archetype local columns = archetype.columns local archetypeRecords = archetype.records - for _, id in componentIds do - table.insert(queryOutput, columns[archetypeRecords[id]][row]) + for index, id in componentIds do + queryOutput[index] = columns[archetypeRecords[id]][row] end - return archetype.entities[row], unpack(queryOutput, 1, #queryOutput) + return archetype.entities[row], unpack(queryOutput, 1, idsCount) end - end + end; } end return table.freeze({ - World = World, - ON_ADD = ON_ADD, - ON_REMOVE = ON_REMOVE, - ON_SET = ON_SET + World = World; + ON_ADD = ON_ADD; + ON_REMOVE = ON_REMOVE; + ON_SET = ON_SET; }) From d5414f1bc453394e0bf045a12e262c06984debdd Mon Sep 17 00:00:00 2001 From: Marcus Date: Sun, 5 May 2024 15:22:02 +0200 Subject: [PATCH 04/25] Add iter method (#20) --- lib/init.lua | 33 ++++++++++++++++++++++++++++++++- lib/init.spec.lua | 33 +++++++++++++++++++++++++++++++++ tests/test1.lua | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/lib/init.lua b/lib/init.lua index 6d9c1fe..8c0b5bd 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -178,7 +178,7 @@ local World = {} World.__index = World function World.new() local self = setmetatable({ - archetypeIndex = {}; + archetypeIndex = {}; archetypes = {}; componentIndex = {}; entityIndex = {}; @@ -651,6 +651,37 @@ function World.observer(world: World, ...) } end +function World.__iter(world: World): () -> (number?, unknown?) + local entityIndex = world.entityIndex + local last + + return function() + local entity, record = next(entityIndex, last) + if not entity then + return + end + last = entity + + 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 + end + + local row = record.row + local types = archetype.types + local columns = archetype.columns + local entityData = {} + for i, column in columns do + -- We use types because the key should be the component ID not the column index + entityData[types[i]] = column[row] + end + + return entity, entityData + end +end + return table.freeze({ World = World; ON_ADD = ON_ADD; diff --git a/lib/init.spec.lua b/lib/init.spec.lua index fdc8331..98f485b 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -299,5 +299,38 @@ return function() expect(world:get(id, Poison)).to.never.be.ok() expect(world:get(id, Health)).to.never.be.ok() end) + + it("should allow iterating the whole world", function() + local world = jecs.World.new() + + local A, B = world:entity(), world:entity() + + 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 + expect(data[A]).to.be.ok() + expect(data[B]).to.never.be.ok() + elseif id == eB then + expect(data[B]).to.be.ok() + expect(data[A]).to.never.be.ok() + elseif id == eAB then + expect(data[A]).to.be.ok() + expect(data[B]).to.be.ok() + else + error("unknown entity", id) + end + end + + expect(count).to.equal(3) + end) end) end \ No newline at end of file diff --git a/tests/test1.lua b/tests/test1.lua index 0b031d3..7ff3b5a 100644 --- a/tests/test1.lua +++ b/tests/test1.lua @@ -110,6 +110,39 @@ TEST("world:query", function() CHECK(world:get(id, Health) == nil) end + do CASE "Should allow iterating the whole world" + local world = jecs.World.new() + + local A, B = world:entity(), world:entity() + + 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[B] == true) + CHECK(data[A] == nil) + elseif id == eAB then + CHECK(data[A] == true) + CHECK(data[B] == true) + else + error("unknown entity", id) + end + end + + CHECK(count == 3) + end + end) FINISH() \ No newline at end of file From 517dbb99c0eb8ae54f731ee6742e96daf50400eb Mon Sep 17 00:00:00 2001 From: alicesaidhi <166900055+alicesaidhi@users.noreply.github.com> Date: Tue, 7 May 2024 18:37:14 +0200 Subject: [PATCH 05/25] Add svg images (#18) * svg logo * fix light mode * Create logo_old.png --- README.md | 3 ++- jecs_darkmode.svg | 6 ++++++ jecs_lightmode.svg | 6 ++++++ logo.png => logo_old.png | Bin 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 jecs_darkmode.svg create mode 100644 jecs_lightmode.svg rename logo.png => logo_old.png (100%) diff --git a/README.md b/README.md index 96f82a8..0386756 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

- + +

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache-blue.svg?style=for-the-badge)](LICENSE-APACHE) diff --git a/jecs_darkmode.svg b/jecs_darkmode.svg new file mode 100644 index 0000000..f64b173 --- /dev/null +++ b/jecs_darkmode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/jecs_lightmode.svg b/jecs_lightmode.svg new file mode 100644 index 0000000..dbcd08c --- /dev/null +++ b/jecs_lightmode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/logo.png b/logo_old.png similarity index 100% rename from logo.png rename to logo_old.png From 887c892c2ebb4406e5e6f7f7d3be4e018b031046 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 7 May 2024 21:30:36 +0200 Subject: [PATCH 06/25] Move root archetype (#33) --- lib/init.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/init.lua b/lib/init.lua index 8c0b5bd..ce0951e 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -190,6 +190,7 @@ function World.new() nextEntityId = 0; ROOT_ARCHETYPE = (nil :: any) :: Archetype; }, World) + self.ROOT_ARCHETYPE = archetypeOf(self, {}, nil) return self end @@ -272,6 +273,7 @@ local function ensureEdge(archetype: Archetype, componentId: i53) end local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype + from = from or world.ROOT_ARCHETYPE if not from then -- If there was no source archetype then it should return the ROOT_ARCHETYPE local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE From bf5908a8f5da83641be19bff397bdf54e852b1a4 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 7 May 2024 21:32:56 +0200 Subject: [PATCH 07/25] Adds symmetic and idempotent function add (#26) * Adds symmetic function add * Should be componentId not entityId --- lib/init.lua | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index ce0951e..250a56f 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -274,15 +274,6 @@ end local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype from = from or world.ROOT_ARCHETYPE - if not from then - -- If there was no source archetype then it should return the ROOT_ARCHETYPE - local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE - if not ROOT_ARCHETYPE then - ROOT_ARCHETYPE = archetypeOf(world, {}, nil) - world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never - end - from = ROOT_ARCHETYPE - end local edge = ensureEdge(from, componentId) local add = edge.add @@ -307,7 +298,23 @@ local function ensureRecord(entityIndex, entityId: i53): Record return record :: Record end -function World.set(world: World, entityId: i53, componentId: i53, data: unknown) + +function World.add(world: World, entityId: i53, componentId: i53) + local record = ensureRecord(world.entityIndex, entityId) + local from = record.archetype + local to = archetypeTraverseAdd(world, componentId, from) + if from then + moveEntity(world.entityIndex, entityId, record, to) + else + if #to.types > 0 then + newEntity(entityId, record, to) + onNotifyAdd(world, to, from, record.row, { componentId }) + end + end +end + +-- Symmetric like `World.add` but idempotent +function World.set(world: World, entityId: i53, componentId: i53, data: unknown) local record = ensureRecord(world.entityIndex, entityId) local from = record.archetype local to = archetypeTraverseAdd(world, componentId, from) @@ -331,7 +338,7 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown) onNotifyAdd(world, to, from, record.row, {componentId}) end end - + local archetypeRecord = to.records[componentId] to.columns[archetypeRecord][record.row] = data end From e8b78f7b50033d02c33fc755c7ec584da12c3acb Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 7 May 2024 21:33:42 +0200 Subject: [PATCH 08/25] Add world.delete (#22) --- lib/init.lua | 50 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index 250a56f..defc1b3 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -596,15 +596,51 @@ 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, archetype: Archetype, row: i24, destruct: boolean) + 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 = entityIndex[entityToMove] + if recordToMove then + recordToMove.row = row + end + end + + 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 + local archetype = record.archetype + archetypeDelete(entityIndex, archetype, record.row, true) + entityIndex[entityId] = nil end function World.observer(world: World, ...) From 91d3fcabc3d63d54ed3362da659d262fa9806286 Mon Sep 17 00:00:00 2001 From: Marcus Date: Wed, 8 May 2024 00:57:22 +0200 Subject: [PATCH 09/25] Add case for when component is not found in archetype (#25) * Add case for when component is not found in archetype * Check only destination archetype first * Omit onNotifyAdd --- .gitignore | 3 --- lib/init.lua | 51 ++++++++++++++++++++++++++++++------------------- tests/test1.lua | 21 ++++++++++++++++++-- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 9143a00..a43fa5f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,3 @@ WallyPatches roblox.toml sourcemap.json drafts/*.lua - -*.code-workspace -roblox.yml diff --git a/lib/init.lua b/lib/init.lua index defc1b3..a57ae9e 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -287,23 +287,31 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet return add end -local function ensureRecord(entityIndex, entityId: i53): Record +local function ensureRecord(world, entityId: i53): Record + local entityIndex = world.entityIndex local record = entityIndex[entityId] - if not record then - record = {} - entityIndex[entityId] = record + if record then + return record end - return record :: Record + local ROOT = world.ROOT_ARCHETYPE + local row = #ROOT.entities + 1 + ROOT.entities[row] = entityId + record = { + archetype = ROOT, + row = row + } + entityIndex[entityId] = record + return record end function World.add(world: World, entityId: i53, componentId: i53) - local record = ensureRecord(world.entityIndex, entityId) + local record = ensureRecord(world, entityId) local from = record.archetype local to = archetypeTraverseAdd(world, componentId, from) - if from then + if from and not (from == world.ROOT_ARCHETYPE) then moveEntity(world.entityIndex, entityId, record, to) else if #to.types > 0 then @@ -315,19 +323,20 @@ end -- Symmetric like `World.add` but idempotent function World.set(world: World, entityId: i53, componentId: i53, data: unknown) - local record = ensureRecord(world.entityIndex, entityId) + local record = ensureRecord(world, entityId) local from = record.archetype - local to = archetypeTraverseAdd(world, componentId, from) - if from == to then + local archetypeRecord = from.records[componentId] + if archetypeRecord then -- If the archetypes are the same it can avoid moving the entity -- and just set the data directly. - local archetypeRecord = to.records[componentId] from.columns[archetypeRecord][record.row] = data -- Should fire an OnSet event here. return end + local to = archetypeTraverseAdd(world, componentId, from) + if from then -- If there was a previous archetype, then the entity needs to move the archetype moveEntity(world.entityIndex, entityId, record, to) @@ -335,22 +344,25 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown) if #to.types > 0 then -- When there is no previous archetype it should create the archetype newEntity(entityId, record, to) - onNotifyAdd(world, to, from, record.row, {componentId}) + --onNotifyAdd(world, to, from, record.row, {componentId}) end end - local archetypeRecord = to.records[componentId] + archetypeRecord = to.records[componentId] to.columns[archetypeRecord][record.row] = data end -local function archetypeTraverseRemove(world: World, componentId: i53, archetype: Archetype?): Archetype - local from = (archetype or world.ROOT_ARCHETYPE) :: Archetype +local function archetypeTraverseRemove(world: World, componentId: i53, from: Archetype): Archetype local edge = ensureEdge(from, componentId) local remove = edge.remove if not remove then local to = table.clone(from.types) - table.remove(to, table.find(to, componentId)) + local at = table.find(to, componentId) + if not at then + return from + end + table.remove(to, at) remove = ensureArchetype(world, to, from) edge.remove = remove :: never end @@ -359,13 +371,12 @@ local function archetypeTraverseRemove(world: World, componentId: i53, archetype end function World.remove(world: World, entityId: i53, componentId: i53) - local entityIndex = world.entityIndex - local record = ensureRecord(entityIndex, entityId) + local record = ensureRecord(world, entityId) local sourceArchetype = record.archetype local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) if sourceArchetype and not (sourceArchetype == destinationArchetype) then - moveEntity(entityIndex, entityId, record, destinationArchetype) + moveEntity(world.entityIndex, entityId, record, destinationArchetype) end end @@ -732,4 +743,4 @@ return table.freeze({ ON_ADD = ON_ADD; ON_REMOVE = ON_REMOVE; ON_SET = ON_SET; -}) +}) \ No newline at end of file diff --git a/tests/test1.lua b/tests/test1.lua index 7ff3b5a..3fe86da 100644 --- a/tests/test1.lua +++ b/tests/test1.lua @@ -35,14 +35,17 @@ TEST("world:query", function() local world = jecs.World.new() local A = world:component() local B = world:component() + local C = world:component() local entities = {} for i = 1, N do local id = world:entity() - world:set(id, A, true) + -- specifically put them in disorder to track regression + -- https://github.com/Ukendio/jecs/pull/15 world:set(id, B, true) - if i > 5 then world:remove(id, B, true) end + world:set(id, A, true) + if i > 5 then world:remove(id, B) end entities[i] = id end @@ -110,6 +113,20 @@ TEST("world:query", function() CHECK(world:get(id, Health) == nil) end + do CASE "show allow remove that doesn't exist on entity" + local world = jecs.World.new() + + local Health = world:entity() + local Poison = world:component() + + local id = world:entity() + world:set(id, Health, 50) + world:remove(id, Poison) + + CHECK(world:get(id, Poison) == nil) + CHECK(world:get(id, Health) == 50) + end + do CASE "Should allow iterating the whole world" local world = jecs.World.new() From 1de41447b62caae571bd1b34a254ea9518b69311 Mon Sep 17 00:00:00 2001 From: Marcus Date: Wed, 8 May 2024 01:04:11 +0200 Subject: [PATCH 10/25] Remove observer for now (#34) * Add case for when component is not found in archetype * Check only destination archetype first * Omit onNotifyAdd * Remove observers --- lib/init.lua | 53 ----------------------------------------------- lib/init.spec.lua | 16 -------------- 2 files changed, 69 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index a57ae9e..29396a5 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -654,59 +654,6 @@ function World.delete(world: World, entityId: i53) entityIndex[entityId] = nil end -function World.observer(world: World, ...) - local componentIds = {...} - local idsCount = #componentIds - local hooks = world.hooks - - return { - event = function(event) - local hook = hooks[event] - hooks[event] = nil - - local last, change - return function() - last, change = next(hook, last) - if not last then - return - end - - local matched = false - local ids = change.ids - - while not matched do - local skip = false - for _, id in ids do - if not table.find(componentIds, id) then - skip = true - break - end - end - - if skip then - last, change = next(hook, last) - ids = change.ids - continue - end - - matched = true - end - - local queryOutput = table.create(idsCount) - local row = change.offset - local archetype = change.archetype - local columns = archetype.columns - local archetypeRecords = archetype.records - for index, id in componentIds do - queryOutput[index] = columns[archetypeRecords[id]][row] - end - - return archetype.entities[row], unpack(queryOutput, 1, idsCount) - end - end; - } -end - function World.__iter(world: World): () -> (number?, unknown?) local entityIndex = world.entityIndex local last diff --git a/lib/init.spec.lua b/lib/init.spec.lua index 98f485b..553c9a4 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -176,22 +176,6 @@ return function() expect(added).to.equal(0) end) - it("track changes", function() - local Position = world:entity() - - local moving = world:entity() - world:set(moving, Position, Vector3.new(1, 2, 3)) - - local count = 0 - - for e, position in world:observer(Position).event(jecs.ON_ADD) do - count += 1 - expect(e).to.equal(moving) - expect(position).to.equal(Vector3.new(1, 2, 3)) - end - expect(count).to.equal(1) - end) - it("should query all matching entities", function() local world = jecs.World.new() From 87d49e513422a47d5022932c1c8143a4dbd0504a Mon Sep 17 00:00:00 2001 From: Ukendio Date: Thu, 9 May 2024 02:20:54 +0200 Subject: [PATCH 11/25] Reorganize file --- lib/a.lua | 5 +++ lib/init.lua | 94 ++++++++++++++++++++++++++-------------------------- 2 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 lib/a.lua diff --git a/lib/a.lua b/lib/a.lua new file mode 100644 index 0000000..68b844c --- /dev/null +++ b/lib/a.lua @@ -0,0 +1,5 @@ +local test = { + ez = "godo" +} + + test.ez = "good" \ No newline at end of file diff --git a/lib/init.lua b/lib/init.lua index 29396a5..93e5021 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -143,14 +143,14 @@ local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archet end end -local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype +local function archetypeOf(world: any, types: {i24}, prev: Archetype?): Archetype local ty = hash(types) local id = world.nextArchetypeId + 1 world.nextArchetypeId = id local length = #types - local columns = table.create(length) :: {any} + local columns = table.create(length) for index in types do columns[index] = {} @@ -174,51 +174,6 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet return archetype end -local World = {} -World.__index = World -function World.new() - local self = setmetatable({ - archetypeIndex = {}; - archetypes = {}; - componentIndex = {}; - entityIndex = {}; - hooks = { - [ON_ADD] = {}; - }; - nextArchetypeId = 0; - nextComponentId = 0; - nextEntityId = 0; - ROOT_ARCHETYPE = (nil :: any) :: Archetype; - }, World) - self.ROOT_ARCHETYPE = archetypeOf(self, {}, nil) - return self -end - -local function emit(world, eventDescription) - local event = eventDescription.event - - table.insert(world.hooks[event], { - archetype = eventDescription.archetype; - ids = eventDescription.ids; - offset = eventDescription.offset; - otherArchetype = eventDescription.otherArchetype; - }) -end - -local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) - if #added > 0 then - emit(world, { - archetype = archetype; - event = ON_ADD; - ids = added; - offset = row; - otherArchetype = otherArchetype; - }) - end -end - -export type World = typeof(World.new()) - local function ensureArchetype(world: World, types, prev) if #types < 1 then return world.ROOT_ARCHETYPE @@ -306,6 +261,51 @@ local function ensureRecord(world, entityId: i53): Record return record end +local World = {} +World.__index = World +function World.new() + local self = setmetatable({ + archetypeIndex = {}; + archetypes = {}; + componentIndex = {}; + entityIndex = {}; + hooks = { + [ON_ADD] = {}; + }; + nextArchetypeId = 0; + nextComponentId = 0; + nextEntityId = 0; + ROOT_ARCHETYPE = (nil :: any) :: Archetype; + }, World) + self.ROOT_ARCHETYPE = archetypeOf(self, {}, nil) + return self +end + +local function emit(world, eventDescription) + local event = eventDescription.event + + table.insert(world.hooks[event], { + archetype = eventDescription.archetype; + ids = eventDescription.ids; + offset = eventDescription.offset; + otherArchetype = eventDescription.otherArchetype; + }) +end + +local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) + if #added > 0 then + emit(world, { + archetype = archetype; + event = ON_ADD; + ids = added; + offset = row; + otherArchetype = otherArchetype; + }) + end +end + +export type World = typeof(World.new()) + function World.add(world: World, entityId: i53, componentId: i53) local record = ensureRecord(world, entityId) From 6775601e21611720699375e2ece2c6d9452c9e3c Mon Sep 17 00:00:00 2001 From: Marcus Date: Fri, 10 May 2024 14:27:38 +0200 Subject: [PATCH 12/25] Delete lib/a.lua --- lib/a.lua | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 lib/a.lua diff --git a/lib/a.lua b/lib/a.lua deleted file mode 100644 index 68b844c..0000000 --- a/lib/a.lua +++ /dev/null @@ -1,5 +0,0 @@ -local test = { - ez = "godo" -} - - test.ez = "good" \ No newline at end of file From d63de48546dc8d499750764adf9db387d8335a24 Mon Sep 17 00:00:00 2001 From: Marcus Date: Fri, 10 May 2024 17:59:57 +0200 Subject: [PATCH 13/25] Relationships (#31) * Sparse set for entity records * Swap dense indexes * Improve inlining * Add benchmarks * Add tests for relations * Add REST * Merge upstream changes * Add back symmetric and non idempotent add function * Only swap when not last row * Assert that the entity is alive * Update example with relations --- README.md | 22 +- benches/query.lua | 46 ++++ benches/visual/insertion.bench.lua | 36 ++- lib/init.lua | 411 +++++++++++++++++------------ tests/{test1.lua => world.lua} | 107 +++++--- 5 files changed, 414 insertions(+), 208 deletions(-) rename tests/{test1.lua => world.lua} (56%) diff --git a/README.md b/README.md index 0386756..2d8d210 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,18 @@ jecs is a stupidly fast Entity Component System (ECS). ### Example ```lua -local world = Jecs.World.new() - -local Health = world:component() -local Damage = world:component() -local Position = world:component() +local world = World.new() local player = world:entity() local opponent = world:entity() +local Health = world:component() +local Position = world:component() +-- Notice how components can just be entities as well? +-- It allows you to model relationships easily! +local Damage = world:entity() +local DamagedBy = world:entity() + world:set(player, Health, 100) world:set(player, Damage, 8) world:set(player, Position, Vector3.new(0, 5, 0)) @@ -38,17 +41,20 @@ world:set(opponent, Position, Vector3.new(0, 5, 3)) for playerId, playerPosition, health in world:query(Position, Health) do local totalDamage = 0 - for _, opponentPosition, damage in world:query(Position, Damage) do + for opponentId, opponentPosition, damage in world:query(Position, Damage) do if (playerPosition - opponentPosition).Magnitude < 5 then totalDamage += damage end + world:set(playerId, ECS_PAIR(DamagedBy, opponentId), totalDamage) end +end - world:set(playerId, Health, health - totalDamage) +-- Gets the damage inflicted by our specific opponent! +for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, opponent)) do + world:set(playerId, health - inflicted) end assert(world:get(playerId, Health) == 79) -assert(world:get(opponentId, Health) == 92) ``` 125 archetypes, 4 random components queried. diff --git a/benches/query.lua b/benches/query.lua index 195e9c6..34b63de 100644 --- a/benches/query.lua +++ b/benches/query.lua @@ -39,6 +39,29 @@ do for _ in world:query(A, B, C, D, E, F, G, H) do end end) + + local e = world:entity() + world:set(e, A, true) + world:set(e, B, true) + world:set(e, C, true) + world:set(e, D, true) + world:set(e, E, true) + world:set(e, F, true) + world:set(e, G, true) + world:set(e, H, true) + + BENCH("Update Data", function() + for _ = 1, 100 do + world:set(e, A, false) + world:set(e, B, false) + world:set(e, C, false) + world:set(e, D, false) + world:set(e, E, false) + world:set(e, F, false) + world:set(e, G, false) + world:set(e, H, false) + end + end) end local D1 = ecs:component() @@ -132,6 +155,29 @@ do for _ in world:query(A, B, C, D, E, F, G, H) do end end) + + local e = world:entity() + world:set(e, A, true) + world:set(e, B, true) + world:set(e, C, true) + world:set(e, D, true) + world:set(e, E, true) + world:set(e, F, true) + world:set(e, G, true) + world:set(e, H, true) + + BENCH("Update Data", function() + for _ = 1, 100 do + world:set(e, A, false) + world:set(e, B, false) + world:set(e, C, false) + world:set(e, D, false) + world:set(e, E, false) + world:set(e, F, false) + world:set(e, G, false) + world:set(e, H, false) + end + end) end local D1 = ecs:component() diff --git a/benches/visual/insertion.bench.lua b/benches/visual/insertion.bench.lua index 3f7415a..e8e50be 100644 --- a/benches/visual/insertion.bench.lua +++ b/benches/visual/insertion.bench.lua @@ -8,6 +8,8 @@ local jecs = require(ReplicatedStorage.Lib) local ecr = require(ReplicatedStorage.DevPackages.ecr) local newWorld = Matter.World.new() local ecs = jecs.World.new() +local mirror = require(ReplicatedStorage.mirror) +local mcs = mirror.World.new() local A1 = Matter.component() local A2 = Matter.component() @@ -35,6 +37,15 @@ local C5 = ecs:entity() local C6 = ecs:entity() local C7 = ecs:entity() local C8 = ecs:entity() +local E1 = mcs:entity() +local E2 = mcs:entity() +local E3 = mcs:entity() +local E4 = mcs:entity() +local E5 = mcs:entity() +local E6 = mcs:entity() +local E7 = mcs:entity() +local E8 = mcs:entity() + local registry2 = ecr.registry() return { @@ -44,7 +55,7 @@ return { Functions = { Matter = function() - for i = 1, 50 do + for i = 1, 500 do newWorld:spawn( A1({ value = true }), A2({ value = true }), @@ -60,8 +71,8 @@ return { ECR = function() - for i = 1, 50 do - local e = registry2.create() + local e = registry2.create() + for i = 1, 500 do registry2:set(e, B1, {value = false}) registry2:set(e, B2, {value = false}) registry2:set(e, B3, {value = false}) @@ -78,7 +89,7 @@ return { local e = ecs:entity() - for i = 1, 50 do + for i = 1, 500 do ecs:set(e, C1, {value = false}) ecs:set(e, C2, {value = false}) @@ -89,6 +100,23 @@ return { ecs:set(e, C7, {value = false}) ecs:set(e, C8, {value = false}) + end + end, + Mirror = function() + + local e = ecs:entity() + + for i = 1, 500 do + + mcs:set(e, E1, {value = false}) + mcs:set(e, E2, {value = false}) + mcs:set(e, E3, {value = false}) + mcs:set(e, E4, {value = false}) + mcs:set(e, E5, {value = false}) + mcs:set(e, E6, {value = false}) + mcs:set(e, E7, {value = false}) + mcs:set(e, E8, {value = false}) + end end diff --git a/lib/init.lua b/lib/init.lua index 93e5021..166e3b6 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,21 +82,27 @@ local function transitionArchetype( column[last] = nil end - -- Move the entity from the source to the destination archetype. - local atSourceRow = sourceEntities[sourceRow] - destinationEntities[destinationRow] = atSourceRow - entityIndex[atSourceRow].row = destinationRow + local sparse = entityIndex.sparse + local movedAway = #sourceEntities + -- Move the entity from the source to the destination archetype. -- Because we have swapped columns we now have to update the records -- corresponding to the entities' rows that were swapped. - local movedAway = #sourceEntities - if sourceRow ~= movedAway then - local atMovedAway = sourceEntities[movedAway] - sourceEntities[sourceRow] = atMovedAway - entityIndex[atMovedAway].row = sourceRow + local e1 = sourceEntities[sourceRow] + local e2 = sourceEntities[movedAway] + + if sourceRow ~= movedAway then + sourceEntities[sourceRow] = e2 end sourceEntities[movedAway] = nil + destinationEntities[destinationRow] = e1 + + local record1 = sparse[e1] + local record2 = sparse[e2] + + record1.row = destinationRow + record2.row = sourceRow end local function archetypeAppend(entity: number, archetype: Archetype): number @@ -143,14 +150,14 @@ local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archet end end -local function archetypeOf(world: any, types: {i24}, prev: Archetype?): Archetype +local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype local ty = hash(types) local id = world.nextArchetypeId + 1 world.nextArchetypeId = id local length = #types - local columns = table.create(length) + local columns = table.create(length) :: {any} for index in types do columns[index] = {} @@ -174,6 +181,194 @@ local function archetypeOf(world: any, types: {i24}, prev: Archetype?): Archetyp return archetype end +local World = {} +World.__index = World +function World.new() + local self = setmetatable({ + archetypeIndex = {}; + archetypes = {}; + componentIndex = {}; + entityIndex = { + dense = {}, + sparse = {} + } :: EntityIndex; + hooks = { + [ON_ADD] = {}; + }; + nextArchetypeId = 0; + nextComponentId = 0; + nextEntityId = 0; + ROOT_ARCHETYPE = (nil :: any) :: Archetype; + }, World) + return self +end + +local FLAGS_PAIR = 0x8 + +local function addFlags(flags) + local typeFlags = 0x0 + if flags.isPair then + typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID. + end + if false then + typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true + end + if false then + typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true + end + if false then + typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID. + end + + return typeFlags +end + +local ECS_ID_FLAGS_MASK = 0x10 + +-- ECS_ENTITY_MASK (0xFFFFFFFFull << 28) +local ECS_ENTITY_MASK = bit32.lshift(1, 24) + +-- ECS_GENERATION_MASK (0xFFFFull << 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) + +local function newId(source: number, target: number) + local e = source * 2^28 + target * ECS_ID_FLAGS_MASK + return e +end + +local function isPair(e: number) + return (e % 2^4) // FLAGS_PAIR ~= 0 +end + +function separate(entity: number) + local _typeFlags = entity % 0x10 + entity //= ECS_ID_FLAGS_MASK + return entity // ECS_ENTITY_MASK, entity % ECS_GENERATION_MASK, _typeFlags +end + +-- HIGH 24 bits LOW 24 bits +local function ECS_GENERATION(e: i53) + e //= 0x10 + return e % ECS_GENERATION_MASK +end + +local function ECS_ID(e: i53) + e //= 0x10 + return e // ECS_ENTITY_MASK +end + +local function ECS_GENERATION_INC(e: i53) + local id, generation, flags = separate(e) + + return newId(id, generation + 1) + flags +end + +-- gets the high ID +local function ECS_PAIR_FIRST(entity: i53): i24 + entity //= 0x10 + local first = entity % ECS_ENTITY_MASK + return first +end + +-- gets the low ID +local ECS_PAIR_SECOND = ECS_ID + +local function ECS_PAIR(source: number, target: number) + local id = newId(ECS_PAIR_SECOND(target), ECS_PAIR_SECOND(source)) + addFlags({ isPair = true }) + return id +end + +local function getAlive(entityIndex: EntityIndex, id: i53) + return assert(entityIndex.dense[id], id .. "is not alive") +end + +local function ecs_get_source(entityIndex, e) + assert(isPair(e)) + return getAlive(entityIndex, ECS_PAIR_FIRST(e)) +end +local function ecs_get_target(entityIndex, e) + assert(isPair(e)) + return getAlive(entityIndex, ECS_PAIR_SECOND(e)) +end + +function World.component(world: World) + local componentId = world.nextComponentId + 1 + if componentId > HI_COMPONENT_ID then + -- IDs are partitioned into ranges because component IDs are not nominal, + -- so it needs to error when IDs intersect into the entity range. + error("Too many components, consider using world:entity() instead to create components.") + end + world.nextComponentId = componentId + return componentId +end + +function World.entity(world: World) + local nextEntityId = world.nextEntityId + 1 + world.nextEntityId = nextEntityId + local index = nextEntityId + REST + local id = newId(index, 0) + local entityIndex = world.entityIndex + entityIndex.sparse[id] = { + dense = index + } :: Record + entityIndex.dense[index] = id + + return id +end + +-- 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, record: Record, entityId: i53, destruct: boolean) + local sparse, dense = entityIndex.sparse, entityIndex.dense + local archetype = record.archetype + local row = record.row + local entities = archetype.entities + local last = #entities + + local entityToMove = entities[last] + + if row ~= last then + dense[record.dense] = entityToMove + sparse[entityToMove] = record + end + + sparse[entityId] = nil + dense[#dense] = nil + + entities[row], entities[last] = entities[last], 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.sparse[entityId] + if not record then + return + end + archetypeDelete(entityIndex, record, entityId, true) +end + +export type World = typeof(World.new()) + local function ensureArchetype(world: World, types, prev) if #types < 1 then return world.ROOT_ARCHETYPE @@ -228,7 +423,15 @@ local function ensureEdge(archetype: Archetype, componentId: i53) end local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype - from = from or world.ROOT_ARCHETYPE + if not from then + -- If there was no source archetype then it should return the ROOT_ARCHETYPE + local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE + if not ROOT_ARCHETYPE then + ROOT_ARCHETYPE = archetypeOf(world, {}, nil) + world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never + end + from = ROOT_ARCHETYPE + end local edge = ensureEdge(from, componentId) local add = edge.add @@ -242,101 +445,35 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet return add end -local function ensureRecord(world, entityId: i53): Record - local entityIndex = world.entityIndex - local record = entityIndex[entityId] - - if record then - return record - end - - local ROOT = world.ROOT_ARCHETYPE - local row = #ROOT.entities + 1 - ROOT.entities[row] = entityId - record = { - archetype = ROOT, - row = row - } - entityIndex[entityId] = record - return record -end - -local World = {} -World.__index = World -function World.new() - local self = setmetatable({ - archetypeIndex = {}; - archetypes = {}; - componentIndex = {}; - entityIndex = {}; - hooks = { - [ON_ADD] = {}; - }; - nextArchetypeId = 0; - nextComponentId = 0; - nextEntityId = 0; - ROOT_ARCHETYPE = (nil :: any) :: Archetype; - }, World) - self.ROOT_ARCHETYPE = archetypeOf(self, {}, nil) - return self -end - -local function emit(world, eventDescription) - local event = eventDescription.event - - table.insert(world.hooks[event], { - archetype = eventDescription.archetype; - ids = eventDescription.ids; - offset = eventDescription.offset; - otherArchetype = eventDescription.otherArchetype; - }) -end - -local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty) - if #added > 0 then - emit(world, { - archetype = archetype; - event = ON_ADD; - ids = added; - offset = row; - otherArchetype = otherArchetype; - }) - end -end - -export type World = typeof(World.new()) - - function World.add(world: World, entityId: i53, componentId: i53) - local record = ensureRecord(world, entityId) + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entityId] local from = record.archetype local to = archetypeTraverseAdd(world, componentId, from) if from and not (from == world.ROOT_ARCHETYPE) then - moveEntity(world.entityIndex, entityId, record, to) + moveEntity(entityIndex, entityId, record, to) else if #to.types > 0 then newEntity(entityId, record, to) - onNotifyAdd(world, to, from, record.row, { componentId }) end end end -- Symmetric like `World.add` but idempotent -function World.set(world: World, entityId: i53, componentId: i53, data: unknown) - local record = ensureRecord(world, entityId) +function World.set(world: World, entityId: i53, componentId: i53, data: unknown) + local record = world.entityIndex.sparse[entityId] local from = record.archetype + local to = archetypeTraverseAdd(world, componentId, from) - local archetypeRecord = from.records[componentId] - if archetypeRecord then + if from == to then -- If the archetypes are the same it can avoid moving the entity -- and just set the data directly. + local archetypeRecord = to.records[componentId] from.columns[archetypeRecord][record.row] = data -- Should fire an OnSet event here. return end - local to = archetypeTraverseAdd(world, componentId, from) - if from then -- If there was a previous archetype, then the entity needs to move the archetype moveEntity(world.entityIndex, entityId, record, to) @@ -344,11 +481,10 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown) if #to.types > 0 then -- When there is no previous archetype it should create the archetype newEntity(entityId, record, to) - --onNotifyAdd(world, to, from, record.row, {componentId}) end end - - archetypeRecord = to.records[componentId] + + local archetypeRecord = to.records[componentId] to.columns[archetypeRecord][record.row] = data end @@ -371,12 +507,13 @@ local function archetypeTraverseRemove(world: World, componentId: i53, from: Arc end function World.remove(world: World, entityId: i53, componentId: i53) - local record = ensureRecord(world, entityId) + local entityIndex = world.entityIndex + local record = entityIndex.sparse[entityId] local sourceArchetype = record.archetype local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype) if sourceArchetype and not (sourceArchetype == destinationArchetype) then - moveEntity(world.entityIndex, entityId, record, destinationArchetype) + moveEntity(entityIndex, entityId, record, destinationArchetype) end end @@ -394,7 +531,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 @@ -590,86 +727,24 @@ function World.query(world: World, ...: i53): Query return setmetatable({}, preparedQuery) :: any end -function World.component(world: World) - local componentId = world.nextComponentId + 1 - if componentId > HI_COMPONENT_ID then - -- IDs are partitioned into ranges because component IDs are not nominal, - -- so it needs to error when IDs intersect into the entity range. - error("Too many components, consider using world:entity() instead to create components.") - end - world.nextComponentId = componentId - return componentId -end - -function World.entity(world: World) - local nextEntityId = world.nextEntityId + 1 - world.nextEntityId = nextEntityId - return nextEntityId + REST -end - --- 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, archetype: Archetype, row: i24, destruct: boolean) - 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 = entityIndex[entityToMove] - if recordToMove then - recordToMove.row = row - end - end - - 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] - local archetype = record.archetype - archetypeDelete(entityIndex, archetype, record.row, true) - entityIndex[entityId] = nil -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 @@ -681,7 +756,7 @@ function World.__iter(world: World): () -> (number?, unknown?) entityData[types[i]] = column[row] end - return entity, entityData + return entityId, entityData end end @@ -690,4 +765,12 @@ return table.freeze({ ON_ADD = ON_ADD; ON_REMOVE = ON_REMOVE; ON_SET = ON_SET; -}) \ No newline at end of file + ECS_ID = ECS_ID, + IS_PAIR = isPair, + ECS_PAIR = ECS_PAIR, + ECS_GENERATION = ECS_GENERATION, + ECS_GENERATION_INC = ECS_GENERATION_INC, + getAlive = getAlive, + ecs_get_target = ecs_get_target, + ecs_get_source = ecs_get_source +}) diff --git a/tests/test1.lua b/tests/world.lua similarity index 56% rename from tests/test1.lua rename to tests/world.lua index 3fe86da..5dd3f95 100644 --- a/tests/test1.lua +++ b/tests/world.lua @@ -1,11 +1,52 @@ local testkit = require("../testkit") local jecs = require("../lib/init") +local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION +local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC +local IS_PAIR = jecs.IS_PAIR +local ECS_PAIR = jecs.ECS_PAIR +local getAlive = jecs.getAlive +local ecs_pair_first = jecs.ecs_pair_first +local ecs_pair_second = jecs.ecs_pair_second +local REST = 256 + 4 local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() local N = 10 -TEST("world:query", function() +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() @@ -16,7 +57,6 @@ TEST("world:query", function() 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 @@ -98,7 +138,7 @@ TEST("world:query", function() CHECK(world:get(id, Poison) == 5) end - do CASE "Should allow deleting components" + do CASE "should allow deleting components" local world = jecs.World.new() local Health = world:entity() @@ -107,13 +147,20 @@ TEST("world:query", function() 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 - do CASE "show allow remove that doesn't exist on entity" + do CASE "should allow remove that doesn't exist on entity" local world = jecs.World.new() local Health = world:entity() @@ -124,40 +171,36 @@ TEST("world:query", function() world:remove(id, Poison) CHECK(world:get(id, Poison) == nil) + print(world:get(id, Health)) CHECK(world:get(id, Health) == 50) end - - do CASE "Should allow iterating the whole world" + + do CASE "should increment generation" local world = jecs.World.new() + local e = world:entity() + CHECK(ECS_ID(e) == 1 + REST) + CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e) + CHECK(ECS_GENERATION(e) == 0) -- 0 + e = ECS_GENERATION_INC(e) + CHECK(ECS_GENERATION(e) == 1) -- 1 + end - local A, B = world:entity(), world:entity() + do CASE "relations" + local world = jecs.World.new() + local _e = world:entity() + local e2 = world:entity() + local e3 = world:entity() + CHECK(ECS_ID(e2) == 2 + REST) + CHECK(ECS_ID(e3) == 3 + REST) + CHECK(ECS_GENERATION(e2) == 0) + CHECK(ECS_GENERATION(e3) == 0) - 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) + CHECK(IS_PAIR(world:entity()) == false) - 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[B] == true) - CHECK(data[A] == nil) - elseif id == eAB then - CHECK(data[A] == true) - CHECK(data[B] == true) - else - error("unknown entity", id) - end - end - - CHECK(count == 3) + local pair = ECS_PAIR(e2, e3) + CHECK(IS_PAIR(pair) == true) + CHECK(ecs_pair_first(world.entityIndex, pair) == e2) + CHECK(ecs_pair_second(world.entityIndex, pair) == e3) end end) From cfee0e9861df9e316b872ae57a31f4fc8bf45a6e Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 18:02:23 +0200 Subject: [PATCH 14/25] Add relations as first class citizens --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2d8d210..bb62bdf 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ jecs is a stupidly fast Entity Component System (ECS). - Zero-dependency Luau package - Optimized for column-major operations - Cache friendly archetype/SoA storage +- Entity Relationships as first class citizens ### Example From 10a54c368caac1dcf78c892fd48bc4b2a9959fa9 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 18:09:34 +0200 Subject: [PATCH 15/25] Update list --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bb62bdf..af675da 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ Just an ECS jecs is a stupidly fast Entity Component System (ECS). +- Entity Relationships as first class citizens - Process tens of thousands of entities with ease every frame -- Zero-dependency Luau package +- Type-safe [Luau](https://luau-lang.org/) API +- Zero-dependency package - Optimized for column-major operations - Cache friendly archetype/SoA storage -- Entity Relationships as first class citizens +- Unit tested for stability ### Example From d087df35946a57a1f473b3d7336c2371fac84976 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 18:10:30 +0200 Subject: [PATCH 16/25] 0.1.0 --- wally.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wally.toml b/wally.toml index 5885799..9e0b760 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "ukendio/jecs" -version = "0.0.0-prototype.rc.3" +version = "0.1.0-rc.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["**"] From 107d260abf8070e1fbf7e57a13d0e9758cc9db5b Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 18:13:22 +0200 Subject: [PATCH 17/25] Remove dependencies --- wally.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/wally.toml b/wally.toml index 9e0b760..ffc6841 100644 --- a/wally.toml +++ b/wally.toml @@ -7,6 +7,4 @@ exclude = ["**"] include = ["default.project.json", "lib", "wally.toml", "README.md"] [dev-dependencies] -TestEZ = "roblox/testez@0.4.1" -Matter = "matter-ecs/matter@0.8.0" -ecr = "centau/ecr@0.8.0" +TestEZ = "roblox/testez@0.4.1" \ No newline at end of file From 076f0ca436749ec552e0570f9638a1cd6fbcaa39 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 18:20:02 +0200 Subject: [PATCH 18/25] Bump to rc.4 and include lib --- wally.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/wally.toml b/wally.toml index ffc6841..fc77a24 100644 --- a/wally.toml +++ b/wally.toml @@ -1,10 +1,9 @@ [package] name = "ukendio/jecs" -version = "0.1.0-rc.0" +version = "0.1.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" +include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"] exclude = ["**"] -include = ["default.project.json", "lib", "wally.toml", "README.md"] -[dev-dependencies] -TestEZ = "roblox/testez@0.4.1" \ No newline at end of file +[dev-dependencies] \ No newline at end of file From 87711eff19ff94686e51dd548d9363b1e52f0bb3 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 20:35:41 +0200 Subject: [PATCH 19/25] Register components as entities --- lib/init.lua | 26 ++++++++++++++------------ tests/world.lua | 14 +++++++------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index 166e3b6..ea1154b 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -291,6 +291,16 @@ local function ecs_get_target(entityIndex, e) return getAlive(entityIndex, ECS_PAIR_SECOND(e)) end +local function nextEntityId(entityIndex, index: i24) + local id = newId(index, 0) + entityIndex.sparse[id] = { + dense = index + } :: Record + entityIndex.dense[index] = id + + return id +end + function World.component(world: World) local componentId = world.nextComponentId + 1 if componentId > HI_COMPONENT_ID then @@ -299,21 +309,13 @@ function World.component(world: World) error("Too many components, consider using world:entity() instead to create components.") end world.nextComponentId = componentId - return componentId + return nextEntityId(world.entityIndex, componentId) end function World.entity(world: World) - local nextEntityId = world.nextEntityId + 1 - world.nextEntityId = nextEntityId - local index = nextEntityId + REST - local id = newId(index, 0) - local entityIndex = world.entityIndex - entityIndex.sparse[id] = { - dense = index - } :: Record - entityIndex.dense[index] = id - - return id + local entityId = world.nextEntityId + 1 + world.nextEntityId = entityId + return nextEntityId(world.entityIndex, entityId + REST) end -- should reuse this logic in World.set instead of swap removing in transition archetype diff --git a/tests/world.lua b/tests/world.lua index 5dd3f95..d95097d 100644 --- a/tests/world.lua +++ b/tests/world.lua @@ -5,8 +5,8 @@ local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC local IS_PAIR = jecs.IS_PAIR local ECS_PAIR = jecs.ECS_PAIR local getAlive = jecs.getAlive -local ecs_pair_first = jecs.ecs_pair_first -local ecs_pair_second = jecs.ecs_pair_second +local ecs_get_source = jecs.ecs_get_source +local ecs_get_target = jecs.ecs_get_target local REST = 256 + 4 local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() @@ -39,12 +39,12 @@ TEST("world", function() elseif id == eAB then CHECK(data[A] == true) CHECK(data[B] == true) - else - error("unknown entity", id) end end - CHECK(count == 3) + -- components are registered in the entity index as well + -- so this test has to add 2 to account for them + CHECK(count == 3 + 2) end do CASE "should query all matching entities" @@ -199,8 +199,8 @@ TEST("world", function() local pair = ECS_PAIR(e2, e3) CHECK(IS_PAIR(pair) == true) - CHECK(ecs_pair_first(world.entityIndex, pair) == e2) - CHECK(ecs_pair_second(world.entityIndex, pair) == e3) + CHECK(ecs_get_source(world.entityIndex, pair) == e2) + CHECK(ecs_get_target(world.entityIndex, pair) == e3) end end) From 4c105fa72ce314c79ff79c273e3a8d023b4d7afe Mon Sep 17 00:00:00 2001 From: Ukendio Date: Fri, 10 May 2024 20:36:01 +0200 Subject: [PATCH 20/25] Bump version --- wally.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wally.toml b/wally.toml index fc77a24..7102c41 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "ukendio/jecs" -version = "0.1.0-rc.4" +version = "0.1.0-rc.5" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"] From e6b16e91ae76a6a448dee5108aa5b880442cb4b6 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 11 May 2024 02:10:04 +0200 Subject: [PATCH 21/25] Remove assert --- lib/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/init.lua b/lib/init.lua index ea1154b..ca6a573 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -279,7 +279,7 @@ local function ECS_PAIR(source: number, target: number) end local function getAlive(entityIndex: EntityIndex, id: i53) - return assert(entityIndex.dense[id], id .. "is not alive") + return entityIndex.dense[id] end local function ecs_get_source(entityIndex, e) From 582b09be6645dd64242abc880693a2e99f6d59fc Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 11 May 2024 02:12:47 +0200 Subject: [PATCH 22/25] Update readme --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af675da..6bb4607 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,14 @@ world:set(opponent, Position, Vector3.new(0, 5, 3)) for playerId, playerPosition, health in world:query(Position, Health) do local totalDamage = 0 for opponentId, opponentPosition, damage in world:query(Position, Damage) do + if playerId == opponentId then + continue + end if (playerPosition - opponentPosition).Magnitude < 5 then totalDamage += damage end + -- We create a pair between the relation component `DamagedBy` and the entity id of the opponent. + -- This will allow us to specifically query for damage exerted by a specific opponent. world:set(playerId, ECS_PAIR(DamagedBy, opponentId), totalDamage) end end @@ -57,7 +62,7 @@ for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, oppon world:set(playerId, health - inflicted) end -assert(world:get(playerId, Health) == 79) +assert(world:get(player, Health) == 79) ``` 125 archetypes, 4 random components queried. From 2df5f3f18e494d5177a17af398e833f60d8d0c3a Mon Sep 17 00:00:00 2001 From: Marcus Date: Mon, 13 May 2024 00:53:51 +0200 Subject: [PATCH 23/25] Add wildcards (#37) * Fix export * Initial commit * Uncomment cases * Rename case * Add tests for wildcards * Support wildcards in records * Add tests for relation data * Add shorthands * Change casing of exports * Change function signatures * Improve inlining of ECS_PAIR * Delete whitespace * Create root archetype * Add back tests * Fix tests --- lib/init.lua | 299 ++++++++++++++++++++++++---------------------- lib/init.spec.lua | 68 ++++++++++- test.project.json | 6 +- tests/world.lua | 77 ++++++++++-- wally.toml | 3 +- 5 files changed, 292 insertions(+), 161 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index ca6a573..b7486e4 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -44,11 +44,118 @@ type ArchetypeDiff = { removed: Ty, } +local FLAGS_PAIR = 0x8 local HI_COMPONENT_ID = 256 local ON_ADD = HI_COMPONENT_ID + 1 local ON_REMOVE = HI_COMPONENT_ID + 2 local ON_SET = HI_COMPONENT_ID + 3 -local REST = HI_COMPONENT_ID + 4 +local WILDCARD = HI_COMPONENT_ID + 4 +local REST = HI_COMPONENT_ID + 5 + +local ECS_ID_FLAGS_MASK = 0x10 +local ECS_ENTITY_MASK = bit32.lshift(1, 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) + +local function addFlags(isPair: boolean) + local typeFlags = 0x0 + + if isPair then + typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID. + end + if false then + typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true + end + if false then + typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true + end + if false then + typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID. + end + + return typeFlags +end + +local function newId(source: number, target: number) + local e = source * 2^28 + target * ECS_ID_FLAGS_MASK + return e +end + +local function ECS_IS_PAIR(e: number) + return (e % 2^4) // FLAGS_PAIR ~= 0 +end + +function separate(entity: number) + local _typeFlags = entity % 0x10 + entity //= ECS_ID_FLAGS_MASK + return entity // ECS_ENTITY_MASK, entity % ECS_GENERATION_MASK, _typeFlags +end + +-- HIGH 24 bits LOW 24 bits +local function ECS_GENERATION(e: i53) + e //= 0x10 + return e % ECS_GENERATION_MASK +end + +local function ECS_ID(e: i53) + e //= 0x10 + return e // ECS_ENTITY_MASK +end + +local function ECS_GENERATION_INC(e: i53) + local id, generation, flags = separate(e) + + return newId(id, generation + 1) + flags +end + +-- gets the high ID +local function ECS_PAIR_FIRST(entity: i53): i24 + entity //= 0x10 + local first = entity % ECS_ENTITY_MASK + return first +end + +-- gets the low ID +local ECS_PAIR_SECOND = ECS_ID + +local function ECS_PAIR(first: number, second: number) + local target = WILDCARD + local relation + + if first == WILDCARD then + relation = second + elseif second == WILDCARD then + relation = first + else + relation = second + target = ECS_PAIR_SECOND(first) + end + + return newId( + ECS_PAIR_SECOND(relation), target) + addFlags(--[[isPair]] true) +end + +local function getAlive(entityIndex: EntityIndex, id: i53) + return entityIndex.dense[id] +end + +local function ecs_get_source(entityIndex, e) + assert(ECS_IS_PAIR(e)) + return getAlive(entityIndex, ECS_PAIR_FIRST(e)) +end +local function ecs_get_target(entityIndex, e) + assert(ECS_IS_PAIR(e)) + return getAlive(entityIndex, ECS_PAIR_SECOND(e)) +end + +local function nextEntityId(entityIndex, index: i24) + local id = newId(index, 0) + entityIndex.sparse[id] = { + dense = index + } :: Record + entityIndex.dense[index] = id + + return id +end local function transitionArchetype( entityIndex: EntityIndex, @@ -132,22 +239,14 @@ local function hash(arr): string | number return table.concat(arr, "_") end -local function createArchetypeRecords(componentIndex: ComponentIndex, to: Archetype, _from: Archetype?) - local destinationIds = to.types - local records = to.records - local id = to.id +local function createArchetypeRecord(componentIndex, id, componentId, i) + local archetypesMap = componentIndex[componentId] - for i, destinationId in destinationIds do - local archetypesMap = componentIndex[destinationId] - - if not archetypesMap then - archetypesMap = {size = 0, sparse = {}} - componentIndex[destinationId] = archetypesMap - end - - archetypesMap.sparse[id] = i - records[destinationId] = i + if not archetypesMap then + archetypesMap = {size = 0, sparse = {}} + componentIndex[componentId] = archetypesMap end + archetypesMap.sparse[id] = i end local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype @@ -157,10 +256,26 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet world.nextArchetypeId = id local length = #types - local columns = table.create(length) :: {any} + local columns = table.create(length) - for index in types do - columns[index] = {} + local records = {} + local componentIndex = world.componentIndex + local entityIndex = world.entityIndex + for i, componentId in types do + createArchetypeRecord(componentIndex, id, componentId, i) + records[componentId] = i + columns[i] = {} + + if ECS_IS_PAIR(componentId) then + local first = ecs_get_source(entityIndex, componentId) + local second = ecs_get_target(entityIndex, componentId) + local firstPair = ECS_PAIR(first, WILDCARD) + local secondPair = ECS_PAIR(WILDCARD, second) + createArchetypeRecord(componentIndex, id, firstPair, i) + createArchetypeRecord(componentIndex, id, secondPair, i) + records[firstPair] = i + records[secondPair] = i + end end local archetype = { @@ -168,15 +283,12 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet edges = {}; entities = {}; id = id; - records = {}; + records = records; type = ty; types = types; } world.archetypeIndex[ty] = archetype world.archetypes[id] = archetype - if length > 0 then - createArchetypeRecords(world.componentIndex, archetype, prev) - end return archetype end @@ -186,8 +298,8 @@ World.__index = World function World.new() local self = setmetatable({ archetypeIndex = {}; - archetypes = {}; - componentIndex = {}; + archetypes = {} :: Archetypes; + componentIndex = {} :: ComponentIndex; entityIndex = { dense = {}, sparse = {} @@ -200,107 +312,10 @@ function World.new() nextEntityId = 0; ROOT_ARCHETYPE = (nil :: any) :: Archetype; }, World) + self.ROOT_ARCHETYPE = archetypeOf(self, {}) return self end -local FLAGS_PAIR = 0x8 - -local function addFlags(flags) - local typeFlags = 0x0 - if flags.isPair then - typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID. - end - if false then - typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true - end - if false then - typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true - end - if false then - typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID. - end - - return typeFlags -end - -local ECS_ID_FLAGS_MASK = 0x10 - --- ECS_ENTITY_MASK (0xFFFFFFFFull << 28) -local ECS_ENTITY_MASK = bit32.lshift(1, 24) - --- ECS_GENERATION_MASK (0xFFFFull << 24) -local ECS_GENERATION_MASK = bit32.lshift(1, 16) - -local function newId(source: number, target: number) - local e = source * 2^28 + target * ECS_ID_FLAGS_MASK - return e -end - -local function isPair(e: number) - return (e % 2^4) // FLAGS_PAIR ~= 0 -end - -function separate(entity: number) - local _typeFlags = entity % 0x10 - entity //= ECS_ID_FLAGS_MASK - return entity // ECS_ENTITY_MASK, entity % ECS_GENERATION_MASK, _typeFlags -end - --- HIGH 24 bits LOW 24 bits -local function ECS_GENERATION(e: i53) - e //= 0x10 - return e % ECS_GENERATION_MASK -end - -local function ECS_ID(e: i53) - e //= 0x10 - return e // ECS_ENTITY_MASK -end - -local function ECS_GENERATION_INC(e: i53) - local id, generation, flags = separate(e) - - return newId(id, generation + 1) + flags -end - --- gets the high ID -local function ECS_PAIR_FIRST(entity: i53): i24 - entity //= 0x10 - local first = entity % ECS_ENTITY_MASK - return first -end - --- gets the low ID -local ECS_PAIR_SECOND = ECS_ID - -local function ECS_PAIR(source: number, target: number) - local id = newId(ECS_PAIR_SECOND(target), ECS_PAIR_SECOND(source)) + addFlags({ isPair = true }) - return id -end - -local function getAlive(entityIndex: EntityIndex, id: i53) - return entityIndex.dense[id] -end - -local function ecs_get_source(entityIndex, e) - assert(isPair(e)) - return getAlive(entityIndex, ECS_PAIR_FIRST(e)) -end -local function ecs_get_target(entityIndex, e) - assert(isPair(e)) - return getAlive(entityIndex, ECS_PAIR_SECOND(e)) -end - -local function nextEntityId(entityIndex, index: i24) - local id = newId(index, 0) - entityIndex.sparse[id] = { - dense = index - } :: Record - entityIndex.dense[index] = id - - return id -end - function World.component(world: World) local componentId = world.nextComponentId + 1 if componentId > HI_COMPONENT_ID then @@ -402,15 +417,16 @@ local function findArchetypeWith(world: World, node: Archetype, componentId: i53 -- Component IDs are added incrementally, so inserting and sorting -- them each time would be expensive. Instead this insertion sort can find the insertion -- point in the types array. + + local destinationType = table.clone(node.types) local at = findInsert(types, componentId) if at == -1 then -- If it finds a duplicate, it just means it is the same archetype so it can return it -- directly instead of needing to hash types for a lookup to the archetype. return node end - - local destinationType = table.clone(node.types) table.insert(destinationType, at, componentId) + return ensureArchetype(world, destinationType, node) end @@ -425,15 +441,7 @@ local function ensureEdge(archetype: Archetype, componentId: i53) end local function archetypeTraverseAdd(world: World, componentId: i53, from: Archetype): Archetype - if not from then - -- If there was no source archetype then it should return the ROOT_ARCHETYPE - local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE - if not ROOT_ARCHETYPE then - ROOT_ARCHETYPE = archetypeOf(world, {}, nil) - world.ROOT_ARCHETYPE = ROOT_ARCHETYPE :: never - end - from = ROOT_ARCHETYPE - end + from = from or world.ROOT_ARCHETYPE local edge = ensureEdge(from, componentId) local add = edge.add @@ -659,14 +667,14 @@ function World.query(world: World, ...: i53): Query function preparedQuery:__iter() return function() local archetype = compatibleArchetype[1] - local row = next(archetype.entities, lastRow) + local row: number = next(archetype.entities, lastRow) :: number while row == nil do lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype) if lastArchetype == nil then return end archetype = compatibleArchetype[1] - row = next(archetype.entities, row) + row = next(archetype.entities, row) :: number end lastRow = row @@ -764,15 +772,22 @@ end return table.freeze({ World = World; - ON_ADD = ON_ADD; - ON_REMOVE = ON_REMOVE; - ON_SET = ON_SET; + + OnAdd = ON_ADD; + OnRemove = ON_REMOVE; + OnSet = ON_SET; + Wildcard = WILDCARD, + w = WILDCARD, + Rest = REST, + ECS_ID = ECS_ID, - IS_PAIR = isPair, + IS_PAIR = ECS_IS_PAIR, ECS_PAIR = ECS_PAIR, - ECS_GENERATION = ECS_GENERATION, ECS_GENERATION_INC = ECS_GENERATION_INC, - getAlive = getAlive, + ECS_GENERATION = ECS_GENERATION, ecs_get_target = ecs_get_target, - ecs_get_source = ecs_get_source + ecs_get_source = ecs_get_source, + + pair = ECS_PAIR, + getAlive = getAlive, }) diff --git a/lib/init.spec.lua b/lib/init.spec.lua index 553c9a4..8de8de9 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -309,12 +309,74 @@ return function() elseif id == eAB then expect(data[A]).to.be.ok() expect(data[B]).to.be.ok() - else - error("unknown entity", id) end end - expect(count).to.equal(3) + expect(count).to.equal(5) end) + + it("should allow querying for relations", function() + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local bob = world:entity() + + world:set(bob, jecs.pair(Eats, Apples), true) + for e, bool in world:query(jecs.pair(Eats, Apples)) do + expect(e).to.equal(bob) + expect(bool).to.equal(bool) + end + end) + + it("should allow wildcards in queries", function() + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local bob = world:entity() + + world:set(bob, jecs.pair(Eats, Apples), "bob eats apples") + for e, data in world:query(jecs.pair(Eats, jecs.w)) do + expect(e).to.equal(bob) + expect(data).to.equal("bob eats apples") + end + for e, data in world:query(jecs.pair(jecs.w, Apples)) do + expect(e).to.equal(bob) + expect(data).to.equal("bob eats apples") + end + end) + + it("should match against multiple pairs", function() + local world = jecs.World.new() + local pair = jecs.pair + local Eats = world:entity() + local Apples = world:entity() + local Oranges =world:entity() + local bob = world:entity() + local alice = world:entity() + + world:set(bob, pair(Eats, Apples), "bob eats apples") + world:set(alice, pair(Eats, Oranges), "alice eats oranges") + + local w = jecs.Wildcard + + local count = 0 + for e, data in world:query(pair(Eats, w)) do + count += 1 + if e == bob then + expect(data).to.equal("bob eats apples") + else + expect(data).to.equal("alice eats oranges") + end + end + + expect(count).to.equal(2) + count = 0 + + for e, data in world:query(pair(w, Apples)) do + count += 1 + expect(data).to.equal("bob eats apples") + end + expect(count).to.equal(1) + end) end) end \ No newline at end of file diff --git a/test.project.json b/test.project.json index b931a84..bdcbd0b 100644 --- a/test.project.json +++ b/test.project.json @@ -11,9 +11,6 @@ }, "ReplicatedStorage": { "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "DevPackages" - }, "Lib": { "$path": "lib" }, @@ -25,6 +22,9 @@ }, "mirror": { "$path": "mirror" + }, + "DevPackages": { + "$path": "DevPackages" } }, "TestService": { diff --git a/tests/world.lua b/tests/world.lua index d95097d..1aff493 100644 --- a/tests/world.lua +++ b/tests/world.lua @@ -7,7 +7,6 @@ local ECS_PAIR = jecs.ECS_PAIR local getAlive = jecs.getAlive local ecs_get_source = jecs.ecs_get_source local ecs_get_target = jecs.ecs_get_target -local REST = 256 + 4 local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() @@ -18,7 +17,6 @@ TEST("world", function() 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() @@ -48,7 +46,6 @@ TEST("world", function() end do CASE "should query all matching entities" - local world = jecs.World.new() local A = world:component() local B = world:component() @@ -71,7 +68,6 @@ TEST("world", function() 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() @@ -99,7 +95,6 @@ TEST("world", function() end do CASE "should query all entities without B" - local world = jecs.World.new() local A = world:component() local B = world:component() @@ -171,29 +166,24 @@ TEST("world", function() world:remove(id, Poison) CHECK(world:get(id, Poison) == nil) - print(world:get(id, Health)) CHECK(world:get(id, Health) == 50) end do CASE "should increment generation" local world = jecs.World.new() local e = world:entity() - CHECK(ECS_ID(e) == 1 + REST) + CHECK(ECS_ID(e) == 1 + jecs.Rest) CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e) CHECK(ECS_GENERATION(e) == 0) -- 0 e = ECS_GENERATION_INC(e) CHECK(ECS_GENERATION(e) == 1) -- 1 end - do CASE "relations" + do CASE "should get alive from index in the dense array" local world = jecs.World.new() local _e = world:entity() local e2 = world:entity() local e3 = world:entity() - CHECK(ECS_ID(e2) == 2 + REST) - CHECK(ECS_ID(e3) == 3 + REST) - CHECK(ECS_GENERATION(e2) == 0) - CHECK(ECS_GENERATION(e3) == 0) CHECK(IS_PAIR(world:entity()) == false) @@ -203,6 +193,69 @@ TEST("world", function() CHECK(ecs_get_target(world.entityIndex, pair) == e3) end + do CASE "should allow querying for relations" + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local bob = world:entity() + + world:set(bob, ECS_PAIR(Eats, Apples), true) + for e, bool in world:query(ECS_PAIR(Eats, Apples)) do + CHECK(e == bob) + CHECK(bool) + end + end + + do CASE "should allow wildcards in queries" + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local bob = world:entity() + + world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") + + local w = jecs.Wildcard + for e, data in world:query(ECS_PAIR(Eats, w)) do + CHECK(e == bob) + CHECK(data == "bob eats apples") + end + for e, data in world:query(ECS_PAIR(w, Apples)) do + CHECK(e == bob) + CHECK(data == "bob eats apples") + end + end + + do CASE "should match against multiple pairs" + local world = jecs.World.new() + local Eats = world:entity() + local Apples = world:entity() + local Oranges =world:entity() + local bob = world:entity() + local alice = world:entity() + + world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") + world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges") + + local w = jecs.Wildcard + local count = 0 + for e, data in world:query(ECS_PAIR(Eats, w)) do + count += 1 + if e == bob then + CHECK(data == "bob eats apples") + else + CHECK(data == "alice eats oranges") + end + end + + CHECK(count == 2) + count = 0 + + for e, data in world:query(ECS_PAIR(w, Apples)) do + count += 1 + CHECK(data == "bob eats apples") + end + CHECK(count == 1) + end end) FINISH() \ No newline at end of file diff --git a/wally.toml b/wally.toml index 7102c41..f17e660 100644 --- a/wally.toml +++ b/wally.toml @@ -6,4 +6,5 @@ realm = "shared" include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"] exclude = ["**"] -[dev-dependencies] \ No newline at end of file +[dev-dependencies] +TestEZ = "roblox/testez@0.4.1" \ No newline at end of file From d6b6caf07afb176842ebe83801987eb1239378f9 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Mon, 13 May 2024 02:26:00 +0200 Subject: [PATCH 24/25] 0.1.0-rc.6 --- wally.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wally.toml b/wally.toml index f17e660..41797b9 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "ukendio/jecs" -version = "0.1.0-rc.5" +version = "0.1.0-rc.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"] From 85e2621cce9a7c788fb904aca7240b1227a1e81b Mon Sep 17 00:00:00 2001 From: Ukendio Date: Mon, 13 May 2024 20:15:09 +0200 Subject: [PATCH 25/25] CompatibleArchetype as a map --- lib/init.lua | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index b7486e4..f45e842 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -623,7 +623,10 @@ function World.query(world: World, ...: i53): Query end length += 1 - compatibleArchetypes[length] = {archetype, indices} + compatibleArchetypes[length] = { + archetype = archetype, + indices = indices + } end local lastArchetype, compatibleArchetype = next(compatibleArchetypes) @@ -637,7 +640,7 @@ function World.query(world: World, ...: i53): Query function preparedQuery:without(...) local withoutComponents = {...} for i = #compatibleArchetypes, 1, -1 do - local archetype = compatibleArchetypes[i][1] + local archetype = compatibleArchetypes[i].archetype local records = archetype.records local shouldRemove = false @@ -666,21 +669,21 @@ function World.query(world: World, ...: i53): Query function preparedQuery:__iter() return function() - local archetype = compatibleArchetype[1] + local archetype = compatibleArchetype.archetype local row: number = next(archetype.entities, lastRow) :: number while row == nil do lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype) if lastArchetype == nil then return end - archetype = compatibleArchetype[1] + archetype = compatibleArchetype.archetype row = next(archetype.entities, row) :: number end lastRow = row local entityId = archetype.entities[row :: number] local columns = archetype.columns - local tr = compatibleArchetype[2] + local tr = compatibleArchetype.indices if queryLength == 1 then return entityId, columns[tr[1]][row]