From 88443cecc3942e323daffa03e5bdd9d0753a071d Mon Sep 17 00:00:00 2001 From: Row Date: Mon, 7 Sep 2026 18:15:38 -0300 Subject: [PATCH 1/2] Start measured optimization work From 2ad68d8bdb191b2038f09ef9841e36aad15b2bab Mon Sep 17 00:00:00 2001 From: Row Date: Mon, 7 Sep 2026 18:20:35 -0300 Subject: [PATCH 2/2] Avoid allocating removal IDs for existing archetypes Materialize the removal sequence only when creating a destination archetype. Preserve PackedSet handling in all operation modes. Validation: 174 tests pass across 21 files; new regression cases also pass on main. Default benchs suite shows 17% faster component removal and 9-10% faster add/remove cycles on Nim 2.2.8 arm64. --- src/world.nim | 17 ++++---- tests/tremoval.nim | 100 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 tests/tremoval.nim diff --git a/src/world.nim b/src/world.nim index e2df96f..c7a0d99 100644 --- a/src/world.nim +++ b/src/world.nim @@ -156,17 +156,22 @@ proc nextArchetypeAddingFrom(world: var World, previousArchetype: Archetype, com result = world.archIdToIndex[nextArchetypeId] -proc nextArchetypeRemovingFrom(world: var World, previousArchetype: Archetype, componentIdsToRemove: seq[ComponentId]): int = +proc nextArchetypeRemovingFrom(world: var World, previousArchetype: Archetype, componentIdsToRemove: PackedSet[ComponentId]): int = let previousArchetypeId = previousArchetype.id var nextArchetypeId = previousArchetypeId - for componentId in componentIdsToRemove: + for componentId in componentIdsToRemove.items: nextArchetypeId.excl componentId result = world.archIdToIndex.getOrDefault(nextArchetypeId, -1) if result < 0: - let newArchetype = previousArchetype.makeNextRemoving(componentIdsToRemove) + var componentIds: seq[ComponentId] + + for componentId in componentIdsToRemove.items: + componentIds.add componentId + + let newArchetype = previousArchetype.makeNextRemoving(componentIds) world.registerArchetype(nextArchetypeId, newArchetype) result = world.archIdToIndex[nextArchetypeId] @@ -432,12 +437,8 @@ proc consolidateAddComponents(world: var World, id: EntityId, componentsToAdd: T proc consolidateRemoveComponents(world: var World, id: EntityId, compIdsToRemove: PackedSet[ComponentId]) = var entity = world.entities[id.value] var previousArchetype = world.archetypes[entity.archetypeIndex] - var componentIds: seq[ComponentId] - - for compId in compIdsToRemove.items: - componentIds.add compId - let nextIndex = world.nextArchetypeRemovingFrom(previousArchetype, componentIds) + let nextIndex = world.nextArchetypeRemovingFrom(previousArchetype, compIdsToRemove) var nextArchetype = world.archetypes[nextIndex] entity.archetypeIndex = nextIndex diff --git a/tests/tremoval.nim b/tests/tremoval.nim new file mode 100644 index 0000000..b7d6213 --- /dev/null +++ b/tests/tremoval.nim @@ -0,0 +1,100 @@ +# ISC License +# Copyright (c) 2025 RowDaBoat +# `vecs` is a free open source ECS library for Nim. +import unittest +import ../src/[examples, vecs] + + +type RemovalPayload = ref object + value: string + + +type RetainedComponent = object + payload: RemovalPayload + + +suite "Component removal should": + test "accept duplicate component types": + var world = World() + let entityId = world.add((Character(name: "Marcus"), Health(health: 100), Weapon(name: "Sword")), Immediate) + + world.remove(entityId, (Health, Weapon, Health), Immediate) + + check world.has(entityId) + check world.read(entityId, Meta).id == entityId + check world.read(entityId, Character).name == "Marcus" + check not world.has(entityId, Health) + check not world.has(entityId, Weapon) + + + test "retain reference values when removal creates and then reuses a destination": + var world = World() + let firstPayload = RemovalPayload(value: "First") + let secondPayload = RemovalPayload(value: "Second") + let firstId = world.add((RetainedComponent(payload: firstPayload), Health(health: 100), Character(name: "Marcus"), Weapon(name: "Sword")), Immediate) + let secondId = world.add((RetainedComponent(payload: secondPayload), Health(health: 80), Character(name: "Elena"), Weapon(name: "Staff")), Immediate) + + world.remove(firstId, (Weapon, Health), Immediate) + world.remove(secondId, (Health, Weapon), Immediate) + GC_fullCollect() + + var query: Query[(Meta, Character, RetainedComponent)] + var entityCount = 0 + + for (meta, character, retained) in world.query(query): + inc entityCount + check not world.has(meta.id, Health) + check not world.has(meta.id, Weapon) + + if meta.id == firstId: + check character.name == "Marcus" + check retained.payload == firstPayload + check retained.payload.value == "First" + else: + check meta.id == secondId + check character.name == "Elena" + check retained.payload == secondPayload + check retained.payload.value == "Second" + + check entityCount == 2 + + + test "retain reference values when destination columns have a different order": + var world = World() + let existingPayload = RemovalPayload(value: "Existing") + let movedPayload = RemovalPayload(value: "Moved") + let existingId = world.add((Character(name: "Elena"), RetainedComponent(payload: existingPayload)), Immediate) + let movedId = world.add((RetainedComponent(payload: movedPayload), Health(health: 100), Character(name: "Marcus"), Weapon(name: "Sword")), Immediate) + + world.remove(movedId, (Weapon, Health), Immediate) + GC_fullCollect() + + check world.read(existingId, Character).name == "Elena" + check world.read(existingId, RetainedComponent).payload == existingPayload + check world.read(existingId, RetainedComponent).payload.value == "Existing" + check world.read(movedId, Meta).id == movedId + check world.read(movedId, Character).name == "Marcus" + check world.read(movedId, RetainedComponent).payload == movedPayload + check world.read(movedId, RetainedComponent).payload.value == "Moved" + check not world.has(movedId, Health) + check not world.has(movedId, Weapon) + + + test "preserve a pending addition across immediate removal": + var world = World() + let entityId = world.add((Character(name: "Marcus"), Health(health: 100)), Immediate) + world.add(entityId, Weapon(name: "Sword", attack: 10)) + + world.remove(entityId, Health, Immediate) + + check not world.has(entityId, Health) + check not world.has(entityId, Weapon) + check world.read(entityId, Character).name == "Marcus" + + world.consolidate() + + check not world.has(entityId, Health) + check world.read(entityId, Meta).id == entityId + check world.read(entityId, Character).name == "Marcus" + check world.read(entityId, Weapon).name == "Sword" + check world.read(entityId, Weapon).attack == 10