diff --git a/.gitignore b/.gitignore index f6049b3640..ed3c3c9502 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ CLAUDE.md # For bison generated files used by ShaderLab *.tab.c -*.output \ No newline at end of file +*.output + +# Local engineering notes +/notes/ diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index 997901573d..3f51aaec47 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -229,6 +229,9 @@ export class Script extends Component { } this._entity._addScript(this); + if (this._hasCollisionEventCallbacks()) { + this.scene.physics._markContactEventDemandDirty(); + } } /** @@ -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 + ); } /** diff --git a/packages/core/src/physics/Collision.ts b/packages/core/src/physics/Collision.ts index 16edfd5bb3..440864e4fe 100644 --- a/packages/core/src/physics/Collision.ts +++ b/packages/core/src/physics/Collision.ts @@ -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++) { diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index ce6bf2e4e8..0550ac18a4 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -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. */ @@ -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()); @@ -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(collider._nativeCollider); } @@ -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(controller._nativeCollider); } @@ -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(collider._nativeCollider); } @@ -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(controller._nativeCollider); } + /** + * @internal + */ + _markContactEventDemandDirty(): void { + this._contactEventDemandDirty = true; + } + /** * @internal */ @@ -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); } diff --git a/packages/design/src/physics/ICollision.ts b/packages/design/src/physics/ICollision.ts index c0dd650797..e3ca0cce90 100644 --- a/packages/design/src/physics/ICollision.ts +++ b/packages/design/src/physics/ICollision.ts @@ -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; } diff --git a/packages/design/src/physics/IPhysicsScene.ts b/packages/design/src/physics/IPhysicsScene.ts index 5520114104..51bba67a57 100644 --- a/packages/design/src/physics/IPhysicsScene.ts +++ b/packages/design/src/physics/IPhysicsScene.ts @@ -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. diff --git a/packages/physics-physx/src/PhysXPhysicsScene.ts b/packages/physics-physx/src/PhysXPhysicsScene.ts index d026304e4d..bee2ab2dc2 100644 --- a/packages/physics-physx/src/PhysXPhysicsScene.ts +++ b/packages/physics-physx/src/PhysXPhysicsScene.ts @@ -50,6 +50,7 @@ export class PhysXPhysicsScene implements IPhysicsScene { private _activeTriggers: DisorderedArray = new DisorderedArray(); private _contactEvents: ContactEvent[] = []; private _contactEventCount = 0; + private _contactEventEnabled = true; private _triggerEvents: TriggerEvent[] = []; private _physicsEvents: IPhysicsEvents = { contactEvents: [], contactEventCount: 0, triggerEvents: [] }; @@ -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 } */ @@ -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; diff --git a/tests/src/core/physics/Collision.test.ts b/tests/src/core/physics/Collision.test.ts index 54b8f3809c..ffb971861a 100644 --- a/tests/src/core/physics/Collision.test.ts +++ b/tests/src/core/physics/Collision.test.ts @@ -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((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); + }); + }); }); diff --git a/tests/src/core/physics/PhysicsScene.test.ts b/tests/src/core/physics/PhysicsScene.test.ts index fae4d817c2..6447c5258c 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -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); @@ -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;