Problem
After PrefabResource.instantiate(), SkinnedMeshRenderer.bounds freezes at the initial placement position and never updates when the entity moves. This causes frustum culling to permanently hide moved skinned characters.
Root Cause
Two layers:
Layer 1 (fundamental): CloneManager remap writes _transformEntity bypassing _setTransformEntity()
CloneManager.cloneProperty checks _remap (Entity references) before @ignoreClone:
// CloneManager.ts:109-111 — highest priority
if (sourceProperty instanceof Object && (<ICustomClone>sourceProperty)._remap) {
target[k] = (<ICustomClone>sourceProperty)._remap(srcRoot, targetRoot);
return; // ← returns before cloneMode check at line 115
}
// line 115 — @ignoreClone never reached for Entity references
if (cloneMode === CloneMode.Ignore) return;
During clone, target._transformEntity is directly assigned the remapped rootBone entity without calling _setTransformEntity(), so no _onTransformChanged listener is registered on the new rootBone's _updateFlagManager.
Layer 2 (missing defense): _setTransformEntity equality guard
The subsequent _cloneTo → _applySkin → _setTransformEntity(rootBone) tries to fix this, but the equality guard skips it:
// Renderer.ts:470-477
protected _setTransformEntity(entity: Entity): void {
const preEntity = this._transformEntity;
if (entity !== preEntity) { // ← false after remap set it, entire block skipped
preEntity?._updateFlagManager.removeListener(this._onTransformChanged);
entity?._updateFlagManager.addListener(this._onTransformChanged);
this._transformEntity = entity;
}
}
It assumes "same reference = listener already registered", which is not true after direct field writes from CloneManager.
Concrete example (character queue in a Cocos-to-Galacean migrated game)
Prefab entity tree:
character_man
├── Body ← SkinnedMeshRenderer is on this entity
└── mixamorig:Hips ← rootBone
Step 1 — SMR constructor:
_setTransformEntity(Body') → listener on Body' ✓, _transformEntity = Body'
Step 2 — CloneManager property copy:
_transformEntity is Entity → has _remap → direct assign: _transformEntity = Hips'
⚠️ No _setTransformEntity() call → Body' listener is orphaned, Hips' has 0 listeners
Step 3 — _cloneTo → _applySkin → _setTransformEntity(Hips'):
Hips' === _transformEntity (set in step 2) → equality guard → SKIP
Result: Hips' (rootBone) has 0 listeners → bounds never update → frustum culling wrong
Evidence
Diagnostic data from 12 pooled skinned characters:
| Field |
Value |
_transformEntity === skin.rootBone |
true (remap worked correctly) |
Listener count on rootBone _updateFlagManager |
0 |
rootBone.worldMatrix |
Up-to-date (correct position) |
bounds |
Frozen at initial spawn position |
After forcing _dirtyUpdateFlag |= WorldVolume |
Bounds correct (proves _updateBounds logic is fine) |
Suggested Fix
Two options (can be combined):
Option A: Fix _setTransformEntity (defensive, minimal change)
Remove the equality guard so it always ensures the listener is registered:
protected _setTransformEntity(entity: Entity): void {
const preEntity = this._transformEntity;
preEntity?._updateFlagManager.removeListener(this._onTransformChanged);
if (entity) {
entity._updateFlagManager.addListener(this._onTransformChanged);
}
this._transformEntity = entity;
}
Verified to fix the issue. Zero per-frame overhead — only called during clone/skin setup.
Option B: Fix CloneManager priority (fundamental)
Respect @ignoreClone before _remap so that entity references on ignored fields are not written at all:
// Move ignore check before remap check:
if (cloneMode === CloneMode.Ignore) return;
if (sourceProperty instanceof Object && (<ICustomClone>sourceProperty)._remap) {
target[k] = (<ICustomClone>sourceProperty)._remap(srcRoot, targetRoot);
return;
}
Combined with @ignoreClone on Renderer._transformEntity, this prevents the direct write entirely.
Note: Option B has broader impact — other components relying on the current remap-first behavior may need review.
Reproduction
- Create a prefab containing a SkinnedMeshRenderer with a rootBone (e.g. Mixamo character)
const instance = prefab.instantiate()
- Add to scene, move the instance entity each frame
- Observe:
instance.getComponentInChildren(SkinnedMeshRenderer).bounds stays at initial position
- With
Camera.enableFrustumCulling = true, the character becomes invisible once moved away from spawn
Problem
After
PrefabResource.instantiate(),SkinnedMeshRenderer.boundsfreezes at the initial placement position and never updates when the entity moves. This causes frustum culling to permanently hide moved skinned characters.Root Cause
Two layers:
Layer 1 (fundamental): CloneManager remap writes
_transformEntitybypassing_setTransformEntity()CloneManager.clonePropertychecks_remap(Entity references) before@ignoreClone:During clone,
target._transformEntityis directly assigned the remapped rootBone entity without calling_setTransformEntity(), so no_onTransformChangedlistener is registered on the new rootBone's_updateFlagManager.Layer 2 (missing defense):
_setTransformEntityequality guardThe subsequent
_cloneTo → _applySkin → _setTransformEntity(rootBone)tries to fix this, but the equality guard skips it:It assumes "same reference = listener already registered", which is not true after direct field writes from CloneManager.
Concrete example (character queue in a Cocos-to-Galacean migrated game)
Evidence
Diagnostic data from 12 pooled skinned characters:
_transformEntity === skin.rootBonetrue(remap worked correctly)_updateFlagManagerrootBone.worldMatrixbounds_dirtyUpdateFlag |= WorldVolume_updateBoundslogic is fine)Suggested Fix
Two options (can be combined):
Option A: Fix
_setTransformEntity(defensive, minimal change)Remove the equality guard so it always ensures the listener is registered:
Verified to fix the issue. Zero per-frame overhead — only called during clone/skin setup.
Option B: Fix CloneManager priority (fundamental)
Respect
@ignoreClonebefore_remapso that entity references on ignored fields are not written at all:Combined with
@ignoreCloneonRenderer._transformEntity, this prevents the direct write entirely.Note: Option B has broader impact — other components relying on the current remap-first behavior may need review.
Reproduction
const instance = prefab.instantiate()instance.getComponentInChildren(SkinnedMeshRenderer).boundsstays at initial positionCamera.enableFrustumCulling = true, the character becomes invisible once moved away from spawn