diff --git a/src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs b/src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs index 954780c..8ba385c 100644 --- a/src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs +++ b/src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs @@ -49,7 +49,14 @@ public interface IImportableAssetHandler : IAssetHandler ValueTask ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default); } +public readonly record struct ImportedSubAsset(Guid Guid, string Kind, string DisplayName, string StablePath, string VirtualSourcePath, Guid HandlerTypeId); + +public interface ISubAssetImportableAssetHandler : IImportableAssetHandler +{ + ValueTask> ImportWithSubAssetsAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default); +} + public interface IPackableAssetHandler : IAssetHandler { ValueTask PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default); -} \ No newline at end of file +} diff --git a/src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs b/src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs index e05a0a2..7e324ae 100644 --- a/src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs +++ b/src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs @@ -69,7 +69,7 @@ internal static class AssetMetaIO public const string META_EXTENSION_NAME = "gmeta"; public const string META_EXTENSION = ".gmeta"; - private static readonly JsonSerializerOptions s_options = new() + internal static readonly JsonSerializerOptions s_options = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -136,6 +136,7 @@ internal static class AssetMetaIO } File.Move(tempPath, metaPath); + } public static string GetMetaPath(string sourceFilePath) diff --git a/src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs b/src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs index 46ab991..0327d56 100644 --- a/src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs +++ b/src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs @@ -1,9 +1,17 @@ using Ghost.Core; using Ghost.Engine; +using Ghost.Editor.Core.Services; +using Ghost.Graphics.Core; using Ghost.Graphics.RHI; +using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Collections; using Misaki.HighPerformance.Mathematics; +using Misaki.HighPerformance.Mathematics.Geometry; +using System.IO.Hashing; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; namespace Ghost.Editor.Core.Assets; @@ -132,6 +140,139 @@ public class LightMeshNode : MeshNode } } +public sealed class ModelManifest +{ + public Guid AssetId + { + get; set; + } + + public ModelManifestNode Root + { + get; set; + } = new ModelManifestNode(); + + public List Meshes + { + get; set; + } = new List(); + + public List Metadata + { + get; set; + } = new List(); +} + +public sealed class ModelManifestNode +{ + public string Name + { + get; set; + } = string.Empty; + + public string StablePath + { + get; set; + } = string.Empty; + + public float4x4 LocalTransform + { + get; set; + } + + public Guid MeshGuid + { + get; set; + } + + public List Children + { + get; set; + } = new List(); +} + +public sealed class ModelManifestSubAsset +{ + public Guid Guid + { + get; set; + } + + public string Name + { + get; set; + } = string.Empty; + + public string StablePath + { + get; set; + } = string.Empty; + + public int MaterialSlotCount + { + get; set; + } + + public int VertexCount + { + get; set; + } + + public int IndexCount + { + get; set; + } +} + +public sealed class ModelManifestMetadata +{ + public string Kind + { + get; set; + } = string.Empty; + + public string Name + { + get; set; + } = string.Empty; + + public string StablePath + { + get; set; + } = string.Empty; +} + +internal sealed class ImportedModelAsset : IAsset +{ + public Guid ID + { + get; + } + + public Guid TypeID => typeof(FBXAsset).GUID; + + public IAssetSettings? Settings + { + get; + } + + public ModelManifest Manifest + { + get; + } + + public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest) + { + ID = id; + Settings = settings; + Manifest = manifest; + } + + public void Dispose() + { + } +} + public abstract class MeshAsset : IAsset { private MeshNode _root; @@ -240,7 +381,8 @@ internal class FbxAssetSettings : MeshAssetSettings { } -internal class FBXAssetHandler : IImportableAssetHandler, IPackableAssetHandler +[CustomAssetHandler(FBXAsset.GUID, [".fbx", ".obj"], 1)] +internal class FBXAssetHandler : ISubAssetImportableAssetHandler, IPackableAssetHandler { public AssetType RuntimeAssetType => AssetType.Mesh; @@ -250,31 +392,326 @@ internal class FBXAssetHandler : IImportableAssetHandler, IPackableAssetHandler public IAssetSettings? CreateDefaultSettings() { - throw new NotImplementedException(); + return new FbxAssetSettings(); } - public ValueTask> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default) + public async ValueTask> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default) { - throw new NotImplementedException(); + var importedPath = ImportCoordinator.GetImportedAssetPath(id); + if (!File.Exists(importedPath)) + { + return Result.Failure("Imported model manifest does not exist."); + } + + try + { + await using var stream = new FileStream(importedPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var manifest = await JsonSerializer.DeserializeAsync(stream, cancellationToken: token).ConfigureAwait(false); + return manifest != null + ? Result.Success(new ImportedModelAsset(id, settings, manifest)) + : Result.Failure("Failed to deserialize model manifest."); + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } } public ValueTask SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default) { - throw new NotImplementedException(); + return ValueTask.FromResult(Result.Failure("Saving model assets is not supported yet.")); } - public ValueTask ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default) + public async ValueTask ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default) { - throw new NotImplementedException(); + return await ImportWithSubAssetsAsync(sourcePath, targetPath, id, settings, token).ConfigureAwait(false); + } + + public async ValueTask> ImportWithSubAssetsAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default) + { + if (!File.Exists(sourcePath)) + { + return Result.Failure("Source file does not exist."); + } + + var meshSettings = ResolveSettings(sourcePath, settings); + var root = new MeshNode(); + try + { + var parseJob = new MeshParsingJob(root, sourcePath, AllocationHandle.Persistent, meshSettings); + var context = default(Misaki.HighPerformance.Jobs.JobExecutionContext); + parseJob.Execute(in context); + + var manifest = new ModelManifest + { + AssetId = id, + }; + + var importedSubAssets = new List(); + manifest.Root = await WriteNodeAsync(id, sourcePath, root, string.Empty, manifest, importedSubAssets, token).ConfigureAwait(false); + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + await using (var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await JsonSerializer.SerializeAsync(stream, manifest, cancellationToken: token).ConfigureAwait(false); + } + + return importedSubAssets.ToArray(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to import mesh asset: {ex.Message}"); + } + finally + { + root.Dispose(); + } } public ValueTask ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default) { - throw new NotImplementedException(); + return ValueTask.FromResult(Result.Failure("Exporting model assets is not supported yet.")); } public ValueTask PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default) { - throw new NotImplementedException(); + return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet.")); } -} \ No newline at end of file + + private static MeshAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings) + { + if (settings is MeshAssetSettings meshSettings) + { + return meshSettings; + } + + return Path.GetExtension(sourcePath).Equals(".obj", StringComparison.OrdinalIgnoreCase) + ? new ObjAssetSettings() + : new FbxAssetSettings(); + } + + private static async ValueTask WriteNodeAsync( + Guid parentGuid, + string sourcePath, + MeshNode node, + string parentPath, + ModelManifest manifest, + List importedSubAssets, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + var stablePath = string.IsNullOrEmpty(parentPath) + ? SanitizePathSegment(node.Name) + : $"{parentPath}/{SanitizePathSegment(node.Name)}"; + + var manifestNode = new ModelManifestNode + { + Name = node.Name, + StablePath = stablePath, + LocalTransform = node.LocalTransform, + }; + + if (node is GeometryMeshNode geometry) + { + var meshGuid = CreateDeterministicSubAssetGuid(parentGuid, "Mesh", stablePath); + var meshPath = ImportCoordinator.GetImportedAssetPath(meshGuid); + Directory.CreateDirectory(Path.GetDirectoryName(meshPath)!); + + var meshInfo = await WriteMeshContentAsync(meshPath, geometry, token).ConfigureAwait(false); + manifestNode.MeshGuid = meshGuid; + + manifest.Meshes.Add(new ModelManifestSubAsset + { + Guid = meshGuid, + Name = node.Name, + StablePath = stablePath, + MaterialSlotCount = meshInfo.materialSlotCount, + VertexCount = geometry.Vertices.Count, + IndexCount = geometry.Indices.Count, + }); + + importedSubAssets.Add(new ImportedSubAsset( + meshGuid, + "Mesh", + node.Name, + stablePath, + $"{sourcePath}#Mesh/{stablePath}", + typeof(FBXAsset).GUID)); + } + else if (node is LightMeshNode) + { + manifest.Metadata.Add(new ModelManifestMetadata + { + Kind = "Light", + Name = node.Name, + StablePath = stablePath, + }); + } + + foreach (var child in node.Children) + { + manifestNode.Children.Add(await WriteNodeAsync(parentGuid, sourcePath, child, stablePath, manifest, importedSubAssets, token).ConfigureAwait(false)); + } + + return manifestNode; + } + + private static ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token) + { + unsafe + { + var meshletData = new MeshletMeshData(); + try + { + MeshProcessor.BuildMeshlets(&meshletData, geometry.Vertices.AsReadOnly(), geometry.Indices.AsReadOnly(), geometry.MaterialParts.AsSpan()); + MeshProcessor.BuildClusterLodHierarchy(&meshletData); + + var bounds = ComputeBounds(geometry.Vertices); + var header = new MeshContentHeader + { + magic = MeshContentHeader.MAGIC, + version = MeshContentHeader.VERSION, + vertexCount = (uint)geometry.Vertices.Count, + indexCount = (uint)geometry.Indices.Count, + materialPartCount = (uint)geometry.MaterialParts.Length, + meshletCount = (uint)meshletData.meshlets.Count, + meshletGroupCount = (uint)meshletData.groups.Count, + meshletHierarchyNodeCount = (uint)meshletData.hierarchyNodes.Count, + meshletVertexCount = (uint)meshletData.meshletVertices.Count, + meshletTriangleCount = (uint)meshletData.meshletTriangles.Count, + materialSlotCount = (uint)meshletData.materialSlotCount, + lodLevelCount = (uint)meshletData.lodLevelCount, + boundsMin = bounds.Min, + boundsMax = bounds.Max, + }; + + using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None); + WriteStruct(stream, in header); + + header.vertexOffset = (ulong)stream.Position; + WriteSpan(stream, geometry.Vertices.AsSpan()); + + header.indexOffset = (ulong)stream.Position; + WriteSpan(stream, geometry.Indices.AsSpan()); + + header.materialPartOffset = (ulong)stream.Position; + WriteMaterialParts(stream, geometry.MaterialParts.AsSpan()); + + header.meshletOffset = (ulong)stream.Position; + WriteSpan(stream, meshletData.meshlets.AsSpan()); + + header.meshletGroupOffset = (ulong)stream.Position; + WriteSpan(stream, meshletData.groups.AsSpan()); + + header.meshletHierarchyNodeOffset = (ulong)stream.Position; + WriteSpan(stream, meshletData.hierarchyNodes.AsSpan()); + + header.meshletVertexOffset = (ulong)stream.Position; + WriteSpan(stream, meshletData.meshletVertices.AsSpan()); + + header.meshletTriangleOffset = (ulong)stream.Position; + WriteSpan(stream, meshletData.meshletTriangles.AsSpan()); + + stream.Position = 0; + WriteStruct(stream, in header); + stream.Flush(); + + return ValueTask.FromResult((meshletData.materialSlotCount, meshletData.lodLevelCount)); + } + finally + { + meshletData.Dispose(); + } + } + } + + private static AABB ComputeBounds(UnsafeList vertices) + { + var min = new float3(float.MaxValue); + var max = new float3(float.MinValue); + for (var i = 0; i < vertices.Count; i++) + { + var p = vertices[i].position; + min = math.min(min, p); + max = math.max(max, p); + } + + return new AABB(min, max); + } + + private static Guid CreateDeterministicSubAssetGuid(Guid parentGuid, string kind, string stablePath) + { + var bytes = Encoding.UTF8.GetBytes($"{parentGuid:N}:{kind}:{stablePath}"); + Span hash = stackalloc byte[16]; + var hashValue = XxHash128.HashToUInt128(bytes); + Unsafe.WriteUnaligned(ref hash[0], hashValue); + + hash[6] = (byte)((hash[6] & 0x0F) | 0x50); + hash[8] = (byte)((hash[8] & 0x3F) | 0x80); + return new Guid(hash); + } + + private static string SanitizePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Node"; + } + + var chars = value.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (chars[i] == '/' || chars[i] == '\\' || chars[i] == '#') + { + chars[i] = '_'; + } + } + + return new string(chars); + } + + private static void WriteMaterialParts(Stream stream, ReadOnlySpan parts) + { + if (parts.IsEmpty) + { + return; + } + + Span buffer = parts.Length <= 64 + ? stackalloc MeshContentMaterialPart[parts.Length] + : new MeshContentMaterialPart[parts.Length]; + + for (var i = 0; i < parts.Length; i++) + { + buffer[i] = new MeshContentMaterialPart + { + materialIndex = parts[i].materialIndex, + indexStart = parts[i].indexStart, + indexCount = parts[i].indexCount, + vertexStart = parts[i].vertexStart, + vertexCount = parts[i].vertexCount, + }; + } + + WriteSpan(stream, buffer); + } + + private static void WriteStruct(Stream stream, ref readonly T value) + where T : unmanaged + { + var span = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in value, 1)); + stream.Write(span); + } + + private static void WriteSpan(Stream stream, ReadOnlySpan value) + where T : unmanaged + { + if (value.IsEmpty) + { + return; + } + + stream.Write(MemoryMarshal.AsBytes(value)); + } +} diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs b/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs index 252429e..e9ecdbd 100644 --- a/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs +++ b/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs @@ -9,6 +9,8 @@ namespace Ghost.Editor.Core.Services; /// public sealed partial class AssetCatalog : IDisposable { + public readonly record struct SubAssetInfo(Guid Guid, Guid ParentGuid, string Kind, string DisplayName, string StablePath, string SourcePath, Guid HandlerTypeId); + private readonly SqliteConnection _connection; private readonly Lock _writeLock = new(); @@ -26,6 +28,8 @@ public sealed partial class AssetCatalog : IDisposable private readonly SqliteCommand _cmdInsertDep; private readonly SqliteCommand _cmdClearDeps; private readonly SqliteCommand _cmdEnumerate; + private readonly SqliteCommand _cmdEnumerateSubAssets; + private readonly SqliteCommand _cmdDeleteSubAssetsForParent; public AssetCatalog(string dbPath) { @@ -54,15 +58,19 @@ public sealed partial class AssetCatalog : IDisposable _cmdGetImportedAt = CreateCommand("SELECT imported_at_ms FROM assets WHERE guid = @guid"); _cmdUpsert = CreateCommand(@" - INSERT INTO assets (guid, source_path, handler_type_id, handler_version, content_hash, settings_hash, imported_at_ms) - VALUES (@guid, @path, @handler_id, @version, @content_hash, @settings_hash, @imported_at_ms) + INSERT INTO assets (guid, source_path, handler_type_id, handler_version, content_hash, settings_hash, imported_at_ms, parent_guid, subasset_kind, display_name, stable_path) + VALUES (@guid, @path, @handler_id, @version, @content_hash, @settings_hash, @imported_at_ms, @parent_guid, @subasset_kind, @display_name, @stable_path) ON CONFLICT(guid) DO UPDATE SET source_path = excluded.source_path, handler_type_id = excluded.handler_type_id, handler_version = excluded.handler_version, content_hash = excluded.content_hash, settings_hash = excluded.settings_hash, - imported_at_ms = excluded.imported_at_ms"); + imported_at_ms = excluded.imported_at_ms, + parent_guid = excluded.parent_guid, + subasset_kind = excluded.subasset_kind, + display_name = excluded.display_name, + stable_path = excluded.stable_path"); _cmdDelete = CreateCommand("DELETE FROM assets WHERE guid = @guid"); _cmdGetReferencers = CreateCommand("SELECT from_guid FROM dependencies WHERE to_guid = @guid"); _cmdGetDependencies = CreateCommand("SELECT to_guid FROM dependencies WHERE from_guid = @guid"); @@ -70,6 +78,8 @@ public sealed partial class AssetCatalog : IDisposable _cmdInsertDep = CreateCommand("INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)"); _cmdClearDeps = CreateCommand("DELETE FROM dependencies WHERE from_guid = @guid"); _cmdEnumerate = CreateCommand("SELECT guid, source_path FROM assets"); + _cmdEnumerateSubAssets = CreateCommand("SELECT guid, parent_guid, subasset_kind, display_name, stable_path, source_path, handler_type_id FROM assets WHERE parent_guid = @parent_guid ORDER BY stable_path"); + _cmdDeleteSubAssetsForParent = CreateCommand("DELETE FROM assets WHERE parent_guid = @parent_guid"); } private SqliteCommand CreateCommand(string sql) @@ -90,9 +100,14 @@ public sealed partial class AssetCatalog : IDisposable handler_version INTEGER NOT NULL DEFAULT 0, content_hash TEXT, settings_hash TEXT, - imported_at_ms INTEGER + imported_at_ms INTEGER, + parent_guid BLOB(16), + subasset_kind TEXT, + display_name TEXT, + stable_path TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path); + CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid); CREATE TABLE IF NOT EXISTS dependencies ( from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, @@ -108,6 +123,28 @@ public sealed partial class AssetCatalog : IDisposable ); CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);"; cmd.ExecuteNonQuery(); + + TryAddColumn("assets", "parent_guid", "BLOB(16)"); + TryAddColumn("assets", "subasset_kind", "TEXT"); + TryAddColumn("assets", "display_name", "TEXT"); + TryAddColumn("assets", "stable_path", "TEXT"); + + using var indexCmd = _connection.CreateCommand(); + indexCmd.CommandText = "CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid);"; + indexCmd.ExecuteNonQuery(); + } + + private void TryAddColumn(string tableName, string columnName, string columnType) + { + try + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnType};"; + cmd.ExecuteNonQuery(); + } + catch (SqliteException) + { + } } private static string ToUniversalPath(string path) @@ -147,6 +184,30 @@ public sealed partial class AssetCatalog : IDisposable _cmdUpsert.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value); _cmdUpsert.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value); _cmdUpsert.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@parent_guid", DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@subasset_kind", DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@display_name", DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@stable_path", DBNull.Value); + _cmdUpsert.ExecuteNonQuery(); + } + } + + public void UpsertSubAsset(Guid parentGuid, AssetMeta meta, string sourcePath, string kind, string displayName, string stablePath) + { + lock (_writeLock) + { + _cmdUpsert.Parameters.Clear(); + _cmdUpsert.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray()); + _cmdUpsert.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath)); + _cmdUpsert.Parameters.AddWithValue("@handler_id", meta.HandlerTypeId?.ToByteArray() ?? (object)DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@version", meta.HandlerVersion); + _cmdUpsert.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray()); + _cmdUpsert.Parameters.AddWithValue("@subasset_kind", kind); + _cmdUpsert.Parameters.AddWithValue("@display_name", displayName); + _cmdUpsert.Parameters.AddWithValue("@stable_path", stablePath); _cmdUpsert.ExecuteNonQuery(); } } @@ -245,6 +306,58 @@ public sealed partial class AssetCatalog : IDisposable } } + public List GetSubAssets(Guid parentGuid) + { + _cmdEnumerateSubAssets.Parameters.Clear(); + _cmdEnumerateSubAssets.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray()); + + using var reader = _cmdEnumerateSubAssets.ExecuteReader(); + var list = new List(); + while (reader.Read()) + { + list.Add(new SubAssetInfo( + new Guid((byte[])reader[0]), + new Guid((byte[])reader[1]), + reader.GetString(2), + reader.GetString(3), + reader.GetString(4), + reader.GetString(5), + new Guid((byte[])reader[6]))); + } + + return list; + } + + public void RemoveSubAssetsExcept(Guid parentGuid, ReadOnlySpan keepGuids) + { + lock (_writeLock) + { + if (keepGuids.Length == 0) + { + _cmdDeleteSubAssetsForParent.Parameters.Clear(); + _cmdDeleteSubAssetsForParent.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray()); + _cmdDeleteSubAssetsForParent.ExecuteNonQuery(); + return; + } + + var keep = new HashSet(); + for (var i = 0; i < keepGuids.Length; i++) + { + keep.Add(keepGuids[i]); + } + + foreach (var subAsset in GetSubAssets(parentGuid)) + { + if (!keep.Contains(subAsset.Guid)) + { + _cmdDelete.Parameters.Clear(); + _cmdDelete.Parameters.AddWithValue("@guid", subAsset.Guid.ToByteArray()); + _cmdDelete.ExecuteNonQuery(); + } + } + } + } + public void Dispose() { _cmdGetGuid.Dispose(); @@ -257,6 +370,8 @@ public sealed partial class AssetCatalog : IDisposable _cmdInsertDep.Dispose(); _cmdClearDeps.Dispose(); _cmdEnumerate.Dispose(); + _cmdEnumerateSubAssets.Dispose(); + _cmdDeleteSubAssetsForParent.Dispose(); _connection.Dispose(); } } diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs index c7002e5..d2d29b4 100644 --- a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs +++ b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs @@ -81,6 +81,11 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable foreach (var (guid, path) in _catalog.EnumerateAll()) { + if (path.Contains('#', StringComparison.Ordinal)) + { + continue; + } + if (!foundGuids.Contains(guid)) { _catalog.Remove(guid); @@ -130,51 +135,58 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable return; } - var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath); - var fileExists = File.Exists(e.FullPath); - - if (ext == AssetMetaIO.META_EXTENSION) + try { - if (fileExists) + var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath); + var fileExists = File.Exists(e.FullPath); + + if (ext == AssetMetaIO.META_EXTENSION) { - var meta = await AssetMetaIO.ReadAsync(e.FullPath); - if (meta != null) + if (fileExists) { - _catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath)); - await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), relativePath, ImportReason.SettingsChanged)); + var meta = await AssetMetaIO.ReadAsync(e.FullPath); + if (meta != null) + { + _catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath)); + await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), relativePath, ImportReason.SettingsChanged)); + } + } + return; + } + + var changeType = AssetChangeType.None; + var guid = _catalog.GetGuid(relativePath); + + if (!fileExists) + { + // The file is no longer on disk. Wait safely completed. + if (guid != Guid.Empty) + { + _catalog.Remove(guid); + changeType = AssetChangeType.Deleted; } } - return; - } - - var changeType = AssetChangeType.None; - var guid = _catalog.GetGuid(relativePath); - - if (!fileExists) - { - // The file is no longer on disk. Wait safely completed. - if (guid != Guid.Empty) + else if (guid == Guid.Empty) { - _catalog.Remove(guid); - changeType = AssetChangeType.Deleted; + // The file exists but isn't located inside our catalog yet -> Essentially a Creation + await HandleNewSourceFileAsync(relativePath); + changeType = AssetChangeType.Created; + } + else + { + // The file exists and is tracked in the catalog, but triggered an event -> Modification + await _importCoordinator.EnqueueAsync(new ImportJob(guid, relativePath, AssetMetaIO.GetMetaPath(relativePath), ImportReason.SourceChanged)); + changeType = AssetChangeType.Modified; + } + + if (changeType != AssetChangeType.None) + { + OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType)); } } - else if (guid == Guid.Empty) + catch (Exception ex) { - // The file exists but isn't located inside our catalog yet -> Essentially a Creation - await HandleNewSourceFileAsync(relativePath); - changeType = AssetChangeType.Created; - } - else - { - // The file exists and is tracked in the catalog, but triggered an event -> Modification - await _importCoordinator.EnqueueAsync(new ImportJob(guid, relativePath, AssetMetaIO.GetMetaPath(relativePath), ImportReason.SourceChanged)); - changeType = AssetChangeType.Modified; - } - - if (changeType != AssetChangeType.None) - { - OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType)); + Logger.Error(ex); } } diff --git a/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs b/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs index 4740753..18f42b3 100644 --- a/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs +++ b/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs @@ -112,10 +112,23 @@ internal sealed partial class ImportCoordinator : IDisposable } var importResult = Result.Success(); + ImportedSubAsset[] subAssets = Array.Empty(); if (handler is IImportableAssetHandler importable) { var targetPath = GetImportedAssetPath(job.AssetGuid); - importResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token); + if (importable is ISubAssetImportableAssetHandler subAssetImportable) + { + var subAssetResult = await subAssetImportable.ImportWithSubAssetsAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token); + importResult = subAssetResult; + if (subAssetResult.IsSuccess) + { + subAssets = subAssetResult.Value; + } + } + else + { + importResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token); + } } if (importResult.IsSuccess) @@ -127,6 +140,36 @@ internal sealed partial class ImportCoordinator : IDisposable await AssetMetaIO.WriteAsync(job.MetaPath, meta, token); + if (subAssets.Length > 0) + { + var dependencies = new Guid[subAssets.Length]; + for (var i = 0; i < subAssets.Length; i++) + { + var subAsset = subAssets[i]; + dependencies[i] = subAsset.Guid; + + var subMeta = new AssetMeta + { + Guid = subAsset.Guid, + HandlerTypeId = subAsset.HandlerTypeId, + HandlerVersion = meta.HandlerVersion, + ContentHash = contentHash, + SettingsHash = settingsHash, + LastImportedUtc = meta.LastImportedUtc, + }; + + _catalog.UpsertSubAsset(job.AssetGuid, subMeta, subAsset.VirtualSourcePath, subAsset.Kind, subAsset.DisplayName, subAsset.StablePath); + } + + _catalog.RemoveSubAssetsExcept(job.AssetGuid, dependencies); + _catalog.SetDependencies(job.AssetGuid, dependencies); + } + else if (handler is ISubAssetImportableAssetHandler) + { + _catalog.RemoveSubAssetsExcept(job.AssetGuid, ReadOnlySpan.Empty); + _catalog.SetDependencies(job.AssetGuid, ReadOnlySpan.Empty); + } + OnImportCompleted?.Invoke(null, job.AssetGuid); } else diff --git a/src/Runtime/Ghost.Core/Ghost.Core.csproj b/src/Runtime/Ghost.Core/Ghost.Core.csproj index 683dea4..ffa5dc5 100644 --- a/src/Runtime/Ghost.Core/Ghost.Core.csproj +++ b/src/Runtime/Ghost.Core/Ghost.Core.csproj @@ -8,7 +8,7 @@ - $(DefineConstants);MHP_ENABLE_SAFETY_CHECKS;MHP_ENABLE_MIMALLOC;MHP_FASTMATH + $(DefineConstants);MHP_ENABLE_SAFETY_CHECKS;MHP_ENABLE_MIMALLOC;MHP_FASTMATH;MHP_ENABLE_STACKTRACE True True diff --git a/src/Runtime/Ghost.Engine/AssetManager.Mesh.cs b/src/Runtime/Ghost.Engine/AssetManager.Mesh.cs new file mode 100644 index 0000000..9ec5e96 --- /dev/null +++ b/src/Runtime/Ghost.Engine/AssetManager.Mesh.cs @@ -0,0 +1,286 @@ +using Ghost.Core; +using Ghost.Graphics; +using Ghost.Graphics.Core; +using Ghost.Graphics.RHI; +using Ghost.Graphics.Utilities; +using Misaki.HighPerformance.Mathematics.Geometry; +using System.Runtime.CompilerServices; + +namespace Ghost.Engine; + +internal unsafe partial class AssetEntry +{ + private sealed unsafe class MeshParsedData + { + public MeshContentHeader header; + public byte* pVertices; + public byte* pIndices; + public byte* pMeshlets; + public byte* pMeshletGroups; + public byte* pMeshletHierarchyNodes; + public byte* pMeshletVertices; + public byte* pMeshletTriangles; + } + + private static void RegisterMeshCallback() + { + s_onCreation[(int)AssetType.Mesh] = static (e) => + { + var handle = e._resourceManager.CreateEmptyMesh(); + e.SetStorage(handle); + }; + + s_onParseRawData[(int)AssetType.Mesh] = static (e) => e.ParseMeshData(); + s_onRecordUpload[(int)AssetType.Mesh] = static (e, ctx) => e.RecordMeshUpload(ctx); + s_onUploadComplete[(int)AssetType.Mesh] = static (e, ctx) => e.OnMeshUploadComplete(ctx); + s_onReleaseResource[(int)AssetType.Mesh] = static (e) => + { + var handle = e.GetStorage>(); + if (handle.IsValid) + { + e._resourceManager.ReleaseMesh(handle); + } + }; + } + + private Result ParseMeshData() + { + var pData = (byte*)_rawData.GetUnsafePtr(); + Logger.DebugAssert(pData != null); + + if (_rawData.Size < (nuint)sizeof(MeshContentHeader)) + { + return Result.Failure("Mesh content is smaller than the header."); + } + + var header = Unsafe.ReadUnaligned(pData); + if (header.magic != MeshContentHeader.MAGIC || header.version != MeshContentHeader.VERSION) + { + return Result.Failure("Unsupported mesh content format."); + } + + if (header.vertexCount == 0 || header.indexCount == 0 || + header.meshletCount == 0 || header.meshletGroupCount == 0 || + header.meshletHierarchyNodeCount == 0 || header.meshletVertexCount == 0 || + header.meshletTriangleCount == 0) + { + return Result.Failure("Mesh content is missing required geometry or meshlet data."); + } + + if (!ValidateRange(header.vertexOffset, header.vertexCount, (uint)sizeof(Vertex)) || + !ValidateRange(header.indexOffset, header.indexCount, sizeof(uint)) || + !ValidateRange(header.meshletOffset, header.meshletCount, (uint)sizeof(Meshlet)) || + !ValidateRange(header.meshletGroupOffset, header.meshletGroupCount, (uint)sizeof(MeshletGroup)) || + !ValidateRange(header.meshletHierarchyNodeOffset, header.meshletHierarchyNodeCount, (uint)sizeof(MeshletHierarchyNode)) || + !ValidateRange(header.meshletVertexOffset, header.meshletVertexCount, sizeof(uint)) || + !ValidateRange(header.meshletTriangleOffset, header.meshletTriangleCount, sizeof(uint))) + { + return Result.Failure("Mesh content contains an invalid data range."); + } + + if (header.materialPartCount > 0 && !ValidateRange(header.materialPartOffset, header.materialPartCount, (uint)sizeof(MeshContentMaterialPart))) + { + return Result.Failure("Mesh content contains an invalid material part range."); + } + + _parsedObject = new MeshParsedData + { + header = header, + pVertices = pData + header.vertexOffset, + pIndices = pData + header.indexOffset, + pMeshlets = pData + header.meshletOffset, + pMeshletGroups = pData + header.meshletGroupOffset, + pMeshletHierarchyNodes = pData + header.meshletHierarchyNodeOffset, + pMeshletVertices = pData + header.meshletVertexOffset, + pMeshletTriangles = pData + header.meshletTriangleOffset, + }; + + return Result.Success(); + + bool ValidateRange(ulong offset, uint count, uint stride) + { + var size = (ulong)count * stride; + return offset <= _rawData.Size && size <= _rawData.Size - (nuint)offset; + } + } + + private Result RecordMeshUpload(ResourceStreamingContext context) + { + if (_parsedObject is not MeshParsedData data) + { + return Result.Failure("Mesh parse data is missing."); + } + + ref readonly var header = ref data.header; + + var vertexBuffer = CreateBuffer(context, data.pVertices, header.vertexCount, (uint)sizeof(Vertex), + BufferUsage.Vertex | BufferUsage.ShaderResource | BufferUsage.Raw, "Mesh_VertexBuffer"); + var indexBuffer = CreateBuffer(context, data.pIndices, header.indexCount, sizeof(uint), + BufferUsage.Index | BufferUsage.ShaderResource | BufferUsage.Raw, "Mesh_IndexBuffer"); + var meshletBuffer = CreateBuffer(context, data.pMeshlets, header.meshletCount, (uint)sizeof(Meshlet), + BufferUsage.Raw | BufferUsage.ShaderResource, "Mesh_Meshlets"); + var meshletVerticesBuffer = CreateBuffer(context, data.pMeshletVertices, header.meshletVertexCount, sizeof(uint), + BufferUsage.Raw | BufferUsage.ShaderResource, "Mesh_MeshletVertices"); + var meshletTrianglesBuffer = CreateBuffer(context, data.pMeshletTriangles, header.meshletTriangleCount, sizeof(uint), + BufferUsage.Raw | BufferUsage.ShaderResource, "Mesh_MeshletTriangles"); + var meshletGroupBuffer = CreateBuffer(context, data.pMeshletGroups, header.meshletGroupCount, (uint)sizeof(MeshletGroup), + BufferUsage.Raw | BufferUsage.ShaderResource, "Mesh_MeshletGroups"); + var meshletHierarchyBuffer = CreateBuffer(context, data.pMeshletHierarchyNodes, header.meshletHierarchyNodeCount, (uint)sizeof(MeshletHierarchyNode), + BufferUsage.Raw | BufferUsage.ShaderResource, "Mesh_MeshletHierarchy"); + + if (vertexBuffer.IsInvalid || indexBuffer.IsInvalid || meshletBuffer.IsInvalid || + meshletVerticesBuffer.IsInvalid || meshletTrianglesBuffer.IsInvalid || + meshletGroupBuffer.IsInvalid || meshletHierarchyBuffer.IsInvalid) + { + return Result.Failure("Failed to create one or more mesh GPU buffers."); + } + + var meshData = new MeshData + { + worldBoundsMin = header.boundsMin, + worldBoundsMax = header.boundsMax, + vertexBuffer = context.ResourceDatabase.GetBindlessIndex(vertexBuffer.AsResource()), + indexBuffer = context.ResourceDatabase.GetBindlessIndex(indexBuffer.AsResource()), + meshletBuffer = context.ResourceDatabase.GetBindlessIndex(meshletBuffer.AsResource()), + meshletVerticesBuffer = context.ResourceDatabase.GetBindlessIndex(meshletVerticesBuffer.AsResource()), + meshletTrianglesBuffer = context.ResourceDatabase.GetBindlessIndex(meshletTrianglesBuffer.AsResource()), + meshletGroupBuffer = context.ResourceDatabase.GetBindlessIndex(meshletGroupBuffer.AsResource()), + meshletHierarchyBuffer = context.ResourceDatabase.GetBindlessIndex(meshletHierarchyBuffer.AsResource()), + meshletCount = header.meshletCount, + lodLevelCount = header.lodLevelCount, + materialSlotCount = header.materialSlotCount, + }; + + var meshDataBufferDesc = new BufferDesc + { + Size = (ulong)sizeof(MeshData), + Stride = (uint)sizeof(MeshData), + Usage = BufferUsage.Raw | BufferUsage.ShaderResource, + HeapType = HeapType.Default, + }; + + var meshDataBuffer = RenderingUtility.CreateBuffer( + context.ResourceManager, + context.ResourceDatabase, + context.ResourceAllocator, + context.CopyPipeline.GetCommandBuffer(), + &meshData, + (nuint)sizeof(MeshData), + in meshDataBufferDesc, + "Mesh_MeshDataBuffer"); + + if (meshDataBuffer.IsInvalid) + { + return Result.Failure("Failed to create mesh data buffer."); + } + + var newHandle = context.ResourceManager.CreateUploadedMesh( + vertexBuffer, + indexBuffer, + meshletBuffer, + meshletVerticesBuffer, + meshletTrianglesBuffer, + meshletGroupBuffer, + meshletHierarchyBuffer, + meshDataBuffer, + (int)header.vertexCount, + (int)header.indexCount, + (int)header.meshletCount, + (int)header.lodLevelCount, + (int)header.materialSlotCount, + new AABB(header.boundsMin, header.boundsMax)); + + if (newHandle.IsInvalid) + { + return Result.Failure("Failed to register uploaded mesh."); + } + + var oldHandle = GetStorage>(); + SetStorage((oldHandle, newHandle)); + + return Result.Success(); + } + + private static Handle CreateBuffer(ResourceStreamingContext context, void* pData, uint count, uint stride, BufferUsage usage, string name) + { + var desc = new BufferDesc + { + Size = (ulong)count * stride, + Stride = stride, + Usage = usage, + HeapType = HeapType.Default, + }; + + return RenderingUtility.CreateBuffer( + context.ResourceManager, + context.ResourceDatabase, + context.ResourceAllocator, + context.CopyPipeline.GetCommandBuffer(), + pData, + (nuint)desc.Size, + in desc, + name); + } + + private void OnMeshUploadComplete(ResourceStreamingContext context) + { + var (oldHandle, newHandle) = GetStorage<(Handle, Handle)>(); + var actualHandle = context.ResourceManager.ReplaceMesh(oldHandle, newHandle); + if (actualHandle.IsInvalid) + { + SetStorage(oldHandle); + return; + } + + var result = context.ResourceManager.GetMeshReference(actualHandle); + if (result.IsSuccess) + { + ref readonly var mesh = ref result.Value; + context.GraphicsCommandBuffer.Barrier( + BarrierDesc.Buffer(mesh.VertexBuffer.AsResource(), BarrierSync.VertexShading, BarrierAccess.VertexBuffer | BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.IndexBuffer.AsResource(), BarrierSync.IndexInput, BarrierAccess.IndexBuffer | BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.MeshLetBuffer.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.MeshletVerticesBuffer.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.MeshletTrianglesBuffer.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.MeshletGroupBuffer.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.MeshletHierarchyBuffer.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource), + BarrierDesc.Buffer(mesh.MeshDataBuffer.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource)); + } + + SetStorage(actualHandle); + + _rawData.Dispose(); + _parsedObject = null; + } +} + +internal partial class AssetManager +{ + public Handle ResolveMesh(Guid assetID) + { + if (assetID == Guid.Empty) + { + return Handle.Invalid; + } + + var entry = GetOrCreateEntry(assetID); + Logger.DebugAssert(entry.AssetType == AssetType.Mesh); + + return entry.GetStorage>(); + } + + public int ReleaseMesh(Guid assetID) + { + if (assetID == Guid.Empty) + { + return 0; + } + + if (!_entries.TryGetValue(assetID, out var entry) || entry.AssetType != AssetType.Mesh) + { + return 0; + } + + return entry.Release(); + } +} diff --git a/src/Runtime/Ghost.Engine/AssetManager.cs b/src/Runtime/Ghost.Engine/AssetManager.cs index 229278c..ddd9e85 100644 --- a/src/Runtime/Ghost.Engine/AssetManager.cs +++ b/src/Runtime/Ghost.Engine/AssetManager.cs @@ -1,6 +1,7 @@ using Ghost.Core; using Ghost.Core.Utilities; using Ghost.Graphics; +using Ghost.Graphics.Services; using Ghost.Graphics.RHI; using Misaki.HighPerformance.Jobs; using Misaki.HighPerformance.LowLevel; @@ -60,6 +61,7 @@ internal partial class AssetEntry static AssetEntry() { RegisterTextureCallback(); + RegisterMeshCallback(); } } @@ -72,6 +74,7 @@ internal unsafe partial class AssetEntry private readonly AssetManager _assetManager; private readonly IResourceDatabase _resourceDatabase; + private readonly ResourceManager _resourceManager; private readonly Guid _assetId; private readonly AssetType _assetType; @@ -101,10 +104,11 @@ internal unsafe partial class AssetEntry set => Volatile.Write(ref _state, (int)value); } - public AssetEntry(AssetManager manager, IResourceDatabase resourceDatabase, Guid assetId, AssetType assetType, Guid[] dependencies) + public AssetEntry(AssetManager manager, IResourceDatabase resourceDatabase, ResourceManager resourceManager, Guid assetId, AssetType assetType, Guid[] dependencies) { _assetManager = manager; _resourceDatabase = resourceDatabase; + _resourceManager = resourceManager; _assetId = assetId; _assetType = assetType; @@ -288,6 +292,7 @@ internal struct LoadAssetJob : IJob internal partial class AssetManager : IDisposable { private readonly IResourceDatabase _resourceDatabase; + private readonly ResourceManager _resourceManager; private readonly IContentProvider _contentProvider; private readonly ResourceStreamingProcessor _streamingProcessor; private readonly JobScheduler _jobScheduler; @@ -297,9 +302,10 @@ internal partial class AssetManager : IDisposable public IContentProvider ContentProvider => _contentProvider; public ResourceStreamingProcessor StreamingProcessor => _streamingProcessor; - internal AssetManager(IResourceDatabase resourceDatabase, IContentProvider contentProvider, ResourceStreamingProcessor streamingProcessor, JobScheduler jobScheduler) + internal AssetManager(IResourceDatabase resourceDatabase, ResourceManager resourceManager, IContentProvider contentProvider, ResourceStreamingProcessor streamingProcessor, JobScheduler jobScheduler) { _resourceDatabase = resourceDatabase; + _resourceManager = resourceManager; _contentProvider = contentProvider; _streamingProcessor = streamingProcessor; _jobScheduler = jobScheduler; @@ -320,9 +326,13 @@ internal partial class AssetManager : IDisposable private void EnsureScheduled(AssetEntry entry) { - if (Interlocked.CompareExchange(ref entry.StateValue, (int)AssetState.Scheduled, (int)AssetState.Unloaded) != (int)AssetState.Unloaded) + var previousState = Interlocked.CompareExchange(ref entry.StateValue, (int)AssetState.Scheduled, (int)AssetState.Unloaded); + if (previousState != (int)AssetState.Unloaded) { - return; + if (previousState != (int)AssetState.Scheduled || entry.LoadJobHandle.IsValid) + { + return; + } } // TODO: Can this be jobified? If the dependency tree is not deep, it should be fine to do it in main thread, otherwise we might need to schedule a job to do it. @@ -397,7 +407,7 @@ internal partial class AssetManager : IDisposable var type = self._contentProvider.GetAssetType(id); var deps = self._contentProvider.GetDependencies(id); - var entry = new AssetEntry(self, self._resourceDatabase, id, type, deps); + var entry = new AssetEntry(self, self._resourceDatabase, self._resourceManager, id, type, deps); self.EnsureScheduled(entry); return entry; @@ -420,6 +430,7 @@ internal partial class AssetManager : IDisposable // Go directly to Scheduled -> Loading -> Loaded -> Uploading -> Ready again. // The swap cycle in RecordTextureUpload/OnTextureUploadComplete handles the // v1 to v2 transition exactly like the fallback to v1 transition. + entry.SetLoadJobHandle(JobHandle.Invalid); EnsureScheduled(entry); } else diff --git a/src/Runtime/Ghost.Engine/EngineCore.cs b/src/Runtime/Ghost.Engine/EngineCore.cs index 543c5dd..96657e7 100644 --- a/src/Runtime/Ghost.Engine/EngineCore.cs +++ b/src/Runtime/Ghost.Engine/EngineCore.cs @@ -47,7 +47,7 @@ public sealed partial class EngineCore : IDisposable }; _renderSystem = new RenderSystem(renderingDesc); - _assetManager = new AssetManager(_renderSystem.GraphicsEngine.ResourceDatabase, _contentProvider, _streamingProcessor, _jobScheduler); + _assetManager = new AssetManager(_renderSystem.GraphicsEngine.ResourceDatabase, _renderSystem.ResourceManager, _contentProvider, _streamingProcessor, _jobScheduler); } public void Dispose() @@ -56,4 +56,4 @@ public sealed partial class EngineCore : IDisposable _renderSystem.Dispose(); _jobScheduler.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Runtime/Ghost.Engine/MeshContent.cs b/src/Runtime/Ghost.Engine/MeshContent.cs new file mode 100644 index 0000000..0db59ff --- /dev/null +++ b/src/Runtime/Ghost.Engine/MeshContent.cs @@ -0,0 +1,47 @@ +using Misaki.HighPerformance.Mathematics; +using System.Runtime.InteropServices; + +namespace Ghost.Engine; + +[StructLayout(LayoutKind.Sequential)] +public struct MeshContentHeader +{ + public const uint MAGIC = 0x48534D47; // GMSH + public const uint VERSION = 1; + + public uint magic; + public uint version; + + public uint vertexCount; + public uint indexCount; + public uint materialPartCount; + public uint meshletCount; + public uint meshletGroupCount; + public uint meshletHierarchyNodeCount; + public uint meshletVertexCount; + public uint meshletTriangleCount; + public uint materialSlotCount; + public uint lodLevelCount; + + public float3 boundsMin; + public float3 boundsMax; + + public ulong vertexOffset; + public ulong indexOffset; + public ulong materialPartOffset; + public ulong meshletOffset; + public ulong meshletGroupOffset; + public ulong meshletHierarchyNodeOffset; + public ulong meshletVertexOffset; + public ulong meshletTriangleOffset; +} + +[StructLayout(LayoutKind.Sequential)] +public struct MeshContentMaterialPart +{ + public int materialIndex; + public int indexStart; + public int indexCount; + public int vertexStart; + public int vertexCount; +} diff --git a/src/Runtime/Ghost.Generator/AssetHandlerRegistrationGenerator.cs b/src/Runtime/Ghost.Generator/AssetHandlerRegistrationGenerator.cs index 46e3f2d..5e57912 100644 --- a/src/Runtime/Ghost.Generator/AssetHandlerRegistrationGenerator.cs +++ b/src/Runtime/Ghost.Generator/AssetHandlerRegistrationGenerator.cs @@ -141,6 +141,11 @@ internal static partial class {registerTypeName} return null; } + if (symbol.IsAbstract) + { + return null; + } + var iSettingsSymbol = context.SemanticModel.Compilation.GetTypeByMetadataName("Ghost.Editor.Core.Assets.IAssetSettings"); if (iSettingsSymbol == null) { diff --git a/src/Runtime/Ghost.Graphics.RHI/RootSignatureLayout.cs b/src/Runtime/Ghost.Graphics.RHI/RootSignatureLayout.cs index accc468..7667477 100644 --- a/src/Runtime/Ghost.Graphics.RHI/RootSignatureLayout.cs +++ b/src/Runtime/Ghost.Graphics.RHI/RootSignatureLayout.cs @@ -70,5 +70,9 @@ public struct MeshData public uint meshletBuffer; public uint meshletVerticesBuffer; public uint meshletTrianglesBuffer; + public uint meshletGroupBuffer; + public uint meshletHierarchyBuffer; + public uint meshletCount; + public uint lodLevelCount; public uint materialSlotCount; // number of material slots baked into this mesh's meshlets }; diff --git a/src/Runtime/Ghost.Graphics/Core/Mesh.cs b/src/Runtime/Ghost.Graphics/Core/Mesh.cs index d410226..4de03c1 100644 --- a/src/Runtime/Ghost.Graphics/Core/Mesh.cs +++ b/src/Runtime/Ghost.Graphics/Core/Mesh.cs @@ -185,6 +185,22 @@ public struct Mesh : IResourceReleasable get; internal set; } + /// + /// Gets the handle to the meshlet group buffer on the GPU. + /// + public Handle MeshletGroupBuffer + { + get; internal set; + } + + /// + /// Gets the handle to the meshlet hierarchy buffer on the GPU. + /// + public Handle MeshletHierarchyBuffer + { + get; internal set; + } + /// /// Gets the handle to the mesh data buffer on the GPU. /// @@ -193,6 +209,19 @@ public struct Mesh : IResourceReleasable get; internal set; } + internal void SetMeshletSummary(int meshletCount, int lodLevelCount, int materialSlotCount) + { + _meshletData.meshletCount = meshletCount; + _meshletData.lodLevelCount = lodLevelCount; + _meshletData.materialSlotCount = materialSlotCount; + } + + internal void SetCounts(int vertexCount, int indexCount) + { + VertexCount = vertexCount; + IndexCount = indexCount; + } + public void ReleaseCpuResources() { _vertices.Dispose(); @@ -209,6 +238,8 @@ public struct Mesh : IResourceReleasable database.ReleaseResource(MeshLetBuffer.AsResource()); database.ReleaseResource(MeshletVerticesBuffer.AsResource()); database.ReleaseResource(MeshletTrianglesBuffer.AsResource()); + database.ReleaseResource(MeshletGroupBuffer.AsResource()); + database.ReleaseResource(MeshletHierarchyBuffer.AsResource()); database.ReleaseResource(MeshDataBuffer.AsResource()); } } diff --git a/src/Runtime/Ghost.Graphics/Core/RenderContext.cs b/src/Runtime/Ghost.Graphics/Core/RenderContext.cs index e639ca0..7d9cc76 100644 --- a/src/Runtime/Ghost.Graphics/Core/RenderContext.cs +++ b/src/Runtime/Ghost.Graphics/Core/RenderContext.cs @@ -254,22 +254,44 @@ public readonly unsafe ref struct RenderContext Usage = BufferUsage.Raw | BufferUsage.ShaderResource, HeapType = HeapType.Default, }; + var groupsDesc = new BufferDesc + { + Size = (uint)(meshletData.groups.Count * sizeof(MeshletGroup)), + Stride = (uint)sizeof(MeshletGroup), + Usage = BufferUsage.Raw | BufferUsage.ShaderResource, + HeapType = HeapType.Default, + }; + var hierarchyDesc = new BufferDesc + { + Size = (uint)(meshletData.hierarchyNodes.Count * sizeof(MeshletHierarchyNode)), + Stride = (uint)sizeof(MeshletHierarchyNode), + Usage = BufferUsage.Raw | BufferUsage.ShaderResource, + HeapType = HeapType.Default, + }; meshRef.MeshLetBuffer = ResourceAllocator.CreateBuffer(in meshletDesc, "Meshlets"); meshRef.MeshletVerticesBuffer = ResourceAllocator.CreateBuffer(in verticesDesc, "MeshletVertices"); meshRef.MeshletTrianglesBuffer = ResourceAllocator.CreateBuffer(in trianglesDesc, "MeshletTriangles"); + meshRef.MeshletGroupBuffer = ResourceAllocator.CreateBuffer(in groupsDesc, "MeshletGroups"); + meshRef.MeshletHierarchyBuffer = ResourceAllocator.CreateBuffer(in hierarchyDesc, "MeshletHierarchy"); TransitionBarrier(meshRef.MeshLetBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.CopyDest, BarrierSync.Copy); TransitionBarrier(meshRef.MeshletVerticesBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.CopyDest, BarrierSync.Copy); TransitionBarrier(meshRef.MeshletTrianglesBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.CopyDest, BarrierSync.Copy); + TransitionBarrier(meshRef.MeshletGroupBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.CopyDest, BarrierSync.Copy); + TransitionBarrier(meshRef.MeshletHierarchyBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.CopyDest, BarrierSync.Copy); UploadBuffer(meshRef.MeshLetBuffer, meshletData.meshlets.AsSpan()); UploadBuffer(meshRef.MeshletVerticesBuffer, meshletData.meshletVertices.AsSpan()); UploadBuffer(meshRef.MeshletTrianglesBuffer, meshletData.meshletTriangles.AsSpan()); + UploadBuffer(meshRef.MeshletGroupBuffer, meshletData.groups.AsSpan()); + UploadBuffer(meshRef.MeshletHierarchyBuffer, meshletData.hierarchyNodes.AsSpan()); TransitionBarrier(meshRef.MeshLetBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.ShaderResource, BarrierSync.NonPixelShading | BarrierSync.PixelShading); TransitionBarrier(meshRef.MeshletVerticesBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.ShaderResource, BarrierSync.NonPixelShading | BarrierSync.PixelShading); TransitionBarrier(meshRef.MeshletTrianglesBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.ShaderResource, BarrierSync.NonPixelShading | BarrierSync.PixelShading); + TransitionBarrier(meshRef.MeshletGroupBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.ShaderResource, BarrierSync.NonPixelShading | BarrierSync.PixelShading); + TransitionBarrier(meshRef.MeshletHierarchyBuffer.AsResource(), false, BarrierLayout.Undefined, BarrierAccess.ShaderResource, BarrierSync.NonPixelShading | BarrierSync.PixelShading); } public void UpdateObjectData(Handle mesh) @@ -290,6 +312,10 @@ public readonly unsafe ref struct RenderContext meshletBuffer = ResourceDatabase.GetBindlessIndex(meshData.MeshLetBuffer.AsResource()), meshletVerticesBuffer = ResourceDatabase.GetBindlessIndex(meshData.MeshletVerticesBuffer.AsResource()), meshletTrianglesBuffer = ResourceDatabase.GetBindlessIndex(meshData.MeshletTrianglesBuffer.AsResource()), + meshletGroupBuffer = ResourceDatabase.GetBindlessIndex(meshData.MeshletGroupBuffer.AsResource()), + meshletHierarchyBuffer = ResourceDatabase.GetBindlessIndex(meshData.MeshletHierarchyBuffer.AsResource()), + meshletCount = (uint)meshData.MeshletData.meshletCount, + lodLevelCount = (uint)meshData.MeshletData.lodLevelCount, materialSlotCount = (uint)meshData.MeshletData.materialSlotCount, }; diff --git a/src/Runtime/Ghost.Graphics/Services/ResourceManager.cs b/src/Runtime/Ghost.Graphics/Services/ResourceManager.cs index 12ea2bc..6d9789f 100644 --- a/src/Runtime/Ghost.Graphics/Services/ResourceManager.cs +++ b/src/Runtime/Ghost.Graphics/Services/ResourceManager.cs @@ -4,6 +4,7 @@ using Ghost.Graphics.Core; using Ghost.Graphics.RHI; using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Collections; +using Misaki.HighPerformance.Mathematics.Geometry; namespace Ghost.Graphics.Services; @@ -165,6 +166,79 @@ public sealed partial class ResourceManager : IDisposable } } + public Handle CreateEmptyMesh(string? name = null) + { + Logger.DebugAssert(!_disposed); + + lock (_meshWriteLock) + { + var id = _meshes.Add(new Mesh(), out var generation); + return new Handle(id, generation); + } + } + + public Handle CreateUploadedMesh( + Handle vertexBuffer, + Handle indexBuffer, + Handle meshletBuffer, + Handle meshletVerticesBuffer, + Handle meshletTrianglesBuffer, + Handle meshletGroupBuffer, + Handle meshletHierarchyBuffer, + Handle meshDataBuffer, + int vertexCount, + int indexCount, + int meshletCount, + int lodLevelCount, + int materialSlotCount, + AABB boundingBox) + { + Logger.DebugAssert(!_disposed); + + var mesh = new Mesh + { + VertexBuffer = vertexBuffer, + IndexBuffer = indexBuffer, + MeshLetBuffer = meshletBuffer, + MeshletVerticesBuffer = meshletVerticesBuffer, + MeshletTrianglesBuffer = meshletTrianglesBuffer, + MeshletGroupBuffer = meshletGroupBuffer, + MeshletHierarchyBuffer = meshletHierarchyBuffer, + MeshDataBuffer = meshDataBuffer, + BoundingBox = boundingBox, + }; + mesh.SetCounts(vertexCount, indexCount); + mesh.SetMeshletSummary(meshletCount, lodLevelCount, materialSlotCount); + + lock (_meshWriteLock) + { + var id = _meshes.Add(mesh, out var generation); + return new Handle(id, generation); + } + } + + public Handle ReplaceMesh(Handle dst, Handle src) + { + Logger.DebugAssert(!_disposed); + + lock (_meshWriteLock) + { + ref var dstMesh = ref _meshes.GetElementReferenceAt(dst.ID, dst.Generation, out var dstExists); + ref var srcMesh = ref _meshes.GetElementReferenceAt(src.ID, src.Generation, out var srcExists); + if (!dstExists || !srcExists) + { + return Handle.Invalid; + } + + var oldMesh = dstMesh; + dstMesh = srcMesh; + _meshes.Remove(src.ID, src.Generation); + + oldMesh.ReleaseResource(_resourceDatabase); + return dst; + } + } + /// /// Creates a new material instance using the specified shader. /// diff --git a/src/Runtime/Ghost.Graphics/Shaders/Includes/Properties.hlsl b/src/Runtime/Ghost.Graphics/Shaders/Includes/Properties.hlsl index a75a1d7..fa9be02 100644 --- a/src/Runtime/Ghost.Graphics/Shaders/Includes/Properties.hlsl +++ b/src/Runtime/Ghost.Graphics/Shaders/Includes/Properties.hlsl @@ -61,6 +61,10 @@ struct MeshData BYTE_ADDRESS_BUFFER meshletBuffer; BYTE_ADDRESS_BUFFER meshletVerticesBuffer; BYTE_ADDRESS_BUFFER meshletTrianglesBuffer; + BYTE_ADDRESS_BUFFER meshletGroupBuffer; + BYTE_ADDRESS_BUFFER meshletHierarchyBuffer; + uint meshletCount; + uint lodLevelCount; uint materialSlotCount; }; diff --git a/src/Runtime/Ghost.Graphics/Utilities/MeshBuilder.cs b/src/Runtime/Ghost.Graphics/Utilities/MeshBuilder.cs index 2de25be..8acedde 100644 --- a/src/Runtime/Ghost.Graphics/Utilities/MeshBuilder.cs +++ b/src/Runtime/Ghost.Graphics/Utilities/MeshBuilder.cs @@ -230,7 +230,7 @@ public static unsafe class MeshBuilder public static void ComputeTangents(Span vertices, Span indices) { using var scope = AllocationManager.CreateStackScope(); - var bitangents = new UnsafeArray(vertices.Length, scope.AllocationHandle, AllocationOption.Clear); + using var bitangents = new UnsafeArray(vertices.Length, scope.AllocationHandle, AllocationOption.Clear); for (var i = 0; i < indices.Length; i += 3) { diff --git a/src/Test/Ghost.MicroTest/Program.cs b/src/Test/Ghost.MicroTest/Program.cs index 3bffb95..c1a3965 100644 --- a/src/Test/Ghost.MicroTest/Program.cs +++ b/src/Test/Ghost.MicroTest/Program.cs @@ -1,4 +1,4 @@ using Ghost.MicroTest; using Ghost.Test.Core; -TestRunner.Run(); \ No newline at end of file +TestRunner.Run(); \ No newline at end of file diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs index 8a07f07..0403706 100644 --- a/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs @@ -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); diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs index 03d82c5..ad000f0 100644 --- a/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs @@ -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]); + } } diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs index b19862b..4fbacd2 100644 --- a/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs @@ -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); + } } diff --git a/src/Test/Ghost.UnitTest/AssetSystem/MeshAssetHandlerTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/MeshAssetHandlerTests.cs new file mode 100644 index 0000000..9015f9d --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/MeshAssetHandlerTests.cs @@ -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()]; + await stream.ReadExactlyAsync(headerBytes, TestContext.CancellationToken); + + var header = MemoryMarshal.Read(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}"); + } + } + } +} diff --git a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs index 450d734..1659544 100644 --- a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs +++ b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs @@ -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(Stream stream, ref readonly T value) + where T : unmanaged + { + stream.Write(MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in value, 1))); + } + + private static void WriteSpan(Stream stream, ReadOnlySpan 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; diff --git a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs index 7013beb..4f72fae 100644 --- a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs +++ b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs @@ -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 _resources = new(); @@ -118,9 +120,21 @@ internal unsafe class MockingResourceDatabase : IResourceDatabase public void* MapResource(Handle 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 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(); } diff --git a/src/ThridParty/Ghost.DXC/Api.cs b/src/ThridParty/Ghost.DXC/Api.cs index c6393ae..d86ea93 100644 --- a/src/ThridParty/Ghost.DXC/Api.cs +++ b/src/ThridParty/Ghost.DXC/Api.cs @@ -15,9 +15,9 @@ public partial class Api // NOTE: Currently only support Windows. if (libraryName == "dxcompiler") { - NativeLibrary.TryLoad("runtimes/win-x64/native/dxil.dll", out _); + NativeLibrary.TryLoad(Path.Combine(AppContext.BaseDirectory, "runtimes", "win-x64", "native", "dxil.dll"), out _); - if (NativeLibrary.TryLoad("runtimes/win-x64/native/dxcompiler.dll", out var dxcHandle)) + if (NativeLibrary.TryLoad(Path.Combine(AppContext.BaseDirectory, "runtimes", "win-x64", "native", "dxcompiler.dll"), out var dxcHandle)) { return dxcHandle; } diff --git a/src/ThridParty/Ghost.MeshOptimizer/Api.cs b/src/ThridParty/Ghost.MeshOptimizer/Api.cs index 808c975..1efdcf1 100644 --- a/src/ThridParty/Ghost.MeshOptimizer/Api.cs +++ b/src/ThridParty/Ghost.MeshOptimizer/Api.cs @@ -22,7 +22,7 @@ public partial class Api OperatingSystem.IsMacOS() ? ".dylib" : ""; var arch = Environment.Is64BitProcess ? "x64" : "x86"; - var nativeDllDir = Path.Combine("./runtimes", platform + "-" + arch, "native"); + var nativeDllDir = Path.Combine(AppContext.BaseDirectory, "runtimes", platform + "-" + arch, "native"); return NativeLibrary.Load(Path.Combine(nativeDllDir, libraryName + ext)); }); diff --git a/src/ThridParty/Ghost.Nvtt/Api.cs b/src/ThridParty/Ghost.Nvtt/Api.cs index 8ab2afc..f5e9f82 100644 --- a/src/ThridParty/Ghost.Nvtt/Api.cs +++ b/src/ThridParty/Ghost.Nvtt/Api.cs @@ -17,7 +17,7 @@ public partial class Api OperatingSystem.IsMacOS() ? ".dylib" : ""; var arch = Environment.Is64BitProcess ? "x64" : "x86"; - var nativeDllDir = Path.Combine("./runtimes", platform + "-" + arch, "native"); + var nativeDllDir = Path.Combine(AppContext.BaseDirectory, "runtimes", platform + "-" + arch, "native"); return NativeLibrary.Load(Path.Combine(nativeDllDir, libraryName + ext)); }); diff --git a/src/ThridParty/Ghost.StbI/Api.cs b/src/ThridParty/Ghost.StbI/Api.cs index 21147aa..b951e5b 100644 --- a/src/ThridParty/Ghost.StbI/Api.cs +++ b/src/ThridParty/Ghost.StbI/Api.cs @@ -17,7 +17,7 @@ public partial class Api OperatingSystem.IsMacOS() ? ".dylib" : ""; var arch = Environment.Is64BitProcess ? "x64" : "x86"; - var nativeDllDir = Path.Combine("./runtimes", platform + "-" + arch, "native"); + var nativeDllDir = Path.Combine(AppContext.BaseDirectory, "runtimes", platform + "-" + arch, "native"); return NativeLibrary.Load(Path.Combine(nativeDllDir, libraryName + ext)); }); diff --git a/src/ThridParty/Ghost.Ufbx/Api.cs b/src/ThridParty/Ghost.Ufbx/Api.cs index d1a981b..faf2126 100644 --- a/src/ThridParty/Ghost.Ufbx/Api.cs +++ b/src/ThridParty/Ghost.Ufbx/Api.cs @@ -22,8 +22,7 @@ public partial class Api OperatingSystem.IsMacOS() ? ".dylib" : ""; var arch = Environment.Is64BitProcess ? "x64" : "x86"; - var nativeDllDir = Path.Combine("./runtimes", platform + "-" + arch, "native"); - + var nativeDllDir = Path.Combine(AppContext.BaseDirectory, "runtimes", platform + "-" + arch, "native"); return NativeLibrary.Load(Path.Combine(nativeDllDir, libraryName + ext)); }); }