Skip to content

Commit 6c955f0

Browse files
committed
Add binary JSON chunk decode tests
1 parent faed95d commit 6c955f0

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// SPDX-FileCopyrightText: 2026 Unity Technologies and the glTFast authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System;
5+
using System.Collections;
6+
using System.Collections.Generic;
7+
using System.Text;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using NUnit.Framework;
11+
using UnityEngine;
12+
13+
namespace GLTFast.Tests.Import
14+
{
15+
/// <summary>
16+
/// Tests for glTF-binary JSON chunk decoding. A large JSON chunk is decoded off the main
17+
/// thread when the defer agent signals the predicted decode time exceeds the frame budget;
18+
/// small chunks keep decoding in-frame. Either path must produce identical results —
19+
/// including correct UTF-8 handling across the thread boundary — and honor cancellation.
20+
/// </summary>
21+
[Category("Import")]
22+
class BinaryJsonDecodeTests
23+
{
24+
/// <summary>Unicode node names crossing the threaded decode must survive intact.</summary>
25+
static readonly string[] k_NodeNames = { "Café", "node/中文", "emoji❤♻" };
26+
27+
/// <summary>
28+
/// A defer agent that always defers, routing a binary JSON chunk of any size through
29+
/// the threaded decode path.
30+
/// </summary>
31+
class AlwaysDeferAgent : IDeferAgent
32+
{
33+
public bool ShouldDefer() => true;
34+
public bool ShouldDefer(float duration) => true;
35+
public async Task BreakPoint() => await Task.Yield();
36+
public async Task BreakPoint(float duration) => await Task.Yield();
37+
}
38+
39+
[UnityEngine.TestTools.UnityTest]
40+
public IEnumerator ThreadedJsonDecodeMatchesInFrameDecode()
41+
{
42+
yield return AsyncWrapper.WaitForTask(ThreadedJsonDecodeMatchesInFrameDecodeInternal());
43+
}
44+
45+
static async Task ThreadedJsonDecodeMatchesInFrameDecodeInternal()
46+
{
47+
var glb = CreateLargeBinaryGltf();
48+
49+
// UninterruptedDeferAgent never defers => in-frame decode; AlwaysDeferAgent defers
50+
// any predicted duration => threaded decode. Results must be identical.
51+
var inFrame = await LoadAndDescribeAsync(glb, new UninterruptedDeferAgent());
52+
var threaded = await LoadAndDescribeAsync(glb, new AlwaysDeferAgent());
53+
54+
if (inFrame != threaded)
55+
Debug.Log($"IN-FRAME:\n{inFrame}\n\nTHREADED:\n{threaded}");
56+
Assert.AreEqual(inFrame, threaded,
57+
"Threaded binary JSON decode produced a different scene than in-frame decode.");
58+
59+
// Unicode must survive the thread boundary byte-for-byte.
60+
foreach (var name in k_NodeNames)
61+
StringAssert.Contains(name, threaded,
62+
$"Node name '{name}' was corrupted by the threaded JSON decode.");
63+
}
64+
65+
[UnityEngine.TestTools.UnityTest]
66+
public IEnumerator ThreadedJsonDecodeHonorsCancellation()
67+
{
68+
yield return AsyncWrapper.WaitForTask(ThreadedJsonDecodeHonorsCancellationInternal());
69+
}
70+
71+
static async Task ThreadedJsonDecodeHonorsCancellationInternal()
72+
{
73+
var glb = CreateLargeBinaryGltf();
74+
using var cts = new CancellationTokenSource();
75+
cts.Cancel();
76+
77+
using var gltf = new GltfImport(deferAgent: new AlwaysDeferAgent());
78+
try
79+
{
80+
await gltf.LoadGltfBinary(glb, cancellationToken: cts.Token);
81+
Assert.Fail("Loading with a cancelled token did not raise OperationCanceledException.");
82+
}
83+
catch (OperationCanceledException)
84+
{
85+
// expected — cancellation before/at the threaded decode must surface cleanly.
86+
}
87+
}
88+
89+
static async Task<string> LoadAndDescribeAsync(byte[] glb, IDeferAgent deferAgent)
90+
{
91+
// The import stays alive until after the description — disposing destroys its meshes.
92+
using var gltf = new GltfImport(deferAgent: deferAgent);
93+
var success = await gltf.LoadGltfBinary(glb);
94+
Assert.IsTrue(success, "Loading the binary test asset failed.");
95+
var root = new GameObject("BinaryJsonDecodeResult");
96+
try
97+
{
98+
success = await gltf.InstantiateMainSceneAsync(root.transform);
99+
Assert.IsTrue(success, "Instantiation failed.");
100+
return DescribeHierarchy(root);
101+
}
102+
finally
103+
{
104+
UnityEngine.Object.Destroy(root);
105+
}
106+
}
107+
108+
/// <summary>Stable, order-independent hierarchy description: path per transform plus its
109+
/// renderer's mesh shape when present.</summary>
110+
static string DescribeHierarchy(GameObject root)
111+
{
112+
var lines = new List<string>();
113+
foreach (var transform in root.GetComponentsInChildren<Transform>(true))
114+
{
115+
if (transform == root.transform)
116+
continue;
117+
var parts = new List<string>();
118+
for (var t = transform; t != null && t != root.transform; t = t.parent)
119+
parts.Add(t.name);
120+
parts.Reverse();
121+
var line = string.Join("/", parts);
122+
var filter = transform.GetComponent<MeshFilter>();
123+
if (filter != null && filter.sharedMesh != null)
124+
line += $" [verts={filter.sharedMesh.vertexCount} submeshes={filter.sharedMesh.subMeshCount}]";
125+
lines.Add(line);
126+
}
127+
lines.Sort(StringComparer.Ordinal);
128+
return string.Join("\n", lines);
129+
}
130+
131+
/// <summary>
132+
/// Self-contained glTF-binary whose JSON chunk is ~1 MB (padded via root "extras"), so
133+
/// its predicted decode time is meaningful, carrying a single-triangle mesh (data-URI
134+
/// buffer, no BIN chunk) and unicode node names.
135+
/// </summary>
136+
static byte[] CreateLargeBinaryGltf()
137+
{
138+
var buffer = new List<byte>();
139+
foreach (var value in new float[] { 0, 0, 0, 1, 0, 0, 0, 1, 0 })
140+
buffer.AddRange(BitConverter.GetBytes(value));
141+
foreach (var index in new ushort[] { 0, 1, 2 })
142+
buffer.AddRange(BitConverter.GetBytes(index));
143+
var bufferUri = $"data:application/octet-stream;base64,{Convert.ToBase64String(buffer.ToArray())}";
144+
145+
var padding = new string('x', 1024 * 1024);
146+
147+
var json = @"{
148+
""asset"": { ""version"": ""2.0"" },
149+
""extras"": { ""padding"": """ + padding + @""" },
150+
""scene"": 0,
151+
""scenes"": [ { ""nodes"": [0] } ],
152+
""nodes"": [
153+
{ ""name"": """ + k_NodeNames[0] + @""", ""children"": [1] },
154+
{ ""name"": """ + k_NodeNames[1].Replace("/", "\\/") + @""", ""children"": [2] },
155+
{ ""name"": """ + k_NodeNames[2] + @""", ""mesh"": 0 }
156+
],
157+
""meshes"": [ { ""primitives"": [ { ""attributes"": { ""POSITION"": 0 }, ""indices"": 1 } ] } ],
158+
""accessors"": [
159+
{ ""bufferView"": 0, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC3"", ""min"": [0,0,0], ""max"": [1,1,0] },
160+
{ ""bufferView"": 1, ""componentType"": 5123, ""count"": 3, ""type"": ""SCALAR"" }
161+
],
162+
""bufferViews"": [
163+
{ ""buffer"": 0, ""byteOffset"": 0, ""byteLength"": 36 },
164+
{ ""buffer"": 0, ""byteOffset"": 36, ""byteLength"": 6 }
165+
],
166+
""buffers"": [ { ""uri"": """ + bufferUri + @""", ""byteLength"": 42 } ]
167+
}";
168+
169+
var jsonBytes = Encoding.UTF8.GetBytes(json);
170+
var paddedLength = (jsonBytes.Length + 3) & ~3;
171+
172+
var glb = new List<byte>();
173+
glb.AddRange(BitConverter.GetBytes(0x46546C67u)); // 'glTF'
174+
glb.AddRange(BitConverter.GetBytes(2u)); // version
175+
glb.AddRange(BitConverter.GetBytes((uint)(12 + 8 + paddedLength))); // total length
176+
glb.AddRange(BitConverter.GetBytes((uint)paddedLength)); // chunk length
177+
glb.AddRange(BitConverter.GetBytes(0x4E4F534Au)); // 'JSON'
178+
glb.AddRange(jsonBytes);
179+
for (var i = jsonBytes.Length; i < paddedLength; i++)
180+
glb.Add(0x20); // pad with spaces
181+
return glb.ToArray();
182+
}
183+
}
184+
}

Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)