Skip to content

Commit af677d3

Browse files
committed
perf(hooks): add fast path for entity deletion lifecycle hooks
Skip unnecessary archetype lookups and on_set checks when deleting entities by collecting component values directly from removedComponents map instead of querying the (now deleted) entity.
1 parent 7217666 commit af677d3

File tree

2 files changed

+99
-6
lines changed

2 files changed

+99
-6
lines changed

src/core/world-hooks.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,44 @@ export function triggerLifecycleHooks(
7979
triggerMultiComponentHooks(ctx, entityId, addedComponents, removedComponents, oldArchetype, newArchetype);
8080
}
8181

82+
/**
83+
* Fast path for triggering lifecycle hooks when an entity is being deleted.
84+
* This avoids unnecessary archetype lookups and on_set checks since the entity
85+
* is being completely removed.
86+
*/
87+
export function triggerRemoveHooksForEntityDeletion(
88+
ctx: HooksContext,
89+
entityId: EntityId,
90+
removedComponents: Map<EntityId<any>, any>,
91+
oldArchetype: Archetype,
92+
): void {
93+
if (removedComponents.size === 0) return;
94+
95+
// Trigger legacy hooks for removed components
96+
invokeHooksForComponents(ctx.hooks, entityId, removedComponents, "on_remove");
97+
98+
// Trigger multi-component hooks - only on_remove since entity is being deleted
99+
for (const entry of oldArchetype.matchingMultiHooks) {
100+
const { hook, requiredComponents, componentTypes } = entry;
101+
if (!hook.on_remove) continue;
102+
103+
// Check if any required component was removed
104+
const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
105+
if (!anyRequiredRemoved) continue;
106+
107+
// For entity deletion, we know:
108+
// 1. All components are being removed, so entity "had" all required components
109+
// 2. Entity will no longer match after deletion
110+
// Just need to verify the entity actually had all required components before
111+
const hadAllRequired = requiredComponents.every((c) => anyComponentMatches(removedComponents, c));
112+
if (!hadAllRequired) continue;
113+
114+
// Collect component values from removedComponents directly (no entity lookup needed)
115+
const components = collectComponentsFromRemoved(componentTypes, removedComponents);
116+
hook.on_remove(entityId, ...components);
117+
}
118+
}
119+
82120
function invokeHooksForComponents(
83121
hooks: HooksMap,
84122
entityId: EntityId,
@@ -262,3 +300,56 @@ function collectMultiHookComponentsWithRemoved(
262300
return match ? match[1] : ctx.get(entityId, compId);
263301
});
264302
}
303+
304+
/**
305+
* Collect component values directly from removedComponents map.
306+
* Used for entity deletion fast path where the entity no longer exists.
307+
*/
308+
function collectComponentsFromRemoved(
309+
componentTypes: readonly ComponentType<any>[],
310+
removedComponents: Map<EntityId<any>, any>,
311+
): any[] {
312+
return componentTypes.map((ct) => {
313+
if (isOptionalEntityId(ct)) {
314+
const optionalId = ct.optional;
315+
316+
if (isWildcardRelationId(optionalId)) {
317+
const result = collectWildcardFromRemoved(optionalId, removedComponents);
318+
return result.length > 0 ? { value: result } : undefined;
319+
}
320+
321+
const match = findMatchingComponent(removedComponents, optionalId);
322+
return match ? { value: match[1] } : undefined;
323+
}
324+
325+
const compId = ct as EntityId<any>;
326+
327+
if (isWildcardRelationId(compId)) {
328+
return collectWildcardFromRemoved(compId, removedComponents);
329+
}
330+
331+
const match = findMatchingComponent(removedComponents, compId);
332+
return match ? match[1] : undefined;
333+
});
334+
}
335+
336+
/**
337+
* Collect all matching wildcard relation data from removed components.
338+
*/
339+
function collectWildcardFromRemoved(
340+
wildcardId: EntityId<any>,
341+
removedComponents: Map<EntityId<any>, any>,
342+
): [EntityId, any][] {
343+
const result: [EntityId, any][] = [];
344+
345+
for (const [removedCompId, removedValue] of removedComponents.entries()) {
346+
if (componentMatchesHookType(removedCompId, wildcardId)) {
347+
const targetId = getTargetIdFromRelationId(removedCompId);
348+
if (targetId !== undefined) {
349+
result.push([targetId, removedValue]);
350+
}
351+
}
352+
}
353+
354+
return result;
355+
}

src/core/world.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ import {
3737
processCommands,
3838
removeMatchingRelations,
3939
} from "./world-commands";
40-
import { collectMultiHookComponents, triggerLifecycleHooks, type HooksContext } from "./world-hooks";
40+
import {
41+
collectMultiHookComponents,
42+
triggerLifecycleHooks,
43+
triggerRemoveHooksForEntityDeletion,
44+
type HooksContext,
45+
} from "./world-hooks";
4146
import {
4247
getEntityReferences,
4348
trackEntityReference,
@@ -154,11 +159,8 @@ export class World {
154159
const removedComponents = archetype.removeEntity(cur)!;
155160
this.entityToArchetype.delete(cur);
156161

157-
// Trigger lifecycle hooks for removed components
158-
if (removedComponents.size > 0) {
159-
const emptyArchetype = this.ensureArchetype([]);
160-
triggerLifecycleHooks(this.createHooksContext(), cur, new Map(), removedComponents, archetype, emptyArchetype);
161-
}
162+
// Trigger lifecycle hooks for removed components (fast path for entity deletion)
163+
triggerRemoveHooksForEntityDeletion(this.createHooksContext(), cur, removedComponents, archetype);
162164

163165
this.cleanupArchetypesReferencingEntity(cur);
164166
this.entityIdManager.deallocate(cur);

0 commit comments

Comments
 (0)