From 155d51a080a0d723162fdd03bd36f3a2cf816f58 Mon Sep 17 00:00:00 2001 From: Marcus Date: Fri, 4 Jul 2025 03:42:10 +0200 Subject: [PATCH] Add exclusive relations (#250) * Add exclusive relationship * Remove focus * Remove whitespace * Make ChildOf exclusive * Test exclusive relation perf * Inline into world:add * Inline into world:set * Fix benchmark of remove --- benches/visual/remove.bench.luau | 19 ++-- jecs.luau | 167 ++++++++++++++++++++++++++++--- mirror.luau | 109 ++++++++++++-------- test/tests.luau | 82 +++++++++++++-- 4 files changed, 307 insertions(+), 70 deletions(-) diff --git a/benches/visual/remove.bench.luau b/benches/visual/remove.bench.luau index 3aab30c..72b94fa 100755 --- a/benches/visual/remove.bench.luau +++ b/benches/visual/remove.bench.luau @@ -4,15 +4,17 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Matter = require(ReplicatedStorage.DevPackages.Matter) local ecr = require(ReplicatedStorage.DevPackages.ecr) -local jecs = require(ReplicatedStorage.Lib) +local jecs = require(ReplicatedStorage.Lib:Clone()) local pair = jecs.pair local ecs = jecs.world() -local mirror = require(ReplicatedStorage.mirror) +local mirror = require(ReplicatedStorage.mirror:Clone()) local mcs = mirror.world() local C1 = ecs:component() local C2 = ecs:entity() ecs:add(C2, pair(jecs.OnDeleteTarget, jecs.Delete)) +ecs:add(C2, jecs.Exclusive) + local C3 = ecs:entity() ecs:add(C3, pair(jecs.OnDeleteTarget, jecs.Delete)) local C4 = ecs:entity() @@ -32,17 +34,18 @@ return { Functions = { Mirror = function() local m = mcs:entity() - for i = 1, 1000 do - mcs:add(m, E3) - mcs:remove(m, E3) + for i = 1, 100 do + mcs:add(m, pair(E2, E3)) + mcs:remove(m, pair(E2, E3)) + mcs:add(m, pair(E2, E4)) end end, Jecs = function() local j = ecs:entity() - for i = 1, 1000 do - ecs:add(j, C3) - ecs:remove(j, C3) + for i = 1, 100 do + ecs:add(j, pair(C2, C3)) + ecs:add(j, pair(C2, C4)) end end, }, diff --git a/jecs.luau b/jecs.luau index 6ca77f9..e42cadd 100755 --- a/jecs.luau +++ b/jecs.luau @@ -196,9 +196,10 @@ local ECS_ENTITY_MASK = bit32.lshift(1, 24) local ECS_GENERATION_MASK = bit32.lshift(1, 16) local ECS_PAIR_OFFSET = 2^48 -local ECS_ID_DELETE = 0b01 -local ECS_ID_IS_TAG = 0b10 -local ECS_ID_MASK = 0b00 +local ECS_ID_DELETE = 0b0001 +local ECS_ID_IS_TAG = 0b0010 +local ECS_ID_IS_EXCLUSIVE = 0b0100 +local ECS_ID_MASK = 0b0000 local HI_COMPONENT_ID = 256 local EcsOnAdd = HI_COMPONENT_ID + 1 @@ -214,7 +215,8 @@ local EcsRemove = HI_COMPONENT_ID + 10 local EcsName = HI_COMPONENT_ID + 11 local EcsOnArchetypeCreate = HI_COMPONENT_ID + 12 local EcsOnArchetypeDelete = HI_COMPONENT_ID + 13 -local EcsRest = HI_COMPONENT_ID + 14 +local EcsExclusive = HI_COMPONENT_ID + 14 +local EcsRest = HI_COMPONENT_ID + 15 local NULL_ARRAY = table.freeze({}) :: Column local NULL = newproxy(false) @@ -319,9 +321,9 @@ end local function entity_index_try_get_any( entity_index: EntityIndex, - entity: number + entity: Entity ): Record? - local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity)] + local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity::number)] if not r or r.dense == 0 then return nil @@ -344,6 +346,20 @@ local function entity_index_try_get(entity_index: EntityIndex, entity: Entity): return r end +local function entity_index_try_get_fast(entity_index: EntityIndex, entity: Entity): Record? + local r = entity_index_try_get_any(entity_index, entity) + if r then + local r_dense = r.dense + -- if r_dense > entity_index.alive_count then + -- return nil + -- end + if entity_index.dense_array[r_dense] ~= entity then + return nil + end + end + return r +end + local function entity_index_is_alive(entity_index: EntityIndex, entity: Entity): boolean return entity_index_try_get(entity_index, entity) ~= nil end @@ -683,6 +699,7 @@ local function id_record_ensure(world: World, id: Entity): ComponentRecord local is_pair = ECS_IS_PAIR(id :: number) local has_delete = false + local is_exclusive = false if is_pair then relation = entity_index_get_alive(entity_index, ECS_PAIR_FIRST(id :: number)) :: i53 @@ -697,6 +714,10 @@ local function id_record_ensure(world: World, id: Entity): ComponentRecord if cleanup_policy_target == EcsDelete then has_delete = true end + + if world_has_one_inline(world, relation, EcsExclusive) then + is_exclusive = true + end else local cleanup_policy = world_target(world, relation, EcsOnDelete, 0) @@ -718,7 +739,8 @@ local function id_record_ensure(world: World, id: Entity): ComponentRecord flags = bit32.bor( flags, if has_delete then ECS_ID_DELETE else 0, - if is_tag then ECS_ID_IS_TAG else 0 + if is_tag then ECS_ID_IS_TAG else 0, + if is_exclusive then ECS_ID_IS_EXCLUSIVE else 0 ) idr = { @@ -929,9 +951,10 @@ end local function find_archetype_with(world: World, id: Id, from: Archetype): Archetype local id_types = from.types + local dst = table.clone(id_types) local at = find_insert(id_types :: { number } , id :: number) - local dst = table.clone(id_types) + table.insert(dst, at, id) return archetype_ensure(world, dst) @@ -967,8 +990,6 @@ local function world_component(world: World): i53 return id end - - local function archetype_fast_delete_last(columns: { Column }, column_count: number) for i, column in columns do if column ~= NULL_ARRAY then @@ -2207,6 +2228,59 @@ local function world_new() end local from = record.archetype + if ECS_IS_PAIR(id::number) then + local src = from or ROOT_ARCHETYPE + local edge = archetype_edges[src.id] + local to = edge[id] + local idr: ComponentRecord + if not to then + local first = ECS_PAIR_FIRST(id::number) + local wc = ECS_PAIR(first, EcsWildcard) + idr = component_index[wc] + if idr and bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) then + local cr = idr.records[src.id] + if cr then + local on_remove = idr.hooks.on_remove + local id_types = src.types + if on_remove then + on_remove(entity, id_types[cr]) + src = record.archetype + id_types = src.types + cr = idr.records[src.id] + end + local dst = table.clone(id_types) + dst[cr] = id + to = archetype_ensure(world, dst) + else + to = find_archetype_with(world, id, src) + idr = component_index[id] + end + else + to = find_archetype_with(world, id, src) + idr = component_index[id] + end + edge[id] = to + else + idr = component_index[id] + end + if from == to then + return + end + if from then + entity_move(entity_index, entity, record, to) + else + if #to.types > 0 then + new_entity(entity, record, to) + end + end + + local on_add = idr.hooks.on_add + + if on_add then + on_add(entity, id) + end + return + end local to = archetype_traverse_add(world, id, from) if from == to then return @@ -2219,7 +2293,7 @@ local function world_new() end end - local idr = world.component_index[id] + local idr = component_index[id] local on_add = idr.hooks.on_add if on_add then @@ -2348,6 +2422,74 @@ local function world_new() end local from: Archetype = record.archetype + if ECS_IS_PAIR(id::number) then + local src = from or ROOT_ARCHETYPE + local edge = archetype_edges[src.id] + local to = edge[id] + local idr: ComponentRecord + if not to then + local first = ECS_PAIR_FIRST(id::number) + local wc = ECS_PAIR(first, EcsWildcard) + idr = component_index[wc] + if idr and bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) then + local cr = idr.records[src.id] + if cr then + local on_remove = idr.hooks.on_remove + local id_types = src.types + if on_remove then + on_remove(entity, id_types[cr]) + src = record.archetype + id_types = src.types + cr = idr.records[src.id] + end + local dst = table.clone(id_types) + dst[cr] = id + to = archetype_ensure(world, dst) + else + to = find_archetype_with(world, id, src) + idr = component_index[id] + end + else + to = find_archetype_with(world, id, src) + idr = component_index[id] + end + edge[id] = to + else + idr = component_index[id] + end + local idr_hooks = idr.hooks + if from == to then + local column = to.columns_map[id] + column[record.row] = data + + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. + local on_change = idr_hooks.on_change + if on_change then + on_change(entity, id, data) + end + + return + end + + if from then + entity_move(entity_index, entity, record, to) + else + if #to.types > 0 then + new_entity(entity, record, to) + end + end + + local column = to.columns_map[id] + column[record.row] = data + + local on_add = idr.hooks.on_add + + if on_add then + on_add(entity, id) + end + return + end local to: Archetype = inner_archetype_traverse_add(id, from) local idr = component_index[id] local idr_hooks = idr.hooks @@ -2563,7 +2705,6 @@ local function world_new() end end end - end local function inner_world_delete(world: World, entity: Entity) @@ -2850,6 +2991,7 @@ local function world_new() inner_world_set(world, EcsRest, EcsRest, "jecs.Rest") inner_world_add(world, EcsChildOf, ECS_PAIR(EcsOnDeleteTarget, EcsDelete)) + inner_world_add(world, EcsChildOf, EcsExclusive) for i = EcsRest + 1, ecs_max_tag_id do entity_index_new_id(entity_index) @@ -2913,6 +3055,7 @@ return { Delete = (EcsDelete :: any) :: Entity, Remove = (EcsRemove :: any) :: Entity, Name = (EcsName :: any) :: Entity, + Exclusive = EcsExclusive :: Entity, Rest = (EcsRest :: any) :: Entity, pair = (ECS_PAIR :: any) :: (first: Id

, second: Id) -> Pair, diff --git a/mirror.luau b/mirror.luau index ff6d917..6ca77f9 100755 --- a/mirror.luau +++ b/mirror.luau @@ -42,8 +42,18 @@ export type Iter = (query: Query) -> () -> (Entity, T...) export type Query = typeof(setmetatable( {} :: { iter: Iter, - with: (self: Query, ...Id) -> Query, - without: (self: Query, ...Id) -> Query, + with: + ((Query, Id) -> Query) + & ((Query, Id, Id) -> Query) + & ((Query, Id, Id, Id) -> Query) + & ((Query, Id, Id, Id) -> Query) + & ((Query, Id, Id, Id, Id) -> Query), + without: + ((Query, Id) -> Query) + & ((Query, Id, Id) -> Query) + & ((Query, Id, Id, Id) -> Query) + & ((Query, Id, Id, Id) -> Query) + & ((Query, Id, Id, Id, Id) -> Query), archetypes: (self: Query) -> { Archetype }, cached: (self: Query) -> Query, }, @@ -439,6 +449,7 @@ end local function archetype_move( entity_index: EntityIndex, + entity: Entity, to: Archetype, dst_row: i24, from: Archetype, @@ -452,48 +463,58 @@ local function archetype_move( local id_types = from.types local columns_map = to.columns_map - for i, column in src_columns do - if column == NULL_ARRAY then - continue - end - -- 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 dst_column = columns_map[id_types[i]] - - -- Sometimes target column may not exist, e.g. when you remove a component. - if dst_column then - dst_column[dst_row] = column[src_row] - end - + if src_row ~= last then -- If the entity is the last row in the archetype then swapping it would be meaningless. - if src_row ~= last then + + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- 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 dst_column = columns_map[id_types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if dst_column then + dst_column[dst_row] = column[src_row] + end + -- Swap rempves columns to ensure there are no holes in the archetype. column[src_row] = column[last] + column[last] = nil end - column[last] = nil - end - local moved = #src_entities - -- 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 e1 = src_entities[src_row] - local e2 = src_entities[moved] + -- 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. - if src_row ~= moved then + local e2 = src_entities[last] src_entities[src_row] = e2 + + local sparse_array = entity_index.sparse_array + local record2 = sparse_array[ECS_ENTITY_T_LO(e2 :: number)] + record2.row = src_row + else + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- 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 dst_column = columns_map[id_types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if dst_column then + dst_column[dst_row] = column[src_row] + end + + column[last] = nil + end end - src_entities[moved] = nil :: any - dst_entities[dst_row] = e1 - - local sparse_array = entity_index.sparse_array - - local record1 = sparse_array[ECS_ENTITY_T_LO(e1 :: number)] - local record2 = sparse_array[ECS_ENTITY_T_LO(e2 :: number)] - record1.row = dst_row - record2.row = src_row + src_entities[last] = nil :: any + dst_entities[dst_row] = entity end local function archetype_append( @@ -526,7 +547,7 @@ local function entity_move( local sourceRow = record.row local from = record.archetype local dst_row = archetype_append(entity, to) - archetype_move(entity_index, to, dst_row, from, sourceRow) + archetype_move(entity_index, entity, to, dst_row, from, sourceRow) record.archetype = to record.row = dst_row end @@ -744,7 +765,7 @@ local function archetype_register(world: World, archetype: Archetype) local columns = archetype.columns for i, component_id in archetype.types do local idr = id_record_ensure(world, component_id) - local is_tag = bit32.band(idr.flags, ECS_ID_IS_TAG) ~= 0 + local is_tag = bit32.btest(idr.flags, ECS_ID_IS_TAG) local column = if is_tag then NULL_ARRAY else {} columns[i] = column @@ -2394,7 +2415,7 @@ local function world_new() return entity else for i = eindex_max_id + 1, index do - eindex_sparse_array[i] = { dense = i } :: Record + eindex_sparse_array[i]= { dense = i } :: Record eindex_dense_array[i] = i end entity_index.max_id = index @@ -2459,8 +2480,8 @@ local function world_new() local idr_archetype = archetypes[archetype_id] local entities = idr_archetype.entities local n = #entities + table.move(entities, 1, n, count + 1, queue) count += n - table.move(entities, 1, n, #queue + 1, queue) end for _, e in queue do inner_world_remove(world, e, entity) @@ -2572,7 +2593,7 @@ local function world_new() if idr then local flags = idr.flags - if bit32.band(flags, ECS_ID_DELETE) ~= 0 then + if bit32.btest(flags, ECS_ID_DELETE) then for archetype_id in idr.records do local idr_archetype = archetypes[archetype_id] @@ -2647,8 +2668,8 @@ local function world_new() end local id_record = component_index[id] local flags = id_record.flags - local flags_delete_mask: number = bit32.band(flags, ECS_ID_DELETE) - if flags_delete_mask ~= 0 then + local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE) + if flags_delete_mask then for i = #entities, 1, -1 do local child = entities[i] inner_world_delete(world, child) @@ -2690,7 +2711,7 @@ local function world_new() if idr_r then local archetype_ids = idr_r.records local flags = idr_r.flags - if (bit32.band(flags, ECS_ID_DELETE) :: number) ~= 0 then + if bit32.btest(flags, ECS_ID_DELETE) then for archetype_id in archetype_ids do local idr_r_archetype = archetypes[archetype_id] local entities = idr_r_archetype.entities @@ -2868,7 +2889,7 @@ end local function ecs_is_tag(world: World, entity: Entity): boolean local idr = world.component_index[entity] if idr then - return bit32.band(idr.flags, ECS_ID_IS_TAG) ~= 0 + return bit32.btest(idr.flags, ECS_ID_IS_TAG) end return not world_has_one_inline(world, entity, EcsComponent) end @@ -2877,7 +2898,7 @@ return { world = world_new :: () -> World, component = (ECS_COMPONENT :: any) :: () -> Entity, tag = (ECS_TAG :: any) :: () -> Entity, - meta = (ECS_META :: any) :: (id: Entity, id: Id, value: T) -> Entity, + meta = (ECS_META :: any) :: (id: Entity, id: Id, value: a?) -> Entity, is_tag = (ecs_is_tag :: any) :: (World, Id) -> boolean, OnAdd = (EcsOnAdd :: any) :: Entity<(entity: Entity, id: Id, data: T) -> ()>, diff --git a/test/tests.luau b/test/tests.luau index 01a22fc..3f9ceb9 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -142,6 +142,66 @@ TEST("repro", function() end) TEST("world:add()", function() + do CASE "exclusive relations" + local world = jecs.world() + local A = world:component() + world:add(A, jecs.Exclusive) + + local B = world:component() + local C = world:component() + + local e = world:entity() + world:add(e, pair(A, B)) + world:add(e, pair(A, C)) + + CHECK(world:has(e, pair(A, B)) == false) + CHECK(world:has(e, pair(A, C)) == true) + end + + do CASE "exclusive relations invoke hooks" + local world = jecs.world() + local A = world:component() + local B = world:component() + local C = world:component() + + local e_ptr = jecs.Rest :: number + 1 + + world:add(A, jecs.Exclusive) + local on_remove_call = false + world:set(A, jecs.OnRemove, function(e, id) + CHECK(e == e_ptr) + CHECK(id == jecs.pair(A, B)) + on_remove_call = true + end) + + local on_add_call_count = 0 + world:set(A, jecs.OnAdd, function(e, id) + on_add_call_count += 1 + if on_add_call_count == 1 then + CHECK(e == e_ptr) + CHECK(id == jecs.pair(A, B)) + elseif on_add_call_count == 2 then + CHECK(e == e_ptr) + CHECK(id == jecs.pair(A, C)) + else + CHECK(false) + end + end) + + + local e = world:entity() + CHECK(e == e_ptr) + world:add(e, pair(A, B)) + CHECK(on_add_call_count == 1) + world:add(e, pair(A, C)) + CHECK(on_add_call_count == 2) + CHECK(on_remove_call) + + CHECK(world:has(e, pair(A, B)) == false) + CHECK(world:has(e, pair(A, C)) == true) + + end + do CASE "idempotent" local world = jecs.world() local d = dwi(world) @@ -193,6 +253,9 @@ TEST("world:children()", function() local e3 = world:entity() world:add(e3, pair(ChildOf, e1)) + CHECK(world:has(e2, pair(ChildOf, e1))) + CHECK(world:has(e3, pair(ChildOf, e1))) + local count = 0 for entity in world:children(e1) do count += 1 @@ -1669,7 +1732,9 @@ end) TEST("#repro2", function() local world = jecs.world() local Lifetime = world:component() :: Id + world:set(Lifetime, jecs.Name, "Lifetime") local Particle = world:entity() + world:set(Particle, jecs.Name, "Particle") local Beam = world:entity() local entity = world:entity() @@ -1677,19 +1742,24 @@ TEST("#repro2", function() world:set(entity, pair(Lifetime, Beam), 2) world:set(entity, pair(4 :: any, 5 :: any), 6) -- noise + CHECK(world:get(entity, pair(Lifetime, Particle)) == 1) + CHECK(world:get(entity, pair(Lifetime, Beam)) == 2) + + CHECK(world:target(entity, Lifetime, 0) == Particle) + CHECK(world:target(entity, Lifetime, 1) == Beam) + -- entity_visualizer.components(world, entity) + -- print(CHECK(world:has(jecs.ChildOf, jecs.Exclusive))) + for e in world:each(pair(Lifetime, __)) do local i = 0 local nth = world:target(e, Lifetime, i) while nth do -- entity_visualizer.components(world, e) - local data = world:get(e, pair(Lifetime, nth)) :: number - data -= 1 - if data <= 0 then - world:remove(e, pair(Lifetime, nth)) - else + if data > 0 then + data -= 1 world:set(e, pair(Lifetime, nth), data) end i += 1 @@ -1697,7 +1767,7 @@ TEST("#repro2", function() end end - CHECK(not world:has(entity, pair(Lifetime, Particle))) + CHECK(world:get(entity, pair(Lifetime, Particle)) == 0) CHECK(world:get(entity, pair(Lifetime, Beam)) == 1) end)