From d6b96b5a088475b46bf3917717e38e0a21fcdfd8 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 20:23:10 +0800 Subject: [PATCH 1/9] 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; From 4e2894bcce2c7439b47cc6825f06d7f8429cfb6f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 11:19:50 +0800 Subject: [PATCH 2/9] refactor(physics): simplify contact event demand --- .../2026-07-13-pr-3025-conflict-resolution.md | 4 +- packages/core/src/Script.ts | 5 +- packages/core/src/physics/PhysicsScene.ts | 35 +++---------- tests/src/core/physics/PhysicsScene.test.ts | 50 ++++++++----------- 4 files changed, 34 insertions(+), 60 deletions(-) diff --git a/notes/physics/2026-07-13-pr-3025-conflict-resolution.md b/notes/physics/2026-07-13-pr-3025-conflict-resolution.md index 8256a1acf2..c14b2ca4d4 100644 --- a/notes/physics/2026-07-13-pr-3025-conflict-resolution.md +++ b/notes/physics/2026-07-13-pr-3025-conflict-resolution.md @@ -4,9 +4,11 @@ PR #3025 was based on `v2.0.0-alpha.35`. The current `dev/2.0` branch has since The conflict was resolved by keeping the backend deletion. Contact-event demand remains an optional `IPhysicsScene` capability implemented by the active PhysX backend, so retaining an orphaned lite implementation would contradict the current architecture. +The post-merge review also collapsed contact-demand ownership to one dirty bit in `PhysicsScene`. `Script` owns the callback predicate, `PhysicsScene` rescans active collider scripts only after lifecycle invalidation, and the native backend owns the current enabled state. This removes duplicated predicates and Core-side cache state without replacing the exact scan with a lossy scene-wide counter. + Verification: - `pnpm run b:module` -- `pnpm -F @galacean/engine-core run b:types` +- `pnpm run b:types` - `pnpm vitest run tests/src/core/physics/PhysicsScene.test.ts tests/src/core/physics/Collision.test.ts` (55 passed) - `git diff --check` diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index 5d96703d53..685d4d7654 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -260,7 +260,10 @@ export class Script extends Component { } } - private _hasCollisionEventCallbacks(): boolean { + /** + * @internal + */ + _hasCollisionEventCallbacks(): boolean { const { prototype } = Script; return ( this.onCollisionEnter !== prototype.onCollisionEnter || diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index 87483fab59..4307f34290 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -30,8 +30,6 @@ 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. @@ -840,42 +838,23 @@ export class PhysicsScene { } } - private _hasCollisionEventConsumers(): boolean { - if (!this._collisionEventConsumersDirty) { - return this._hasCollisionEventConsumersCache; - } - + private _syncContactEventDemand(): void { + if (!this._collisionEventConsumersDirty) return; + this._collisionEventConsumersDirty = false; 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; + if (scriptElements[j]._hasCollisionEventCallbacks()) { + this._nativePhysicsScene.setContactEventEnabled?.(true); + return; } } } - 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; - } + this._nativePhysicsScene.setContactEventEnabled?.(false); } private _setGravity(): void { diff --git a/tests/src/core/physics/PhysicsScene.test.ts b/tests/src/core/physics/PhysicsScene.test.ts index 1fa03b9ebe..5f2584d73a 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -77,27 +77,7 @@ function updatePhysics(physics) { } 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; + return vi.spyOn((physicsScene as any)._nativePhysicsScene, "setContactEventEnabled"); } function resetSpy() { @@ -181,13 +161,14 @@ describe("Physics Test", () => { 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(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + expect(contactEventDemand).toHaveBeenLastCalledWith(false); } finally { - contactEventDemand.restore(); + contactEventDemand.mockRestore(); root.destroy(); } }); @@ -204,13 +185,21 @@ describe("Physics Test", () => { try { physicsScene._update(physicsScene.fixedTimeStep); - expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(true); + 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(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + expect(contactEventDemand).toHaveBeenLastCalledWith(false); } finally { - contactEventDemand.restore(); + contactEventDemand.mockRestore(); root.destroy(); } }); @@ -229,10 +218,11 @@ describe("Physics Test", () => { try { physicsScene.fixedTimeStep = 1 / 480; physicsScene._update(1 / 60); - expect(contactEventDemand.calls).to.deep.eq([true]); + expect(contactEventDemand).toHaveBeenCalledTimes(1); + expect(contactEventDemand).toHaveBeenLastCalledWith(true); } finally { physicsScene.fixedTimeStep = fixedTimeStep; - contactEventDemand.restore(); + contactEventDemand.mockRestore(); root.destroy(); } }); @@ -249,9 +239,9 @@ describe("Physics Test", () => { try { physicsScene._update(physicsScene.fixedTimeStep); - expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + expect(contactEventDemand).toHaveBeenLastCalledWith(false); } finally { - contactEventDemand.restore(); + contactEventDemand.mockRestore(); root.destroy(); } }); From 2606d04fbbca21a7821cee900d25e5574b157b24 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 11:24:23 +0800 Subject: [PATCH 3/9] chore: keep engineering notes local --- .gitignore | 5 ++++- .../2026-07-13-pr-3025-conflict-resolution.md | 14 -------------- 2 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 notes/physics/2026-07-13-pr-3025-conflict-resolution.md 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/notes/physics/2026-07-13-pr-3025-conflict-resolution.md b/notes/physics/2026-07-13-pr-3025-conflict-resolution.md deleted file mode 100644 index c14b2ca4d4..0000000000 --- a/notes/physics/2026-07-13-pr-3025-conflict-resolution.md +++ /dev/null @@ -1,14 +0,0 @@ -# PR 3025 conflict resolution - -PR #3025 was based on `v2.0.0-alpha.35`. The current `dev/2.0` branch has since removed the entire `physics-lite` backend in #3053, producing a modify/delete conflict in `LitePhysicsScene.ts` because the PR had added a no-op `setContactEventEnabled` implementation there. - -The conflict was resolved by keeping the backend deletion. Contact-event demand remains an optional `IPhysicsScene` capability implemented by the active PhysX backend, so retaining an orphaned lite implementation would contradict the current architecture. - -The post-merge review also collapsed contact-demand ownership to one dirty bit in `PhysicsScene`. `Script` owns the callback predicate, `PhysicsScene` rescans active collider scripts only after lifecycle invalidation, and the native backend owns the current enabled state. This removes duplicated predicates and Core-side cache state without replacing the exact scan with a lossy scene-wide counter. - -Verification: - -- `pnpm run b:module` -- `pnpm run b:types` -- `pnpm vitest run tests/src/core/physics/PhysicsScene.test.ts tests/src/core/physics/Collision.test.ts` (55 passed) -- `git diff --check` From 87e2e84a1c656330126cecb29ae2e84a124e62e7 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 11:43:14 +0800 Subject: [PATCH 4/9] test(physics): clarify contact demand coverage --- tests/src/core/physics/PhysicsScene.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/core/physics/PhysicsScene.test.ts b/tests/src/core/physics/PhysicsScene.test.ts index 5f2584d73a..ff2586e48e 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -154,7 +154,7 @@ 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", () => { + 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"); From efa90b75200142e3d8e2758ee6f9edd9d0e6a284 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 11:58:26 +0800 Subject: [PATCH 5/9] docs(physics): define native contact direction --- packages/design/src/physics/ICollision.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/design/src/physics/ICollision.ts b/packages/design/src/physics/ICollision.ts index c0dd650797..406b2313f7 100644 --- a/packages/design/src/physics/ICollision.ts +++ b/packages/design/src/physics/ICollision.ts @@ -8,7 +8,7 @@ export interface ICollision { shape1Id: number; /** Count of contact points. */ contactCount: number; - /** Get contact points. */ + /** Get contact points. Contact normals and impulses must point from shape1 to shape0. */ getContacts(): VectorContactPairPoint; } From 8b3576a96d15a0b767b83c77c8e97b26c464a859 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 12:09:25 +0800 Subject: [PATCH 6/9] docs(physics): clarify contact demand lifecycle --- packages/core/src/physics/PhysicsScene.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index 4307f34290..a94f5ba089 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -29,6 +29,12 @@ 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 _collisionEventConsumersDirty = true; /** From 5fbf0742cdd131894c0649af8776389c547e6f89 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 14:08:21 +0800 Subject: [PATCH 7/9] docs(physics): explain reverse demand scan --- packages/core/src/physics/PhysicsScene.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index a94f5ba089..8771980df7 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -849,6 +849,7 @@ export class PhysicsScene { this._collisionEventConsumersDirty = 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; From 6fe08652f4ecfb67032c2f3d02e9596b22496146 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 14:21:13 +0800 Subject: [PATCH 8/9] refactor(physics): align contact demand terminology --- packages/core/src/Script.ts | 4 ++-- packages/core/src/physics/PhysicsScene.ts | 18 +++++++++--------- packages/design/src/physics/ICollision.ts | 9 ++++++--- tests/src/core/physics/Collision.test.ts | 2 +- tests/src/core/physics/PhysicsScene.test.ts | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index 685d4d7654..3f51aaec47 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -230,7 +230,7 @@ export class Script extends Component { this._entity._addScript(this); if (this._hasCollisionEventCallbacks()) { - this.scene.physics._markCollisionEventConsumersDirty(); + this.scene.physics._markContactEventDemandDirty(); } } @@ -256,7 +256,7 @@ export class Script extends Component { this._entity._removeScript(this); if (this._hasCollisionEventCallbacks()) { - this.scene.physics._markCollisionEventConsumersDirty(); + this.scene.physics._markContactEventDemandDirty(); } } diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index 8771980df7..8dd12bae2d 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -35,7 +35,7 @@ export class PhysicsScene { * @remarks Callback methods are treated as stable while a script remains active in the scene; * runtime method replacement does not invalidate this cache. */ - private _collisionEventConsumersDirty = true; + private _contactEventDemandDirty = true; /** * The gravity of physics scene. @@ -669,7 +669,7 @@ export class PhysicsScene { if (collider._index === -1) { collider._index = this._colliders.length; this._colliders.add(collider); - this._markCollisionEventConsumersDirty(); + this._markContactEventDemandDirty(); } this._nativePhysicsScene.addCollider(collider._nativeCollider); } @@ -683,7 +683,7 @@ export class PhysicsScene { if (controller._index === -1) { controller._index = this._colliders.length; this._colliders.add(controller); - this._markCollisionEventConsumersDirty(); + this._markContactEventDemandDirty(); } this._nativePhysicsScene.addCharacterController(controller._nativeCollider); } @@ -697,7 +697,7 @@ export class PhysicsScene { const replaced = this._colliders.deleteByIndex(collider._index); replaced && (replaced._index = collider._index); collider._index = -1; - this._markCollisionEventConsumersDirty(); + this._markContactEventDemandDirty(); this._nativePhysicsScene.removeCollider(collider._nativeCollider); } @@ -710,15 +710,15 @@ export class PhysicsScene { const replaced = this._colliders.deleteByIndex(controller._index); replaced && (replaced._index = controller._index); controller._index = -1; - this._markCollisionEventConsumersDirty(); + this._markContactEventDemandDirty(); this._nativePhysicsScene.removeCharacterController(controller._nativeCollider); } /** * @internal */ - _markCollisionEventConsumersDirty(): void { - this._collisionEventConsumersDirty = true; + _markContactEventDemandDirty(): void { + this._contactEventDemandDirty = true; } /** @@ -845,8 +845,8 @@ export class PhysicsScene { } private _syncContactEventDemand(): void { - if (!this._collisionEventConsumersDirty) return; - this._collisionEventConsumersDirty = false; + 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. diff --git a/packages/design/src/physics/ICollision.ts b/packages/design/src/physics/ICollision.ts index 406b2313f7..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. Contact normals and impulses must point from shape1 to shape0. */ + /** + * 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/tests/src/core/physics/Collision.test.ts b/tests/src/core/physics/Collision.test.ts index 8fc27ef8b9..ffb971861a 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-rhi-webgl"; +import { WebGLEngine } from "@galacean/engine"; import { Collision } from "packages/core/types/physics/Collision"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; diff --git a/tests/src/core/physics/PhysicsScene.test.ts b/tests/src/core/physics/PhysicsScene.test.ts index ff2586e48e..6447c5258c 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -17,7 +17,7 @@ import { } from "@galacean/engine-core"; import { Ray, Vector3, Quaternion } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { WebGLEngine } from "@galacean/engine"; import { vi, describe, beforeAll, expect, it, afterEach } from "vitest"; class CollisionTestScript extends Script { From b9b56160c38a2fe4d8e8db041192aa83860f585e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 13 Jul 2026 17:48:48 +0800 Subject: [PATCH 9/9] style(physics): align inline comment punctuation --- packages/core/src/physics/PhysicsScene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index 8dd12bae2d..0550ac18a4 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -849,7 +849,7 @@ export class PhysicsScene { 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. + // 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;