Skip to content

fix(physics): gate contact buffering and correct collision normals#3025

Open
luzhuang wants to merge 10 commits into
dev/2.0from
fix/physics-shaderlab-split
Open

fix(physics): gate contact buffering and correct collision normals#3025
luzhuang wants to merge 10 commits into
dev/2.0from
fix/physics-shaderlab-split

Conversation

@luzhuang

@luzhuang luzhuang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Unused contact buffering

PhysX contact points were copied across the Embind boundary whenever a contact was reported, even when no active collision callback could consume them.

Incorrect collision normal orientation

Collision.getContacts() oriented contact normals and impulses by comparing the numeric IDs of the two shapes:

const smallerShapeId = Math.min(nativeCollision.shape0Id, nativeCollision.shape1Id);
const factor = this.shape.id === smallerShapeId ? 1 : -1;

This ordering was intentional, but relied on a correlation rather than the PhysX contact-pair contract:

  • Galacean shape IDs are monotonically assigned creation IDs and are passed to PhysX through PxShape.userData.
  • The physX.js binding exports PxContactPair::shapes[0] and shapes[1] unchanged; it does not sort them by Galacean ID.
  • PhysX defines extracted contact normals as pointing from pair shape1 to pair shape0.
  • PhysX orders interactions using actor type, kinematic/articulation state, broadphase grouping, and internal RigidID — not the Galacean shape ID.

For the existing dynamic-dynamic tests, PhysX's internal RigidID ordering commonly placed the earlier-created actor in shape1. Because Galacean shape IDs also followed creation order, shape1Id happened to equal the smaller Galacean shape ID, so the old heuristic passed even when creation order was reversed.

Static-dynamic pairs break that correlation. PhysX places the static actor in shape1 regardless of the Galacean ID. When the dynamic shape is created first and the static shape second, shape1 has the larger Galacean ID. The old heuristic then reverses an already-correct normal.

The core layer dispatches Collision.shape as the other shape. Therefore the receiver-relative direction follows directly from the native pair:

  • Other is shape1: the native shape1 -> shape0 direction already points from other to self; keep it.
  • Other is shape0: reverse it.
const factor = this.shape.id === nativeCollision.shape1Id ? 1 : -1;

The native ICollision contract now explicitly requires contact normals and impulses to point from shape1 to shape0, so other physics backends can implement the same semantics without relying on PhysX-specific assumptions.

Solution

  • Detect collision callback overrides in Script.
  • Let PhysicsScene invalidate contact-event demand when script, collider, or character-controller lifecycle state changes.
  • Rescan active collider scripts only while demand is dirty. The steady-state fixed-step path performs one dirty check with no allocation; the dirty scan exits on the first consumer.
  • Let the native backend own the enabled state and skip contact buffering before reading or copying PhysX contact points.
  • Keep third-party backends compatible through the optional setContactEventEnabled capability.
  • Orient contact normals and impulses using the native shape1 -> shape0 pair contract instead of numeric shape ID ordering.

Script callback lifecycle boundary

Contact-event demand follows the engine's existing lifecycle-based Script callback registration model:

  • A Script entering or leaving the active scene marks demand dirty when it overrides onCollisionEnter, onCollisionExit, or onCollisionStay.
  • Collider and character-controller additions or removals also mark demand dirty.
  • The next fixed step rescans once, while unchanged fixed steps only perform a dirty-flag check.

Callback methods are treated as stable while a Script remains active in the scene. Assigning a collision callback directly to an active instance, or replacing its prototype method in place, does not itself invalidate contact-event demand. This matches the existing registration behavior of onUpdate, onLateUpdate, and onPhysicsUpdate, which are also inspected when a Script enters the active scene rather than polled every frame.

A source reload that recreates Script instances naturally follows the lifecycle path above. If live HMR needs to patch active instances without recreation, the HMR integration must trigger a unified Script callback-registration refresh covering update and collision callbacks. Adding per-frame scans or collision-only method setters would create a separate callback model and add permanent hot-path cost.

Split Scope

This is PR 1/3 from the former broad physics split:

  1. fix(physics): gate contact buffering and correct collision normals #3025: contact event demand + collision normal direction.
  2. Follow-up PR: kinematic transform sync / re-enter teleport / CCD state.
  3. Follow-up PR: mesh collider rebuild/retry + PhysX scaled defaults + physics material clone sync.

DynamicCollider.applyForceAtPosition is intentionally out of scope here; the long-term PhysX binding path is handled by #3039.

Regression Proof

With the old shape-ID-based orientation restored:

HEADLESS=true pnpm vitest run tests/src/core/physics/Collision.test.ts --reporter=verbose

  • 1 failed, 4 passed
  • Expected normal X: -1
  • Received normal X: 1

The failing case creates the dynamic shape first and the static shape second. PhysX places the static shape in pair shape1, making shape1Id the larger Galacean ID and disproving the old ordering assumption.

With this PR: 5 passed.

Verification

  • pnpm run b:module
  • pnpm run b:types
  • HEADLESS=true pnpm vitest run tests/src/core/physics/PhysicsScene.test.ts tests/src/core/physics/Collision.test.ts — 55 passed
  • git diff --check

Summary by CodeRabbit

  • New Features
    • Added setContactEventEnabled(enabled) to control native contact event buffering/dispatch behavior.
  • Bug Fixes
    • Fixed collision contact normal and impulse scaling for dynamic vs. static interactions.
  • Performance
    • Native contact events now auto-enable only when an active collision callback exists, and disable for trigger-only cases; demand is synced efficiently per fixed step.
  • Tests
    • Added/extended physics tests for contact normal direction and native contact-event demand behavior.
  • Documentation
    • Clarified the expected direction for collision contact normals and impulses.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR adds demand-based PhysX contact-event buffering, tracks collision callback consumers, corrects contact orientation scaling, and adds regression tests for normals and event-demand transitions.

Changes

Physics contact events and collision reporting

Layer / File(s) Summary
Contact event buffering contract and PhysX gate
packages/design/src/physics/IPhysicsScene.ts, packages/physics-physx/src/PhysXPhysicsScene.ts
Adds contact-event enablement to the physics-scene contract and gates PhysX contact buffering, clearing pending events when disabled.
Collision consumer demand synchronization
packages/core/src/Script.ts, packages/core/src/physics/PhysicsScene.ts
Tracks collider, controller, and script changes, then enables native contact events only when active collision callbacks are detected.
Collision contact orientation correction
packages/core/src/physics/Collision.ts, packages/design/src/physics/ICollision.ts
Determines contact normal and impulse scaling from the collision shape identity and documents the required contact direction.
Contact demand and normal regression coverage
tests/src/core/physics/Collision.test.ts, tests/src/core/physics/PhysicsScene.test.ts
Tests contact normal orientation and native contact-event enablement across collision, disabled, and trigger-only callback scenarios.
Local notes ignore rule
.gitignore
Adds /notes/ to the ignored paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Script
  participant PhysicsScene
  participant PhysXPhysicsScene
  participant CollisionCallback
  Script->>PhysicsScene: mark collision consumer state dirty
  PhysicsScene->>PhysicsScene: scan collider scripts before fixed-step update
  PhysicsScene->>PhysXPhysicsScene: setContactEventEnabled(true or false)
  PhysXPhysicsScene->>PhysXPhysicsScene: buffer or discard native contact events
  PhysXPhysicsScene->>CollisionCallback: dispatch buffered collision contacts
Loading

Possibly related issues

Suggested labels: physics

Suggested reviewers: guolei1990

Poem

A rabbit watched the colliders meet,
While contacts danced on tiny feet.
“Buffer when collision calls are near,
And point the normals crystal-clear!”
The physics burrow hummed with cheer.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: gating contact buffering and correcting collision normals.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/physics-shaderlab-split

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/physics/shape/MeshColliderShape.ts (1)

201-204: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop retrying unsupported non-convex dynamic mesh creation every tick.

Line [201]-Line [204] returns for a permanent unsupported state, but Line [249]-Line [250] keeps retrying while pending is true. After mesh assignment on non-kinematic DynamicCollider, this can spam errors and do needless per-frame work indefinitely.

Suggested fix
override _onPhysicsUpdate(): void {
-  if (!this._pendingNativeShapeCreation || !this._mesh || !this._positions) return;
+  if (!this._pendingNativeShapeCreation || !this._mesh || !this._positions) return;
+  if (!this._isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) {
+    // Unsupported until collider becomes kinematic; avoid per-frame error spam.
+    return;
+  }
   this._createNativeShape();
}

Also applies to: 249-250

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/physics/shape/MeshColliderShape.ts` around lines 201 - 204,
The MeshColliderShape code currently logs an error for non-convex meshes on
non-kinematic DynamicCollider but later keeps retrying while pending is true;
change the logic so when you detect (!this._isConvex && this._collider
instanceof DynamicCollider && !this._collider.isKinematic) you both log the
error once and mark the creation as terminal by clearing/setting the pending
flag (e.g., pending = false or this._pending = false) and avoid assigning mesh
or scheduling further retries; update the same handling at the location that
does the mesh assignment (the code that references mesh and pending) so it
short-circuits on this unsupported state and prevents per-frame work.
🧹 Nitpick comments (3)
packages/physics-physx/src/PhysXPhysics.ts (2)

336-346: 💤 Low value

Partial tolerancesScale overrides blend user options with PhysX native defaults.

When a user provides only length or only speed in tolerancesScale, the unprovided parameter falls back to the native PhysX tolerancesScale value (lines 337-338). This allows partial overrides but could surprise users who expect independent control.

For example, if a user sets { tolerancesScale: { length: 2 } }, the speed will come from PhysX's native default (typically 10), and the computed _defaultSleepThreshold will use that native speed value.

This behavior appears intentional and enables flexible configuration, but consider documenting it in the PhysXTolerancesScale interface JSDoc to clarify that partial overrides are supported and will blend with PhysX defaults.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/physics-physx/src/PhysXPhysics.ts` around lines 336 - 346, The
_applyTolerancesScale function currently blends user-provided tolerances with
PhysX native defaults when only one of length or speed is supplied; add clear
JSDoc to the PhysXTolerancesScale interface explaining that partial overrides
are supported and that unspecified fields fall back to the PhysX runtime
defaults (which will affect computed values such as _defaultSleepThreshold via
_updateScaledDefaults), and include a short example (e.g., { length: 2 } uses
native speed) and mention that _assertPositiveFinite still validates provided
values; update the interface JSDoc text adjacent to PhysXTolerancesScale to
reflect this behavior so callers are not surprised.

348-351: ⚡ Quick win

Validation of tolerancesScale options is deferred until initialize().

The validation at lines 349-351 only runs during initialize(), not in the constructor. If a user passes invalid options (e.g., negative or non-finite values), they won't receive an error until the async initialize() promise rejects, which may be confusing.

Consider validating tolerancesScaleOptions in the constructor so errors are raised synchronously at construction time:

✅ Suggested early validation

In the constructor (after line 87):

   this._tolerancesScaleOptions = resolvedOptions?.tolerancesScale;
+  if (this._tolerancesScaleOptions) {
+    if (this._tolerancesScaleOptions.length !== undefined) {
+      this._assertPositiveFinite(this._tolerancesScaleOptions.length, "tolerancesScale.length");
+    }
+    if (this._tolerancesScaleOptions.speed !== undefined) {
+      this._assertPositiveFinite(this._tolerancesScaleOptions.speed, "tolerancesScale.speed");
+    }
+  }
   this._updateScaledDefaults(this._tolerancesScaleOptions?.length ?? 1, this._tolerancesScaleOptions?.speed ?? 10);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/physics-physx/src/PhysXPhysics.ts` around lines 348 - 351, The
constructor currently defers validation of tolerancesScale options until
initialize(), causing async errors; validate them synchronously by calling the
existing _assertPositiveFinite for each tolerancesScale option inside the class
constructor (after setting tolerancesScaleOptions) so invalid values throw
immediately. Locate the constructor where tolerancesScaleOptions (or
tolerancesScale) is assigned and invoke _assertPositiveFinite for each numeric
field (e.g., length, mass, speed or whatever keys are used) or add a small
helper that iterates keys and calls _assertPositiveFinite, leaving initialize()
validation as a fallback.
packages/physics-lite/src/LitePhysicsMaterial.ts (1)

63-67: ⚡ Quick win

Use console.warn and fix grammar in the warning message.

The warning message has a grammatical error and uses console.log instead of console.warn. For a feature limitation that developers should notice, console.warn is more appropriate and will be more visible in console filters.

📝 Suggested fix
   private static _warnOnce(): void {
     if (LitePhysicsMaterial._warned) return;
     LitePhysicsMaterial._warned = true;
-    console.log("Physics-lite don't support physics material. Use Physics-PhysX instead!");
+    console.warn("Physics-lite doesn't support physics material. Use Physics-PhysX instead!");
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/physics-lite/src/LitePhysicsMaterial.ts` around lines 63 - 67,
Update LitePhysicsMaterial._warnOnce to use console.warn instead of console.log
and fix the message grammar; locate the static method _warnOnce and the _warned
flag, keep the early return and flag set, then replace the string "Physics-lite
don't support physics material. Use Physics-PhysX instead!" with a grammatically
correct warning such as "physics-lite does not support physics materials; use
physics-physx instead." (or similar), ensuring casing/branding matches project
conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/physics/Collider.ts`:
- Around line 155-158: When re-enabling a collider the native object is re-added
to physics before its pose is synchronized, creating a stale-pose window; inside
_onEnableInScene() update the implementation to sync the entity transform to the
native collider immediately (call the existing native-pose sync helper or set
the native pose from this.entity.transform) before calling
this.scene.physics._addCollider(this), and clear/adjust
this._pendingReenterTeleport accordingly so there is no deferred pose update on
next tick. Ensure the same change is applied to both collider re-enable code
paths so the native object always starts with the current entity transform.

In `@packages/core/src/physics/DynamicCollider.ts`:
- Around line 404-414: The torque is being computed from this.entity.transform
but the native collider may have a different pose; update applyForceAtPosition
logic in DynamicCollider to fetch the native actor's current world pose (use the
native collider/actor API such as its getGlobalPose/getWorldTransform method)
and transform localCoM with that pose (replace transform.worldRotationQuaternion
and transform.worldPosition usage) before computing torque into
DynamicCollider._tempVector3_2, then call nativeCollider.addForce and
nativeCollider.addTorque using those values so the torque matches the native
actor's actual pose.

In `@packages/core/src/physics/shape/ColliderShape.ts`:
- Around line 57-59: The contactOffset getter on ColliderShape currently reads
Engine._nativePhysics.getDefaultContactOffset every time, coupling shape
behavior to a mutable global; fix by capturing the resolved default once when
the shape (or its native counterpart) is created and store it on the instance
(e.g., add a private _capturedDefaultContactOffset set during the constructor or
wherever the native shape is initialized), then change contactOffset to return
this._contactOffset ?? this._capturedDefaultContactOffset ?? 0.02; ensure the
capture uses Engine._nativePhysics?.getDefaultContactOffset?.() at creation time
so later changes to Engine._nativePhysics do not affect existing ColliderShape
instances.

In `@packages/design/src/physics/IPhysicsScene.ts`:
- Around line 46-50: The interface change made setContactEventEnabled required,
which breaks older IPhysicsScene implementations; make the hook optional in the
contract (declare setContactEventEnabled as an optional method on IPhysicsScene)
and update the call site in PhysicsScene (where
nativePhysicsManager.setContactEventEnabled is invoked) to use optional chaining
when calling it (i.e., call
setContactEventEnabled?.(this._hasCollisionEventConsumers())). This keeps the
API backward-compatible and avoids runtime undefined method errors.

In `@tests/src/core/physics/DynamicCollider.test.ts`:
- Around line 492-529: The test overrides scene.physics.fixedTimeStep and only
restores it after assertions, so a failed assertion leaks the modified timestep;
capture the originalFTS, run the probe/expect block inside a try, and restore
scene.physics.fixedTimeStep = originalFTS in a finally block. Locate the
originalFTS variable, the probe(...) calls that compute dv_1_60 and dv_1_480,
and the subsequent expects, then move the assignment that resets
scene.physics.fixedTimeStep into finally so the timestep is always restored even
if assertions fail.

In `@tests/src/core/physics/MeshColliderShape.test.ts`:
- Around line 207-212: The test assumes clone cooking completes synchronously by
asserting clonedGround.getComponent(StaticCollider).shapes[0]
(MeshColliderShape) has a non-null _nativeShape and a defined
_nativeShape._pxShape immediately; change this to a retry/wait-style assertion:
poll or await until clonedShape._nativeShape is non-null and its _pxShape is
defined (for example, use an existing test helper waitFor/poll utility or build
a short retry loop) instead of immediate expect() calls so the test tolerates
next-tick retries in the clone cooking flow.

---

Outside diff comments:
In `@packages/core/src/physics/shape/MeshColliderShape.ts`:
- Around line 201-204: The MeshColliderShape code currently logs an error for
non-convex meshes on non-kinematic DynamicCollider but later keeps retrying
while pending is true; change the logic so when you detect (!this._isConvex &&
this._collider instanceof DynamicCollider && !this._collider.isKinematic) you
both log the error once and mark the creation as terminal by clearing/setting
the pending flag (e.g., pending = false or this._pending = false) and avoid
assigning mesh or scheduling further retries; update the same handling at the
location that does the mesh assignment (the code that references mesh and
pending) so it short-circuits on this unsupported state and prevents per-frame
work.

---

Nitpick comments:
In `@packages/physics-lite/src/LitePhysicsMaterial.ts`:
- Around line 63-67: Update LitePhysicsMaterial._warnOnce to use console.warn
instead of console.log and fix the message grammar; locate the static method
_warnOnce and the _warned flag, keep the early return and flag set, then replace
the string "Physics-lite don't support physics material. Use Physics-PhysX
instead!" with a grammatically correct warning such as "physics-lite does not
support physics materials; use physics-physx instead." (or similar), ensuring
casing/branding matches project conventions.

In `@packages/physics-physx/src/PhysXPhysics.ts`:
- Around line 336-346: The _applyTolerancesScale function currently blends
user-provided tolerances with PhysX native defaults when only one of length or
speed is supplied; add clear JSDoc to the PhysXTolerancesScale interface
explaining that partial overrides are supported and that unspecified fields fall
back to the PhysX runtime defaults (which will affect computed values such as
_defaultSleepThreshold via _updateScaledDefaults), and include a short example
(e.g., { length: 2 } uses native speed) and mention that _assertPositiveFinite
still validates provided values; update the interface JSDoc text adjacent to
PhysXTolerancesScale to reflect this behavior so callers are not surprised.
- Around line 348-351: The constructor currently defers validation of
tolerancesScale options until initialize(), causing async errors; validate them
synchronously by calling the existing _assertPositiveFinite for each
tolerancesScale option inside the class constructor (after setting
tolerancesScaleOptions) so invalid values throw immediately. Locate the
constructor where tolerancesScaleOptions (or tolerancesScale) is assigned and
invoke _assertPositiveFinite for each numeric field (e.g., length, mass, speed
or whatever keys are used) or add a small helper that iterates keys and calls
_assertPositiveFinite, leaving initialize() validation as a fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: caa25bf2-3321-4a47-bfe4-7a8b0e79956d

📥 Commits

Reviewing files that changed from the base of the PR and between de75496 and 7a07cc8.

📒 Files selected for processing (28)
  • packages/core/src/physics/CharacterController.ts
  • packages/core/src/physics/Collider.ts
  • packages/core/src/physics/Collision.ts
  • packages/core/src/physics/DynamicCollider.ts
  • packages/core/src/physics/PhysicsMaterial.ts
  • packages/core/src/physics/PhysicsScene.ts
  • packages/core/src/physics/index.ts
  • packages/core/src/physics/shape/ColliderShape.ts
  • packages/core/src/physics/shape/MeshColliderShape.ts
  • packages/design/src/physics/IPhysics.ts
  • packages/design/src/physics/IPhysicsScene.ts
  • packages/physics-lite/src/LitePhysicsMaterial.ts
  • packages/physics-lite/src/LitePhysicsScene.ts
  • packages/physics-physx/src/PhysXDynamicCollider.ts
  • packages/physics-physx/src/PhysXPhysics.ts
  • packages/physics-physx/src/PhysXPhysicsScene.ts
  • packages/physics-physx/src/shape/PhysXColliderShape.ts
  • tests/src/core/physics/CharacterController.test.ts
  • tests/src/core/physics/Collider.test.ts
  • tests/src/core/physics/ColliderShape.test.ts
  • tests/src/core/physics/Collision.test.ts
  • tests/src/core/physics/DynamicCollider.test.ts
  • tests/src/core/physics/HingeJoint.test.ts
  • tests/src/core/physics/Joint.test.ts
  • tests/src/core/physics/MeshColliderShape.test.ts
  • tests/src/core/physics/PhysicsMaterial.test.ts
  • tests/src/core/physics/PhysicsScene.test.ts
  • tests/src/core/physics/SpringJoint.test.ts

Comment on lines 155 to 158
override _onEnableInScene(): void {
this.scene.physics._addCollider(this);
this._pendingReenterTeleport = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enable-time native pose sync is still deferred in both collider paths.

Both re-enable flows add the native object back to physics before synchronizing it to the entity transform. That leaves a stale-pose window where immediate manual physics steps or scene queries can observe the pre-disable transform. The fix should happen inside _onEnableInScene() for both types rather than waiting for a later update tick.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/physics/Collider.ts` around lines 155 - 158, When
re-enabling a collider the native object is re-added to physics before its pose
is synchronized, creating a stale-pose window; inside _onEnableInScene() update
the implementation to sync the entity transform to the native collider
immediately (call the existing native-pose sync helper or set the native pose
from this.entity.transform) before calling
this.scene.physics._addCollider(this), and clear/adjust
this._pendingReenterTeleport accordingly so there is no deferred pose update on
next tick. Ensure the same change is applied to both collider re-enable code
paths so the native object always starts with the current entity transform.

Comment on lines +404 to +414
const transform = this.entity.transform;
const worldCoM = DynamicCollider._tempVector3_1;
Vector3.transformByQuat(localCoM, transform.worldRotationQuaternion, worldCoM);
worldCoM.add(transform.worldPosition);

const torque = DynamicCollider._tempVector3_2;
Vector3.subtract(position, worldCoM, torque);
Vector3.cross(torque, force, torque);

nativeCollider.addForce(force);
nativeCollider.addTorque(torque);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compute applyForceAtPosition() from the native actor pose.

Line 404-Line 414 transforms the center of mass with this.entity.transform, but the rest of the method talks directly to the native body. Because entity→physics sync is deferred until the tick, a same-frame transform.setPosition()/rotate() followed by applyForceAtPosition() will derive torque from a pose the native actor does not yet have.

Suggested fix
-    const transform = this.entity.transform;
-    const worldCoM = DynamicCollider._tempVector3_1;
-    Vector3.transformByQuat(localCoM, transform.worldRotationQuaternion, worldCoM);
-    worldCoM.add(transform.worldPosition);
-
-    const torque = DynamicCollider._tempVector3_2;
+    const actorPosition = DynamicCollider._tempVector3_1;
+    const actorRotation = DynamicCollider._tempQuat;
+    nativeCollider.getWorldTransform(actorPosition, actorRotation);
+
+    const worldCoM = DynamicCollider._tempVector3_2;
+    Vector3.transformByQuat(localCoM, actorRotation, worldCoM);
+    worldCoM.add(actorPosition);
+
+    const torque = actorPosition;
     Vector3.subtract(position, worldCoM, torque);
     Vector3.cross(torque, force, torque);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const transform = this.entity.transform;
const worldCoM = DynamicCollider._tempVector3_1;
Vector3.transformByQuat(localCoM, transform.worldRotationQuaternion, worldCoM);
worldCoM.add(transform.worldPosition);
const torque = DynamicCollider._tempVector3_2;
Vector3.subtract(position, worldCoM, torque);
Vector3.cross(torque, force, torque);
nativeCollider.addForce(force);
nativeCollider.addTorque(torque);
const actorPosition = DynamicCollider._tempVector3_1;
const actorRotation = DynamicCollider._tempQuat;
nativeCollider.getWorldTransform(actorPosition, actorRotation);
const worldCoM = DynamicCollider._tempVector3_2;
Vector3.transformByQuat(localCoM, actorRotation, worldCoM);
worldCoM.add(actorPosition);
const torque = actorPosition;
Vector3.subtract(position, worldCoM, torque);
Vector3.cross(torque, force, torque);
nativeCollider.addForce(force);
nativeCollider.addTorque(torque);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/physics/DynamicCollider.ts` around lines 404 - 414, The
torque is being computed from this.entity.transform but the native collider may
have a different pose; update applyForceAtPosition logic in DynamicCollider to
fetch the native actor's current world pose (use the native collider/actor API
such as its getGlobalPose/getWorldTransform method) and transform localCoM with
that pose (replace transform.worldRotationQuaternion and transform.worldPosition
usage) before computing torque into DynamicCollider._tempVector3_2, then call
nativeCollider.addForce and nativeCollider.addTorque using those values so the
torque matches the native actor's actual pose.

Comment on lines 57 to 59
get contactOffset(): number {
return this._contactOffset;
return this._contactOffset ?? Engine._nativePhysics?.getDefaultContactOffset?.() ?? 0.02;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

contactOffset is now coupled to the global backend, not the shape.

For shapes with no explicit override, this getter now reads Engine._nativePhysics every time instead of returning the default that was actually captured by that shape/native object. If another engine or backend is created later, the same shape can start reporting a different contactOffset even though its native value never changed.

Please snapshot the resolved default per shape/native-shape creation path instead of resolving it through the mutable global on every read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/physics/shape/ColliderShape.ts` around lines 57 - 59, The
contactOffset getter on ColliderShape currently reads
Engine._nativePhysics.getDefaultContactOffset every time, coupling shape
behavior to a mutable global; fix by capturing the resolved default once when
the shape (or its native counterpart) is created and store it on the instance
(e.g., add a private _capturedDefaultContactOffset set during the constructor or
wherever the native shape is initialized), then change contactOffset to return
this._contactOffset ?? this._capturedDefaultContactOffset ?? 0.02; ensure the
capture uses Engine._nativePhysics?.getDefaultContactOffset?.() at creation time
so later changes to Engine._nativePhysics do not affect existing ColliderShape
instances.

Comment thread packages/design/src/physics/IPhysicsScene.ts Outdated
Comment on lines +492 to +529
const scene = engine.sceneManager.activeScene;
const originalFTS = scene.physics.fixedTimeStep;

const probe = (fts: number) => {
rootEntity.clearChildren();
scene.physics.fixedTimeStep = fts;
const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0));
const c = box.getComponent(DynamicCollider);
c.mass = 1;
c.useGravity = false;
c.linearDamping = 0;
c.angularDamping = 0;
c.applyForce(new Vector3(100, 0, 0));
// Advance exactly one *frame* of wall time. Galacean's _update loops simulate
// until frame_dt accumulates: 1/60 → 1 substep; 1/480 → 8 substeps.
// @ts-ignore
scene.physics._update(1 / 60);
return c.linearVelocity.x;
};

const dv_1_60 = probe(1 / 60);
const dv_1_480 = probe(1 / 480);

console.info(
`[fixedTimeStep probe] applyForce(F=100) over 1 frame (1/60s):\n` +
` 1/60 step → dv = ${dv_1_60.toFixed(4)} m/s\n` +
` 1/480 step → dv = ${dv_1_480.toFixed(4)} m/s\n` +
` ratio (1/60 / 1/480) = ${(dv_1_60 / dv_1_480).toFixed(3)} (theory: 8)`
);

scene.physics.fixedTimeStep = originalFTS;

// Theory: dv_1_60 = F·(1/60)/m = 100/60 ≈ 1.667
expect(dv_1_60).toBeCloseTo(100 / 60, 2);
// Theory: dv_1_480 = F·(1/480)/m = 100/480 ≈ 0.208
expect(dv_1_480).toBeCloseTo(100 / 480, 2);
// Ratio must be 8 (PhysX clears force per simulate)
expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore fixedTimeStep in a finally block.

If any assertion in this probe fails, Line 522 never runs and later tests inherit the overridden timestep from this case.

Suggested fix
-    const dv_1_60 = probe(1 / 60);
-    const dv_1_480 = probe(1 / 480);
-
-    console.info(
-      `[fixedTimeStep probe] applyForce(F=100) over 1 frame (1/60s):\n` +
-        `  1/60  step → dv = ${dv_1_60.toFixed(4)} m/s\n` +
-        `  1/480 step → dv = ${dv_1_480.toFixed(4)} m/s\n` +
-        `  ratio (1/60 / 1/480) = ${(dv_1_60 / dv_1_480).toFixed(3)}  (theory: 8)`
-    );
-
-    scene.physics.fixedTimeStep = originalFTS;
-
-    // Theory: dv_1_60 = F·(1/60)/m = 100/60 ≈ 1.667
-    expect(dv_1_60).toBeCloseTo(100 / 60, 2);
-    // Theory: dv_1_480 = F·(1/480)/m = 100/480 ≈ 0.208
-    expect(dv_1_480).toBeCloseTo(100 / 480, 2);
-    // Ratio must be 8 (PhysX clears force per simulate)
-    expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1);
+    try {
+      const dv_1_60 = probe(1 / 60);
+      const dv_1_480 = probe(1 / 480);
+
+      console.info(
+        `[fixedTimeStep probe] applyForce(F=100) over 1 frame (1/60s):\n` +
+          `  1/60  step → dv = ${dv_1_60.toFixed(4)} m/s\n` +
+          `  1/480 step → dv = ${dv_1_480.toFixed(4)} m/s\n` +
+          `  ratio (1/60 / 1/480) = ${(dv_1_60 / dv_1_480).toFixed(3)}  (theory: 8)`
+      );
+
+      // Theory: dv_1_60 = F·(1/60)/m = 100/60 ≈ 1.667
+      expect(dv_1_60).toBeCloseTo(100 / 60, 2);
+      // Theory: dv_1_480 = F·(1/480)/m = 100/480 ≈ 0.208
+      expect(dv_1_480).toBeCloseTo(100 / 480, 2);
+      // Ratio must be 8 (PhysX clears force per simulate)
+      expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1);
+    } finally {
+      scene.physics.fixedTimeStep = originalFTS;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const scene = engine.sceneManager.activeScene;
const originalFTS = scene.physics.fixedTimeStep;
const probe = (fts: number) => {
rootEntity.clearChildren();
scene.physics.fixedTimeStep = fts;
const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0));
const c = box.getComponent(DynamicCollider);
c.mass = 1;
c.useGravity = false;
c.linearDamping = 0;
c.angularDamping = 0;
c.applyForce(new Vector3(100, 0, 0));
// Advance exactly one *frame* of wall time. Galacean's _update loops simulate
// until frame_dt accumulates: 1/60 → 1 substep; 1/480 → 8 substeps.
// @ts-ignore
scene.physics._update(1 / 60);
return c.linearVelocity.x;
};
const dv_1_60 = probe(1 / 60);
const dv_1_480 = probe(1 / 480);
console.info(
`[fixedTimeStep probe] applyForce(F=100) over 1 frame (1/60s):\n` +
` 1/60 step → dv = ${dv_1_60.toFixed(4)} m/s\n` +
` 1/480 step → dv = ${dv_1_480.toFixed(4)} m/s\n` +
` ratio (1/60 / 1/480) = ${(dv_1_60 / dv_1_480).toFixed(3)} (theory: 8)`
);
scene.physics.fixedTimeStep = originalFTS;
// Theory: dv_1_60 = F·(1/60)/m = 100/60 ≈ 1.667
expect(dv_1_60).toBeCloseTo(100 / 60, 2);
// Theory: dv_1_480 = F·(1/480)/m = 100/480 ≈ 0.208
expect(dv_1_480).toBeCloseTo(100 / 480, 2);
// Ratio must be 8 (PhysX clears force per simulate)
expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1);
const scene = engine.sceneManager.activeScene;
const originalFTS = scene.physics.fixedTimeStep;
const probe = (fts: number) => {
rootEntity.clearChildren();
scene.physics.fixedTimeStep = fts;
const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0));
const c = box.getComponent(DynamicCollider);
c.mass = 1;
c.useGravity = false;
c.linearDamping = 0;
c.angularDamping = 0;
c.applyForce(new Vector3(100, 0, 0));
// Advance exactly one *frame* of wall time. Galacean's _update loops simulate
// until frame_dt accumulates: 1/60 → 1 substep; 1/480 → 8 substeps.
// `@ts-ignore`
scene.physics._update(1 / 60);
return c.linearVelocity.x;
};
try {
const dv_1_60 = probe(1 / 60);
const dv_1_480 = probe(1 / 480);
console.info(
`[fixedTimeStep probe] applyForce(F=100) over 1 frame (1/60s):\n` +
` 1/60 step → dv = ${dv_1_60.toFixed(4)} m/s\n` +
` 1/480 step → dv = ${dv_1_480.toFixed(4)} m/s\n` +
` ratio (1/60 / 1/480) = ${(dv_1_60 / dv_1_480).toFixed(3)} (theory: 8)`
);
// Theory: dv_1_60 = F·(1/60)/m = 100/60 ≈ 1.667
expect(dv_1_60).toBeCloseTo(100 / 60, 2);
// Theory: dv_1_480 = F·(1/480)/m = 100/480 ≈ 0.208
expect(dv_1_480).toBeCloseTo(100 / 480, 2);
// Ratio must be 8 (PhysX clears force per simulate)
expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1);
} finally {
scene.physics.fixedTimeStep = originalFTS;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/physics/DynamicCollider.test.ts` around lines 492 - 529, The
test overrides scene.physics.fixedTimeStep and only restores it after
assertions, so a failed assertion leaks the modified timestep; capture the
originalFTS, run the probe/expect block inside a try, and restore
scene.physics.fixedTimeStep = originalFTS in a finally block. Locate the
originalFTS variable, the probe(...) calls that compute dv_1_60 and dv_1_480,
and the subsequent expects, then move the assignment that resets
scene.physics.fixedTimeStep into finally so the timestep is always restored even
if assertions fail.

Comment on lines +207 to +212
const clonedShape = clonedGround.getComponent(StaticCollider).shapes[0] as MeshColliderShape;
// @ts-ignore — inspect that the cloned shape actually has a usable native PhysX handle
expect(clonedShape._nativeShape).not.toBeNull();
// @ts-ignore
expect(clonedShape._nativeShape._pxShape).toBeDefined();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid immediate native-handle assertions in a retry-based flow.

Line [209]-Line [212] assumes clone cooking completes synchronously, but the runtime now explicitly allows transient failures with next-tick retries. This can make the test flaky even when behavior is correct.

Suggested stabilization
-      // `@ts-ignore` — inspect that the cloned shape actually has a usable native PhysX handle
-      expect(clonedShape._nativeShape).not.toBeNull();
-      // `@ts-ignore`
-      expect(clonedShape._nativeShape._pxShape).toBeDefined();
+      // Allow retry path to complete before asserting internal native handle.
+      for (let i = 0; i < 10; i++) {
+        // `@ts-ignore`
+        if (clonedShape._nativeShape?._pxShape) break;
+        physicsScene._update(1 / 60);
+      }
+      // `@ts-ignore`
+      expect(clonedShape._nativeShape?._pxShape).toBeDefined();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const clonedShape = clonedGround.getComponent(StaticCollider).shapes[0] as MeshColliderShape;
// @ts-ignore — inspect that the cloned shape actually has a usable native PhysX handle
expect(clonedShape._nativeShape).not.toBeNull();
// @ts-ignore
expect(clonedShape._nativeShape._pxShape).toBeDefined();
const clonedShape = clonedGround.getComponent(StaticCollider).shapes[0] as MeshColliderShape;
// Allow retry path to complete before asserting internal native handle.
for (let i = 0; i < 10; i++) {
// `@ts-ignore`
if (clonedShape._nativeShape?._pxShape) break;
physicsScene._update(1 / 60);
}
// `@ts-ignore`
expect(clonedShape._nativeShape?._pxShape).toBeDefined();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/physics/MeshColliderShape.test.ts` around lines 207 - 212, The
test assumes clone cooking completes synchronously by asserting
clonedGround.getComponent(StaticCollider).shapes[0] (MeshColliderShape) has a
non-null _nativeShape and a defined _nativeShape._pxShape immediately; change
this to a retry/wait-style assertion: poll or await until
clonedShape._nativeShape is non-null and its _pxShape is defined (for example,
use an existing test helper waitFor/poll utility or build a short retry loop)
instead of immediate expect() calls so the test tolerates next-tick retries in
the clone cooking flow.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 mentioned this pull request Jun 14, 2026
3 tasks

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

#3014 / fix/shaderlab 的物理部分拆出来重提:collider 重入 teleport、kinematic transform 同步模式(Target/Teleport)、applyForceAtPosition、per-instance 物理材质 clone、contact event 按需开关、MeshColliderShape 异步 cook 重试、tolerancesScale 配置、kinematic 状态下 CCD flag 缓存。整体方向合理(contact event 按需 toggle、material per-instance clone、shape 延迟 cook 重试、kinematic CCD 缓存都对)。但有一个 per-frame 错误刷屏的真实 bug、一个热路径开销、design 接口破坏性变更,以及 PR 元信息/注释规范问题。

代码自上次审查(commit 7a07cc8)未变,以下保留仍未修复的问题并补充本轮新发现。

问题

[P1] MeshColliderShape.ts:185-188 — 永久不支持的 non-convex 动态网格会每帧 console.error 刷屏

_createNativeShape 在 "non-convex + 非 kinematic DynamicCollider" 这个永久不支持配置上 console.error 后直接 return,但没清掉 _pendingNativeShapeCreation。链路:

  • set mesh(mesh 可访问)→ _extractMeshData 成功 → _createNativeShape() 命中 185 行早退、_nativeShape 仍为 null
  • set mesh_pendingNativeShapeCreation = !this._nativeShape = true
  • 此后每个物理 tick _onPhysicsUpdate 看到 pending=true + mesh/positions 都在 → 再调 _createNativeShape → 再 console.error永久每帧刷屏 + 每帧白跑

把 "transient cook 失败要重试" 和 "永久不支持要放弃" 两种语义压在同一个 pending flag 上了。修复落点在不支持分支,明确标记为永久失败:

if (!this._isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) {
  Logger.error("MeshColliderShape: Non-convex mesh is not supported on non-kinematic DynamicCollider.");
  this._pendingNativeShapeCreation = false; // permanent, not transient — don't retry
  return;
}

(顺带:项目有统一的 Logger,本文件这里及 _extractMeshData 几处都用原生 console.error/warn,建议统一走 Logger。)

[P1] PhysicsScene.ts:649 + 829-849 — _hasCollisionEventConsumers() 每个 substep 全量重扫,热路径开销

setContactEventEnabled(this._hasCollisionEventConsumers()) 放在 substep 循环内(for i < step),而 _hasCollisionEventConsumers 是 O(colliders × scriptsPerCollider) 的全量扫描,逐个对比 3 个 Script.prototype 方法引用。fixedTimeStep 越小、substep 越多(如 1/480 → 8 substep/帧),同一份消费者集合每帧被重扫 8 次。这是本 PR 新引入的每帧成本(改前 contact event 恒开、无扫描)。

两层改进:

  1. 最小修:消费者集合在单次 _update 内不会变(substep 之间不会有 script enable/disable),把调用移到 substep 循环外,每帧只算一次。
  2. 本质解(失效信号挂 source-of-truth):在 Script 的 collision callback enable/disable 时维护一个 scene 级消费者计数,_hasCollisionEventConsumers 退化为 count > 0 的 O(1) 判断,彻底消除每帧扫描。当前是消费者每帧自己 hash 整个集合的反模式。

[P1] IPhysicsScene.ts:50 区域 — setContactEventEnabled 设为必选,破坏 design 接口契约

新增 setContactEventEnabled(enabled: boolean): void;?,是必选方法。packages/design 是对外的物理后端契约,任何第三方自定义后端(实现 IPhysicsScene)不实现就编译失败。仓库内 physx/lite 都已更新,但接口本身的破坏性变更应兜底。建议设为可选 + 调用点可选链:

// IPhysicsScene
setContactEventEnabled?(enabled: boolean): void;
// PhysicsScene.ts:649
nativePhysicsManager.setContactEventEnabled?.(this._hasCollisionEventConsumers());

[P1] PR 标题 / commit 前缀与内容不符

标题 fix(physics): split shaderlab physics fixes 里的 "shaderlab" 与实际内容(纯物理,不动任何 shaderlab 文件)无关。更重要的是本 PR 不只是 fix:kinematicTransformSyncMode 枚举、applyForceAtPosition 公开方法、per-instance material clone、PhysXPhysicsOptions/tolerancesScale 配置都是新功能 / 新 API,挂 fix: 前缀会影响 changelog 生成和回滚判断。建议改 feat(physics): 并去掉 "shaderlab" 字样。

[P2] PhysXDynamicCollider.ts:29-37 / 176-204 / 255-280 — 大段中文 JSDoc,偏离引擎英文注释惯例

新增了 16+ 行中文 JSDoc 解释(CCD 缓存、addForce/addTorque kinematic 提前 return、move normalize 等)。引擎是国际开源代码库,源码注释惯例为英文(base 分支各文件仅有零星 unicode 字符如 keyname 表,无成段中文 prose)。内容本身解释得很到位,建议译为英文以便国际贡献者维护。PhysXPhysicsScene.ts 同样有中文注释,一并处理。

关于 CodeRabbit 几条判断(给结论,部分不成立,不重复提出)

  • CodeRabbit:applyForceAtPosition(DynamicCollider.ts)应从 native actor pose 取 CoM 而非 entity transform。不成立:该方法用 transform.worldPosition/worldRotationQuaternion(Transform getter 契约保证 dirty 时自动 flush 为最新),native actor 在 _onUpdate 同步到同一 entity pose 后才进物理 step,torque 与 entity pose 自洽。entity transform 作为 source-of-truth 是引擎一贯设计。
  • CodeRabbit:"Collider 重入有 stale-pose 窗口"。不成立_onEnableInScene_pendingReenterTeleport,下一个 _onUpdate 在物理 step 前先 teleport 到 entity pose,step 看到的是正确 pose,窗口不暴露给模拟,注释已说明该时序。
  • CodeRabbit 的 console.warn/grammar、tolerancesScale JSDoc、clone 断言改 retry 等均为 nitpick,可选采纳,不阻塞。

测试

  • 回归覆盖扎实:R0(CCD kinematic 切换)、R6(clone 重建 native shape)、applyForceAtPosition 全分量 + clone 路径、contact-event 按需 enable/disable/trigger-only 三态、kine-kine/kine-dynamic 接触回调,多数是 red-verified 反向测试,方向对。
  • DynamicCollider.test.ts fixedTimeStep probe 若断言失败不还原 fixedTimeStep(CodeRabbit 也提了),建议 try/finally 包裹避免污染后续用例。非阻塞。

@GuoLei1990 GuoLei1990 marked this pull request as draft June 15, 2026 02:51
@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.63%. Comparing base (75af66f) to head (b9b5616).
⚠️ Report is 1 commits behind head on dev/2.0.

Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3025      +/-   ##
===========================================
+ Coverage    79.52%   79.63%   +0.10%     
===========================================
  Files          904      904              
  Lines       101168   101278     +110     
  Branches     11377    11425      +48     
===========================================
+ Hits         80456    80653     +197     
+ Misses       20528    20441      -87     
  Partials       184      184              
Flag Coverage Δ
unittests 79.63% <100.00%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@luzhuang luzhuang force-pushed the fix/physics-shaderlab-split branch from e508107 to d6b96b5 Compare June 17, 2026 12:23
@luzhuang luzhuang changed the title fix(physics): split shaderlab physics fixes fix(physics): gate contact event buffering Jun 17, 2026
@luzhuang luzhuang changed the title fix(physics): gate contact event buffering fix(physics): gate contact buffering and correct collision normals Jul 13, 2026
@luzhuang luzhuang marked this pull request as ready for review July 13, 2026 03:44
GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

第四轮审查(87e2e84a1c → tip 8b3576a96d,线性追加两个 docs(physics): commit,无 force-push,无 behavior change)。上轮我已 --approve 解除门控,本轮 delta 是 纯文档 6 添加 + 1 改行,无 P0/P1/P2。方向正确,收尾干净。

本轮 delta 验证

对照 87e2e84a1c...8b3576a96d(compare 报 status: ahead by 2,纯线性、无 force-push),只有两处改动,均为文档:

  • PhysicsScene.ts:32-37_collisionEventConsumersDirty 私有字段补多行 JSDoc + @remarks,把 PR 描述里的"callback 在 script active 期间视为稳定、runtime 方法替换不失效缓存"契约固化到字段旁。逐点核实非 aspirational@remarks 声称"collider 或 script 生命周期变更才 rescan",与代码一致——_markCollisionEventConsumersDirty() 仅在 collider add/remove(:672/:700)、character-controller add/remove(:686/:713)及 Script 生命周期路径调用,_syncContactEventDemand 首行 if (!this._collisionEventConsumersDirty) return:848)早退,从不每帧轮询、不在 runtime 方法赋值时置脏。两句 @remarks 均句号收尾,字段多行 JSDoc 合规。

  • ICollision.ts:11getContacts() JSDoc 从 Get contact points. 扩为 Get contact points. Contact normals and impulses must point from shape1 to shape0.,即上轮我已核对并肯定的 shape1 → shape0 契约的最终固化。与本文件既有惯例一致(interface 成员单行 /** */、句号结尾),与 Collision.ts:32 的 sign-fix(factor = shape.id === shape1Id ? 1 : -1)语义闭环,无矛盾。Git 历史透镜:该行原文只有 Get contact points.,本次是新增契约句,不推翻任何前序记录理由。

CI 全绿(build×3 / e2e×4 / codecov patch+project+coverage / lint / labeler)。改动行无 console.*、无注释规范问题。

结论

LGTM。 纯文档收尾,门控上轮已解除(reviewDecision=REVIEW_REQUIRED 是 repo 要求他人 owner 审,非我门控),无需再动 review 状态。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

第五轮审查(8b3576a96d → tip 5fbf0742cd,线性追加一个 docs(physics): commit,无 force-push,无 behavior change)。上轮我已 --approve 解除门控,本轮 delta 是纯文档 1 行新增,无 P0/P1/P2,仅 1 条 P3 风格。方向正确。

本轮 delta 验证

对照 8b3576a96d...5fbf0742cd(compare 报 status: ahead by 1 / behind by 0,纯线性、无 force-push),唯一改动是 PhysicsScene.ts:852_syncContactEventDemand 的反向扫描循环前补一行 // 注释:

// Scan backward so newly appended colliders and scripts are checked first, increasing the chance of an early exit.

非 aspirational,逐点核实与代码一致:外层 for (let i = this._colliders.length - 1; i >= 0; --i) 反向遍历 collider、内层 for (let j = scripts.length - 1; j >= 0; --j) 反向遍历 script,命中 _hasCollisionEventCallbacks()setContactEventEnabled?.(true); return 早退——三点全对。「newest-first 抬高早退概率」的理由自洽:置脏(_markCollisionEventConsumersDirty)的诱因往往是刚追加的 collision-event 消费者,位于数组尾部,先查尾部更可能在扫完全集前命中并早退。

问题

  • [P3] PhysicsScene.ts:852 — 单行 // 注释末尾多了句号

    本文件所有既有单行 // 注释均无尾句号(// Parse parameters based on new overload patterns// Dispatch contact events// entity.destroy() is deferred... 等),新增这行以 ...early exit. 收尾是唯一 outlier。建议去掉句号与全文件惯例对齐。锦上添花,不阻塞。

CI 全绿(build×3 / e2e×4 / codecov patch+project+coverage / lint / labeler)。

结论

LGTM。 纯文档收尾,仅一处 // 尾句号可选修正。门控上轮已解除(reviewDecision=REVIEW_REQUIRED 是 repo 要求他人 owner 审,非我门控),无需再动 review 状态。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

第六轮审查(5fbf0742cd → tip 6fe08652f4,线性追加一个 refactor(physics): commit,无 force-push,无 behavior change)。上轮我已 --approve 解除门控,本轮 delta 是纯术语重命名 + JSDoc 措辞 + 测试 import 等价替换,无 P0/P1/P2/新 P3。方向正确。

本轮 delta 验证

对照 5fbf0742cd...6fe08652f4(compare 报 status: ahead by 1 / behind by 0,纯线性、无 force-push),唯一 commit 是 refactor(physics): align contact demand terminology,跨 5 文件 +18/-16,分三组,逐组核实:

① 术语重命名(Script.ts + PhysicsScene.ts_markCollisionEventConsumersDirty_markContactEventDemandDirty_collisionEventConsumersDirty_contactEventDemandDirty纯改名,零行为变化:GitHub 代码搜索全仓两个旧名 total_count=0(无残留);PhysicsScene.ts 5 个 mark 调用点 + 方法定义 + 字段 + _syncContactEventDemand 早退读取全部同步改名。方向正确——新名向早已存在的 _syncContactEventDemand(source: PR #2458 前即有)的 "contact event demand" 概念收敛,消除了 mark/field 用 "collisionEventConsumers" 而 sync 用 "contactEventDemand" 的双词根不一致。字段的 @remarks 多行 JSDoc("callback 在 script active 期间视为稳定、runtime 方法替换不失效缓存")随改名原样保留、仍准确。反向扫描循环体 + // Scan backward... 注释字节级不变。

ICollision.ts JSDoc(两处):

  • shape0Id/shape1IdThe unique id of the first/second **collider**The unique ID of the first/second **shape** in the contact pair语义修正:该 ID 经 PxShape.userData 传入、是 shape ID 非 collider ID(一个 collider 可含多个 shape),与 Collision.ts:32 比对 nativeCollision.shape1Id(shape 而非 collider)一致。单行 /** */ + 句号,符合本文件 interface 成员惯例。
  • getContacts():单行 → 多行 /** */ + @remarkspoint from shape1 to shape0point from the second shape in the pair to the first契约语义保留非反转:Git 历史透镜——66011dd9cf(#2458) 原文只有 Get contact points.,上轮 efa90b7520 新增 must point from shape1 to shape0,本 commit 只是把它重排为 @remarks 多行 + 改用序数英文措辞。「second shape → first」经紧邻字段文档(shape0Id=first / shape1Id=second)无歧义映射回 shape1 → shape0,与 Collision.ts:32 sign-fix 语义闭环,不推翻 efa90b7520 的契约。多行 JSDoc 句号收尾合规。

③ 测试 import 等价替换Collision.test.ts + PhysicsScene.test.ts)— WebGLEngine@galacean/engine-rhi-webgl 改为 @galacean/engine。等价:packages/galacean/src/index.ts:12 export * from "@galacean/engine-rhi-webgl" 透出同一符号,import 正常解析,与仓库测试走顶层 facade 的趋势对齐。Collision.test.ts 的方向断言(normal.x === -1)未动,回归网仍在。

CI 全绿(build×3 / e2e×4 / codecov patch+project+coverage / lint / labeler)。

结论

LGTM。 纯术语/文档收尾 + 测试 import 等价替换,无新问题。既有 [P3] PhysicsScene.ts // 扫描注释尾句号在未改动行上、上轮已提,不重复。门控上轮已解除(reviewDecision=REVIEW_REQUIRED 是 repo 要求他人 owner 审,非我门控),无需再动 review 状态。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

第七轮审查(6fe08652f4 → tip b9b56160c3,线性追加一个 style(physics): commit,无 force-push,无 behavior change)。上轮我唯一开放的那条 [P3] 本轮已精确闭环,无新 P0/P1/P2/P3。方向正确,收尾干净。

本轮 delta 验证

对照 6fe08652f4...b9b56160c3(compare 报 status: ahead by 1 / behind by 0,纯线性、无 force-push),唯一 commit 是 style(physics): align inline comment punctuation,单文件 +1/-1:

  • PhysicsScene.ts:852_syncContactEventDemand 反向扫描循环前那行 // 注释去掉尾句号:

    // Scan backward so newly appended colliders and scripts are checked first, increasing the chance of an early exit

    这正是我第五轮(5fbf0742cd)提的唯一开放 [P3]——该行是全文件唯一以句号结尾的单行 // 注释,本轮去句号后与文件既有惯例(// Dispatch contact events// entity.destroy() is deferred... 等均无尾句号)对齐。零行为变化,注释文本仍准确:外层 for (let i = this._colliders.length - 1; i >= 0; --i) 反向遍历 collider、内层 for (let j = scripts.length - 1; j >= 0; --j) 反向遍历 script,命中 _hasCollisionEventCallbacks()setContactEventEnabled?.(true); return 早退——三点全对,「newest-first 抬高早退概率」的理由仍自洽。

已关闭问题清单(对照前六轮)

  • [P1] setContactEventEnabled 必选破坏接口契约 — ✅ 已修(第三轮闭环,现为可选 + 可选链)。
  • [P1] PR 标题含无关 "shaderlab" + fix/feat 混装 — ✅ 已修(现为 fix(physics): gate contact buffering and correct collision normals)。
  • [P1] MeshColliderShape 每帧 console.error 刷屏 — 不在本 PR 范围(已随 MeshColliderShape 迁到 #3066)。
  • [P3] PhysicsScene.ts 扫描注释尾句号 — ✅ 本轮精确闭环。

CI 全绿(build×3 / e2e×4 / codecov patch+project+coverage / lint / labeler)。

结论

LGTM。 纯 style 收尾,上轮唯一 P3 已闭环,无新问题。门控我在第三轮已解除(reviewDecision=REVIEW_REQUIRED 是 repo 要求他人 owner 审,非我门控),无需再动 review 状态。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants