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

@@ -49,6 +49,13 @@ public interface IImportableAssetHandler : IAssetHandler
ValueTask<Result> 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<Result<ImportedSubAsset[]>> ImportWithSubAssetsAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
}
public interface IPackableAssetHandler : IAssetHandler
{
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);

View File

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

View File

@@ -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<ModelManifestSubAsset> Meshes
{
get; set;
} = new List<ModelManifestSubAsset>();
public List<ModelManifestMetadata> Metadata
{
get; set;
} = new List<ModelManifestMetadata>();
}
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<ModelManifestNode> Children
{
get; set;
} = new List<ModelManifestNode>();
}
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<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
public async ValueTask<Result<IAsset>> 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<IAsset>("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<ModelManifest>(stream, cancellationToken: token).ConfigureAwait(false);
return manifest != null
? Result.Success<IAsset>(new ImportedModelAsset(id, settings, manifest))
: Result.Failure<IAsset>("Failed to deserialize model manifest.");
}
catch (Exception ex)
{
return Result.Failure<IAsset>(ex.Message);
}
}
public ValueTask<Result> 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<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
public async ValueTask<Result> 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<Result<ImportedSubAsset[]>> ImportWithSubAssetsAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
if (!File.Exists(sourcePath))
{
return Result.Failure<ImportedSubAsset[]>("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<ImportedSubAsset>();
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<ImportedSubAsset[]>($"Failed to import mesh asset: {ex.Message}");
}
finally
{
root.Dispose();
}
}
public ValueTask<Result> 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<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{
throw new NotImplementedException();
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
}
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<ModelManifestNode> WriteNodeAsync(
Guid parentGuid,
string sourcePath,
MeshNode node,
string parentPath,
ModelManifest manifest,
List<ImportedSubAsset> 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<Vertex> 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<byte> 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<MaterialPartInfo> parts)
{
if (parts.IsEmpty)
{
return;
}
Span<MeshContentMaterialPart> 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<T>(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<T>(Stream stream, ReadOnlySpan<T> value)
where T : unmanaged
{
if (value.IsEmpty)
{
return;
}
stream.Write(MemoryMarshal.AsBytes(value));
}
}

View File

@@ -9,6 +9,8 @@ namespace Ghost.Editor.Core.Services;
/// </summary>
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<SubAssetInfo> GetSubAssets(Guid parentGuid)
{
_cmdEnumerateSubAssets.Parameters.Clear();
_cmdEnumerateSubAssets.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
using var reader = _cmdEnumerateSubAssets.ExecuteReader();
var list = new List<SubAssetInfo>();
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<Guid> keepGuids)
{
lock (_writeLock)
{
if (keepGuids.Length == 0)
{
_cmdDeleteSubAssetsForParent.Parameters.Clear();
_cmdDeleteSubAssetsForParent.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
_cmdDeleteSubAssetsForParent.ExecuteNonQuery();
return;
}
var keep = new HashSet<Guid>();
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();
}
}

View File

@@ -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);
}
}

View File

@@ -112,10 +112,23 @@ internal sealed partial class ImportCoordinator : IDisposable
}
var importResult = Result.Success();
ImportedSubAsset[] subAssets = Array.Empty<ImportedSubAsset>();
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<Guid>.Empty);
_catalog.SetDependencies(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
}
OnImportCompleted?.Invoke(null, job.AssetGuid);
}
else

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DefineConstants>$(DefineConstants);MHP_ENABLE_SAFETY_CHECKS;MHP_ENABLE_MIMALLOC;MHP_FASTMATH</DefineConstants>
<DefineConstants>$(DefineConstants);MHP_ENABLE_SAFETY_CHECKS;MHP_ENABLE_MIMALLOC;MHP_FASTMATH;MHP_ENABLE_STACKTRACE</DefineConstants>
<IsAotCompatible>True</IsAotCompatible>
<IsTrimmable>True</IsTrimmable>
</PropertyGroup>

View File

@@ -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<Handle<Mesh>>();
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<MeshContentHeader>(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<Handle<Mesh>>();
SetStorage((oldHandle, newHandle));
return Result.Success();
}
private static Handle<GPUBuffer> 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<Mesh>, Handle<Mesh>)>();
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<Mesh> ResolveMesh(Guid assetID)
{
if (assetID == Guid.Empty)
{
return Handle<Mesh>.Invalid;
}
var entry = GetOrCreateEntry(assetID);
Logger.DebugAssert(entry.AssetType == AssetType.Mesh);
return entry.GetStorage<Handle<Mesh>>();
}
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();
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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;
}

View File

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

View File

@@ -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
};

View File

@@ -185,6 +185,22 @@ public struct Mesh : IResourceReleasable
get; internal set;
}
/// <summary>
/// Gets the handle to the meshlet group buffer on the GPU.
/// </summary>
public Handle<GPUBuffer> MeshletGroupBuffer
{
get; internal set;
}
/// <summary>
/// Gets the handle to the meshlet hierarchy buffer on the GPU.
/// </summary>
public Handle<GPUBuffer> MeshletHierarchyBuffer
{
get; internal set;
}
/// <summary>
/// Gets the handle to the mesh data buffer on the GPU.
/// </summary>
@@ -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());
}
}

View File

@@ -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> 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,
};

View File

@@ -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<Mesh> CreateEmptyMesh(string? name = null)
{
Logger.DebugAssert(!_disposed);
lock (_meshWriteLock)
{
var id = _meshes.Add(new Mesh(), out var generation);
return new Handle<Mesh>(id, generation);
}
}
public Handle<Mesh> CreateUploadedMesh(
Handle<GPUBuffer> vertexBuffer,
Handle<GPUBuffer> indexBuffer,
Handle<GPUBuffer> meshletBuffer,
Handle<GPUBuffer> meshletVerticesBuffer,
Handle<GPUBuffer> meshletTrianglesBuffer,
Handle<GPUBuffer> meshletGroupBuffer,
Handle<GPUBuffer> meshletHierarchyBuffer,
Handle<GPUBuffer> 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<Mesh>(id, generation);
}
}
public Handle<Mesh> ReplaceMesh(Handle<Mesh> dst, Handle<Mesh> 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<Mesh>.Invalid;
}
var oldMesh = dstMesh;
dstMesh = srcMesh;
_meshes.Remove(src.ID, src.Generation);
oldMesh.ReleaseResource(_resourceDatabase);
return dst;
}
}
/// <summary>
/// Creates a new material instance using the specified shader.
/// </summary>

View File

@@ -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;
};

View File

@@ -230,7 +230,7 @@ public static unsafe class MeshBuilder
public static void ComputeTangents(Span<Vertex> vertices, Span<uint> indices)
{
using var scope = AllocationManager.CreateStackScope();
var bitangents = new UnsafeArray<float3>(vertices.Length, scope.AllocationHandle, AllocationOption.Clear);
using var bitangents = new UnsafeArray<float3>(vertices.Length, scope.AllocationHandle, AllocationOption.Clear);
for (var i = 0; i < indices.Length; i += 3)
{

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]);
var sourcePath = "Assets/test.text";
await File.WriteAllBytesAsync(sourcePath, [1, 2, 3], TestContext.CancellationToken);
await Task.Delay(1000); // Wait for FSW to trigger
var metaPath = AssetMetaIO.GetMetaPath(sourcePath);
using var cts = new CancellationTokenSource(5000);
while (!File.Exists(metaPath) && !cts.IsCancellationRequested)
{
await Task.Delay(50, cts.Token);
}
var metaPath = AssetMetaIO.GetMetaPath(fullSourcePath);
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();
}

View File

@@ -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;
}

View File

@@ -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));
});

View File

@@ -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));
});

View File

@@ -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));
});

View File

@@ -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));
});
}