diff --git a/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/InstantiationDeferTests.cs b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/InstantiationDeferTests.cs
new file mode 100644
index 00000000..ebd9f05f
--- /dev/null
+++ b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/InstantiationDeferTests.cs
@@ -0,0 +1,245 @@
+// SPDX-FileCopyrightText: 2026 Unity Technologies and the glTFast authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace GLTFast.Tests.Import
+{
+ ///
+ /// Tests that scene instantiation honors the defer agent per mesh assignment (not just per
+ /// node) and that yielding at those points does not alter the instantiation result.
+ /// A node referencing a mesh whose primitives use differing vertex buffer layouts gets one
+ /// mesh assignment per layout cluster, so a single node can carry many assignments.
+ ///
+ [Category("Import")]
+ class InstantiationDeferTests
+ {
+ /// Primitive layout clusters in the test asset's single mesh (see the JSON
+ /// below: POSITION+NORMAL, +TEXCOORD_0, +COLOR_0, +TEXCOORD_1 — four distinct layouts,
+ /// each becoming its own mesh assignment).
+ const int k_AssignmentsPerMeshNode = 4;
+
+ /// Nodes in the test asset referencing the mesh (root and grandchild).
+ const int k_MeshNodes = 2;
+
+ /// Total nodes in the test asset (root, child, grandchild).
+ const int k_NodeCount = 3;
+
+ ///
+ /// A defer agent that always defers and counts how often the loading/instantiation
+ /// procedure offered to yield.
+ ///
+ class CountingDeferAgent : IDeferAgent
+ {
+ public int BreakPointCount { get; private set; }
+
+ public void Reset() => BreakPointCount = 0;
+
+ public bool ShouldDefer() => true;
+ public bool ShouldDefer(float duration) => true;
+
+ public async Task BreakPoint()
+ {
+ BreakPointCount++;
+ await Task.Yield();
+ }
+
+ public async Task BreakPoint(float duration)
+ {
+ BreakPointCount++;
+ await Task.Yield();
+ }
+ }
+
+ [UnityEngine.TestTools.UnityTest]
+ public IEnumerator InstantiationYieldsPerMeshAssignment()
+ {
+ yield return AsyncWrapper.WaitForTask(InstantiationYieldsPerMeshAssignmentInternal());
+ }
+
+ static async Task InstantiationYieldsPerMeshAssignmentInternal()
+ {
+ var deferAgent = new CountingDeferAgent();
+ using var gltf = new GltfImport(deferAgent: deferAgent);
+ var success = await gltf.LoadGltfJson(CreateMultiLayoutMeshJson());
+ Assert.IsTrue(success, "Loading the multi-layout test asset failed.");
+
+ var root = new GameObject(nameof(InstantiationYieldsPerMeshAssignment));
+ try
+ {
+ deferAgent.Reset();
+ success = await gltf.InstantiateMainSceneAsync(root.transform);
+ Assert.IsTrue(success, "Instantiation failed.");
+
+ // Hierarchy creation and population each iterate all nodes with a break point per
+ // node; population additionally breaks once per mesh assignment. A regression to
+ // per-node-only yielding lands well below this floor.
+ var expectedMinimum = 2 * k_NodeCount + k_MeshNodes * k_AssignmentsPerMeshNode;
+ Assert.GreaterOrEqual(
+ deferAgent.BreakPointCount,
+ expectedMinimum,
+ "Instantiation did not offer to yield once per mesh assignment. A node with " +
+ "many mesh assignments must not populate them all in one un-yielding stretch."
+ );
+ }
+ finally
+ {
+ UnityEngine.Object.Destroy(root);
+ }
+ }
+
+ [UnityEngine.TestTools.UnityTest]
+ public IEnumerator DeferredInstantiationMatchesUninterrupted()
+ {
+ yield return AsyncWrapper.WaitForTask(DeferredInstantiationMatchesUninterruptedInternal());
+ }
+
+ static async Task DeferredInstantiationMatchesUninterruptedInternal()
+ {
+ var json = CreateMultiLayoutMeshJson();
+
+ // The imports stay alive until AFTER the comparison — disposing a GltfImport destroys
+ // the meshes it owns, which would strip the described hierarchies mid-test.
+ var (uninterruptedGltf, uninterrupted) = await InstantiateAsync(json, new UninterruptedDeferAgent());
+ var (deferredGltf, deferred) = await InstantiateAsync(json, new CountingDeferAgent());
+ try
+ {
+ // Yielding to the player loop between mesh assignments (and nodes) must not
+ // change the result: same hierarchy, same renderers, same mesh/material shapes.
+ var expected = DescribeHierarchy(uninterrupted);
+ var actual = DescribeHierarchy(deferred);
+ if (expected != actual)
+ Debug.Log($"UNINTERRUPTED:\n{expected}\n\nDEFERRED:\n{actual}");
+ Assert.AreEqual(expected, actual,
+ "Frame-deferred instantiation produced a different scene than uninterrupted instantiation.");
+ }
+ finally
+ {
+ uninterruptedGltf.Dispose();
+ deferredGltf.Dispose();
+ UnityEngine.Object.Destroy(uninterrupted);
+ UnityEngine.Object.Destroy(deferred);
+ }
+ }
+
+ static async Task<(GltfImport gltf, GameObject root)> InstantiateAsync(string json, IDeferAgent deferAgent)
+ {
+ var gltf = new GltfImport(deferAgent: deferAgent);
+ var success = await gltf.LoadGltfJson(json);
+ Assert.IsTrue(success, "Loading the multi-layout test asset failed.");
+ var root = new GameObject("InstantiationResult");
+ success = await gltf.InstantiateMainSceneAsync(root.transform);
+ Assert.IsTrue(success, "Instantiation failed.");
+ return (gltf, root);
+ }
+
+ ///
+ /// Stable, order-independent description of an instantiated hierarchy: one line per
+ /// transform (path relative to the root) plus its renderer's mesh shape when present.
+ /// Parent paths double as the parent-before-child check — an orphaned or re-parented
+ /// node changes its path.
+ ///
+ static string DescribeHierarchy(GameObject root)
+ {
+ var lines = new List();
+ foreach (var transform in root.GetComponentsInChildren(true))
+ {
+ if (transform == root.transform)
+ continue;
+ var line = GetPath(transform, root.transform);
+ var renderer = transform.GetComponent();
+ var filter = transform.GetComponent();
+ if (renderer != null && filter != null && filter.sharedMesh != null)
+ {
+ var mesh = filter.sharedMesh;
+ line += $" [verts={mesh.vertexCount} submeshes={mesh.subMeshCount} materials={renderer.sharedMaterials.Length}]";
+ }
+ lines.Add(line);
+ }
+ lines.Sort(StringComparer.Ordinal);
+ return string.Join("\n", lines);
+ }
+
+ static string GetPath(Transform transform, Transform root)
+ {
+ var parts = new List();
+ for (var t = transform; t != null && t != root; t = t.parent)
+ parts.Add(t.name);
+ parts.Reverse();
+ return string.Join("/", parts);
+ }
+
+ ///
+ /// Self-contained glTF (data-URI buffer): one mesh with four single-triangle primitives
+ /// in four distinct vertex buffer layouts (so it yields four mesh assignments), assigned
+ /// to two nodes of a three-node hierarchy (root → child → grandchild).
+ ///
+ static string CreateMultiLayoutMeshJson()
+ {
+ // Buffer layout: positions (36 B) | normals (36 B) | uvs (24 B) | colors (48 B) | indices (6 B)
+ var buffer = new List();
+
+ void AddFloats(params float[] values)
+ {
+ foreach (var value in values)
+ buffer.AddRange(BitConverter.GetBytes(value));
+ }
+
+ AddFloats(0, 0, 0, 1, 0, 0, 0, 1, 0); // POSITION (VEC3)
+ AddFloats(0, 0, 1, 0, 0, 1, 0, 0, 1); // NORMAL (VEC3)
+ AddFloats(0, 0, 1, 0, 0, 1); // TEXCOORD (VEC2)
+ AddFloats(1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1); // COLOR_0 (VEC4)
+ foreach (var index in new ushort[] { 0, 1, 2 }) // indices (SCALAR ushort)
+ buffer.AddRange(BitConverter.GetBytes(index));
+
+ var uri = $"data:application/octet-stream;base64,{Convert.ToBase64String(buffer.ToArray())}";
+
+ return @"{
+ ""asset"": { ""version"": ""2.0"" },
+ ""scene"": 0,
+ ""scenes"": [ { ""nodes"": [0] } ],
+ ""nodes"": [
+ { ""name"": ""Root"", ""mesh"": 0, ""children"": [1] },
+ { ""name"": ""Child"", ""children"": [2] },
+ { ""name"": ""Grandchild"", ""mesh"": 0 }
+ ],
+ ""meshes"": [ {
+ ""name"": ""MultiLayout"",
+ ""primitives"": [
+ { ""attributes"": { ""POSITION"": 0, ""NORMAL"": 1 }, ""indices"": 4, ""material"": 0 },
+ { ""attributes"": { ""POSITION"": 0, ""NORMAL"": 1, ""TEXCOORD_0"": 2 }, ""indices"": 4, ""material"": 1 },
+ { ""attributes"": { ""POSITION"": 0, ""NORMAL"": 1, ""COLOR_0"": 3 }, ""indices"": 4, ""material"": 2 },
+ { ""attributes"": { ""POSITION"": 0, ""NORMAL"": 1, ""TEXCOORD_0"": 2, ""TEXCOORD_1"": 2 }, ""indices"": 4, ""material"": 3 }
+ ]
+ } ],
+ ""materials"": [
+ { ""pbrMetallicRoughness"": { ""baseColorFactor"": [1,0,0,1] } },
+ { ""pbrMetallicRoughness"": { ""baseColorFactor"": [0,1,0,1] } },
+ { ""pbrMetallicRoughness"": { ""baseColorFactor"": [0,0,1,1] } },
+ { ""pbrMetallicRoughness"": { ""baseColorFactor"": [1,1,0,1] } }
+ ],
+ ""accessors"": [
+ { ""bufferView"": 0, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC3"", ""min"": [0,0,0], ""max"": [1,1,0] },
+ { ""bufferView"": 1, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC3"" },
+ { ""bufferView"": 2, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC2"" },
+ { ""bufferView"": 3, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC4"" },
+ { ""bufferView"": 4, ""componentType"": 5123, ""count"": 3, ""type"": ""SCALAR"" }
+ ],
+ ""bufferViews"": [
+ { ""buffer"": 0, ""byteOffset"": 0, ""byteLength"": 36 },
+ { ""buffer"": 0, ""byteOffset"": 36, ""byteLength"": 36 },
+ { ""buffer"": 0, ""byteOffset"": 72, ""byteLength"": 24 },
+ { ""buffer"": 0, ""byteOffset"": 96, ""byteLength"": 48 },
+ { ""buffer"": 0, ""byteOffset"": 144, ""byteLength"": 6 }
+ ],
+ ""buffers"": [ { ""uri"": """ + uri + @""", ""byteLength"": 150 } ]
+}";
+ }
+ }
+}
diff --git a/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/InstantiationDeferTests.cs.meta b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/InstantiationDeferTests.cs.meta
new file mode 100644
index 00000000..447179bc
--- /dev/null
+++ b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/InstantiationDeferTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b27f98fb16d984c138ee1de54128f057
\ No newline at end of file
diff --git a/Packages/com.unity.cloud.gltfast/CHANGELOG.md b/Packages/com.unity.cloud.gltfast/CHANGELOG.md
index 6752c2a6..82d64d2d 100644
--- a/Packages/com.unity.cloud.gltfast/CHANGELOG.md
+++ b/Packages/com.unity.cloud.gltfast/CHANGELOG.md
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- Frame budget overruns during scene instantiation of nodes with many mesh assignments: the hierarchy population now yields to the defer agent per mesh assignment, not only per node.
+
+## [6.19.0] - 2026-05-19
+
### Added
- (Add-Ons) Import glTF animations to custom animation systems.
- [IAnimationProcessor](xref:GLTFast.Animations.IAnimationProcessor) — animation clips conversion
diff --git a/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs b/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs
index b11dfd0b..1930f135 100755
--- a/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs
+++ b/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs
@@ -3003,10 +3003,10 @@ async Task InstantiateSceneInternal(IInstantiator instantiator, int sceneId, Can
}
}
- async Task IterateNodes(uint nodeIndex, uint? parentIndex, Action callback)
+ async Task IterateNodes(uint nodeIndex, uint? parentIndex, Func callback)
{
var node = this.Root.Nodes[(int)nodeIndex];
- callback(nodeIndex, parentIndex);
+ await callback(nodeIndex, parentIndex);
await DeferAgent.BreakPoint();
if (node.children != null)
{
@@ -3045,10 +3045,8 @@ void CreateHierarchy(uint nodeIndex, uint? parentIndex)
Profiler.EndSample();
}
- void PopulateHierarchy(uint nodeIndex, uint? parentIndex)
+ async Task PopulateHierarchy(uint nodeIndex, uint? parentIndex)
{
-
- Profiler.BeginSample("PopulateHierarchy");
var node = this.Root.Nodes[(int)nodeIndex];
if (node.mesh >= 0)
@@ -3057,6 +3055,11 @@ void PopulateHierarchy(uint nodeIndex, uint? parentIndex)
foreach (var meshAssignment in m_MeshAssignments.Values(node.mesh))
{
cancellationToken.ThrowIfCancellationRequestedWithTracking();
+ // A node can carry many mesh assignments (one per primitive cluster of a
+ // multi-material mesh). Populating all of them in one un-yielding callback
+ // can far exceed the defer agent's frame budget
+ // => yield per assignment, like the node iteration above does per node.
+ Profiler.BeginSample("PopulateHierarchy");
var mesh = meshAssignment.mesh;
var meshName = string.IsNullOrEmpty(mesh.name) ? null : mesh.name;
@@ -3151,9 +3154,12 @@ void PopulateHierarchy(uint nodeIndex, uint? parentIndex)
}
meshNumeration++;
+ Profiler.EndSample();
+ await DeferAgent.BreakPoint();
}
}
+ Profiler.BeginSample("PopulateHierarchy");
if (node.camera >= 0
&& Root.Cameras != null
&& node.camera < Root.Cameras.Count
@@ -3185,7 +3191,11 @@ void PopulateHierarchy(uint nodeIndex, uint? parentIndex)
foreach (var nodeId in scene.nodes)
{
cancellationToken.ThrowIfCancellationRequestedWithTracking();
- await IterateNodes(nodeId, null, CreateHierarchy);
+ await IterateNodes(nodeId, null, (index, parent) =>
+ {
+ CreateHierarchy(index, parent);
+ return Task.CompletedTask;
+ });
}
foreach (var nodeId in scene.nodes)
{