Summary
All colliders create their native physics actor — and read transform.worldPosition / register the transform change-listener — in the component constructor rather than on enter-scene. During entity.clone() (and prefab instantiate / deserialize) the cloned entity's transform is only populated after its components are constructed, so the native actor is seeded with a stale (0,0,0) pose. Correctness then depends on a later transform-change dispatch firing — which the bulk-write clone path (Transform._cloneTo, which nulls _onValueChanged then copyFroms) bypasses.
Where
packages/core/src/physics/DynamicCollider.ts — constructor reads transform.worldPosition / worldRotationQuaternion and creates the native actor.
packages/core/src/physics/StaticCollider.ts / CharacterController.ts — same constructor-time pattern.
packages/core/src/physics/Collider.ts — registers the world-change listener in the constructor: this._updateFlag = entity.registerWorldChangeFlag().
Current workaround (symptom patch)
Transform._cloneTo was made to eagerly re-dispatch the world dirty flags — target._worldAssociatedChange(TransformModifyFlags.WmWpWeWqWsWus) — so the collider's listener re-fires after clone (the "fix Transform clone dirty flag" change on the physics branch). This patches the symptom on the Transform side; the root cause is the collider lifecycle.
Why that's the wrong layer
Eagerly dispatching transform changes during clone is not what other engines do — they create native/physics state after the transform is finalized, on enter-scene:
- Unreal: physics state is created in
OnCreatePhysicsState, which runs during registration after OnRegister → UpdateComponentToWorld finalizes the transform. PIE duplication is PostDuplicate → the normal register flow. No dispatch-on-duplicate.
- Unity:
Instantiate copies local TRS, world matrix is lazily recomputed; the Rigidbody body is created in Awake/OnEnable, after the transform is set.
- Cocos:
Node._onBatchCreated (the clone path) only sets dirty bits (_transformFlags |= TRS); it does not emit TRANSFORM_CHANGED. Physics syncs in onEnable.
The shared rule: keep transform dirty bits unconditionally (lazy recompute), but only register listeners / create native state on enter-scene, after the transform is finalized. Then clone / deserialize "just work" with no eager dispatch.
Proposed fix
For every collider, move out of the constructor into the scene lifecycle:
- native actor creation +
worldPosition read → _onEnableInScene
_updateFlag = registerWorldChangeFlag() → _onEnableInScene; release in _onDisableInScene
- property setters /
addShape tolerate a not-yet-created native (write the JS field; do a one-shot _syncNative() on enable — JS fields are the source of truth)
Then the Transform._cloneTo → _worldAssociatedChange workaround can be removed.
Prototype (StaticCollider only)
Moving StaticCollider's native creation to _onEnableInScene (reading the final transform) + null-native guards in the base Collider: 62 physics tests pass (PhysicsMaterial, MeshColliderShape incl. cloned-mesh-shape, Collider, Collision) with no Transform dispatch involved. DynamicCollider / CharacterController need the same treatment — larger, because of the many property setters that must guard a missing native and sync on enable.
Context
Surfaced while re-architecting the clone system (#3018). As clone / prefab-instantiate becomes a first-class, relied-upon path after that lands, entities with colliders will hit the stale-pose more visibly, and the Transform-side workaround will be the thing keeping it afloat. Filing so the root-cause lifecycle fix is tracked separately.
Regression tests (belong here, not in #3018)
These two Transform._cloneTo tests encode the stale-world-cache / missed-listener-dispatch scenario. They were written during #3018 but moved here because they document this issue, not the clone re-architecture. They currently fail on a base without the Transform workaround. (Imports: Entity, TransformModifyFlags from @galacean/engine-core; engine from the suite's beforeAll.)
it("clone — _cloneTo re-dirties the world cache cleared by an earlier component's ctor read", () => {
const source = new Entity(engine, "source");
source.transform.setPosition(1, 2, 3);
const target = new Entity(engine, "target");
expect(target.transform.worldPosition.x).to.equal(0);
// @ts-ignore — the getter above cleared the WorldPosition dirty flag
expect(target.transform._dirtyFlag & TransformModifyFlags.WorldPosition).to.equal(0);
// @ts-ignore — internal clone hook
source.transform._cloneTo(target.transform);
const afterClone = target.transform.worldPosition;
expect(afterClone.x).to.equal(1);
expect(afterClone.y).to.equal(2);
expect(afterClone.z).to.equal(3);
});
it("clone — _cloneTo dispatches the world-flag change to registered listeners", () => {
const source = new Entity(engine, "source");
source.transform.setPosition(7, 8, 9);
const target = new Entity(engine, "target");
const updateFlag = target.registerWorldChangeFlag();
target.transform.worldPosition; // clears + caches, mirrors a component ctor read
updateFlag.flag = false;
// @ts-ignore — internal clone hook
source.transform._cloneTo(target.transform);
expect(updateFlag.flag).to.equal(true);
});
Note: the two tests above assert the Transform-side dispatch (i.e. the _worldAssociatedChange workaround). The root-cause fix (collider native creation → enter-scene) should additionally be guarded by a collider-level test: clone an entity whose collider sits at a non-origin world position, add the clone to the scene, then assert the native actor's world pose matches the entity (not the constructor-default origin).
Summary
All colliders create their native physics actor — and read
transform.worldPosition/ register the transform change-listener — in the component constructor rather than on enter-scene. Duringentity.clone()(and prefabinstantiate/ deserialize) the cloned entity's transform is only populated after its components are constructed, so the native actor is seeded with a stale(0,0,0)pose. Correctness then depends on a later transform-change dispatch firing — which the bulk-write clone path (Transform._cloneTo, which nulls_onValueChangedthencopyFroms) bypasses.Where
packages/core/src/physics/DynamicCollider.ts— constructor readstransform.worldPosition/worldRotationQuaternionand creates the native actor.packages/core/src/physics/StaticCollider.ts/CharacterController.ts— same constructor-time pattern.packages/core/src/physics/Collider.ts— registers the world-change listener in the constructor:this._updateFlag = entity.registerWorldChangeFlag().Current workaround (symptom patch)
Transform._cloneTowas made to eagerly re-dispatch the world dirty flags —target._worldAssociatedChange(TransformModifyFlags.WmWpWeWqWsWus)— so the collider's listener re-fires after clone (the "fix Transform clone dirty flag" change on the physics branch). This patches the symptom on the Transform side; the root cause is the collider lifecycle.Why that's the wrong layer
Eagerly dispatching transform changes during clone is not what other engines do — they create native/physics state after the transform is finalized, on enter-scene:
OnCreatePhysicsState, which runs during registration afterOnRegister→UpdateComponentToWorldfinalizes the transform. PIE duplication isPostDuplicate→ the normal register flow. No dispatch-on-duplicate.Instantiatecopies local TRS, world matrix is lazily recomputed; the Rigidbody body is created inAwake/OnEnable, after the transform is set.Node._onBatchCreated(the clone path) only sets dirty bits (_transformFlags |= TRS); it does not emitTRANSFORM_CHANGED. Physics syncs inonEnable.The shared rule: keep transform dirty bits unconditionally (lazy recompute), but only register listeners / create native state on enter-scene, after the transform is finalized. Then clone / deserialize "just work" with no eager dispatch.
Proposed fix
For every collider, move out of the constructor into the scene lifecycle:
worldPositionread →_onEnableInScene_updateFlag = registerWorldChangeFlag()→_onEnableInScene; release in_onDisableInSceneaddShapetolerate a not-yet-created native (write the JS field; do a one-shot_syncNative()on enable — JS fields are the source of truth)Then the
Transform._cloneTo→_worldAssociatedChangeworkaround can be removed.Prototype (StaticCollider only)
Moving
StaticCollider's native creation to_onEnableInScene(reading the final transform) + null-native guards in the baseCollider: 62 physics tests pass (PhysicsMaterial, MeshColliderShape incl. cloned-mesh-shape, Collider, Collision) with no Transform dispatch involved.DynamicCollider/CharacterControllerneed the same treatment — larger, because of the many property setters that must guard a missing native and sync on enable.Context
Surfaced while re-architecting the clone system (#3018). As clone / prefab-instantiate becomes a first-class, relied-upon path after that lands, entities with colliders will hit the stale-pose more visibly, and the Transform-side workaround will be the thing keeping it afloat. Filing so the root-cause lifecycle fix is tracked separately.
Regression tests (belong here, not in #3018)
These two
Transform._cloneTotests encode the stale-world-cache / missed-listener-dispatch scenario. They were written during #3018 but moved here because they document this issue, not the clone re-architecture. They currently fail on a base without the Transform workaround. (Imports:Entity,TransformModifyFlagsfrom@galacean/engine-core;enginefrom the suite'sbeforeAll.)Note: the two tests above assert the Transform-side dispatch (i.e. the
_worldAssociatedChangeworkaround). The root-cause fix (collider native creation → enter-scene) should additionally be guarded by a collider-level test: clone an entity whose collider sits at a non-origin world position, add the clone to the scene, then assert the native actor's world pose matches the entity (not the constructor-default origin).