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) {