From faed95dd18e4b22bd53ee13f93267f9b6f647f7a Mon Sep 17 00:00:00 2001 From: nickdnk Date: Wed, 15 Jul 2026 18:47:51 +0200 Subject: [PATCH 1/2] Decode large glTF-binary JSON chunks off the main thread ReadToEndAsync completes synchronously on a memory stream, stalling the main thread ~100 ms on a 12.6 MB JSON chunk. Gate the decode through the defer agent like the parse already is. --- Packages/com.unity.cloud.gltfast/CHANGELOG.md | 5 +++ .../Runtime/Scripts/GltfImport.cs | 33 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Packages/com.unity.cloud.gltfast/CHANGELOG.md b/Packages/com.unity.cloud.gltfast/CHANGELOG.md index 6752c2a6..1c5c5dcb 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 +- Main thread stall when loading glTF-binary files with large JSON chunks: the JSON string decode now runs on a background thread (gated by the defer agent, like the JSON parse), instead of always executing synchronously on the calling thread. + +## [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..8102b383 100755 --- a/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs +++ b/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs @@ -2075,11 +2075,38 @@ CancellationToken cancellationToken { Assert.IsNull(Root); - Profiler.BeginSample("GetJSON"); var bytesStream = bytes.ToUnmanagedMemoryStream((uint)index, chLength); var reader = new StreamReader(bytesStream); - var json = await reader.ReadToEndAsync(); - Profiler.EndSample(); + string json = null; + var predictedTime = chLength / (float)k_JsonParseSpeed; +#if GLTFAST_THREADS && !MEASURE_TIMINGS + if (DeferAgent.ShouldDefer(predictedTime)) + { + // ReadToEndAsync completes synchronously on a memory stream + // => decode in a thread. + try + { + json = await Task.Run(() => + { + Profiler.BeginSample("GetJSON"); + var result = reader.ReadToEnd(); + Profiler.EndSample(); + return result; + }, cancellationToken); + } + catch (OperationCanceledException) + { + cancellationToken.ThrowIfCancellationRequestedWithTracking(); + } + } + else +#endif + { + // Chunk is small enough to decode within the frame budget. + Profiler.BeginSample("GetJSON"); + json = await reader.ReadToEndAsync(); + Profiler.EndSample(); + } var success = await ParseJsonAndLoadBuffers(json, cancellationToken); From 6c955f01088651f4c13f0a73faf49a227674849e Mon Sep 17 00:00:00 2001 From: nickdnk Date: Fri, 17 Jul 2026 01:09:54 +0200 Subject: [PATCH 2/2] Add binary JSON chunk decode tests --- .../Scripts/Import/BinaryJsonDecodeTests.cs | 184 ++++++++++++++++++ .../Import/BinaryJsonDecodeTests.cs.meta | 2 + 2 files changed, 186 insertions(+) create mode 100644 Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs create mode 100644 Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta diff --git a/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs new file mode 100644 index 00000000..9c8933f6 --- /dev/null +++ b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs @@ -0,0 +1,184 @@ +// 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.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine; + +namespace GLTFast.Tests.Import +{ + /// + /// Tests for glTF-binary JSON chunk decoding. A large JSON chunk is decoded off the main + /// thread when the defer agent signals the predicted decode time exceeds the frame budget; + /// small chunks keep decoding in-frame. Either path must produce identical results — + /// including correct UTF-8 handling across the thread boundary — and honor cancellation. + /// + [Category("Import")] + class BinaryJsonDecodeTests + { + /// Unicode node names crossing the threaded decode must survive intact. + static readonly string[] k_NodeNames = { "Café", "node/中文", "emoji❤♻" }; + + /// + /// A defer agent that always defers, routing a binary JSON chunk of any size through + /// the threaded decode path. + /// + class AlwaysDeferAgent : IDeferAgent + { + public bool ShouldDefer() => true; + public bool ShouldDefer(float duration) => true; + public async Task BreakPoint() => await Task.Yield(); + public async Task BreakPoint(float duration) => await Task.Yield(); + } + + [UnityEngine.TestTools.UnityTest] + public IEnumerator ThreadedJsonDecodeMatchesInFrameDecode() + { + yield return AsyncWrapper.WaitForTask(ThreadedJsonDecodeMatchesInFrameDecodeInternal()); + } + + static async Task ThreadedJsonDecodeMatchesInFrameDecodeInternal() + { + var glb = CreateLargeBinaryGltf(); + + // UninterruptedDeferAgent never defers => in-frame decode; AlwaysDeferAgent defers + // any predicted duration => threaded decode. Results must be identical. + var inFrame = await LoadAndDescribeAsync(glb, new UninterruptedDeferAgent()); + var threaded = await LoadAndDescribeAsync(glb, new AlwaysDeferAgent()); + + if (inFrame != threaded) + Debug.Log($"IN-FRAME:\n{inFrame}\n\nTHREADED:\n{threaded}"); + Assert.AreEqual(inFrame, threaded, + "Threaded binary JSON decode produced a different scene than in-frame decode."); + + // Unicode must survive the thread boundary byte-for-byte. + foreach (var name in k_NodeNames) + StringAssert.Contains(name, threaded, + $"Node name '{name}' was corrupted by the threaded JSON decode."); + } + + [UnityEngine.TestTools.UnityTest] + public IEnumerator ThreadedJsonDecodeHonorsCancellation() + { + yield return AsyncWrapper.WaitForTask(ThreadedJsonDecodeHonorsCancellationInternal()); + } + + static async Task ThreadedJsonDecodeHonorsCancellationInternal() + { + var glb = CreateLargeBinaryGltf(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + using var gltf = new GltfImport(deferAgent: new AlwaysDeferAgent()); + try + { + await gltf.LoadGltfBinary(glb, cancellationToken: cts.Token); + Assert.Fail("Loading with a cancelled token did not raise OperationCanceledException."); + } + catch (OperationCanceledException) + { + // expected — cancellation before/at the threaded decode must surface cleanly. + } + } + + static async Task LoadAndDescribeAsync(byte[] glb, IDeferAgent deferAgent) + { + // The import stays alive until after the description — disposing destroys its meshes. + using var gltf = new GltfImport(deferAgent: deferAgent); + var success = await gltf.LoadGltfBinary(glb); + Assert.IsTrue(success, "Loading the binary test asset failed."); + var root = new GameObject("BinaryJsonDecodeResult"); + try + { + success = await gltf.InstantiateMainSceneAsync(root.transform); + Assert.IsTrue(success, "Instantiation failed."); + return DescribeHierarchy(root); + } + finally + { + UnityEngine.Object.Destroy(root); + } + } + + /// Stable, order-independent hierarchy description: path per transform plus its + /// renderer's mesh shape when present. + static string DescribeHierarchy(GameObject root) + { + var lines = new List(); + foreach (var transform in root.GetComponentsInChildren(true)) + { + if (transform == root.transform) + continue; + var parts = new List(); + for (var t = transform; t != null && t != root.transform; t = t.parent) + parts.Add(t.name); + parts.Reverse(); + var line = string.Join("/", parts); + var filter = transform.GetComponent(); + if (filter != null && filter.sharedMesh != null) + line += $" [verts={filter.sharedMesh.vertexCount} submeshes={filter.sharedMesh.subMeshCount}]"; + lines.Add(line); + } + lines.Sort(StringComparer.Ordinal); + return string.Join("\n", lines); + } + + /// + /// Self-contained glTF-binary whose JSON chunk is ~1 MB (padded via root "extras"), so + /// its predicted decode time is meaningful, carrying a single-triangle mesh (data-URI + /// buffer, no BIN chunk) and unicode node names. + /// + static byte[] CreateLargeBinaryGltf() + { + var buffer = new List(); + foreach (var value in new float[] { 0, 0, 0, 1, 0, 0, 0, 1, 0 }) + buffer.AddRange(BitConverter.GetBytes(value)); + foreach (var index in new ushort[] { 0, 1, 2 }) + buffer.AddRange(BitConverter.GetBytes(index)); + var bufferUri = $"data:application/octet-stream;base64,{Convert.ToBase64String(buffer.ToArray())}"; + + var padding = new string('x', 1024 * 1024); + + var json = @"{ + ""asset"": { ""version"": ""2.0"" }, + ""extras"": { ""padding"": """ + padding + @""" }, + ""scene"": 0, + ""scenes"": [ { ""nodes"": [0] } ], + ""nodes"": [ + { ""name"": """ + k_NodeNames[0] + @""", ""children"": [1] }, + { ""name"": """ + k_NodeNames[1].Replace("/", "\\/") + @""", ""children"": [2] }, + { ""name"": """ + k_NodeNames[2] + @""", ""mesh"": 0 } + ], + ""meshes"": [ { ""primitives"": [ { ""attributes"": { ""POSITION"": 0 }, ""indices"": 1 } ] } ], + ""accessors"": [ + { ""bufferView"": 0, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC3"", ""min"": [0,0,0], ""max"": [1,1,0] }, + { ""bufferView"": 1, ""componentType"": 5123, ""count"": 3, ""type"": ""SCALAR"" } + ], + ""bufferViews"": [ + { ""buffer"": 0, ""byteOffset"": 0, ""byteLength"": 36 }, + { ""buffer"": 0, ""byteOffset"": 36, ""byteLength"": 6 } + ], + ""buffers"": [ { ""uri"": """ + bufferUri + @""", ""byteLength"": 42 } ] +}"; + + var jsonBytes = Encoding.UTF8.GetBytes(json); + var paddedLength = (jsonBytes.Length + 3) & ~3; + + var glb = new List(); + glb.AddRange(BitConverter.GetBytes(0x46546C67u)); // 'glTF' + glb.AddRange(BitConverter.GetBytes(2u)); // version + glb.AddRange(BitConverter.GetBytes((uint)(12 + 8 + paddedLength))); // total length + glb.AddRange(BitConverter.GetBytes((uint)paddedLength)); // chunk length + glb.AddRange(BitConverter.GetBytes(0x4E4F534Au)); // 'JSON' + glb.AddRange(jsonBytes); + for (var i = jsonBytes.Length; i < paddedLength; i++) + glb.Add(0x20); // pad with spaces + return glb.ToArray(); + } + } +} diff --git a/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta new file mode 100644 index 00000000..d1032727 --- /dev/null +++ b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8855ebf0e74cc4895be043ab4d0f9f49 \ No newline at end of file