Has anyone actually got USD to work at runtime on a packaged build!

Hey everyone!
I managed to fix the runtime USD static mesh loading issue! Here’s the solution (generated by Claude.ai because I was too lazy to write it all out myself :sweat_smile:, but I’ve reviewed everything and it accurately describes how I got it working on my setup):

USD Static Mesh Runtime Build Fix for Unreal Engine 5.6.0

Problem Description

When loading USD stages at runtime in Unreal Engine 5.6.0 (source build), static meshes fail to display with the error:

LogUsd: Warning: Discarding StaticMesh generated for prim '/path/to/mesh' as it didn't produce any valid RenderData (likely all triangles were degenerate)
LogStaticMesh: Verbose: ShouldCreateRenderState returned false for InstancedStaticMeshComponent (StaticMesh is null)

Diagnostic logs showed:

  • Valid geometry data (e.g., 1810 vertices, 1114 triangles)
  • Valid bounding boxes (non-zero, non-NaN)
  • LODResources correctly populated
  • GetCurrentFirstLODIdx(0) returns -1 instead of 0

Root Cause

At runtime (!WITH_EDITOR), the BuildFromMeshDescription() function populates vertex and index buffers but does NOT initialize the BuffersSize property.

The GetFirstValidLODIdx() function validates LODs using this check:

while (LODIndex < LODCount && 
       (LODResources[LODIndex].GetNumVertices() == 0 || 
        LODResources[LODIndex].BuffersSize == 0))  // ⬅️ FAILS HERE
{
    ++LODIndex;
}

When BuffersSize == 0, the LOD is considered invalid, causing GetCurrentFirstLODIdx() to return INDEX_NONE (-1), which leads to mesh rejection.

Solution

File to modify:

Engine/Plugins/Importers/USDImporter/Source/USDSchemas/Private/USDGeomMeshTranslator.cpp

Method:

bool UsdGeomMeshTranslatorImpl::BuildStaticMesh(...)

Location: In the runtime build path (#else block / !WITH_EDITOR), in the Post-LOD Processing section (after the LOD processing loop).

Code to add:

// === Post-LOD Processing ===
UE_LOG(LogUsd, Warning, TEXT("=== Post-LOD Processing ==="));
FStaticMeshRenderData* RenderData = StaticMesh.GetRenderData();

if (!RenderData)
{
    UE_LOG(LogUsd, Error, TEXT("ERROR: RenderData is NULL!"));
}
else
{
    UE_LOG(LogUsd, Warning, TEXT("RenderData exists, LODResources.Num: %d"), RenderData->LODResources.Num());
    
    if (RenderData->LODResources.Num() > 0)
    {
        // Calculate BuffersSize manually for each LOD
        for (int32 LODIndex = 0; LODIndex < RenderData->LODResources.Num(); ++LODIndex)
        {
            FStaticMeshLODResources& LODRes = RenderData->LODResources[LODIndex];
            
            if (LODRes.BuffersSize == 0 && LODRes.GetNumVertices() > 0)
            {
                LODRes.BuffersSize = 0;
                
                // Position buffer (X,Y,Z coordinates)
                LODRes.BuffersSize += LODRes.VertexBuffers.PositionVertexBuffer.GetNumVertices() 
                                    * LODRes.VertexBuffers.PositionVertexBuffer.GetStride();
                
                // Static mesh vertex buffer (normals, tangents, UVs)
                LODRes.BuffersSize += LODRes.VertexBuffers.StaticMeshVertexBuffer.GetResourceSize();
                
                // Color buffer (if present)
                if (LODRes.VertexBuffers.ColorVertexBuffer.GetNumVertices() > 0)
                {
                    LODRes.BuffersSize += LODRes.VertexBuffers.ColorVertexBuffer.GetAllocatedSize();
                }
                
                // Index buffer (triangles)
                LODRes.BuffersSize += LODRes.IndexBuffer.GetAllocatedSize();
                
                UE_LOG(LogUsd, Warning, TEXT("  - LOD%d BuffersSize calculated: %d bytes"), LODIndex, LODRes.BuffersSize);
            }
        }
        
        // Force CurrentFirstLODIdx to 0
        RenderData->CurrentFirstLODIdx = 0;
        
        UE_LOG(LogUsd, Warning, TEXT("GetCurrentFirstLODIdx(0) after fix: %d"), RenderData->GetCurrentFirstLODIdx(0));
    }
}
UE_LOG(LogUsd, Warning, TEXT("=== End Post-LOD Processing ==="));

In editor builds, StaticMesh.Build() calculates BuffersSize automatically. At runtime, BuildFromMeshDescription() populates the buffers but leaves BuffersSize = 0, causing validation to fail.

If you need, here is the complete method BuildStaticMesh:

	bool BuildStaticMesh(UStaticMesh& StaticMesh, const FStaticFeatureLevel& FeatureLevel, TArray<FMeshDescription>& LODIndexToMeshDescription)
	{
		TRACE_CPUPROFILER_EVENT_SCOPE(UsdGeomMeshTranslatorImpl::BuildStaticMesh);

		if (LODIndexToMeshDescription.Num() == 0)
		{
			return false;
		}
		
		UE_LOG(LogUsd, Warning, TEXT("BuildStaticMesh: Building mesh with %d LODs"), LODIndexToMeshDescription.Num());

#if WITH_EDITOR
		UE_LOG(LogUsd, Warning, TEXT("BuildStaticMesh: Editor build path"));
		
		ITargetPlatformManagerModule& TargetPlatformManager = GetTargetPlatformManagerRef();
		ITargetPlatform* RunningPlatform = TargetPlatformManager.GetRunningTargetPlatform();
		check(RunningPlatform);

		const FStaticMeshLODSettings& LODSettings = RunningPlatform->GetStaticMeshLODSettings();
		StaticMesh.GetRenderData()->Cache(RunningPlatform, &StaticMesh, LODSettings);
#else
		UE_LOG(LogUsd, Warning, TEXT("BuildStaticMesh: Runtime build path"));
		
		StaticMesh.GetRenderData()->AllocateLODResources(LODIndexToMeshDescription.Num());

		// Build render data from each mesh description
		for (int32 LODIndex = 0; LODIndex < LODIndexToMeshDescription.Num(); ++LODIndex)
		{
			UE_LOG(LogUsd, Warning, TEXT("=== START Processing LOD%d ==="), LODIndex);
			
			FStaticMeshLODResources& LODResources = StaticMesh.GetRenderData()->LODResources[LODIndex];

			FMeshDescription& MeshDescription = LODIndexToMeshDescription[LODIndex];
			
			UE_LOG(LogUsd, Warning, TEXT("BuildStaticMesh LOD%d: %d vertices, %d triangles"), 
				LODIndex, 
				MeshDescription.Vertices().Num(), 
				MeshDescription.Triangles().Num()
			);
			
			TVertexInstanceAttributesConstRef<FVector4f>
				MeshDescriptionColors = MeshDescription.VertexInstanceAttributes().GetAttributesRef<FVector4f>(MeshAttribute::VertexInstance::Color);

			// Compute normals here if necessary because they're not going to be computed via the regular static mesh build pipeline at runtime
			// (i.e. StaticMeshBuilder is not available at runtime)
			// We need polygon info because ComputeTangentsAndNormals uses it to repair the invalid vertex normals/tangents
			// Can't calculate just the required polygons as ComputeTangentsAndNormals is parallel and we can't guarantee thread-safe access patterns
			UE_LOG(LogUsd, Warning, TEXT("  - Computing normals and tangents..."));
			FStaticMeshOperations::ComputeTriangleTangentsAndNormals(MeshDescription);
			FStaticMeshOperations::ComputeTangentsAndNormals(MeshDescription, EComputeNTBsFlags::UseMikkTSpace);
			UE_LOG(LogUsd, Warning, TEXT("  - Normals and tangents computed"));

			// Manually set this as it seems the UStaticMesh only sets this whenever the mesh is serialized, which we won't do
			LODResources.bHasColorVertexData = MeshDescriptionColors.GetNumElements() > 0;
			UE_LOG(LogUsd, Warning, TEXT("  - bHasColorVertexData: %d"), LODResources.bHasColorVertexData);

			UE_LOG(LogUsd, Warning, TEXT("  - Calling BuildFromMeshDescription..."));
			StaticMesh.BuildFromMeshDescription(MeshDescription, LODResources);
			UE_LOG(LogUsd, Warning, TEXT("  - BuildFromMeshDescription completed"));
			
			UE_LOG(LogUsd, Error, TEXT("After BuildFromMeshDescription LOD%d:"), LODIndex);
			UE_LOG(LogUsd, Error, TEXT("  - NumVertices: %d"), LODResources.GetNumVertices());
			UE_LOG(LogUsd, Error, TEXT("  - NumTriangles: %d"), LODResources.GetNumTriangles());
			UE_LOG(LogUsd, Error, TEXT("  - IndexBuffer.Num: %d"), LODResources.IndexBuffer.GetNumIndices());
			
			if (LODResources.GetNumVertices() > 0)
			{
				UE_LOG(LogUsd, Warning, TEXT("  - Computing bounding box manually..."));
				FBox BoundingBox(ForceInit);
				const FPositionVertexBuffer& PositionVertexBuffer = LODResources.VertexBuffers.PositionVertexBuffer;
	
				for (uint32 VertexIndex = 0; VertexIndex < PositionVertexBuffer.GetNumVertices(); ++VertexIndex)
				{
					BoundingBox += FVector(PositionVertexBuffer.VertexPosition(VertexIndex));
				}
	
				if (BoundingBox.IsValid)
				{
					StaticMesh.GetRenderData()->Bounds = FBoxSphereBounds(BoundingBox);
					UE_LOG(LogUsd, Warning, TEXT("  - Manually computed bounds: %s"), *BoundingBox.ToString());
					UE_LOG(LogUsd, Warning, TEXT("  - Bounds center: %s"), *BoundingBox.GetCenter().ToString());
					UE_LOG(LogUsd, Warning, TEXT("  - Bounds extent: %s"), *BoundingBox.GetExtent().ToString());
				}
				else
				{
					UE_LOG(LogUsd, Error, TEXT("  - ERROR: Computed bounding box is INVALID!"));
				}
			}
			else
			{
				UE_LOG(LogUsd, Error, TEXT("  - ERROR: LODResources has 0 vertices, skipping bounds computation"));
			}
			
			UE_LOG(LogUsd, Warning, TEXT("=== END Processing LOD%d ==="), LODIndex);
		}
		
		UE_LOG(LogUsd, Warning, TEXT("=== Post-LOD Processing ==="));
		FStaticMeshRenderData* RenderData = StaticMesh.GetRenderData();

		if (!RenderData)
		{
		    UE_LOG(LogUsd, Error, TEXT("ERROR: RenderData is NULL!"));
		}
		else
		{
		    UE_LOG(LogUsd, Warning, TEXT("RenderData exists, LODResources.Num: %d"), RenderData->LODResources.Num());
		    UE_LOG(LogUsd, Warning, TEXT("CurrentFirstLODIdx BEFORE fix: %d"), RenderData->CurrentFirstLODIdx);
		    UE_LOG(LogUsd, Warning, TEXT("Bounds BEFORE fix: %s"), *RenderData->Bounds.ToString());
		    
		    if (RenderData->LODResources.Num() > 0)
		    {
		        // === FIX CRITIQUE : Calculer BuffersSize ===
		        for (int32 LODIndex = 0; LODIndex < RenderData->LODResources.Num(); ++LODIndex)
		        {
		            FStaticMeshLODResources& LODRes = RenderData->LODResources[LODIndex];
		            
		            UE_LOG(LogUsd, Warning, TEXT("  - LOD%d BuffersSize BEFORE: %d"), LODIndex, LODRes.BuffersSize);
		            
		            if (LODRes.BuffersSize == 0 && LODRes.GetNumVertices() > 0)
		            {
		                // Calculer la taille des buffers manuellement
		                LODRes.BuffersSize = 0;
		                
		                // Position buffer
		                LODRes.BuffersSize += LODRes.VertexBuffers.PositionVertexBuffer.GetNumVertices() * LODRes.VertexBuffers.PositionVertexBuffer.GetStride();
		                
		                // Static mesh vertex buffer
		                LODRes.BuffersSize += LODRes.VertexBuffers.StaticMeshVertexBuffer.GetResourceSize();
		                
		                // Color buffer (si présent)
		                if (LODRes.VertexBuffers.ColorVertexBuffer.GetNumVertices() > 0)
		                {
		                    LODRes.BuffersSize += LODRes.VertexBuffers.ColorVertexBuffer.GetAllocatedSize();
		                }
		                
		                // Index buffer
		                LODRes.BuffersSize += LODRes.IndexBuffer.GetAllocatedSize();
		                
		                UE_LOG(LogUsd, Warning, TEXT("  - LOD%d BuffersSize AFTER calculation: %d"), LODIndex, LODRes.BuffersSize);
		            }
		        }
		        
		        // Force CurrentFirstLODIdx à 0
		        RenderData->CurrentFirstLODIdx = 0;
		        
		        UE_LOG(LogUsd, Warning, TEXT("CurrentFirstLODIdx AFTER fix: %d"), RenderData->CurrentFirstLODIdx);
		        UE_LOG(LogUsd, Warning, TEXT("GetCurrentFirstLODIdx(0) AFTER fix: %d"), RenderData->GetCurrentFirstLODIdx(0));
		        UE_LOG(LogUsd, Warning, TEXT("BuildStaticMesh: Fixed CurrentFirstLODIdx and BuffersSize"));
		    }
		}
		UE_LOG(LogUsd, Warning, TEXT("=== End Post-LOD Processing ==="));

#if RHI_RAYTRACING
		if (IsRayTracingAllowed() && StaticMesh.bSupportRayTracing)
		{
			StaticMesh.GetRenderData()->InitializeRayTracingRepresentationFromRenderingLODs();
		}
#endif	  // RHI_RAYTRACING
#endif	  // WITH_EDITOR

		return true;
	}

Testing Status

  • Tested on: Unreal Engine 5.6.0 (source build)
  • Not yet tested on: Other UE versions (5.5, 5.4, etc… from source build or launcher)
  • Result: All USD prototype meshes now render correctly at runtime
  • Verified: GetCurrentFirstLODIdx(0) now returns 0 instead of -1

Verification Logs

After applying the fix, you should see:

LogUsd: Warning: === Post-LOD Processing ===
LogUsd: Warning: RenderData exists, LODResources.Num: 1
LogUsd: Warning:   - LOD0 BuffersSize calculated: 245760 bytes
LogUsd: Warning: GetCurrentFirstLODIdx(0) after fix: 0
LogUsd: Warning: === End Post-LOD Processing ===
LogUsd: Stage loaded [stage_name] in [X min Y s]

Instead of the previous errors showing GetCurrentFirstLODIdx(0): -1 and discarded meshes.

Additional Notes

  • This fix only affects the runtime code path (!WITH_EDITOR)
  • The editor path continues to use the standard StaticMesh.Build() workflow
  • No impact on cooked assets or editor-imported USD stages
  • This resolves the issue for procedurally loaded USD stages at runtime

Environment:

  • Unreal Engine: 5.6.0 (Source Build)
  • USD Plugin: Built-in USDImporter
  • Platform: Windows (likely affects all platforms)

Feel free to test this fix and report back if it works on other UE versions!