Improve AssetDatabase performance.

This commit is contained in:
2026-01-29 20:37:45 +09:00
parent e71851550b
commit 9f05944d81
10 changed files with 140 additions and 183 deletions

View File

@@ -5,7 +5,7 @@ namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase public static partial class AssetDatabase
{ {
private static readonly Dictionary<Type, object> s_importerInstances = new(); private static readonly Dictionary<Type, AssetImporter> s_importerInstances = new();
/// <summary> /// <summary>
/// Import an asset at the specified path. /// Import an asset at the specified path.
@@ -25,8 +25,8 @@ public static partial class AssetDatabase
// Get or create importer instance // Get or create importer instance
if (!s_importerInstances.TryGetValue(importerType, out var importerInstance)) if (!s_importerInstances.TryGetValue(importerType, out var importerInstance))
{ {
importerInstance = Activator.CreateInstance(importerType); importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
if (importerInstance == null) if (importerInstance is null)
{ {
return Result.Failure($"Failed to create importer instance for type {importerType.Name}"); return Result.Failure($"Failed to create importer instance for type {importerType.Name}");
} }
@@ -41,45 +41,7 @@ public static partial class AssetDatabase
return Result.Failure($"Failed to read asset metadata: {metaResult.Message}"); return Result.Failure($"Failed to read asset metadata: {metaResult.Message}");
} }
// TODO: Avoid reflection. return await importerInstance.ImportAsync(assetPath, metaResult.Value, token);
// Find and invoke the ImportAsync method. Support importers that accept (string, AssetMeta)
// or (string, AssetMeta, CancellationToken).
var importMethod = importerType.GetMethod("ImportAsync", BindingFlags.Public | BindingFlags.Instance);
if (importMethod == null)
{
return Result.Failure($"ImportAsync method not found on importer {importerType.Name}");
}
try
{
var parameters = importMethod.GetParameters();
object? invokeResult;
if (parameters.Length == 2)
{
invokeResult = importMethod.Invoke(importerInstance, new object[] { assetPath, metaResult.Value });
}
else if (parameters.Length == 3 && parameters[2].ParameterType == typeof(CancellationToken))
{
invokeResult = importMethod.Invoke(importerInstance, new object[] { assetPath, metaResult.Value, token });
}
else
{
return Result.Failure($"Unsupported ImportAsync signature on importer {importerType.Name}");
}
if (invokeResult is not Task<Result> task)
{
return Result.Failure("Importer did not return a valid Task<Result>");
}
var result = await task;
return result;
}
catch (Exception ex)
{
return Result.Failure($"Asset import failed: {ex.Message}");
}
} }
/// <summary> /// <summary>
@@ -122,8 +84,8 @@ public static partial class AssetDatabase
// Get or create importer instance // Get or create importer instance
if (!s_importerInstances.TryGetValue(importerType, out var importerInstance)) if (!s_importerInstances.TryGetValue(importerType, out var importerInstance))
{ {
importerInstance = Activator.CreateInstance(importerType); importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
if (importerInstance == null) if (importerInstance is null)
{ {
return Result<Guid>.Failure($"Failed to create importer instance for type {importerType.Name}"); return Result<Guid>.Failure($"Failed to create importer instance for type {importerType.Name}");
} }
@@ -138,39 +100,20 @@ public static partial class AssetDatabase
return Result<Guid>.Failure($"ExportAsync method not found on importer {importerType.Name}. This importer does not support exporting."); return Result<Guid>.Failure($"ExportAsync method not found on importer {importerType.Name}. This importer does not support exporting.");
} }
try
{
// Generate metadata for the new asset // Generate metadata for the new asset
await GenerateMetaFileAsync(assetPath, token); var result = await GenerateMetaFileAsync(assetPath, token);
if (result.IsFailure)
{
return Result<Guid>.Failure($"Failed to generate metadata: {result.Message}");
}
var metaResult = await ReadMetaFileAsync(assetPath, token); var metaResult = await ReadMetaFileAsync(assetPath, token);
if (metaResult.IsFailure) if (metaResult.IsFailure)
{ {
return Result<Guid>.Failure($"Failed to generate metadata: {metaResult.Message}"); return Result<Guid>.Failure($"Failed to read metadata: {metaResult.Message}");
} }
var parameters = exportMethod.GetParameters(); result = await importerInstance.ExportAsync(assetPath, assetData, metaResult.Value, token);
object? invokeResult;
if (parameters.Length == 3)
{
invokeResult = exportMethod.Invoke(importerInstance, new object[] { assetPath, assetData, metaResult.Value });
}
else if (parameters.Length == 4 && parameters[3].ParameterType == typeof(CancellationToken))
{
invokeResult = exportMethod.Invoke(importerInstance, new object[] { assetPath, assetData, metaResult.Value, token });
}
else
{
return Result<Guid>.Failure($"Unsupported ExportAsync signature on importer {importerType.Name}");
}
if (invokeResult is not Task<Result> task)
{
return Result<Guid>.Failure("Exporter did not return a valid Task<Result>");
}
var result = await task;
if (result.IsFailure) if (result.IsFailure)
{ {
return Result<Guid>.Failure(result.Message); return Result<Guid>.Failure(result.Message);
@@ -182,9 +125,4 @@ public static partial class AssetDatabase
return metaResult.Value.Guid; return metaResult.Value.Guid;
} }
catch (Exception ex)
{
return Result<Guid>.Failure($"Asset export failed: {ex.Message}");
}
}
} }

View File

@@ -2,7 +2,6 @@ using Ghost.Core;
using Ghost.Editor.Core.Utilities; using Ghost.Editor.Core.Utilities;
using System.Reflection; using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle; namespace Ghost.Editor.Core.AssetHandle;

View File

@@ -180,7 +180,7 @@ public static partial class AssetDatabase
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Failed to load asset cache: {ex.Message}"); Logger.LogError($"Failed to load asset cache: {ex.Message}");
} }
} }

View File

@@ -51,6 +51,8 @@ public static partial class AssetDatabase
private static bool s_autoRefreshEnabled = true; private static bool s_autoRefreshEnabled = true;
private static readonly Queue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh private static readonly Queue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
private static TaskCompletionSource<bool>? s_refreshTcs;
// Initialization guard // Initialization guard
private static readonly Lock s_initializationLock = new(); private static readonly Lock s_initializationLock = new();
private static bool s_initialized = false; private static bool s_initialized = false;
@@ -77,7 +79,7 @@ public static partial class AssetDatabase
/// Initialize the asset database. /// Initialize the asset database.
/// Must be called after project is loaded. /// Must be called after project is loaded.
/// </summary> /// </summary>
internal static async void Initialize(CancellationToken token = default) internal static async Task Initialize(CancellationToken token = default)
{ {
lock (s_initializationLock) lock (s_initializationLock)
{ {
@@ -95,7 +97,6 @@ public static partial class AssetDatabase
AssetsDirectory = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(ProjectService.CurrentProject.Path)!, ProjectService.ASSETS_FOLDER)); AssetsDirectory = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(ProjectService.CurrentProject.Path)!, ProjectService.ASSETS_FOLDER));
// Initialize command channel (unbounded for simplicity)
s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
{ {
SingleReader = false, // Timer callback reads SingleReader = false, // Timer callback reads
@@ -105,13 +106,9 @@ public static partial class AssetDatabase
// Initialize command processor timer (starts disabled, triggered by events) // Initialize command processor timer (starts disabled, triggered by events)
s_commandProcessorTimer = new Timer(ProcessPendingCommands, null, Timeout.Infinite, Timeout.Infinite); s_commandProcessorTimer = new Timer(ProcessPendingCommands, null, Timeout.Infinite, Timeout.Infinite);
// Initialize database
await InitializeDatabaseAsync(token); await InitializeDatabaseAsync(token);
// Load asset cache from database
await LoadAssetCacheFromDatabaseAsync(token); await LoadAssetCacheFromDatabaseAsync(token);
// Initialize file system watcher
s_watcher = new FileSystemWatcher s_watcher = new FileSystemWatcher
{ {
Path = AssetsDirectory.FullName, Path = AssetsDirectory.FullName,
@@ -123,7 +120,6 @@ public static partial class AssetDatabase
InitializeAssetHandle(); InitializeAssetHandle();
InitializeMetaData(); InitializeMetaData();
// Validate and fix database on startup
await ValidateAndFixDatabaseAsync(token); await ValidateAndFixDatabaseAsync(token);
} }
@@ -193,16 +189,17 @@ public static partial class AssetDatabase
{ {
s_commandChannel?.Writer.TryWrite(cmd); s_commandChannel?.Writer.TryWrite(cmd);
} }
s_refreshTcs = new TaskCompletionSource<bool>();
} }
// Post manual refresh command
s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty)); s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
// Trigger timer immediately
s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
// Wait a bit for processing to complete (this is best-effort) if (!await s_refreshTcs.Task.WaitAsync(token))
await Task.Delay(200, token); {
return Result.Failure("Asset database refresh failed");
}
return Result.Success(); return Result.Success();
} }
@@ -328,7 +325,7 @@ public static partial class AssetDatabase
/// <summary> /// <summary>
/// Timer callback to process pending commands. /// Timer callback to process pending commands.
/// </summary> /// </summary>
private static void ProcessPendingCommands(object? state) private static async void ProcessPendingCommands(object? state)
{ {
try try
{ {
@@ -347,32 +344,38 @@ public static partial class AssetDatabase
commandsByPath[cmd.Path] = cmd; commandsByPath[cmd.Path] = cmd;
} }
// Filter out temp files (files that were created then deleted) // NOTE: We handle the temp file filtering in each command handler now
lock (s_commandLock) // We should able to remove this allocation heavy code
{
var pathsToProcess = commandsByPath.Keys.ToList();
foreach (var path in pathsToProcess)
{
// If file was created/modified but doesn't exist anymore, skip
if (!File.Exists(path) && commandsByPath[path].Type != AssetCommandType.FileDeleted)
{
commandsByPath.Remove(path);
}
}
// Clear pending paths // Filter out temp files (files that were created then deleted)
s_pendingCommandPaths.Clear(); // lock (s_commandLock)
} // {
// var pathsToProcess = commandsByPath.Keys.ToList();
// foreach (var path in pathsToProcess)
// {
// // If file was created/modified but doesn't exist anymore, skip
// if (!File.Exists(path) && commandsByPath[path].Type != AssetCommandType.FileDeleted)
// {
// commandsByPath.Remove(path);
// }
// }
//
// // Clear pending paths
// s_pendingCommandPaths.Clear();
// }
// Execute commands // Execute commands
foreach (var cmd in commandsByPath.Values) foreach (var cmd in commandsByPath.Values)
{ {
ExecuteCommandAsync(cmd).GetAwaiter().GetResult(); await ExecuteCommandAsync(cmd);
} }
s_refreshTcs?.SetResult(true);
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error processing commands: {ex.Message}"); Logger.LogError($"Error processing commands: {ex.Message}");
s_refreshTcs?.SetResult(false);
} }
} }
@@ -413,6 +416,11 @@ public static partial class AssetDatabase
/// </summary> /// </summary>
private static async Task HandleFileCreatedAsync(string path) private static async Task HandleFileCreatedAsync(string path)
{ {
if (!File.Exists(path))
{
return;
}
await GenerateMetaFileAsync(path, CancellationToken.None); await GenerateMetaFileAsync(path, CancellationToken.None);
} }
@@ -421,6 +429,11 @@ public static partial class AssetDatabase
/// </summary> /// </summary>
private static async Task HandleFileModifiedAsync(string path) private static async Task HandleFileModifiedAsync(string path)
{ {
if (!File.Exists(path))
{
return;
}
// Check if file hash changed // Check if file hash changed
var metaResult = await ReadMetaFileAsync(path, CancellationToken.None); var metaResult = await ReadMetaFileAsync(path, CancellationToken.None);
if (metaResult.IsFailure) if (metaResult.IsFailure)
@@ -447,6 +460,11 @@ public static partial class AssetDatabase
/// </summary> /// </summary>
private static async Task HandleFileDeletedAsync(string path) private static async Task HandleFileDeletedAsync(string path)
{ {
if (!File.Exists(path))
{
return;
}
var metaFileResult = GetMetaFilePath(path); var metaFileResult = GetMetaFilePath(path);
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value)) if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
{ {
@@ -468,7 +486,7 @@ public static partial class AssetDatabase
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error deleting asset metadata: {ex.Message}"); Logger.LogError($"Error deleting asset metadata: {ex.Message}");
} }
} }
} }
@@ -478,6 +496,17 @@ public static partial class AssetDatabase
/// </summary> /// </summary>
private static async Task HandleFileRenamedAsync(string oldPath, string newPath) private static async Task HandleFileRenamedAsync(string oldPath, string newPath)
{ {
if (!File.Exists(oldPath))
{
return;
}
if (File.Exists(newPath))
{
Logger.LogWarning($"Cannot rename asset from '{oldPath}' to '{newPath}': target file already exists.");
return;
}
var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION; var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION;
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION; var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;

View File

@@ -2,21 +2,16 @@ using Ghost.Core;
namespace Ghost.Editor.Core.AssetHandle; namespace Ghost.Editor.Core.AssetHandle;
/// <summary> public abstract class AssetImporter
/// Base class for all asset importers.
/// Asset importers process source files and convert them into engine-ready formats.
/// </summary>
/// <typeparam name="TSettings">The type of importer settings this importer uses.</typeparam>
internal abstract class AssetImporter<TSettings>
where TSettings : ImporterSettings, new()
{ {
/// <summary> /// <summary>
/// Import the asset at the specified path with the given settings. /// Import the asset at the specified path with the given settings.
/// </summary> /// </summary>
/// <param name="assetPath">Full path to the source asset file.</param> /// <param name="assetPath">Full path to the source asset file.</param>
/// <param name="meta">Metadata for the asset.</param> /// <param name="meta">Metadata for the asset.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns> /// <returns>Result indicating success or failure.</returns>
public abstract Task<Result> ImportAsync(string assetPath, AssetMeta meta); public abstract ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default);
/// <summary> /// <summary>
/// Export in-memory asset data to disk. /// Export in-memory asset data to disk.
@@ -26,31 +21,12 @@ internal abstract class AssetImporter<TSettings>
/// <param name="assetPath">Full path where the asset should be saved.</param> /// <param name="assetPath">Full path where the asset should be saved.</param>
/// <param name="assetData">In-memory asset data to serialize.</param> /// <param name="assetData">In-memory asset data to serialize.</param>
/// <param name="meta">Metadata for the asset.</param> /// <param name="meta">Metadata for the asset.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns> /// <returns>Result indicating success or failure.</returns>
public virtual Task<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta) where T : class public virtual ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
where T : class
{ {
return Task.FromResult(Result.Failure("This importer does not support exporting assets.")); return ValueTask.FromResult(Result.Failure("This importer does not support exporting assets."));
}
/// <summary>
/// Get the settings for this importer from the metadata.
/// Creates default settings if none exist.
/// </summary>
/// <param name="meta">Asset metadata.</param>
/// <returns>The importer settings.</returns>
protected TSettings GetSettings(AssetMeta meta)
{
var typeName = GetType().Name;
var settings = meta.GetImporterSettings<TSettings>(typeName);
if (settings != null)
{
return settings;
}
var defaultSettings = new TSettings();
meta.SetImporterSettings(typeName, defaultSettings);
return defaultSettings;
} }
/// <summary> /// <summary>
@@ -78,3 +54,28 @@ internal abstract class AssetImporter<TSettings>
return ValueTask.FromResult(Result.Success()); return ValueTask.FromResult(Result.Success());
} }
} }
public abstract class AssetImporter<TSettings> : AssetImporter
where TSettings : ImporterSettings, new()
{
/// <summary>
/// Get the settings for this importer from the metadata.
/// Creates default settings if none exist.
/// </summary>
/// <param name="meta">Asset metadata.</param>
/// <returns>The importer settings.</returns>
protected TSettings GetSettings(AssetMeta meta)
{
var typeName = GetType().Name;
var settings = meta.GetImporterSettings<TSettings>(typeName);
if (settings != null)
{
return settings;
}
var defaultSettings = new TSettings();
meta.SetImporterSettings(typeName, defaultSettings);
return defaultSettings;
}
}

View File

@@ -8,7 +8,7 @@ namespace Ghost.Editor.Core.AssetHandle;
/// Contains GUID, version, tags, and importer settings. /// Contains GUID, version, tags, and importer settings.
/// FileHash and Dependencies are stored in the database only, not in .gmeta files. /// FileHash and Dependencies are stored in the database only, not in .gmeta files.
/// </summary> /// </summary>
internal class AssetMeta public class AssetMeta
{ {
/// <summary> /// <summary>
/// Unique identifier for the asset. /// Unique identifier for the asset.

View File

@@ -1,5 +1,5 @@
namespace Ghost.Editor.Core.AssetHandle; namespace Ghost.Editor.Core.AssetHandle;
internal abstract class ImporterSettings public abstract class ImporterSettings
{ {
} }

View File

@@ -1,3 +1,4 @@
using Ghost.Core;
using Ghost.Entities; using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections; using Misaki.HighPerformance.LowLevel.Collections;
@@ -58,7 +59,7 @@ public readonly struct Scene : IEquatable<Scene>
public override string ToString() public override string ToString()
{ {
return $"Scene {{ ID: {_id} }}"; return $"Scene(ID: {_id})";
} }
} }
@@ -69,24 +70,17 @@ public readonly struct Scene : IEquatable<Scene>
/// This is a minimal runtime representation. All metadata (like scene names) /// This is a minimal runtime representation. All metadata (like scene names)
/// should be stored in editor-only classes (SceneNode). /// should be stored in editor-only classes (SceneNode).
/// </remarks> /// </remarks>
public class SceneManager public static class SceneManager
{ {
private readonly World _world; private static short s_nextSceneID;
private short _nextSceneID;
internal SceneManager(World world)
{
_world = world;
_nextSceneID = 0;
}
/// <summary> /// <summary>
/// Creates a new scene in the world. /// Creates a new scene in the world.
/// </summary> /// </summary>
/// <returns>The created scene.</returns> /// <returns>The created scene.</returns>
public Scene CreateScene() public static Scene CreateScene()
{ {
var scene = new Scene(_nextSceneID++); var scene = new Scene(s_nextSceneID++);
return scene; return scene;
} }
@@ -94,13 +88,11 @@ public class SceneManager
/// Destroys all entities belonging to the specified scene. /// Destroys all entities belonging to the specified scene.
/// </summary> /// </summary>
/// <param name="scene">The scene to unload.</param> /// <param name="scene">The scene to unload.</param>
public void UnloadScene(Scene scene) /// <param name="world">The world containing the entities.</param>
public static void UnloadScene(Scene scene, World world)
{ {
// Build query for entities with SceneID var queryID = new QueryBuilder().WithAll<Components.SceneID>().Build(world);
var builder = new QueryBuilder(); ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
builder.WithAll([ComponentTypeID<Components.SceneID>.Value]);
var queryID = builder.Build(_world);
ref var query = ref _world.ComponentManager.GetEntityQueryReference(queryID);
using var scope = AllocationManager.CreateStackScope(); using var scope = AllocationManager.CreateStackScope();
var entitiesToDestroy = new UnsafeList<Entity>(128, scope.AllocationHandle); var entitiesToDestroy = new UnsafeList<Entity>(128, scope.AllocationHandle);
@@ -120,22 +112,20 @@ public class SceneManager
} }
} }
_world.EntityManager.DestroyEntities(entitiesToDestroy.AsSpan()); world.EntityManager.DestroyEntities(entitiesToDestroy.AsSpan());
} }
/// <summary> /// <summary>
/// Gets all entities belonging to the specified scene. /// Gets all entities belonging to the specified scene.
/// </summary> /// </summary>
/// <param name="scene">The scene to query.</param> /// <param name="scene">The scene to query.</param>
/// <param name="world">The world containing the entities.</param>
/// <param name="entities">Span to store the entities.</param> /// <param name="entities">Span to store the entities.</param>
/// <returns>The number of entities written to the span.</returns> /// <returns>The number of entities written to the span.</returns>
public int GetSceneEntities(Scene scene, Span<Entity> entities) public static int GetSceneEntities(Scene scene, World world, Span<Entity> entities)
{ {
// Build query for entities with SceneID var queryID = new QueryBuilder().WithAll<Components.SceneID>().Build(world);
var builder = new QueryBuilder(); ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
builder.WithAll([ComponentTypeID<Components.SceneID>.Value]);
var queryID = builder.Build(_world);
ref var query = ref _world.ComponentManager.GetEntityQueryReference(queryID);
var index = 0; var index = 0;

View File

@@ -164,8 +164,8 @@ public struct Material : IResourceReleasable
} }
dataSpan.CopyTo(cacheSpan); dataSpan.CopyTo(cacheSpan);
_isDirty = true; _isDirty = true;
return Error.None; return Error.None;
} }
@@ -184,8 +184,8 @@ public struct Material : IResourceReleasable
} }
data.CopyTo(cacheSpan); data.CopyTo(cacheSpan);
_isDirty = true; _isDirty = true;
return Error.None; return Error.None;
} }

View File

@@ -47,7 +47,7 @@ public class AssetDatabaseIntegrationTest
ProjectService.CurrentProject = projectMetadataInfo; ProjectService.CurrentProject = projectMetadataInfo;
// Initialize AssetDatabase // Initialize AssetDatabase
AssetDatabase.Initialize(TestContext.CancellationToken); await AssetDatabase.Initialize(TestContext.CancellationToken);
// Give the file system watcher time to start // Give the file system watcher time to start
await Task.Delay(100, TestContext.CancellationToken); await Task.Delay(100, TestContext.CancellationToken);