Fixed: per-instance motion vectors on UInstancedStaticMeshComponent (TAA/TSR ghosting)

Problem: Instances moved every frame via UInstancedStaticMeshComponent smear/ghost badly under TAA/TSR. Motion-vector visualization shows the mesh, but with zero motion vectors — even though the instances are clearly moving.

Root cause (not an engine bug): per-instance velocity depends entirely on PerInstancePrevTransform being 1:1 with the instance count. If that array is empty, bHasPerInstanceDynamicData is false, and the velocity pass falls back to primitive-level LocalToWorld vs PreviousLocalToWorld. If your component doesn’t move (only the instances scroll), that comparison is equal → the primitive is treated as “not moving” → no motion vectors → ghosting.

The traps that make this hard

  1. AddInstances() (plural) does not append to PerInstancePrevTransform — only AddInstance() (singular) does. (Confirmed bug; Epic reproduced it — see the thread linked at the bottom.)
  2. SetHasPerInstancePrevTransforms(true) must be called after instances exist, so it can seed prev from the current transforms.
  3. If prev ends up empty, the flag silently drops back to false (ISMInstanceUpdateChangeSet.cpp), with no log.
  4. Prev transforms must be component-local (bWorldSpace = false), not world space — otherwise the engine converts them with the current component transform while the shader multiplies by the previous primitive local-to-world.

Working fix (no engine modification)

// Setup
ISM->SetMobility(EComponentMobility::Movable);

// Build / rebuild (once, or when instance count changes)
void Rebuild(const TArray<FTransform>& CurrentLocal)
{
    ISM->ClearInstances();
    ISM->AddInstances(CurrentLocal, false, /*bWorldSpace*/ false, /*bUpdateNavigation*/ false);
    ISM->SetHasPerInstancePrevTransforms(true); // must be AFTER AddInstances

    checkf(ISM->PerInstancePrevTransform.Num() == ISM->GetInstanceCount(),
        TEXT("prev %d vs instances %d"), ISM->PerInstancePrevTransform.Num(), ISM->GetInstanceCount());
}

// Every frame
void Update(const TArray<FTransform>& CurrentLocal, const TArray<FTransform>& PrevLocal)
{
    ISM->BatchUpdateInstancesTransforms(
        0, CurrentLocal, PrevLocal,
        /*bWorldSpace*/ false,            // see trap #4
        /*bMarkRenderStateDirty*/ false,  // TransformChanged already drives the upload
        /*bTeleport*/ true);              // no physics, skip the body path
}

Key points:

  • Keep PrevLocal = the transforms you sampled last frame (component-local), so the previous frame is real.
  • The checkf catches the silent-degrade trap during development.

How to verify (do all three)

  1. Log PerInstancePrevTransform.Num() == GetInstanceCount() — you want 1.
  2. r.Velocity.ForceOutput 0 (it’s 0 by default) — if ghosting is gone with it off, bAlwaysHasVelocity came from the flag, not the CVar.
  3. Acid test: vehicle stationary + instances scrolling. Primitive-level velocity is zero here, so no ghosting = per-instance prev is genuinely working.

Tested on UE 5.5.4 (++UE5+Release-5.5).

Related threads