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
{
private static readonly Dictionary<Type, object> s_importerInstances = new();
private static readonly Dictionary<Type, AssetImporter> s_importerInstances = new();
/// <summary>
/// Import an asset at the specified path.
@@ -25,8 +25,8 @@ public static partial class AssetDatabase
// Get or create importer instance
if (!s_importerInstances.TryGetValue(importerType, out var importerInstance))
{
importerInstance = Activator.CreateInstance(importerType);
if (importerInstance == null)
importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
if (importerInstance is null)
{
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}");
}
// TODO: Avoid reflection.
// 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}");
}
return await importerInstance.ImportAsync(assetPath, metaResult.Value, token);
}
/// <summary>
@@ -122,8 +84,8 @@ public static partial class AssetDatabase
// Get or create importer instance
if (!s_importerInstances.TryGetValue(importerType, out var importerInstance))
{
importerInstance = Activator.CreateInstance(importerType);
if (importerInstance == null)
importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
if (importerInstance is null)
{
return Result<Guid>.Failure($"Failed to create importer instance for type {importerType.Name}");
}
@@ -138,53 +100,29 @@ public static partial class AssetDatabase
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
var result = await GenerateMetaFileAsync(assetPath, token);
if (result.IsFailure)
{
// Generate metadata for the new asset
await GenerateMetaFileAsync(assetPath, token);
var metaResult = await ReadMetaFileAsync(assetPath, token);
if (metaResult.IsFailure)
{
return Result<Guid>.Failure($"Failed to generate metadata: {metaResult.Message}");
}
var parameters = exportMethod.GetParameters();
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)
{
return Result<Guid>.Failure(result.Message);
}
// Calculate file hash and update database
var fileHash = await CalculateFileHashAsync(assetPath, token);
await UpsertAssetAsync(assetPath, metaResult.Value, fileHash, null, token);
return metaResult.Value.Guid;
return Result<Guid>.Failure($"Failed to generate metadata: {result.Message}");
}
catch (Exception ex)
var metaResult = await ReadMetaFileAsync(assetPath, token);
if (metaResult.IsFailure)
{
return Result<Guid>.Failure($"Asset export failed: {ex.Message}");
return Result<Guid>.Failure($"Failed to read metadata: {metaResult.Message}");
}
result = await importerInstance.ExportAsync(assetPath, assetData, metaResult.Value, token);
if (result.IsFailure)
{
return Result<Guid>.Failure(result.Message);
}
// Calculate file hash and update database
var fileHash = await CalculateFileHashAsync(assetPath, token);
await UpsertAssetAsync(assetPath, metaResult.Value, fileHash, null, token);
return metaResult.Value.Guid;
}
}

View File

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

View File

@@ -180,7 +180,7 @@ public static partial class AssetDatabase
}
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 readonly Queue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
private static TaskCompletionSource<bool>? s_refreshTcs;
// Initialization guard
private static readonly Lock s_initializationLock = new();
private static bool s_initialized = false;
@@ -77,7 +79,7 @@ public static partial class AssetDatabase
/// Initialize the asset database.
/// Must be called after project is loaded.
/// </summary>
internal static async void Initialize(CancellationToken token = default)
internal static async Task Initialize(CancellationToken token = default)
{
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));
// Initialize command channel (unbounded for simplicity)
s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
{
SingleReader = false, // Timer callback reads
@@ -105,13 +106,9 @@ public static partial class AssetDatabase
// Initialize command processor timer (starts disabled, triggered by events)
s_commandProcessorTimer = new Timer(ProcessPendingCommands, null, Timeout.Infinite, Timeout.Infinite);
// Initialize database
await InitializeDatabaseAsync(token);
// Load asset cache from database
await LoadAssetCacheFromDatabaseAsync(token);
// Initialize file system watcher
s_watcher = new FileSystemWatcher
{
Path = AssetsDirectory.FullName,
@@ -123,7 +120,6 @@ public static partial class AssetDatabase
InitializeAssetHandle();
InitializeMetaData();
// Validate and fix database on startup
await ValidateAndFixDatabaseAsync(token);
}
@@ -193,16 +189,17 @@ public static partial class AssetDatabase
{
s_commandChannel?.Writer.TryWrite(cmd);
}
s_refreshTcs = new TaskCompletionSource<bool>();
}
// Post manual refresh command
s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
// Trigger timer immediately
s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
// Wait a bit for processing to complete (this is best-effort)
await Task.Delay(200, token);
if (!await s_refreshTcs.Task.WaitAsync(token))
{
return Result.Failure("Asset database refresh failed");
}
return Result.Success();
}
@@ -328,7 +325,7 @@ public static partial class AssetDatabase
/// <summary>
/// Timer callback to process pending commands.
/// </summary>
private static void ProcessPendingCommands(object? state)
private static async void ProcessPendingCommands(object? state)
{
try
{
@@ -347,32 +344,38 @@ public static partial class AssetDatabase
commandsByPath[cmd.Path] = cmd;
}
// Filter out temp files (files that were created then deleted)
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);
}
}
// NOTE: We handle the temp file filtering in each command handler now
// We should able to remove this allocation heavy code
// Clear pending paths
s_pendingCommandPaths.Clear();
}
// Filter out temp files (files that were created then deleted)
// 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
foreach (var cmd in commandsByPath.Values)
{
ExecuteCommandAsync(cmd).GetAwaiter().GetResult();
await ExecuteCommandAsync(cmd);
}
s_refreshTcs?.SetResult(true);
}
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>
private static async Task HandleFileCreatedAsync(string path)
{
if (!File.Exists(path))
{
return;
}
await GenerateMetaFileAsync(path, CancellationToken.None);
}
@@ -421,6 +429,11 @@ public static partial class AssetDatabase
/// </summary>
private static async Task HandleFileModifiedAsync(string path)
{
if (!File.Exists(path))
{
return;
}
// Check if file hash changed
var metaResult = await ReadMetaFileAsync(path, CancellationToken.None);
if (metaResult.IsFailure)
@@ -447,6 +460,11 @@ public static partial class AssetDatabase
/// </summary>
private static async Task HandleFileDeletedAsync(string path)
{
if (!File.Exists(path))
{
return;
}
var metaFileResult = GetMetaFilePath(path);
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
{
@@ -468,7 +486,7 @@ public static partial class AssetDatabase
}
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>
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 newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;

View File

@@ -2,21 +2,16 @@ using Ghost.Core;
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// 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()
public abstract class AssetImporter
{
/// <summary>
/// Import the asset at the specified path with the given settings.
/// </summary>
/// <param name="assetPath">Full path to the source asset file.</param>
/// <param name="meta">Metadata for the asset.</param>
/// <param name="token">Cancellation token.</param>
/// <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>
/// 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="assetData">In-memory asset data to serialize.</param>
/// <param name="meta">Metadata for the asset.</param>
/// <param name="token">Cancellation token.</param>
/// <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."));
}
/// <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;
return ValueTask.FromResult(Result.Failure("This importer does not support exporting assets."));
}
/// <summary>
@@ -78,3 +54,28 @@ internal abstract class AssetImporter<TSettings>
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.
/// FileHash and Dependencies are stored in the database only, not in .gmeta files.
/// </summary>
internal class AssetMeta
public class AssetMeta
{
/// <summary>
/// Unique identifier for the asset.

View File

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