Skip to content

Commit 6261a49

Browse files
willeastcottclaude
andauthored
fix(physics): clear cached collisions when a collision component is removed (#8898)
The rigid-body `collisions` map is keyed by entity GUID and was never cleared on entity destroy. Reloading the same scene recreates entities with identical GUIDs in a single task, so the stale entry (still referencing the destroyed entity) was reused, and triggerleave/collisionend stopped firing because `_cleanOldCollisions` inspects the cached, now-destroyed entity. Clear the entity's entry on collision `beforeremove`; every physics body entity has a collision component, so one hook covers triggers and rigid bodies. Fixes #5797 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3a137f2 commit 6261a49

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

src/framework/components/collision/system.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,12 @@ class CollisionComponentSystem extends ComponentSystem {
732732
onBeforeRemove(entity, component) {
733733
this.implementations[component.type].beforeRemove(entity, component);
734734
component.onBeforeRemove();
735+
736+
// discard any stored collisions keyed to this entity so a later entity that reuses the
737+
// same GUID (e.g. after reloading the same scene) does not inherit stale tracking
738+
if (this.app.systems.rigidbody) {
739+
this.app.systems.rigidbody.clearEntityCollisions(entity);
740+
}
735741
}
736742

737743
onRemove(entity) {

src/framework/components/rigid-body/system.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,20 @@ class RigidBodyComponentSystem extends ComponentSystem {
541541
}
542542
}
543543

544+
/**
545+
* Removes any stored collision keyed to the given entity. Called when a collision component is
546+
* removed so the persistent collisions map does not retain a destroyed entity. A new entity
547+
* that later reuses the same GUID (for example after reloading the same scene) would otherwise
548+
* inherit the stale entry and never fire `triggerleave` / `collisionend`, because the cached
549+
* entity no longer has a trigger or body.
550+
*
551+
* @param {Entity} entity - The entity whose stored collision should be removed.
552+
* @ignore
553+
*/
554+
clearEntityCollisions(entity) {
555+
delete this.collisions[entity.guid];
556+
}
557+
544558
/**
545559
* Returns true if the entity has a contact event attached and false otherwise.
546560
*
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expect } from 'chai';
2+
3+
import { Entity } from '../../../../src/framework/entity.js';
4+
import { createApp } from '../../../app.mjs';
5+
import { jsdomSetup, jsdomTeardown } from '../../../jsdom.mjs';
6+
7+
describe('RigidBodyComponentSystem', function () {
8+
let app;
9+
10+
beforeEach(function () {
11+
jsdomSetup();
12+
app = createApp();
13+
});
14+
15+
afterEach(function () {
16+
app?.destroy();
17+
app = null;
18+
jsdomTeardown();
19+
});
20+
21+
describe('stored collisions', function () {
22+
23+
// Regression test for https://github.com/playcanvas/engine/issues/5797 - the persistent
24+
// collisions map is keyed by entity GUID. Reloading the same scene recreates entities with
25+
// the same GUIDs, so a stale entry referencing a destroyed entity must not survive removal,
26+
// otherwise its triggerleave / collisionend events would never fire again.
27+
it('discards stored collisions when the collision component is removed', function () {
28+
const e = new Entity();
29+
app.root.addChild(e);
30+
e.addComponent('collision');
31+
32+
app.systems.rigidbody.collisions[e.guid] = { entity: e, others: [new Entity()] };
33+
34+
e.removeComponent('collision');
35+
36+
expect(app.systems.rigidbody.collisions[e.guid]).to.be.undefined;
37+
});
38+
39+
it('discards stored collisions when the entity is destroyed', function () {
40+
const e = new Entity();
41+
app.root.addChild(e);
42+
e.addComponent('collision');
43+
44+
const guid = e.guid;
45+
app.systems.rigidbody.collisions[guid] = { entity: e, others: [] };
46+
47+
e.destroy();
48+
49+
expect(app.systems.rigidbody.collisions[guid]).to.be.undefined;
50+
});
51+
52+
it('leaves collisions keyed to other entities untouched', function () {
53+
const a = new Entity();
54+
const b = new Entity();
55+
app.root.addChild(a);
56+
app.root.addChild(b);
57+
a.addComponent('collision');
58+
b.addComponent('collision');
59+
60+
app.systems.rigidbody.collisions[a.guid] = { entity: a, others: [b] };
61+
app.systems.rigidbody.collisions[b.guid] = { entity: b, others: [a] };
62+
63+
a.removeComponent('collision');
64+
65+
expect(app.systems.rigidbody.collisions[a.guid]).to.be.undefined;
66+
expect(app.systems.rigidbody.collisions[b.guid]).to.exist;
67+
});
68+
69+
});
70+
71+
});

0 commit comments

Comments
 (0)