wip entity deserialization phase 1. Unit test not passing because of globals that assume only one entity manager / game state.
This commit is contained in:
@@ -374,28 +374,52 @@ Under the new pipeline:
|
||||
5. `serialize(ar, NonNullPtr<Entity>(base_ptr))` is called. Base (`Entity::kind`) and derived (`base_ptr->derived_kind`) fields stream **directly into their permanent memory arenas** without temporary staging buffers or stack copies.
|
||||
|
||||
### 4.3 Runtime Class Resolution
|
||||
To ensure fast and safe type lookup during file deserialization:
|
||||
To ensure fast and safe type lookup during file deserialization, Juliet provides two tiers of type resolution:
|
||||
|
||||
1. **Low-Level Indexed Validation (`ResolveEntityClass`)**: An $O(1)$ array lookup into `kEntity_type_class_ptr[kind]` that validates the class pointer and verifies schema compatibility against `classPtr->CRC`. This is the core validation primitive used by binary streaming, network replication, and internal lookups.
|
||||
2. **High-Level Name Resolution (`find_class_by_name`)**: Bridges the human-readable text archive format (`; class\nInert`) to the entity class registry. It computes the `crc32` of the parsed class name and queries `ResolveEntityClass` across registered entity kinds.
|
||||
|
||||
```cpp
|
||||
[[nodiscard]] Class* ResolveEntityClass(uint8 kind, uint32 crc)
|
||||
[[nodiscard]] const Class* ResolveEntityClass(uint8 kind, uint32 crc)
|
||||
{
|
||||
if (kind >= ENTITY(Count))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Class* classPtr = kEntity_type_class_ptr[kind];
|
||||
if (!classPtr)
|
||||
const Class* class_ptr = kEntity_type_class_ptr[kind];
|
||||
if (class_ptr == nullptr)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (classPtr->CRC != crc)
|
||||
if (class_ptr->CRC != crc)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return classPtr;
|
||||
return class_ptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] Class* find_class_by_name(String name)
|
||||
{
|
||||
if (!IsValid(name))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const uint32 name_crc = crc32(name.Str, name.Size);
|
||||
|
||||
for (uint8 kind = 0; kind < ENTITY(Count); ++kind)
|
||||
{
|
||||
const Class* class_ptr = ResolveEntityClass(kind, name_crc);
|
||||
if (class_ptr != nullptr)
|
||||
{
|
||||
return const_cast<Class*>(class_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -469,190 +493,17 @@ To ensure fast and safe type lookup during file deserialization:
|
||||
|
||||
---
|
||||
|
||||
## 5. Entity Deletion Lifecycle & Disk Synchronization
|
||||
## 5. Entity Deletion Lifecycle & Disk Synchronization (Extracted for Rework)
|
||||
|
||||
### 5.1 In-Memory Removal vs. Immediate Disk Deletion Hazards
|
||||
In game development, deleting an entity in the editor or during gameplay must **never synchronously invoke disk deletion**:
|
||||
1. **Frame Rate Stutters:** Blocking on synchronous OS filesystem APIs (`DeleteFileA`) introduces multisecond frame freezes.
|
||||
2. **Transactional Safety:** If the editor crashes or the user exits without saving, disk modifications cannot be rolled back.
|
||||
3. **Undo/Redo Support:** An editor action stack must allow recovering deleted entities before changes are permanently committed to disk.
|
||||
|
||||
Therefore, Juliet enforces a strict separation:
|
||||
- **Immediate in-memory destruction:** Releases the entity from active simulation and registers its identifier in `World::PendingDeletions`.
|
||||
- **Deferred disk deletion:** Executed strictly during explicit `SaveWorld` operations.
|
||||
|
||||
### 5.2 Fast In-Memory Removal (`RemoveAtFast`) & Mutual Pointer Fixup
|
||||
`VectorArena::RemoveAtFast` utilizes swap-and-pop: the element at the target index is replaced by the last element in the vector, and `Count` is decremented.
|
||||
|
||||
```cpp
|
||||
void RemoveAtFast(index_t index)
|
||||
{
|
||||
Assert(Arena);
|
||||
Assert(index < Count);
|
||||
Assert(Count > 0);
|
||||
|
||||
Type* elementAdr = DataFirst + index;
|
||||
|
||||
if (DataLast != elementAdr)
|
||||
{
|
||||
Swap(DataLast, elementAdr);
|
||||
}
|
||||
|
||||
--DataLast;
|
||||
--Count;
|
||||
}
|
||||
```
|
||||
|
||||
#### The Pointer Invalidation Problem:
|
||||
When `Entity A` (at `index`) is swapped with `Entity Z` (at `DataLast`), the physical address of `Entity Z` changes from `DataLast` to `elementAdr`.
|
||||
If `Entity Z` has a derived struct `derivedZ`, `derivedZ->Base` previously pointed to `DataLast`. After `RemoveAtFast`, `derivedZ->Base` points to garbage or the freed slot!
|
||||
|
||||
#### The Pointer Fixup Protocol:
|
||||
To preserve the mutual back-pointer invariant, `DestroyEntity` explicitly fixes up the swapped entity's derived back-pointer:
|
||||
|
||||
```cpp
|
||||
void DestroyEntity(EntityManager& manager, EntityID id)
|
||||
{
|
||||
Entity* baseArray = manager.Entities.DataPtr();
|
||||
size_t count = manager.Entities.Size();
|
||||
|
||||
size_t targetIndex = indexMax;
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
if (baseArray[i].ID == id)
|
||||
{
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex == indexMax)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Entity* targetEntity = &baseArray[targetIndex];
|
||||
Class* classPtr = targetEntity->Kind;
|
||||
Assert(classPtr != nullptr);
|
||||
|
||||
// 1. Remove derived component from typed array via swap-and-pop
|
||||
RemoveDerivedComponent(manager, classPtr, targetEntity->Derived);
|
||||
|
||||
// 2. Remove base entity via swap-and-pop in VectorArena
|
||||
bool wasLast = (targetIndex == count - 1);
|
||||
manager.Entities.RemoveAtFast(targetIndex);
|
||||
|
||||
// 3. Pointer fixup: If an element was swapped into targetIndex, fix its back-pointer!
|
||||
if (!wasLast && targetIndex < manager.Entities.Size())
|
||||
{
|
||||
Entity* movedEntity = &manager.Entities[targetIndex];
|
||||
auto* derivedTemp = reinterpret_cast<entity_template*>(movedEntity->Derived);
|
||||
Assert(derivedTemp != nullptr);
|
||||
derivedTemp->Base = movedEntity;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 O(1) Component Removal in `typed_entity_array` via Swap-and-Pop
|
||||
To keep derived components packed contiguously for SIMD/cache iteration:
|
||||
1. Locate the component's index within `by_type[kind].arena`. Because components have uniform stride `classPtr->size_of`:
|
||||
$$\text{componentIndex} = \frac{\text{reinterpret\_cast<uint8*>(derivedPtr)} - \text{reinterpret\_cast<uint8*>(typedArray.array)}}{\text{classPtr->size\_of}}$$
|
||||
2. If the component is not the last one in the typed arena:
|
||||
- Copy the last component into the slot occupied by the deleted component.
|
||||
- Update the moved component's `Base->Derived` pointer to point to its new slot.
|
||||
3. Decrement `typedArray.count`.
|
||||
4. Pop the arena allocation if it was the top of the stack, or decrement count to mark slot reclamation.
|
||||
|
||||
```cpp
|
||||
void RemoveDerivedComponent(EntityManager& manager, Class* classPtr, DerivedType derivedPtr)
|
||||
{
|
||||
Assert(classPtr != nullptr);
|
||||
Assert(derivedPtr != nullptr);
|
||||
|
||||
typed_entity_array& typedArray = manager.by_type[classPtr->kind];
|
||||
Assert(typedArray.count > 0);
|
||||
Assert(typedArray.array != nullptr);
|
||||
|
||||
size_t stride = classPtr->size_of;
|
||||
auto* targetByte = reinterpret_cast<uint8*>(derivedPtr);
|
||||
auto* firstByte = reinterpret_cast<uint8*>(typedArray.array);
|
||||
|
||||
size_t componentIndex = static_cast<size_t>(targetByte - firstByte) / stride;
|
||||
Assert(componentIndex < typedArray.count);
|
||||
|
||||
size_t lastIndex = typedArray.count - 1;
|
||||
if (componentIndex != lastIndex)
|
||||
{
|
||||
uint8* lastByte = firstByte + (lastIndex * stride);
|
||||
|
||||
// Copy last component data into target slot
|
||||
MemCopy(targetByte, lastByte, stride);
|
||||
|
||||
// Fixup the base pointer of the moved component
|
||||
auto* movedDerived = reinterpret_cast<entity_template*>(targetByte);
|
||||
Assert(movedDerived->Base != nullptr);
|
||||
movedDerived->Base->Derived = targetByte;
|
||||
}
|
||||
|
||||
typedArray.count -= 1;
|
||||
if (typedArray.count == 0)
|
||||
{
|
||||
typedArray.array = nullptr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Tracking Deletions in `World::PendingDeletions`
|
||||
In `World.h`, the `World` struct is extended with a pending deletions container:
|
||||
|
||||
```cpp
|
||||
struct World
|
||||
{
|
||||
Arena* WorldArena = nullptr;
|
||||
EntityManager* EntityManager = nullptr;
|
||||
VectorArena<EntityID, 1024> PendingDeletions;
|
||||
};
|
||||
```
|
||||
|
||||
When an entity is deleted in the world:
|
||||
```cpp
|
||||
void RemoveWorldEntity(World& world, EntityID id)
|
||||
{
|
||||
Assert(world.EntityManager != nullptr);
|
||||
|
||||
// Record pending disk deletion
|
||||
world.PendingDeletions.PushBack(id);
|
||||
|
||||
// Destroy in memory immediately
|
||||
DestroyEntity(*world.EntityManager, id);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Disk File Cleanup during `SaveWorld`
|
||||
During `SaveWorld`, before saving modified entities, the engine iterates over `world.PendingDeletions` and removes their associated `.jasset` files:
|
||||
|
||||
```cpp
|
||||
void ProcessPendingDeletions(World& world, NonNullPtr<Arena> scratchArena)
|
||||
{
|
||||
for (size_t i = 0; i < world.PendingDeletions.Size(); ++i)
|
||||
{
|
||||
EntityID id = world.PendingDeletions[i];
|
||||
|
||||
// Format relative asset path: Assets/Entities/{ID}.jasset
|
||||
char filenameBuffer[64];
|
||||
juliet_snprintf(filenameBuffer, sizeof(filenameBuffer), "Entities/%llu.jasset", id);
|
||||
|
||||
String assetPath = GetAssetPath(scratchArena, WrapString(filenameBuffer));
|
||||
|
||||
if (PlatformDeleteFile(assetPath))
|
||||
{
|
||||
Log(LogLevel::Message, LogCategory::Game, "Deleted entity asset: %s", CStr(assetPath));
|
||||
}
|
||||
}
|
||||
|
||||
world.PendingDeletions.Clear();
|
||||
}
|
||||
```
|
||||
> [!WARNING]
|
||||
> **Status: Extracted for Rework**
|
||||
> The original swap-and-pop in-memory removal logic (`DestroyEntity`, `RemoveDerivedComponent`, and mutual back-pointer fixups) was determined to be overly complex and has been extracted to [`Game/Plans/Entity_Removal_Brainstorm.md`](file:///w:/Classified/Juliet/Game/Plans/Entity_Removal_Brainstorm.md) for further brainstorming and redesign.
|
||||
>
|
||||
> Simpler alternative architectures under consideration include:
|
||||
> - **Active / Tombstone Flag (`is_active` bool):** Retaining entities in-place without moving memory during frame simulation, eliminating pointer invalidation entirely.
|
||||
> - **Intrusive Free List:** Linking inactive slots via an intrusive linked list to find the first free slot in $O(1)$ without memory shifting.
|
||||
> - **Generational Handles / Slot Map:** Enabling safe, non-dangling entity references across systems.
|
||||
> - **Deferred Compaction:** Batch-compacting memory during level loads or scene transitions rather than per-frame swap-and-pop.
|
||||
|
||||
---
|
||||
|
||||
@@ -741,10 +592,10 @@ if (ImGui::DragFloat3("Position", pos, 0.1f))
|
||||
- Update `DEFINE_ENTITY_VERSIONED` and `DEFINE_CLASS_VERSIONED` to define `default_init_##entity` and pass it to `MakeClass`.
|
||||
- Add `bool is_dirty = false;` to `struct Entity`.
|
||||
- Update `MakeEntity<EntityType>` to assign `base_ptr->ID = EntityManager::ID++;` and delegate allocation and defaults cleanly to `AllocateEntity`.
|
||||
- Declare `[[nodiscard]] const Class* ResolveEntityClass(uint8 kind, uint32 crc);` and `[[nodiscard]] Class* find_class_by_name(String name);` in `Entity.h`.
|
||||
2. **Update `EntityManager.h`:**
|
||||
- Declare `[[nodiscard]] Entity* AllocateEntity(EntityManager& manager, Class* class_ptr);`.
|
||||
- Declare `void DestroyEntity(EntityManager& manager, EntityID id);`.
|
||||
- Declare `void RemoveDerivedComponent(EntityManager& manager, Class* class_ptr, DerivedType derived_ptr);`.
|
||||
- *(Note: `DestroyEntity` and `RemoveDerivedComponent` deferred to `Entity_Removal_Brainstorm.md`)*
|
||||
3. **Update `World.h`:**
|
||||
- Add `VectorArena<EntityID, 1024> PendingDeletions;` to `struct World`.
|
||||
- Update `SaveWorld` and `LoadWorld` signatures to take directory paths.
|
||||
@@ -757,12 +608,11 @@ if (ImGui::DragFloat3("Position", pos, 0.1f))
|
||||
- Call `derivedClassPtr->default_init_fct(rawMemory)` (or `MemZero` if null) to initialize struct defaults.
|
||||
- Wire mutual pointers (`base->derived` and `derived->base`).
|
||||
- Increment `typedArray.count` and initialize `typedArray.array`.
|
||||
2. Implement `DestroyEntity` & `RemoveDerivedComponent`:
|
||||
- Implement swap-and-pop for derived components with base pointer update.
|
||||
- Implement `manager.Entities.RemoveAtFast` with mutual back-pointer fixup.
|
||||
2. *(Deferred for rework)* `DestroyEntity` & `RemoveDerivedComponent` (See [`Game/Plans/Entity_Removal_Brainstorm.md`](file:///w:/Classified/Juliet/Game/Plans/Entity_Removal_Brainstorm.md)).
|
||||
|
||||
### Phase 3: In-Place Deserialization & Serialization Pipeline
|
||||
1. In `Entity.cpp`:
|
||||
- Implement `ResolveEntityClass` and `find_class_by_name`.
|
||||
- `serialize(Archive& ar, NonNullPtr<Entity> entity)` handles both base and derived class serialization.
|
||||
2. In `World.cpp`:
|
||||
- Implement `serialize_entity_asset(Archive& ar, NonNullPtr<Entity> entity, String filepath)`.
|
||||
@@ -917,53 +767,6 @@ namespace UnitTest
|
||||
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestInPlaceDeserialization");
|
||||
}
|
||||
|
||||
void TestSwapAndPopPointerFixup()
|
||||
{
|
||||
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestSwapAndPopPointerFixup...");
|
||||
|
||||
TempArena tempArena = scratch_begin(nullptr, 0);
|
||||
|
||||
World testWorld{};
|
||||
InitWorld(&testWorld, tempArena.Arena);
|
||||
InitEntityManager(&testWorld);
|
||||
EntityManager& manager = *testWorld.EntityManager;
|
||||
|
||||
// Allocate 3 entities: E0, E1, E2
|
||||
Inert* e0 = MakeEntity<Inert>(manager, 1.0f, 0.0f, 0.0f);
|
||||
Inert* e1 = MakeEntity<Inert>(manager, 2.0f, 0.0f, 0.0f);
|
||||
Inert* e2 = MakeEntity<Inert>(manager, 3.0f, 0.0f, 0.0f);
|
||||
|
||||
EntityID id0 = e0->base->ID;
|
||||
EntityID id1 = e1->base->ID;
|
||||
EntityID id2 = e2->base->ID;
|
||||
|
||||
Assert(manager.Entities.Size() == 3);
|
||||
|
||||
// Delete middle entity E1 (triggers swap with E2)
|
||||
DestroyEntity(manager, id1);
|
||||
|
||||
Assert(manager.Entities.Size() == 2);
|
||||
|
||||
// Verify E2's mutual back-pointers are still completely intact
|
||||
Entity* remaining0 = &manager.Entities[0];
|
||||
Entity* remaining1 = &manager.Entities[1];
|
||||
|
||||
Assert(remaining0->ID == id0);
|
||||
Assert(remaining1->ID == id2);
|
||||
|
||||
auto* derived0 = reinterpret_cast<entity_template*>(remaining0->derived);
|
||||
auto* derived1 = reinterpret_cast<entity_template*>(remaining1->derived);
|
||||
|
||||
Assert(derived0->base == remaining0);
|
||||
Assert(derived1->base == remaining1);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&testWorld);
|
||||
|
||||
scratch_end(tempArena);
|
||||
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestSwapAndPopPointerFixup");
|
||||
}
|
||||
|
||||
void TestDirtyTrackingLifecycle()
|
||||
{
|
||||
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestDirtyTrackingLifecycle...");
|
||||
@@ -1003,7 +806,7 @@ namespace UnitTest
|
||||
|
||||
TestEntityAllocationAndWiring();
|
||||
TestInPlaceDeserialization();
|
||||
TestSwapAndPopPointerFixup();
|
||||
// TestSwapAndPopPointerFixup(); // Deferred to Entity_Removal_Brainstorm.md
|
||||
TestDirtyTrackingLifecycle();
|
||||
|
||||
Log(LogLevel::Message, LogCategory::Game, "==================================================");
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# Entity Removal & Deletion Lifecycle (Brainstorm & Rework)
|
||||
|
||||
> [!WARNING]
|
||||
> **Status: Needs Rework**
|
||||
> The entity deletion and removal logic below was extracted as-is from [`Game/Plans/02_Entity_Allocation_And_Lifecycle.md`](file:///w:/Classified/Juliet/Game/Plans/02_Entity_Allocation_And_Lifecycle.md) for future brainstorming.
|
||||
> The original swap-and-pop approach required complex mutual back-pointer fixups across both base and derived memory buffers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Brainstorming Notes & Alternative Approaches
|
||||
|
||||
The original design relied on `VectorArena::RemoveAtFast` (swap-and-pop) for both `Entities` and `by_type[kind].arena`. While this maintained dense contiguous memory, swapping arbitrary elements in memory invalidated pointers in both directions, requiring runtime pointer patching (`derived->Base = movedEntity; base->Derived = movedComponent`).
|
||||
|
||||
### Ideas for Simpler & More Robust Alternatives:
|
||||
|
||||
1. **Active Flag / Tombstone (`is_active` bool)**:
|
||||
- Keep deleted entities in place rather than moving or swapping them.
|
||||
- Simply toggle `entity->is_active = false;`.
|
||||
- Iterators and simulation loops skip inactive entities (`if (!entity->is_active) continue;`).
|
||||
- Pointers to entities and derived components remain stable for their entire lifetime.
|
||||
|
||||
2. **Intrusive Free List for Slot Recycling**:
|
||||
- Instead of shifting memory on deletion, vacant slots are linked into an intrusive free list (`first_free_index`).
|
||||
- When an entity is destroyed:
|
||||
- `entity->is_active = false;`
|
||||
- Overwrite unused slot memory with `next_free_index`.
|
||||
- When allocating a new entity:
|
||||
- Check if `first_free_index != indexMax`.
|
||||
- Pop from the free list in $O(1)$; otherwise push back to the end of the arena.
|
||||
- Zero pointer invalidation, zero memmove/memcpy overhead during deletion.
|
||||
|
||||
3. **Stable Generational Handles / Slot Map**:
|
||||
- If external systems need references to entities that might be deleted, combine the free list with a generational counter to detect stale lookups safely.
|
||||
|
||||
4. **Deferred Compaction / Garbage Collection**:
|
||||
- If contiguous packing is strictly necessary for SIMD/cache optimization, defer compaction to a designated level load or scene transition rather than doing it per-frame on individual deletes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Extracted Original Removal Logic (As-Is from Plan 2)
|
||||
|
||||
### 2.1 In-Memory Removal vs. Immediate Disk Deletion Hazards
|
||||
In game development, deleting an entity in the editor or during gameplay must **never synchronously invoke disk deletion**:
|
||||
1. **Frame Rate Stutters:** Blocking on synchronous OS filesystem APIs (`DeleteFileA`) introduces multisecond frame freezes.
|
||||
2. **Transactional Safety:** If the editor crashes or the user exits without saving, disk modifications cannot be rolled back.
|
||||
3. **Undo/Redo Support:** An editor action stack must allow recovering deleted entities before changes are permanently committed to disk.
|
||||
|
||||
Therefore, Juliet enforces a strict separation:
|
||||
- **Immediate in-memory destruction:** Releases the entity from active simulation and registers its identifier in `World::PendingDeletions`.
|
||||
- **Deferred disk deletion:** Executed strictly during explicit `SaveWorld` operations.
|
||||
|
||||
### 2.2 Fast In-Memory Removal (`RemoveAtFast`) & Mutual Pointer Fixup
|
||||
`VectorArena::RemoveAtFast` utilizes swap-and-pop: the element at the target index is replaced by the last element in the vector, and `Count` is decremented.
|
||||
|
||||
```cpp
|
||||
void RemoveAtFast(index_t index)
|
||||
{
|
||||
Assert(Arena);
|
||||
Assert(index < Count);
|
||||
Assert(Count > 0);
|
||||
|
||||
Type* elementAdr = DataFirst + index;
|
||||
|
||||
if (DataLast != elementAdr)
|
||||
{
|
||||
Swap(DataLast, elementAdr);
|
||||
}
|
||||
|
||||
--DataLast;
|
||||
--Count;
|
||||
}
|
||||
```
|
||||
|
||||
#### The Pointer Invalidation Problem:
|
||||
When `Entity A` (at `index`) is swapped with `Entity Z` (at `DataLast`), the physical address of `Entity Z` changes from `DataLast` to `elementAdr`.
|
||||
If `Entity Z` has a derived struct `derivedZ`, `derivedZ->Base` previously pointed to `DataLast`. After `RemoveAtFast`, `derivedZ->Base` points to garbage or the freed slot!
|
||||
|
||||
#### The Pointer Fixup Protocol:
|
||||
To preserve the mutual back-pointer invariant, `DestroyEntity` explicitly fixes up the swapped entity's derived back-pointer:
|
||||
|
||||
```cpp
|
||||
void DestroyEntity(EntityManager& manager, EntityID id)
|
||||
{
|
||||
Entity* baseArray = manager.Entities.DataPtr();
|
||||
size_t count = manager.Entities.Size();
|
||||
|
||||
size_t targetIndex = indexMax;
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
if (baseArray[i].ID == id)
|
||||
{
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex == indexMax)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Entity* targetEntity = &baseArray[targetIndex];
|
||||
Class* classPtr = targetEntity->derived_kind;
|
||||
Assert(classPtr != nullptr);
|
||||
|
||||
// 1. Remove derived component from typed array via swap-and-pop
|
||||
RemoveDerivedComponent(manager, classPtr, targetEntity->derived);
|
||||
|
||||
// 2. Remove base entity via swap-and-pop in VectorArena
|
||||
bool wasLast = (targetIndex == count - 1);
|
||||
manager.Entities.RemoveAtFast(targetIndex);
|
||||
|
||||
// 3. Pointer fixup: If an element was swapped into targetIndex, fix its back-pointer!
|
||||
if (!wasLast && targetIndex < manager.Entities.Size())
|
||||
{
|
||||
Entity* movedEntity = &manager.Entities[targetIndex];
|
||||
auto* derivedTemp = reinterpret_cast<entity_template*>(movedEntity->derived);
|
||||
Assert(derivedTemp != nullptr);
|
||||
derivedTemp->base = movedEntity;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 O(1) Component Removal in `typed_entity_array` via Swap-and-Pop
|
||||
To keep derived components packed contiguously for SIMD/cache iteration:
|
||||
1. Locate the component's index within `by_type[kind].arena`. Because components have uniform stride `classPtr->size_of`:
|
||||
$$\text{componentIndex} = \frac{\text{reinterpret\_cast<uint8*>(derivedPtr)} - \text{reinterpret\_cast<uint8*>(typedArray.array)}}{\text{classPtr->size\_of}}$$
|
||||
2. If the component is not the last one in the typed arena:
|
||||
- Copy the last component into the slot occupied by the deleted component.
|
||||
- Update the moved component's `Base->Derived` pointer to point to its new slot.
|
||||
3. Decrement `typedArray.count`.
|
||||
4. Pop the arena allocation if it was the top of the stack, or decrement count to mark slot reclamation.
|
||||
|
||||
```cpp
|
||||
void RemoveDerivedComponent(EntityManager& manager, Class* classPtr, DerivedType derivedPtr)
|
||||
{
|
||||
Assert(classPtr != nullptr);
|
||||
Assert(derivedPtr != nullptr);
|
||||
|
||||
typed_entity_array& typedArray = manager.by_type[classPtr->kind];
|
||||
Assert(typedArray.count > 0);
|
||||
Assert(typedArray.array != nullptr);
|
||||
|
||||
size_t stride = classPtr->size_of;
|
||||
auto* targetByte = reinterpret_cast<uint8*>(derivedPtr);
|
||||
auto* firstByte = reinterpret_cast<uint8*>(typedArray.array);
|
||||
|
||||
size_t componentIndex = static_cast<size_t>(targetByte - firstByte) / stride;
|
||||
Assert(componentIndex < typedArray.count);
|
||||
|
||||
size_t lastIndex = typedArray.count - 1;
|
||||
if (componentIndex != lastIndex)
|
||||
{
|
||||
uint8* lastByte = firstByte + (lastIndex * stride);
|
||||
|
||||
// Copy last component data into target slot
|
||||
MemCopy(targetByte, lastByte, stride);
|
||||
|
||||
// Fixup the base pointer of the moved component
|
||||
auto* movedDerived = reinterpret_cast<entity_template*>(targetByte);
|
||||
Assert(movedDerived->base != nullptr);
|
||||
movedDerived->base->derived = targetByte;
|
||||
}
|
||||
|
||||
typedArray.count -= 1;
|
||||
if (typedArray.count == 0)
|
||||
{
|
||||
typedArray.array = nullptr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Tracking Deletions in `World::PendingDeletions`
|
||||
In `World.h`, the `World` struct is extended with a pending deletions container:
|
||||
|
||||
```cpp
|
||||
struct World
|
||||
{
|
||||
Arena* WorldArena = nullptr;
|
||||
EntityManager* EntityManager = nullptr;
|
||||
VectorArena<EntityID, 1024> PendingDeletions;
|
||||
};
|
||||
```
|
||||
|
||||
When an entity is deleted in the world:
|
||||
```cpp
|
||||
void RemoveWorldEntity(World& world, EntityID id)
|
||||
{
|
||||
Assert(world.EntityManager != nullptr);
|
||||
|
||||
// Record pending disk deletion
|
||||
world.PendingDeletions.PushBack(id);
|
||||
|
||||
// Destroy in memory immediately
|
||||
DestroyEntity(*world.EntityManager, id);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 Disk File Cleanup during `SaveWorld`
|
||||
During `SaveWorld`, before saving modified entities, the engine iterates over `world.PendingDeletions` and removes their associated `.jasset` files:
|
||||
|
||||
```cpp
|
||||
void ProcessPendingDeletions(World& world, NonNullPtr<Arena> scratchArena)
|
||||
{
|
||||
for (size_t i = 0; i < world.PendingDeletions.Size(); ++i)
|
||||
{
|
||||
EntityID id = world.PendingDeletions[i];
|
||||
|
||||
// Format relative asset path: Assets/Entities/{ID}.jasset
|
||||
char filenameBuffer[64];
|
||||
juliet_snprintf(filenameBuffer, sizeof(filenameBuffer), "Entities/%llu.jasset", id);
|
||||
|
||||
String assetPath = GetAssetPath(scratchArena, WrapString(filenameBuffer));
|
||||
|
||||
if (PlatformDeleteFile(assetPath))
|
||||
{
|
||||
Log(LogLevel::Message, LogCategory::Game, "Deleted entity asset: %s", CStr(assetPath));
|
||||
}
|
||||
}
|
||||
|
||||
world.PendingDeletions.Clear();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 Associated Swap-and-Pop Unit Test
|
||||
```cpp
|
||||
void TestSwapAndPopPointerFixup()
|
||||
{
|
||||
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestSwapAndPopPointerFixup...");
|
||||
|
||||
TempArena tempArena = scratch_begin(nullptr, 0);
|
||||
|
||||
World testWorld{};
|
||||
InitWorld(&testWorld, tempArena.Arena);
|
||||
InitEntityManager(&testWorld);
|
||||
EntityManager& manager = *testWorld.EntityManager;
|
||||
|
||||
// Allocate 3 entities: E0, E1, E2
|
||||
Inert* e0 = MakeEntity<Inert>(manager, 1.0f, 0.0f, 0.0f);
|
||||
Inert* e1 = MakeEntity<Inert>(manager, 2.0f, 0.0f, 0.0f);
|
||||
Inert* e2 = MakeEntity<Inert>(manager, 3.0f, 0.0f, 0.0f);
|
||||
|
||||
EntityID id0 = e0->base->ID;
|
||||
EntityID id1 = e1->base->ID;
|
||||
EntityID id2 = e2->base->ID;
|
||||
|
||||
Assert(manager.Entities.Size() == 3);
|
||||
|
||||
// Delete middle entity E1 (triggers swap with E2)
|
||||
DestroyEntity(manager, id1);
|
||||
|
||||
Assert(manager.Entities.Size() == 2);
|
||||
|
||||
// Verify E2's mutual back-pointers are still completely intact
|
||||
Entity* remaining0 = &manager.Entities[0];
|
||||
Entity* remaining1 = &manager.Entities[1];
|
||||
|
||||
Assert(remaining0->ID == id0);
|
||||
Assert(remaining1->ID == id2);
|
||||
|
||||
auto* derived0 = reinterpret_cast<entity_template*>(remaining0->derived);
|
||||
auto* derived1 = reinterpret_cast<entity_template*>(remaining1->derived);
|
||||
|
||||
Assert(derived0->base == remaining0);
|
||||
Assert(derived1->base == remaining1);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&testWorld);
|
||||
|
||||
scratch_end(tempArena);
|
||||
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestSwapAndPopPointerFixup");
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user