Skip to content
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ CLAUDE.md

# For bison generated files used by ShaderLab
*.tab.c
*.output
*.output

# Local engineering notes
/notes/
18 changes: 18 additions & 0 deletions packages/core/src/Script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ export class Script extends Component {
}

this._entity._addScript(this);
if (this._hasCollisionEventCallbacks()) {
this.scene.physics._markContactEventDemandDirty();
}
}

/**
Expand All @@ -252,6 +255,21 @@ export class Script extends Component {
}

this._entity._removeScript(this);
if (this._hasCollisionEventCallbacks()) {
this.scene.physics._markContactEventDemandDirty();
}
}

/**
* @internal
*/
_hasCollisionEventCallbacks(): boolean {
const { prototype } = Script;
return (
this.onCollisionEnter !== prototype.onCollisionEnter ||
this.onCollisionExit !== prototype.onCollisionExit ||
this.onCollisionStay !== prototype.onCollisionStay
);
}

/**
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/physics/Collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ export class Collision {
*/
getContacts(outContacts: ContactPoint[]): number {
const nativeCollision = this._nativeCollision;
const smallerShapeId = Math.min(nativeCollision.shape0Id, nativeCollision.shape1Id);
const factor = this.shape.id === smallerShapeId ? 1 : -1;
const factor = this.shape.id === nativeCollision.shape1Id ? 1 : -1;
const nativeContactPoints = nativeCollision.getContacts();
const length = nativeContactPoints.size();
for (let i = 0; i < length; i++) {
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/physics/PhysicsScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export class PhysicsScene {
private _gravity: Vector3 = new Vector3(0, -9.81, 0);
private _nativePhysicsScene: IPhysicsScene;

/**
* Whether contact-event demand must be rescanned after collider or script lifecycle changes.
* @remarks Callback methods are treated as stable while a script remains active in the scene;
* runtime method replacement does not invalidate this cache.
*/
private _contactEventDemandDirty = true;

/**
* The gravity of physics scene.
*/
Expand Down Expand Up @@ -646,6 +653,7 @@ export class PhysicsScene {
for (let i = 0; i < step; i++) {
componentsManager.callScriptOnPhysicsUpdate();
this._callColliderOnUpdate();
this._syncContactEventDemand();
nativePhysicsManager.update(fixedTimeStep);
this._callColliderOnLateUpdate();
this._dispatchEvents(nativePhysicsManager.updateEvents());
Expand All @@ -661,6 +669,7 @@ export class PhysicsScene {
if (collider._index === -1) {
collider._index = this._colliders.length;
this._colliders.add(collider);
this._markContactEventDemandDirty();
}
this._nativePhysicsScene.addCollider(<ICollider>collider._nativeCollider);
}
Expand All @@ -674,6 +683,7 @@ export class PhysicsScene {
if (controller._index === -1) {
controller._index = this._colliders.length;
this._colliders.add(controller);
this._markContactEventDemandDirty();
}
this._nativePhysicsScene.addCharacterController(<ICharacterController>controller._nativeCollider);
}
Expand All @@ -687,6 +697,7 @@ export class PhysicsScene {
const replaced = this._colliders.deleteByIndex(collider._index);
replaced && (replaced._index = collider._index);
collider._index = -1;
this._markContactEventDemandDirty();
this._nativePhysicsScene.removeCollider(<ICollider>collider._nativeCollider);
}

Expand All @@ -699,9 +710,17 @@ export class PhysicsScene {
const replaced = this._colliders.deleteByIndex(controller._index);
replaced && (replaced._index = controller._index);
controller._index = -1;
this._markContactEventDemandDirty();
this._nativePhysicsScene.removeCharacterController(<ICharacterController>controller._nativeCollider);
}

/**
* @internal
*/
_markContactEventDemandDirty(): void {
this._contactEventDemandDirty = true;
}

/**
* @internal
*/
Expand Down Expand Up @@ -825,6 +844,26 @@ export class PhysicsScene {
}
}

private _syncContactEventDemand(): void {
if (!this._contactEventDemandDirty) return;
this._contactEventDemandDirty = false;
const { _elements: colliders } = this._colliders;

// Scan backward so newly appended colliders and scripts are checked first, increasing the chance of an early exit
for (let i = this._colliders.length - 1; i >= 0; --i) {
const scripts = colliders[i].entity._scripts;
const scriptElements = scripts._elements;
for (let j = scripts.length - 1; j >= 0; --j) {
if (scriptElements[j]._hasCollisionEventCallbacks()) {
this._nativePhysicsScene.setContactEventEnabled?.(true);
return;
}
}
}

this._nativePhysicsScene.setContactEventEnabled?.(false);
}

private _setGravity(): void {
this._nativePhysicsScene.setGravity(this._gravity);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/design/src/physics/ICollision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
* Interface of collision.
*/
export interface ICollision {
/** The unique id of the first collider. */
/** The unique ID of the first shape in the contact pair. */
shape0Id: number;
/** The unique id of the second collider. */
/** The unique ID of the second shape in the contact pair. */
shape1Id: number;
/** Count of contact points. */
contactCount: number;
/** Get contact points. */
/**
* Get contact points.
* @remarks Contact normals and impulses must point from the second shape in the pair to the first.
*/
getContacts(): VectorContactPairPoint;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/design/src/physics/IPhysicsScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface IPhysicsScene {
*/
update(elapsedTime: number): void;

/**
* Enable contact event buffering.
* @param enabled - Whether collision contact events should be buffered for dispatch.
*/
setContactEventEnabled?(enabled: boolean): void;

/**
* Collect buffered collision and trigger events.
* Must be called after update() and after syncing transforms back from physics.
Expand Down
17 changes: 17 additions & 0 deletions packages/physics-physx/src/PhysXPhysicsScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class PhysXPhysicsScene implements IPhysicsScene {
private _activeTriggers: DisorderedArray<TriggerEvent> = new DisorderedArray<TriggerEvent>();
private _contactEvents: ContactEvent[] = [];
private _contactEventCount = 0;
private _contactEventEnabled = true;
private _triggerEvents: TriggerEvent[] = [];
private _physicsEvents: IPhysicsEvents = { contactEvents: [], contactEventCount: 0, triggerEvents: [] };

Expand Down Expand Up @@ -191,6 +192,20 @@ export class PhysXPhysicsScene implements IPhysicsScene {
this._fetchResults();
}

/**
* {@inheritDoc IPhysicsScene.setContactEventEnabled }
*/
setContactEventEnabled(enabled: boolean): void {
if (this._contactEventEnabled === enabled) {
return;
}

this._contactEventEnabled = enabled;
if (!enabled) {
this._contactEventCount = 0;
}
}

/**
* {@inheritDoc IPhysicsScene.updateEvents }
*/
Expand Down Expand Up @@ -547,6 +562,8 @@ export class PhysXPhysicsScene implements IPhysicsScene {
}

private _bufferContactEvent(collision: ICollision, state: number): void {
if (!this._contactEventEnabled) return;

const index = this._contactEventCount++;
const event = (this._contactEvents[index] ||= new ContactEvent());
event.shape0Id = collision.shape0Id;
Expand Down
26 changes: 26 additions & 0 deletions tests/src/core/physics/Collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,30 @@ describe("Collision", function () {
engine.sceneManager.activeScene.physics._update(1);
});
});

it("reports contact normal from static other shape to dynamic self shape", function () {
engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0);
const dynamicBox = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0));
const staticBox = addBox(new Vector3(1, 1, 1), StaticCollider, new Vector3(0, 0, 0));

return new Promise<void>((done) => {
dynamicBox.addComponent(
class extends Script {
onCollisionEnter(other: Collision): void {
expect(other.shape).toBe(staticBox.getComponent(StaticCollider).shapes[0]);
const contacts = [];
other.getContacts(contacts);
expect(contacts.length).toBeGreaterThan(0);
expect(formatValue(contacts[0].normal.x)).toBe(-1);

done();
}
}
);

dynamicBox.getComponent(DynamicCollider).applyForce(new Vector3(1000, 0, 0));
// @ts-ignore
engine.sceneManager.activeScene.physics._update(1);
});
});
});
104 changes: 104 additions & 0 deletions tests/src/core/physics/PhysicsScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,24 @@ class CollisionTestScript extends Script {
}
}

class CollisionDemandScript extends Script {
onCollisionEnter(): void {}
}

class TriggerDemandScript extends Script {
onTriggerEnter(): void {}
}

function updatePhysics(physics) {
for (let i = 0; i < 5; ++i) {
physics._update(8);
}
}

function watchNativeContactEventDemand(physicsScene: PhysicsScene) {
return vi.spyOn((physicsScene as any)._nativePhysicsScene, "setContactEventEnabled");
}

function resetSpy() {
// reset spy on collision test script.
CollisionTestScript.prototype.onCollisionEnter = vi.fn(CollisionTestScript.prototype.onCollisionEnter);
Expand Down Expand Up @@ -142,6 +154,98 @@ describe("Physics Test", () => {
expect(enginePhysX.sceneManager.scenes[0].physics.fixedTimeStep).to.eq(fixedTimeStep);
});

it("auto-disables native contact events when no active collider has a collision callback", () => {
const scene = enginePhysX.sceneManager.activeScene;
const physicsScene = scene.physics;
const root = scene.createRootEntity("contact-demand-disabled");
const entity = root.createChild("body");
const collider = entity.addComponent(StaticCollider);
collider.addShape(new BoxColliderShape());
root.createChild("script-only").addComponent(CollisionDemandScript);
const contactEventDemand = watchNativeContactEventDemand(physicsScene);

try {
physicsScene._update(physicsScene.fixedTimeStep);
expect(contactEventDemand).toHaveBeenLastCalledWith(false);
} finally {
contactEventDemand.mockRestore();
root.destroy();
}
});

it("auto-enables native contact events only while an active collision callback exists", () => {
const scene = enginePhysX.sceneManager.activeScene;
const physicsScene = scene.physics;
const root = scene.createRootEntity("contact-demand-enabled");
const entity = root.createChild("body");
const collider = entity.addComponent(StaticCollider);
collider.addShape(new BoxColliderShape());
const script = entity.addComponent(CollisionDemandScript);
const contactEventDemand = watchNativeContactEventDemand(physicsScene);

try {
physicsScene._update(physicsScene.fixedTimeStep);
expect(contactEventDemand).toHaveBeenLastCalledWith(true);

collider.enabled = false;
physicsScene._update(physicsScene.fixedTimeStep);
expect(contactEventDemand).toHaveBeenLastCalledWith(false);

collider.enabled = true;
physicsScene._update(physicsScene.fixedTimeStep);
expect(contactEventDemand).toHaveBeenLastCalledWith(true);

script.enabled = false;
physicsScene._update(physicsScene.fixedTimeStep);
expect(contactEventDemand).toHaveBeenLastCalledWith(false);
} finally {
contactEventDemand.mockRestore();
root.destroy();
}
});

it("does not rescan contact event demand on every fixed substep", () => {
const scene = enginePhysX.sceneManager.activeScene;
const physicsScene = scene.physics;
const fixedTimeStep = physicsScene.fixedTimeStep;
const root = scene.createRootEntity("contact-demand-substeps");
const entity = root.createChild("body");
const collider = entity.addComponent(StaticCollider);
collider.addShape(new BoxColliderShape());
entity.addComponent(CollisionDemandScript);
const contactEventDemand = watchNativeContactEventDemand(physicsScene);

try {
physicsScene.fixedTimeStep = 1 / 480;
physicsScene._update(1 / 60);
expect(contactEventDemand).toHaveBeenCalledTimes(1);
expect(contactEventDemand).toHaveBeenLastCalledWith(true);
} finally {
physicsScene.fixedTimeStep = fixedTimeStep;
contactEventDemand.mockRestore();
root.destroy();
}
});

it("keeps native contact events disabled when only trigger callbacks exist", () => {
const scene = enginePhysX.sceneManager.activeScene;
const physicsScene = scene.physics;
const root = scene.createRootEntity("contact-demand-trigger-only");
const entity = root.createChild("body");
const collider = entity.addComponent(StaticCollider);
collider.addShape(new BoxColliderShape());
entity.addComponent(TriggerDemandScript);
const contactEventDemand = watchNativeContactEventDemand(physicsScene);

try {
physicsScene._update(physicsScene.fixedTimeStep);
expect(contactEventDemand).toHaveBeenLastCalledWith(false);
} finally {
contactEventDemand.mockRestore();
root.destroy();
}
});

it("raycast", () => {
const scene = enginePhysX.sceneManager.activeScene;
const physicsScene = scene.physics;
Expand Down
Loading