From d6b96b5a088475b46bf3917717e38e0a21fcdfd8 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 20:23:10 +0800 Subject: [PATCH] fix(physics): gate contact event buffering --- packages/core/src/Script.ts | 15 +++ packages/core/src/physics/Collision.ts | 3 +- packages/core/src/physics/PhysicsScene.ts | 53 ++++++++ packages/design/src/physics/IPhysicsScene.ts | 6 + packages/physics-lite/src/LitePhysicsScene.ts | 7 ++ .../physics-physx/src/PhysXPhysicsScene.ts | 17 +++ tests/src/core/physics/Collision.test.ts | 28 ++++- tests/src/core/physics/PhysicsScene.test.ts | 116 +++++++++++++++++- 8 files changed, 241 insertions(+), 4 deletions(-) diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index 997901573d..5d96703d53 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._markCollisionEventConsumersDirty(); + } } /** @@ -252,6 +255,18 @@ export class Script extends Component { } this._entity._removeScript(this); + if (this._hasCollisionEventCallbacks()) { + this.scene.physics._markCollisionEventConsumersDirty(); + } + } + + private _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..87483fab59 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -29,6 +29,9 @@ export class PhysicsScene { private _gravity: Vector3 = new Vector3(0, -9.81, 0); private _nativePhysicsScene: IPhysicsScene; + private _collisionEventConsumersDirty = true; + private _hasCollisionEventConsumersCache = false; + private _contactEventEnabled: boolean | undefined; /** * The gravity of physics scene. @@ -646,6 +649,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 +665,7 @@ export class PhysicsScene { if (collider._index === -1) { collider._index = this._colliders.length; this._colliders.add(collider); + this._markCollisionEventConsumersDirty(); } this._nativePhysicsScene.addCollider(collider._nativeCollider); } @@ -674,6 +679,7 @@ export class PhysicsScene { if (controller._index === -1) { controller._index = this._colliders.length; this._colliders.add(controller); + this._markCollisionEventConsumersDirty(); } this._nativePhysicsScene.addCharacterController(controller._nativeCollider); } @@ -687,6 +693,7 @@ export class PhysicsScene { const replaced = this._colliders.deleteByIndex(collider._index); replaced && (replaced._index = collider._index); collider._index = -1; + this._markCollisionEventConsumersDirty(); this._nativePhysicsScene.removeCollider(collider._nativeCollider); } @@ -699,9 +706,17 @@ export class PhysicsScene { const replaced = this._colliders.deleteByIndex(controller._index); replaced && (replaced._index = controller._index); controller._index = -1; + this._markCollisionEventConsumersDirty(); this._nativePhysicsScene.removeCharacterController(controller._nativeCollider); } + /** + * @internal + */ + _markCollisionEventConsumersDirty(): void { + this._collisionEventConsumersDirty = true; + } + /** * @internal */ @@ -825,6 +840,44 @@ export class PhysicsScene { } } + private _hasCollisionEventConsumers(): boolean { + if (!this._collisionEventConsumersDirty) { + return this._hasCollisionEventConsumersCache; + } + + const { _elements: colliders } = this._colliders; + const { onCollisionEnter, onCollisionExit, onCollisionStay } = Script.prototype; + + 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) { + const script = scriptElements[j]; + if ( + script.onCollisionEnter !== onCollisionEnter || + script.onCollisionExit !== onCollisionExit || + script.onCollisionStay !== onCollisionStay + ) { + this._collisionEventConsumersDirty = false; + this._hasCollisionEventConsumersCache = true; + return true; + } + } + } + + this._collisionEventConsumersDirty = false; + this._hasCollisionEventConsumersCache = false; + return this._hasCollisionEventConsumersCache; + } + + private _syncContactEventDemand(): void { + const enabled = this._hasCollisionEventConsumers(); + if (this._contactEventEnabled !== enabled) { + this._nativePhysicsScene.setContactEventEnabled?.(enabled); + this._contactEventEnabled = enabled; + } + } + private _setGravity(): void { this._nativePhysicsScene.setGravity(this._gravity); } 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-lite/src/LitePhysicsScene.ts b/packages/physics-lite/src/LitePhysicsScene.ts index 0b6b6eb3e8..58742b5069 100644 --- a/packages/physics-lite/src/LitePhysicsScene.ts +++ b/packages/physics-lite/src/LitePhysicsScene.ts @@ -115,6 +115,13 @@ export class LitePhysicsScene implements IPhysicsScene { } } + /** + * {@inheritDoc IPhysicsScene.setContactEventEnabled } + */ + setContactEventEnabled(_enabled: boolean): void { + // Physics-lite only produces trigger events, so there is no contact buffer to toggle. + } + /** * {@inheritDoc IPhysicsScene.updateEvents } */ 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..8fc27ef8b9 100644 --- a/tests/src/core/physics/Collision.test.ts +++ b/tests/src/core/physics/Collision.test.ts @@ -1,7 +1,7 @@ import { BoxColliderShape, DynamicCollider, Entity, Engine, Script, StaticCollider } from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { Collision } from "packages/core/types/physics/Collision"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; @@ -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 bc215b1315..dd84c2d5f9 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -18,7 +18,7 @@ import { import { Ray, Vector3, Quaternion } from "@galacean/engine-math"; import { LitePhysics } from "@galacean/engine-physics-lite"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { vi, describe, beforeAll, expect, it, afterEach } from "vitest"; class CollisionTestScript extends Script { @@ -74,12 +74,44 @@ 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) { + const nativeScene = (physicsScene as any)._nativePhysicsScene; + const original = nativeScene.setContactEventEnabled; + const calls: boolean[] = []; + nativeScene.setContactEventEnabled = (enabled: boolean) => { + calls.push(enabled); + original?.call(nativeScene, enabled); + }; + return { + calls, + restore() { + if (original) { + nativeScene.setContactEventEnabled = original; + } else { + delete nativeScene.setContactEventEnabled; + } + } + }; +} + +function getLastContactEventDemandCall(calls: boolean[]): boolean { + return calls[calls.length - 1] ?? false; +} + function resetSpy() { // reset spy on collision test script. CollisionTestScript.prototype.onCollisionEnter = vi.fn(CollisionTestScript.prototype.onCollisionEnter); @@ -422,6 +454,88 @@ describe("Physics Test", () => { expect(enginePhysX.sceneManager.scenes[0].physics.fixedTimeStep).to.eq(fixedTimeStep); }); + it("auto-disables native contact events when no active collision callback exists", () => { + 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()); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + 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(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(true); + + script.enabled = false; + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + 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.calls).to.deep.eq([true]); + } finally { + physicsScene.fixedTimeStep = fixedTimeStep; + contactEventDemand.restore(); + 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(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + root.destroy(); + } + }); + it("raycast", () => { const scene = enginePhysX.sceneManager.activeScene; const physicsScene = scene.physics;