Skip to content

SkinnedMeshRenderer.bounds freezes after PrefabResource.instantiate() — clone remap bypasses listener registration #2978

@zhuxudong

Description

@zhuxudong

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

  1. Create a prefab containing a SkinnedMeshRenderer with a rootBone (e.g. Mixamo character)
  2. const instance = prefab.instantiate()
  3. Add to scene, move the instance entity each frame
  4. Observe: instance.getComponentInChildren(SkinnedMeshRenderer).bounds stays at initial position
  5. With Camera.enableFrustumCulling = true, the character becomes invisible once moved away from spawn

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions