Add sub-asset import and mesh asset support

- Implement sub-asset import for mesh/model assets with manifest generation and deterministic GUIDs
- Extend AssetCatalog for sub-asset tracking and management
- Update AssetRegistry and ImportCoordinator for sub-asset workflows
- Add mesh asset parsing, GPU upload, and resource management
- Update mesh data structures for meshlet groups/hierarchy and LODs
- Improve tests for sub-asset import and mesh handling
- Enhance mocks for mesh asset testing and resource mapping
- Fix path handling and native DLL loading issues
- Miscellaneous bug fixes and refactoring
This commit is contained in:
2026-05-04 21:25:03 +09:00
parent bffe05f0ef
commit 8d3e1c91d7
30 changed files with 1604 additions and 85 deletions

View File

@@ -1,4 +1,4 @@
using Ghost.MicroTest;
using Ghost.Test.Core;
TestRunner.Run<NvttBindingTest>();
TestRunner.Run<UfbxBindingTest>();

View File

@@ -1,3 +1,4 @@
using Ghost.Editor.Core;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Services;
@@ -7,16 +8,21 @@ namespace Ghost.UnitTest.AssetSystem;
[TestClass]
public class AssertRegistryTest
{
private string _assetsRoot = null!;
private IAssetRegistry _registry = null!;
public TestContext TestContext
{
get; set;
}
[TestInitialize]
public void Setup()
{
var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(testDir);
_assetsRoot = Path.Combine(testDir, "Assets");
EditorApplication.Initialize(null!, testDir, "Test");
_registry = new AssetRegistry();
}
@@ -29,16 +35,20 @@ public class AssertRegistryTest
[TestMethod]
public async Task TestAssetRegistry_AutoImport()
{
var sourcePath = "test.text";
var fullSourcePath = Path.Combine(_assetsRoot, sourcePath);
await File.WriteAllBytesAsync(fullSourcePath, [1, 2, 3]);
await Task.Delay(1000); // Wait for FSW to trigger
var sourcePath = "Assets/test.text";
await File.WriteAllBytesAsync(sourcePath, [1, 2, 3], TestContext.CancellationToken);
var metaPath = AssetMetaIO.GetMetaPath(fullSourcePath);
var metaPath = AssetMetaIO.GetMetaPath(sourcePath);
using var cts = new CancellationTokenSource(5000);
while (!File.Exists(metaPath) && !cts.IsCancellationRequested)
{
await Task.Delay(50, cts.Token);
}
Assert.IsTrue(File.Exists(metaPath));
var meta = await AssetMetaIO.ReadAsync(metaPath);
var meta = await AssetMetaIO.ReadAsync(metaPath, TestContext.CancellationToken);
Assert.IsNotNull(meta);
var guid = _registry.GetAssetGuid(sourcePath);

View File

@@ -49,7 +49,7 @@ public class AssetCatalogTests
catalog.Upsert(meta, path);
Assert.AreEqual(guid, catalog.GetGuid(path));
Assert.AreEqual(path, catalog.GetSourcePath(guid));
Assert.AreEqual(Path.GetFullPath(path).Replace('\\', '/'), catalog.GetSourcePath(guid));
}
[TestMethod]
@@ -68,4 +68,35 @@ public class AssetCatalogTests
Assert.AreEqual(1, referencers.Count);
Assert.AreEqual(asset1, referencers[0]);
}
[TestMethod]
public void TestAssetCatalog_VirtualSubAssets()
{
using var catalog = new AssetCatalog(_dbPath);
var parent = Guid.NewGuid();
var subMesh = Guid.NewGuid();
var handlerTypeId = Guid.NewGuid();
catalog.Upsert(new AssetMeta { Guid = parent, HandlerTypeId = handlerTypeId, HandlerVersion = 1 }, "Props/kit.fbx");
catalog.UpsertSubAsset(parent,
new AssetMeta { Guid = subMesh, HandlerTypeId = handlerTypeId, HandlerVersion = 1 },
"Props/kit.fbx#Mesh/Root/Crate",
"Mesh",
"Crate",
"Root/Crate");
catalog.SetDependencies(parent, stackalloc[] { subMesh });
Assert.AreEqual(subMesh, catalog.GetGuid("Props/kit.fbx#Mesh/Root/Crate"));
var subAssets = catalog.GetSubAssets(parent);
Assert.AreEqual(1, subAssets.Count);
Assert.AreEqual(subMesh, subAssets[0].Guid);
Assert.AreEqual(parent, subAssets[0].ParentGuid);
Assert.AreEqual("Mesh", subAssets[0].Kind);
Assert.AreEqual("Crate", subAssets[0].DisplayName);
Assert.AreEqual("Root/Crate", subAssets[0].StablePath);
var dependencies = catalog.GetDependencies(parent);
Assert.AreEqual(1, dependencies.Count);
Assert.AreEqual(subMesh, dependencies[0]);
}
}

View File

@@ -50,7 +50,7 @@ public class AssetManagerTest
};
_jobScheduler = new JobScheduler(in schedulerDesc);
_assetManager = new AssetManager(_graphicsEngine.ResourceDatabase, _provider, _processor, _jobScheduler);
_assetManager = new AssetManager(_graphicsEngine.ResourceDatabase, _resourceManager, _provider, _processor, _jobScheduler);
}
[TestCleanup]
@@ -109,4 +109,89 @@ public class AssetManagerTest
Assert.AreEqual(BarrierLayout.ShaderResource, data.layout);
Assert.AreEqual(BarrierSync.AllShading, data.sync);
}
[TestMethod]
public async Task AssetManager_ResolveMeshThenBackgroundUpload()
{
var assetID = Guid.NewGuid();
_provider.AddMockMesh(assetID, readDelayMs: Random.Shared.Next(10, 50));
var handle = _assetManager.ResolveMesh(assetID);
Assert.IsTrue(handle.IsValid);
Assert.IsTrue(_assetManager.TryGetEntry(assetID, out var entry));
Assert.IsGreaterThanOrEqualTo((int)AssetState.Scheduled, entry.StateValue);
await Task.Delay(1000, TestContext.CancellationToken);
Assert.IsGreaterThanOrEqualTo((int)AssetState.Loaded, entry.StateValue);
var ctx = new ResourceStreamingContext
{
ResourceManager = _resourceManager,
ResourceDatabase = _graphicsEngine.ResourceDatabase,
ResourceAllocator = _graphicsEngine.ResourceAllocator,
CopyPipeline = _copyPipeline,
GraphicsCommandBuffer = _commandBuffer,
};
_processor.ProcessPendingUploads(ctx);
await Task.Delay(1000, TestContext.CancellationToken);
Assert.IsGreaterThanOrEqualTo((int)AssetState.Uploading, entry.StateValue);
_processor.ProcessPendingUploads(ctx);
Assert.IsGreaterThanOrEqualTo((int)AssetState.Ready, entry.StateValue);
Assert.IsTrue(_resourceManager.HasMesh(handle));
ref readonly var mesh = ref _resourceManager.GetMeshReference(handle).GetValueOrThrow();
var (vertexBarrier, vertexError) = _graphicsEngine.ResourceDatabase.GetResourceBarrierData(mesh.VertexBuffer.AsResource());
var (indexBarrier, indexError) = _graphicsEngine.ResourceDatabase.GetResourceBarrierData(mesh.IndexBuffer.AsResource());
var (meshDataBarrier, meshDataError) = _graphicsEngine.ResourceDatabase.GetResourceBarrierData(mesh.MeshDataBuffer.AsResource());
Assert.AreEqual(Error.None, vertexError);
Assert.AreEqual(Error.None, indexError);
Assert.AreEqual(Error.None, meshDataError);
Assert.IsTrue(vertexBarrier.access.HasFlag(BarrierAccess.VertexBuffer));
Assert.IsTrue(indexBarrier.access.HasFlag(BarrierAccess.IndexBuffer));
Assert.AreEqual(BarrierAccess.ShaderResource, meshDataBarrier.access);
}
[TestMethod]
public async Task AssetManager_ReimportMeshKeepsStableHandle()
{
var assetID = Guid.NewGuid();
_provider.AddMockMesh(assetID);
var handle = _assetManager.ResolveMesh(assetID);
await Task.Delay(1000, TestContext.CancellationToken);
var ctx = new ResourceStreamingContext
{
ResourceManager = _resourceManager,
ResourceDatabase = _graphicsEngine.ResourceDatabase,
ResourceAllocator = _graphicsEngine.ResourceAllocator,
CopyPipeline = _copyPipeline,
GraphicsCommandBuffer = _commandBuffer,
};
_processor.ProcessPendingUploads(ctx);
_processor.ProcessPendingUploads(ctx);
Assert.IsTrue(_assetManager.TryGetEntry(assetID, out var entry));
Assert.AreEqual(AssetState.Ready, entry.State);
_provider.AddMockMesh(assetID);
_assetManager.ReimportAsset(assetID);
await Task.Delay(1000, TestContext.CancellationToken);
_processor.ProcessPendingUploads(ctx);
_processor.ProcessPendingUploads(ctx);
var reimportedHandle = _assetManager.ResolveMesh(assetID);
Assert.AreEqual(handle, reimportedHandle);
Assert.AreEqual(AssetState.Ready, entry.State);
}
}

View File

@@ -0,0 +1,145 @@
using Ghost.Core;
using Ghost.Editor.Core;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Services;
using Ghost.Engine;
using Misaki.HighPerformance.LowLevel.Buffer;
using System.Runtime.InteropServices;
using System.Text;
namespace Ghost.UnitTest.AssetSystem;
[TestClass]
public class MeshAssetHandlerTests
{
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType)
{
return null;
}
}
private string _projectRoot = null!;
private string _previousCurrentDirectory = null!;
[TestInitialize]
public void Setup()
{
AllocationManager.Initialize(AllocationManagerDesc.Default);
_previousCurrentDirectory = Environment.CurrentDirectory;
_projectRoot = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_projectRoot);
EditorApplication.Initialize(new EmptyServiceProvider(), _projectRoot, "MeshImportTest");
}
[TestCleanup]
public void Cleanup()
{
AllocationManager.Dispose();
Environment.CurrentDirectory = _previousCurrentDirectory;
if (Directory.Exists(_projectRoot))
{
try
{
Directory.Delete(_projectRoot, true);
}
catch (IOException)
{
Thread.Sleep(100);
if (Directory.Exists(_projectRoot))
{
Directory.Delete(_projectRoot, true);
}
}
}
}
[TestMethod]
public async Task FBXAssetHandler_ImportsObjAsManifestAndMeshSubAssets()
{
var sourcePath = Path.Combine(EditorApplication.AssetsFolderPath, "kit.obj");
await File.WriteAllTextAsync(sourcePath, CreateTwoObjectObj());
var parentGuid = Guid.NewGuid();
var targetPath = ImportCoordinator.GetImportedAssetPath(parentGuid);
var handler = new FBXAssetHandler();
var result = await handler.ImportWithSubAssetsAsync(sourcePath, targetPath, parentGuid, new ObjAssetSettings(), TestContext.CancellationToken);
if (result.IsFailure && result.Message?.Contains("Unable to load DLL", StringComparison.OrdinalIgnoreCase) == true)
{
Assert.Inconclusive(result.Message);
}
Assert.IsTrue(result.IsSuccess, result.Message);
Assert.IsTrue(File.Exists(targetPath));
Assert.IsGreaterThanOrEqualTo(result.Value.Length, 2);
foreach (var subAsset in result.Value)
{
Assert.AreEqual("Mesh", subAsset.Kind);
Assert.IsTrue(subAsset.VirtualSourcePath.Contains("#Mesh/", StringComparison.Ordinal));
var meshPath = ImportCoordinator.GetImportedAssetPath(subAsset.Guid);
Assert.IsTrue(File.Exists(meshPath));
await using var stream = new FileStream(meshPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var headerBytes = new byte[Marshal.SizeOf<MeshContentHeader>()];
await stream.ReadExactlyAsync(headerBytes, TestContext.CancellationToken);
var header = MemoryMarshal.Read<MeshContentHeader>(headerBytes);
Assert.AreEqual(MeshContentHeader.MAGIC, header.magic);
Assert.AreEqual(MeshContentHeader.VERSION, header.version);
Assert.IsGreaterThan(0u, header.vertexCount);
Assert.IsGreaterThan(0u, header.indexCount);
Assert.IsGreaterThan(0u, header.meshletCount);
Assert.IsGreaterThan(0u, header.meshletGroupCount);
Assert.IsGreaterThan(0u, header.meshletHierarchyNodeCount);
}
}
public TestContext TestContext
{
get; set;
} = null!;
private static string CreateTwoObjectObj()
{
var sb = new StringBuilder();
AppendGrid(sb, "PropA", 0, 0);
AppendGrid(sb, "PropB", 49, 1);
return sb.ToString();
}
private static void AppendGrid(StringBuilder sb, string name, int vertexOffset, int z)
{
const int size = 6;
sb.AppendLine($"o {name}");
for (var y = 0; y <= size; y++)
{
for (var x = 0; x <= size; x++)
{
sb.AppendLine($"v {x} {y} {z}");
sb.AppendLine("vn 0 0 1");
sb.AppendLine($"vt {x / (float)size} {y / (float)size}");
}
}
for (var y = 0; y < size; y++)
{
for (var x = 0; x < size; x++)
{
var i0 = vertexOffset + y * (size + 1) + x + 1;
var i1 = i0 + 1;
var i2 = i0 + size + 1;
var i3 = i2 + 1;
sb.AppendLine($"f {i0}/{i0}/{i0} {i1}/{i1}/{i1} {i2}/{i2}/{i2}");
sb.AppendLine($"f {i1}/{i1}/{i1} {i3}/{i3}/{i3} {i2}/{i2}/{i2}");
}
}
}
}

View File

@@ -1,6 +1,11 @@
using Ghost.Core;
using Ghost.Engine;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.Mathematics;
using Misaki.HighPerformance.Mathematics.Geometry;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace Ghost.UnitTest.MockingEnvironment;
@@ -59,6 +64,118 @@ internal class MockingContentProvider : IContentProvider
});
}
public void AddMockMesh(Guid guid, int readDelayMs = 0)
{
var vertices = new[]
{
new Vertex { position = new float3(0, 0, 0), normal = new float3(0, 1, 0), tangent = new float4(1, 0, 0, 1), uv = new float2(0, 0), color = new Color128(1, 1, 1, 1) },
new Vertex { position = new float3(1, 0, 0), normal = new float3(0, 1, 0), tangent = new float4(1, 0, 0, 1), uv = new float2(1, 0), color = new Color128(1, 1, 1, 1) },
new Vertex { position = new float3(0, 1, 0), normal = new float3(0, 1, 0), tangent = new float4(1, 0, 0, 1), uv = new float2(0, 1), color = new Color128(1, 1, 1, 1) },
};
var indices = new uint[] { 0, 1, 2 };
var materialParts = new[]
{
new MeshContentMaterialPart { materialIndex = 0, indexStart = 0, indexCount = 3, vertexStart = 0, vertexCount = 3 }
};
var meshlets = new[]
{
new Meshlet
{
boundingSphere = new SphereBounds(new float3(0.5f, 0.5f, 0), 1.0f),
parentBoundingSphere = new SphereBounds(new float3(0.5f, 0.5f, 0), 1.0f),
boundingBox = new AABB(new float3(0, 0, 0), new float3(1, 1, 0)),
vertexOffset = 0,
triangleOffset = 0,
groupIndex = 0,
clusterError = 0,
parentError = 0,
vertexCount = 3,
triangleCount = 1,
localMaterialIndex = 0,
lodLevel = 0,
}
};
var groups = new[]
{
new MeshletGroup
{
boundingSphere = new SphereBounds(new float3(0.5f, 0.5f, 0), 1.0f),
boundingBox = new AABB(new float3(0, 0, 0), new float3(1, 1, 0)),
parentError = 0,
meshletStartIndex = 0,
meshletCount = 1,
lodLevel = 0,
}
};
var hierarchy = new[]
{
new MeshletHierarchyNode
{
minX = new float4(0, float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity),
minY = new float4(0, float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity),
minZ = new float4(0, float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity),
maxX = new float4(1, float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity),
maxY = new float4(1, float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity),
maxZ = new float4(0, float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity),
maxParentError = new float4(0),
nodeData = new uint4(0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF),
}
};
var meshletVertices = new uint[] { 0, 1, 2 };
var meshletTriangles = new uint[] { 0 | (1u << 8) | (2u << 16) };
using var stream = new MemoryStream();
var header = new MeshContentHeader
{
magic = MeshContentHeader.MAGIC,
version = MeshContentHeader.VERSION,
vertexCount = (uint)vertices.Length,
indexCount = (uint)indices.Length,
materialPartCount = (uint)materialParts.Length,
meshletCount = (uint)meshlets.Length,
meshletGroupCount = (uint)groups.Length,
meshletHierarchyNodeCount = (uint)hierarchy.Length,
meshletVertexCount = (uint)meshletVertices.Length,
meshletTriangleCount = (uint)meshletTriangles.Length,
materialSlotCount = 1,
lodLevelCount = 1,
boundsMin = new float3(0, 0, 0),
boundsMax = new float3(1, 1, 0),
};
WriteStruct(stream, in header);
header.vertexOffset = (ulong)stream.Position; WriteSpan(stream, vertices);
header.indexOffset = (ulong)stream.Position; WriteSpan(stream, indices);
header.materialPartOffset = (ulong)stream.Position; WriteSpan(stream, materialParts);
header.meshletOffset = (ulong)stream.Position; WriteSpan(stream, meshlets);
header.meshletGroupOffset = (ulong)stream.Position; WriteSpan(stream, groups);
header.meshletHierarchyNodeOffset = (ulong)stream.Position; WriteSpan(stream, hierarchy);
header.meshletVertexOffset = (ulong)stream.Position; WriteSpan(stream, meshletVertices);
header.meshletTriangleOffset = (ulong)stream.Position; WriteSpan(stream, meshletTriangles);
stream.Position = 0;
WriteStruct(stream, in header);
AddMockAsset(guid, new MockAssetData
{
type = AssetType.Mesh,
data = stream.ToArray(),
readDelayMs = readDelayMs
});
}
private static void WriteStruct<T>(Stream stream, ref readonly T value)
where T : unmanaged
{
stream.Write(MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in value, 1)));
}
private static void WriteSpan<T>(Stream stream, ReadOnlySpan<T> value)
where T : unmanaged
{
stream.Write(MemoryMarshal.AsBytes(value));
}
public AssetType GetAssetType(Guid guid)
{
return _assets.TryGetValue(guid, out var asset) ? asset.type : AssetType.Unknown;

View File

@@ -1,6 +1,7 @@
using Ghost.Core;
using Ghost.Graphics.RHI;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace Ghost.UnitTest.MockingEnvironment;
@@ -13,6 +14,7 @@ internal unsafe class MockingResourceDatabase : IResourceDatabase
public string? name;
public int refCount = 1;
public bool isShared;
public unsafe void* mappedData;
}
private readonly ConcurrentDictionary<ulong, MockResourceRecord> _resources = new();
@@ -118,9 +120,21 @@ internal unsafe class MockingResourceDatabase : IResourceDatabase
public void* MapResource(Handle<GPUResource> handle, uint subResource, ResourceRange? readRange)
{
// Real pointers are tricky in mocks unless native mem is allocated.
// Usually unit tests don't do CPU readbacks directly on the raw pointer unless necessary.
throw new NotSupportedException("MapResource is not supported in MockingResourceDatabase. Use a custom mechanism for tests.");
if (!_resources.TryGetValue(GetKey(handle), out var record))
{
return null;
}
lock (record)
{
if (record.mappedData == null)
{
var size = record.desc.Type == ResourceType.Buffer ? Math.Max(1UL, record.desc.BufferDescriptor.Size) : 1UL;
record.mappedData = NativeMemory.Alloc((nuint)size);
}
return record.mappedData;
}
}
public void ReleaseResource(Handle<GPUResource> handle)
@@ -137,6 +151,12 @@ internal unsafe class MockingResourceDatabase : IResourceDatabase
record.refCount--;
if (record.refCount <= 0)
{
if (record.mappedData != null)
{
NativeMemory.Free(record.mappedData);
record.mappedData = null;
}
_resources.TryRemove(GetKey(handle), out _);
}
}
@@ -207,6 +227,15 @@ internal unsafe class MockingResourceDatabase : IResourceDatabase
public void Dispose()
{
foreach (var record in _resources.Values)
{
if (record.mappedData != null)
{
NativeMemory.Free(record.mappedData);
record.mappedData = null;
}
}
_resources.Clear();
_samplers.Clear();
}