UE5 Skeletal mesh merge w/ morph targets [help required]

Hello,

I’ve been trying to generate a skeletal mesh with morph targets.
The reason I’m doing this, is because it is a modular character, with lerping between morph targets.

I can render the character, but the morph target does not seem to be working at all, probably because of the following check:

Error: Ensure condition failed: DynamicData->MorphTargetWeights.Num() == LODData.MorphTargetVertexInfoBuffers.GetNumMorphs() [File:D:\build\++UE5\Sync\Engine\Source\Runtime\Engine\Private\SkeletalRenderGPUSkin.cpp] [Line: 937]

LODData.MorphTargetVertexInfoBuffers.GetNumMorphs() seems to return 0, however the morph target does exist on the mesh. I have also given the merged mesh CPU and GPU access just to be sure.

Here is my code, sorry if it’s not great - I’m only 3 days into learning C++:

USkeletalMesh* URenderer::MergeSkeletalMeshesWithMorphTargets(USkeletalMeshComponent* PlayerMesh, const TArray<USkeletalMesh*> MeshesToMerge, const FMeshParts MeshPartsA, const FMeshParts MeshPartsB, const float LerpAmount)
{
	// Return if base mesh null
	if (!PlayerMesh)
	{
		UE_LOG(LogTemp, Warning, TEXT("No player mesh supplied."));
		return nullptr;
	}
	
	if (MeshesToMerge.Num() == 0)
	{
		UE_LOG(LogTemp, Warning, TEXT("No meshes to merge."));
		return nullptr;
	}
	
	// Set up new base mesh
	USkeletalMesh* BaseMesh = NewObject<USkeletalMesh>(PlayerMesh, "BaseMesh");
	
	// Return if base mesh null
	if (!BaseMesh)
	{
		UE_LOG(LogTemp, Warning, TEXT("No base mesh supplied."));
		return nullptr;
	}
	
	// Set up other optional params
	const TArray<FSkelMeshMergeSectionMapping> SectionMappings;
	const TArray<FSkelMeshMergeMeshUVTransforms> UvTransforms;
	const int32 StripTopLODs = 0;
	
	FSkelMeshMergeUVTransformMapping Mapping = FSkelMeshMergeUVTransformMapping();
	Mapping.UVTransformsPerMesh = UvTransforms;
	
	// Create a skeletal mesh merge utility object
	FSkeletalMeshMerge MeshMerge = FSkeletalMeshMerge(
		BaseMesh,
		MeshesToMerge,
		SectionMappings,
		StripTopLODs,
		EMeshBufferAccess::ForceCPUAndGPU,
		&Mapping
	);
	
	// Perform the merge and get the resulting skeletal mesh
	if (!MeshMerge.DoMerge())
	{
		UE_LOG(LogTemp, Warning, TEXT("Skeletal mesh merge failed."));
		return nullptr;
	}
	
	// Calculate and aggregate morph target deltas
	TArray<FMorphTargetDelta> NewDeltas;
	TArray<FSkelMeshSection> Sections;
	const int32 NumVertices = 0;
		
	AppendDeltas(&NewDeltas, &Sections, MeshPartsA.Head, MeshPartsB.Head, LerpAmount);
	AppendDeltas(&NewDeltas, &Sections, MeshPartsA.Body, MeshPartsB.Body, LerpAmount);
	
	// Create a new morph target and populate it with our deltas
	constexpr bool bCompareNormal = false;
	constexpr bool bGeneratedByReductionSetting = true;
	UMorphTarget* NewMorphTarget = NewObject<UMorphTarget>(BaseMesh, "LerpedMorph");
	NewMorphTarget->PopulateDeltas(NewDeltas, 0, Sections, bCompareNormal, bGeneratedByReductionSetting);
	
	// Add the new morph target to the base skeletal mesh
	TArray<TObjectPtr<UMorphTarget>> MorphTargets = BaseMesh->GetMorphTargets();
	MorphTargets.Add(NewMorphTarget);
	BaseMesh->SetMorphTargets(MorphTargets);
	BaseMesh->InitMorphTargets();
	
	// Log number of local morph target deltas
	int32 NumSrcDeltas = 0;
	const FMorphTargetDelta* MorphDeltas = NewMorphTarget->GetMorphTargetDelta(LodIndex, NumSrcDeltas);
		UE_LOG(LogTemp, Warning, TEXT("MorphDeltas: %d"), NumSrcDeltas);
	
	// Log number of morph targets in the render data
	FSkeletalMeshRenderData* RenderData = BaseMesh->GetResourceForRendering();
	const FSkeletalMeshLODRenderData& LODData = RenderData->LODRenderData[LodIndex];
	int32 NumMorphs = LODData.MorphTargetVertexInfoBuffers.GetNumMorphs();
	UE_LOG(LogTemp, Warning, TEXT("NumRenderMorphs: %d"), NumMorphs);
	bool bMorphCpuDataValid = LODData.MorphTargetVertexInfoBuffers.IsMorphCPUDataValid();
	bool bMorphResourcesInitialized = LODData.MorphTargetVertexInfoBuffers.IsMorphResourcesInitialized();
	
	if (bMorphCpuDataValid)
		UE_LOG(LogTemp, Warning, TEXT("Morph CPU data valid"));
	
	if (bMorphResourcesInitialized)
		UE_LOG(LogTemp, Warning, TEXT("Morph resources initialized"));
	
	// Set the morph target value to 0.5 for testing
	PlayerMesh->SetMorphTarget(FName("LerpedMorph"), 0.5);
	float value = PlayerMesh->GetMorphTarget(FName("LerpedMorph"));
	UE_LOG(LogTemp, Warning, TEXT("LerpedMorph: %f"), value);
	PlayerMesh->MarkRenderStateDirty();
	
	// Return the merged skeletal mesh
	return BaseMesh;
}

void URenderer::AppendDeltas(TArray<FMorphTargetDelta>* Deltas, TArray<FSkelMeshSection>* Sections, USkeletalMesh* SkeletalMeshA, USkeletalMesh* SkeletalMeshB, const float LerpAmount)
{
	if (!SkeletalMeshA || !SkeletalMeshB)
		return;
	
	TArray<FMorphTargetDelta> NewDeltas = LerpMorphTargets(SkeletalMeshA, SkeletalMeshB, LerpAmount);

	for (FMorphTargetDelta Delta : NewDeltas)
		Deltas->Add(Delta);

	AppendSections(Sections, SkeletalMeshA, NewDeltas.Num());
}

void URenderer::AppendSections(TArray<FSkelMeshSection>* Sections, const USkeletalMesh* SkeletalMesh, int32 MeshDeltaCount)
{
	TArray<FSkelMeshSection> MeshSections = SkeletalMesh->GetImportedModel()->LODModels[0].Sections;
	
	for (int i = 0; i < MeshSections.Num(); ++i)
	{
		Sections->Add(MeshSections[i]);
		Sections->Last().BaseIndex += MeshDeltaCount;
	}
}

// Blueprint function that takes in two skeletal meshes and a float value (0-1) and lerps between their morph targets
TArray<FMorphTargetDelta> URenderer::LerpMorphTargets(USkeletalMesh* SkeletalMeshA, USkeletalMesh* SkeletalMeshB, float Amount)
{
	// Create an array to store the new deltas
	TArray<FMorphTargetDelta> NewDeltas;
	
    //Throw error if lerp amount is invalid
    if (Amount < 0 || Amount > 1)
    {
        UE_LOG(LogTemp, Error, TEXT("Invalid lerp amount (%f) - lerp must be between 0 and 1"), Amount);
        return NewDeltas;
    }
    
    // Get morph targets for both skeletal meshes
    TArray<UMorphTarget*>& SkeletalMeshAMorphTargets = SkeletalMeshA->GetMorphTargets();
    TArray<UMorphTarget*>& SkeletalMeshBMorphTargets = SkeletalMeshB->GetMorphTargets();
    
    TArray<FString> MorphTargetNames;
    MorphTargetNames.Add("Morph 1");
    MorphTargetNames.Add("Morph 2");
    MorphTargetNames.Add("Morph 3");

    // Find morph target by name for each
    for (FString MorphTargetName : MorphTargetNames)
    {
        UE_LOG(LogTemp, Log, TEXT("Processing morph target: %s"), *MorphTargetName);
    
        // If both found, lerp between them, else skip
        UMorphTarget* a = FindMorphTargetByName(SkeletalMeshAMorphTargets, MorphTargetName);
        UMorphTarget* b = FindMorphTargetByName(SkeletalMeshBMorphTargets, MorphTargetName);
    
        if (a == nullptr || b == nullptr)
        {
            UE_LOG(LogTemp, Log, TEXT("Skipping morph target: %s"), *MorphTargetName);
            continue;
        }
    
        // Throw an error if morph target has invalid data
        if (!a->HasValidData() || !b->HasValidData())
        {
            UE_LOG(LogTemp, Warning, TEXT("Invalid data on morph target!"));
            return NewDeltas;
        }
    
        // Get LOD meshes for both morph targets
        TArray<FMorphTargetLODModel> LODModelsA = a->GetMorphLODModels();
        TArray<FMorphTargetLODModel> LODModelsB = b->GetMorphLODModels();
    
        UE_LOG(LogTemp, Log, TEXT("LOD model A count: %d"), LODModelsA.Num());
        UE_LOG(LogTemp, Log, TEXT("LOD model B count: %d"), LODModelsB.Num());
    
        // Throw an error if LOD model count different
        if (LODModelsA.Num() != LODModelsB.Num())
        {
            UE_LOG(LogTemp, Warning, TEXT("Skeletal mesh 1 has a different number of LOD models to Skeletal mesh 2! (%d | %d)"), LODModelsA.Num(), LODModelsB.Num());
            return NewDeltas;
        }
    
        // Iterate over LOD models
        for (int32 lodIndex = 0; lodIndex < LODModelsA.Num(); lodIndex++)
        {
            UE_LOG(LogTemp, Log, TEXT("Processing LOD index: %d"), lodIndex);
    
            // Get the vertex deltas for each LOD mesh
            TArray<FMorphTargetDelta>& DeltasA = LODModelsA[lodIndex].Vertices;
            TArray<FMorphTargetDelta>& DeltasB = LODModelsB[lodIndex].Vertices;
    
            UE_LOG(LogTemp, Log, TEXT("Vertex deltas A count: %d"), DeltasA.Num());
            UE_LOG(LogTemp, Log, TEXT("Vertex deltas B count: %d"), DeltasA.Num());
    
            // Iterate over the deltas in DeltasA
            for (const FMorphTargetDelta DeltaA : DeltasA)
            {
                // Get the related delta in DeltasB
                FMorphTargetDelta DeltaB = FindVertexById(DeltasB, DeltaA.SourceIdx);
    
                // Lerp the position and tangent vectors
                float PositionX = FMath::Lerp(DeltaA.PositionDelta.X, DeltaB.PositionDelta.X, Amount);
                float PositionY = FMath::Lerp(DeltaA.PositionDelta.Y, DeltaB.PositionDelta.Y, Amount);
                float PositionZ = FMath::Lerp(DeltaA.PositionDelta.Z, DeltaB.PositionDelta.Z, Amount);
    
                float TangentX = FMath::Lerp(DeltaA.TangentZDelta.X, DeltaB.TangentZDelta.X, Amount);
                float TangentY = FMath::Lerp(DeltaA.TangentZDelta.Y, DeltaB.TangentZDelta.Y, Amount);
                float TangentZ = FMath::Lerp(DeltaA.TangentZDelta.Z, DeltaB.TangentZDelta.Z, Amount);
    
                // Assign result to a new delta and add it to the array
                FMorphTargetDelta NewDelta;
                NewDelta.SourceIdx = DeltaA.SourceIdx;
                NewDelta.PositionDelta = FVector3f(PositionX, PositionY, PositionZ);
                NewDelta.TangentZDelta = FVector3f(TangentX, TangentY, TangentZ);
                NewDeltas.Add(NewDelta);
            }
        }
    }

    return NewDeltas;
}

UMorphTarget* URenderer::FindMorphTargetByName(TArray<UMorphTarget*>& MorphTargets, const FString& MorphTargetName)
{
    for (UMorphTarget* MorphTarget : MorphTargets)
        if (MorphTarget->GetName().Compare(MorphTargetName, ESearchCase::CaseSensitive) == 0)
            return MorphTarget;
    return nullptr;
}

FMorphTargetDelta URenderer::FindVertexById(TArray<FMorphTargetDelta>& Deltas, const int32 Index)
{
    for (const FMorphTargetDelta Delta : Deltas)
        if (Delta.SourceIdx == Index)
            return Delta;
    return Deltas[0];
}

with the result of the logs showing:

LerpedMorph: 0.500000 //morph target *does* exist at runtime, and weight is applied
MorphDeltas: 1243 //number of deltas on the morph target before render step
NumRenderMorphs: 0 //number of supposed morphs available during render step (where the check is failing)
bMorphCpuDataValid: false //invalid CPU data
bMorphResourcesInitialized: false //not initialized, probably because the data is invalid

Questions are:

  1. What would be causing the Morph CPU data to be invalid?
  2. Would this be causing the render data to not be set up properly, thus causing this error?

The only resources I’ve managed to find on this are either old/outdated or unanswered/unresolved like this one: FSkeletalMeshMerge and MorphTargets - #6 by Omerpaz95

I just wish there was a good example of this actually working and being possible.