Refactor folder structure
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
|||||||
*.user
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
*.userprefs
|
*.userprefs
|
||||||
|
|||||||
297
AGENTS.md
@@ -1,297 +0,0 @@
|
|||||||
# GhostEngine - Agent Development Guide
|
|
||||||
|
|
||||||
This guide provides essential information for AI coding agents working on the GhostEngine codebase.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
- **Type**: Game Engine
|
|
||||||
- **Language**: C#
|
|
||||||
- **Target Framework**: .NET 10.0
|
|
||||||
- **Special Features**: ECS architecture, D3D12 rendering, AOT compilation, WinUI 3 editor
|
|
||||||
- **Platform**: Windows (net10.0-windows10.0.22621.0 for editor projects)
|
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
|
|
||||||
### Build Entire Solution
|
|
||||||
```bash
|
|
||||||
dotnet build GhostEngine.slnx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Specific Project
|
|
||||||
```bash
|
|
||||||
dotnet build Ghost.Entities/Ghost.Entities.csproj
|
|
||||||
dotnet build Ghost.Editor/Ghost.Editor.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build with Configuration
|
|
||||||
```bash
|
|
||||||
dotnet build GhostEngine.slnx -c Release
|
|
||||||
dotnet build GhostEngine.slnx -c Debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean Build
|
|
||||||
```bash
|
|
||||||
dotnet clean GhostEngine.slnx
|
|
||||||
dotnet build GhostEngine.slnx
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Commands
|
|
||||||
|
|
||||||
### Run All Tests (Custom Framework)
|
|
||||||
Tests use a custom test framework (not xUnit/NUnit/MSTest). Each test project is an executable.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run entity tests
|
|
||||||
dotnet run --project Ghost.Entities.Test/Ghost.Entities.Test.csproj
|
|
||||||
|
|
||||||
# Run shader tests
|
|
||||||
dotnet run --project Ghost.Shader.Test/Ghost.Shader.Test.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Single Test
|
|
||||||
Tests implement `ITest` interface. To run a specific test, modify the test project's `Program.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// In Ghost.Entities.Test/Program.cs
|
|
||||||
TestRunner.Run<EntityQueryTest>(); // Run specific test
|
|
||||||
TestRunner.Run<EntityQueryTest>(10); // Run with 10 iterations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Visual Tests (Graphics)
|
|
||||||
Graphics tests use WinUI 3 and require running as packaged apps:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project Ghost.Graphics.Test/Ghost.Graphics.Test.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
### Formatting (from .editorconfig)
|
|
||||||
|
|
||||||
- **Braces**: Allman style - all opening braces on new lines
|
|
||||||
- **Line Length**: Max 400 characters (very permissive)
|
|
||||||
- **Single-line statements**: Preserved (allowed)
|
|
||||||
- **Single-line blocks**: Preserved (allowed)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Correct brace style
|
|
||||||
public void Method()
|
|
||||||
{
|
|
||||||
if (condition)
|
|
||||||
{
|
|
||||||
DoSomething();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Imports
|
|
||||||
|
|
||||||
- **System directives**: NOT sorted first (dotnet_sort_system_directives_first = false)
|
|
||||||
- **No grouping**: Import directives not separated by blank lines
|
|
||||||
- **Order**: Organize by project convention, not alphabetically
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Ghost.Core;
|
|
||||||
using Ghost.Entities;
|
|
||||||
using Misaki.HighPerformance.Collections;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using TerraFX.Interop.DirectX;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Types and Nullability
|
|
||||||
|
|
||||||
- **Nullable**: Enabled for all projects
|
|
||||||
- **Implicit usings**: Enabled
|
|
||||||
- **Unsafe code**: Allowed in most projects (AllowUnsafeBlocks = True)
|
|
||||||
- **Primary constructors**: NOT preferred (csharp_style_prefer_primary_constructors = false)
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- **Classes/Interfaces**: PascalCase (`EntityManager`, `ICommandBuffer`)
|
|
||||||
- **Methods**: PascalCase (`CreateEntity`, `GetComponent`)
|
|
||||||
- **Properties**: PascalCase (`IsSuccess`, `Value`)
|
|
||||||
- **Fields (private)**: Camel case with underscore prefix (`_entityLocations`, `_world`)
|
|
||||||
- **Fields (public/internal)**: Camel case, no prefix for struct fields (`archetypeID`, `chunkIndex`)
|
|
||||||
- **Type parameters**: Single letter or PascalCase (`T`, `TComponent`)
|
|
||||||
- **Constants**: PascalCase (no SCREAMING_SNAKE_CASE)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class EntityManager
|
|
||||||
{
|
|
||||||
private readonly World _world; // Private field
|
|
||||||
private UnsafeSlotMap<EntityLocation> _entityLocations;
|
|
||||||
|
|
||||||
public World World => _world; // Property
|
|
||||||
|
|
||||||
public Entity CreateEntity() { } // Method
|
|
||||||
}
|
|
||||||
|
|
||||||
internal struct EntityLocation // Struct
|
|
||||||
{
|
|
||||||
public int archetypeID; // Public struct field
|
|
||||||
public int chunkIndex;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
**Use Result Types** - Railway-oriented programming pattern:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Custom result types defined in Ghost.Core
|
|
||||||
public ErrorStatus DoOperation()
|
|
||||||
{
|
|
||||||
return ErrorStatus.None; // or ErrorStatus.NotFound, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result<T> GetValue()
|
|
||||||
{
|
|
||||||
if (success)
|
|
||||||
return Result<T>.Success(value);
|
|
||||||
else
|
|
||||||
return Result<T>.Failure("Error message");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result<T, ErrorStatus> GetValueWithStatus()
|
|
||||||
{
|
|
||||||
if (success)
|
|
||||||
return value; // Implicit conversion
|
|
||||||
else
|
|
||||||
return ErrorStatus.NotFound; // Implicit conversion
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extension methods for checking results
|
|
||||||
result.ThrowIfFailed();
|
|
||||||
var value = result.GetValueOrThrow();
|
|
||||||
var value = result.GetValueOrDefault(defaultValue);
|
|
||||||
if (result.TryGetValue(out var value)) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Status Values**: None, NotFound, InvalidArgument, InvalidState, InternalError, PermissionDenied, NotSupported, OutOfMemory, Timeout, Cancelled, UnknownError
|
|
||||||
|
|
||||||
### Memory and Performance
|
|
||||||
|
|
||||||
- **Use unsafe code** when needed for performance-critical paths
|
|
||||||
- **Span<T> and stackalloc**: Prefer for temporary allocations
|
|
||||||
- **ref returns**: Use for zero-copy access to internal data
|
|
||||||
- **Allocator patterns**: Use `Allocator.Persistent` for long-lived allocations
|
|
||||||
- **AllocationManager**: Create stack scopes for temporary allocations
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Stack allocation pattern
|
|
||||||
var entities = (Span<Entity>)stackalloc Entity[1];
|
|
||||||
|
|
||||||
// Using allocation scope
|
|
||||||
using var scope = AllocationManager.CreateStackScope();
|
|
||||||
var batchDestroy = new UnsafeList<EntityLocation>(entities.Length, scope.AllocationHandle);
|
|
||||||
|
|
||||||
// Ref returns for zero-copy access
|
|
||||||
public ref T GetSingleton<T>() where T : unmanaged, IComponent
|
|
||||||
{
|
|
||||||
var ptr = GetSingleton(ComponentTypeID<T>.Value);
|
|
||||||
return ref *(T*)ptr;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Safety Patterns
|
|
||||||
|
|
||||||
**Strongly-typed identifiers**:
|
|
||||||
```csharp
|
|
||||||
Identifier<IComponent> componentID;
|
|
||||||
Identifier<Archetype> archetypeID;
|
|
||||||
Handle<T> resourceHandle;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Generic constraints**:
|
|
||||||
```csharp
|
|
||||||
public void Method<T>() where T : unmanaged, IComponent
|
|
||||||
public void Method<T, E>() where E : struct, Enum
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **XML comments**: Required for public APIs
|
|
||||||
- **Summary tags**: Describe what, not how
|
|
||||||
- **Remarks**: Add for complex behavior, thread-safety warnings, structural changes
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
/// <summary>
|
|
||||||
/// Create an entity with specified components.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="set">A set of component space IDs to add to the entities.</param>
|
|
||||||
/// <returns>The created entity.</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// This method causes structural changes and is not thread-safe.
|
|
||||||
/// Use <see cref="EntityCommandBuffer"/> to defer changes.
|
|
||||||
/// </remarks>
|
|
||||||
public Entity CreateEntity(ComponentSet set) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Patterns
|
|
||||||
|
|
||||||
**ECS Component Registration**:
|
|
||||||
```csharp
|
|
||||||
// Type-safe component ID
|
|
||||||
ComponentTypeID<Transform>.Value
|
|
||||||
|
|
||||||
// Component sets for archetypes
|
|
||||||
var set = new ComponentSet(ComponentTypeID<Transform>.Value, ComponentTypeID<Velocity>.Value);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Disposal Pattern**:
|
|
||||||
```csharp
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
~MyClass()
|
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
|
|
||||||
// Cleanup code
|
|
||||||
_disposed = true;
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debug-only validation**:
|
|
||||||
```csharp
|
|
||||||
#if DEBUG || GHOST_EDITOR
|
|
||||||
if (!_isSuccess)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Error: {_message}");
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Notes
|
|
||||||
|
|
||||||
### Entity Component System (ECS)
|
|
||||||
- Archetype-based storage (similar to Unity DOTS)
|
|
||||||
- Component data stored in chunks
|
|
||||||
- Queries use bitset signatures for fast matching
|
|
||||||
- Structural changes move entities between archetypes
|
|
||||||
|
|
||||||
### Graphics (D3D12)
|
|
||||||
- Hardware abstraction via `ICommandBuffer`
|
|
||||||
- Resource lifetime managed via handles
|
|
||||||
- Pipeline state objects (PSO) cached in library
|
|
||||||
- Native interop via TerraFX.Interop
|
|
||||||
|
|
||||||
### Custom Dependencies
|
|
||||||
- `Misaki.HighPerformance.*`: High-performance collections and utilities
|
|
||||||
- `TerraFX.Interop.*`: Native Windows/DirectX interop
|
|
||||||
- Custom source generators in `Ghost.Generator`
|
|
||||||
|
|
||||||
## Important Rules
|
|
||||||
|
|
||||||
1. **Never disable nullable warnings** - fix the root cause
|
|
||||||
2. **Use Result types** instead of throwing exceptions for expected failures
|
|
||||||
3. **Document thread-safety** in XML comments for public APIs
|
|
||||||
4. **AllowUnsafeBlocks** is enabled - use unsafe code when it improves performance
|
|
||||||
5. **Avoid collection expressions/initializers** (disabled in .editorconfig)
|
|
||||||
6. **Prefer explicit over implicit** - clarity over brevity
|
|
||||||
7. **Test changes** by running the appropriate test project executable
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# Asset Database Plan
|
|
||||||
|
|
||||||
AssetDB is a core component of the Ghost Editor that manages the storage, retrieval, and organization of various assets used within the editor.
|
|
||||||
This document outlines the plan for implementing the AssetDB, including its structure, functionality, and integration with other components of the Ghost Editor.
|
|
||||||
|
|
||||||
## Data Structure
|
|
||||||
|
|
||||||
- Asset Metadata: Each asset will have associated metadata, including:
|
|
||||||
- Unique Identifier (GUID)
|
|
||||||
- Version (Version of the asset pipeline, not the asset. This is primarily for migration when we redesign the asset pipeline in the future)
|
|
||||||
- Tags
|
|
||||||
- Importer Settings
|
|
||||||
|
|
||||||
An simplified example of metadata file (filename.png.gmeta):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Guid": "123e4567-e89b-12d3-a456-426614174000",
|
|
||||||
"Version": 1,
|
|
||||||
"Tags": ["Environment", "Texture"],
|
|
||||||
"ImporterSettings": [
|
|
||||||
"TextureImporter": {
|
|
||||||
"Version": 1,
|
|
||||||
"MaxSize": 2048,
|
|
||||||
"MipLevels": 1
|
|
||||||
},
|
|
||||||
"OtherImporter": {
|
|
||||||
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Asset: The base class for all assets.
|
|
||||||
|
|
||||||
- Asset Database: A centralized database that stores and manages all assets. It will handle:
|
|
||||||
- Asset registration and deregistration
|
|
||||||
- Asset lookup by GUID
|
|
||||||
- Asset lookup by path
|
|
||||||
- Automatic handle file creation, remove, rename, move, etc.
|
|
||||||
- Asset dependency management
|
|
||||||
- Automatic asset re-importing when source files change.
|
|
||||||
- Asset tagging.
|
|
||||||
- Add type specific default importer settings for new asset.
|
|
||||||
- SQLite (`Microsoft.Data.Sqlite`) for persistent storage and efficient querying.
|
|
||||||
|
|
||||||
An simplified data model example in SQLite:
|
|
||||||
```sql
|
|
||||||
CREATE TABLE Assets (
|
|
||||||
Guid TEXT PRIMARY KEY,
|
|
||||||
Path TEXT NOT NULL,
|
|
||||||
Type INTEGER,
|
|
||||||
Version INTEGER,
|
|
||||||
Tags TEXT,
|
|
||||||
DependencyGuids TEXT
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Simplified Workflow
|
|
||||||
|
|
||||||
### New Asset Addition
|
|
||||||
|
|
||||||
1. A file is added to the project directory.
|
|
||||||
2. Generates the metadata for the asset with name filename.etx + ".gmeta" (You can get the extension from Ghost.Editor.Core.Utilities.FileExtensions.META_FILE_EXTENSION)
|
|
||||||
3. Add the asset to the database.
|
|
||||||
|
|
||||||
### File Removal
|
|
||||||
|
|
||||||
1. A file is removed from the project directory.
|
|
||||||
2. Deletes the corresponding asset metadata.
|
|
||||||
3. Remove the asset from the database.
|
|
||||||
4. Mark dependent assets as dirty for re-importing.
|
|
||||||
|
|
||||||
### File Renaming/Moving
|
|
||||||
|
|
||||||
1. A file is renamed or moved within the project directory.
|
|
||||||
2. Check if the new path has an existing metadata file (just in case user move the file with the metadata file together).
|
|
||||||
- If exists, validate the data and update the database accordingly.
|
|
||||||
- if not, regenerate the metadata file for the new path and update the database.
|
|
||||||
3. Delete the old metadata file if exists.
|
|
||||||
|
|
||||||
### File Modification
|
|
||||||
|
|
||||||
1. A file is modified in the project directory.
|
|
||||||
2. Check the file hash to see if it has changed.
|
|
||||||
- If changed, mark the asset as dirty for re-importing.
|
|
||||||
- If not, do nothing.
|
|
||||||
|
|
||||||
### Asset Importing (You don't need to write any assets importer right now, just write the framework and a simple test importer if it's needed for unit test)
|
|
||||||
|
|
||||||
1. An asset is marked as dirty.
|
|
||||||
2. The asset importer for that type processes the asset based on its importer settings.
|
|
||||||
3. Validate the references and dependencies, report errors if any (for example, missing dependencies).
|
|
||||||
|
|
||||||
## Features Checklist
|
|
||||||
|
|
||||||
### In Code (API)
|
|
||||||
|
|
||||||
- [ ] Find GUID by path
|
|
||||||
- [ ] Find path by GUID
|
|
||||||
- [ ] Load asset by GUID (May need special asset loader, not included in this plan, leave an API and TODO comment)
|
|
||||||
- [ ] API for adding/removing/moving/copying assets
|
|
||||||
- [ ] API for opening asset in editor or external program
|
|
||||||
- [ ] API to set asset dirty and save all assets if dirty
|
|
||||||
- [ ] Get and set asset tags.
|
|
||||||
- [ ] Refresh asset database (re-scan project directory for changes)
|
|
||||||
|
|
||||||
### In Editor (API Only, I will handle the UI part)
|
|
||||||
|
|
||||||
- [ ] Asset Browser window to view and manage assets (automatically refresh when assets change)
|
|
||||||
- [ ] Skip meta file in asset browser view
|
|
||||||
- [ ] Search assets by name, tag, type, etc.
|
|
||||||
|
|
||||||
### In Background
|
|
||||||
|
|
||||||
- [ ] File system watcher to monitor changes in the project directory and update the AssetDB accordingly.
|
|
||||||
- [ ] Automatic asset re-importing when source files change (detect changes via file system watcher and quick hash comparison).
|
|
||||||
- [ ] Asset dependency management.
|
|
||||||
- [ ] Validate and fix AssetDB on project load (check for missing/corrupted assets if user add/delete/rename/move files when the editor is not running, etc.)
|
|
||||||
- [ ] Asset importer system to handle different asset types and their import settings.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Make sure everything builds correctly at first.
|
|
||||||
Write unit tests and integration tests to ensure the AssetDB functions correctly inside the `Ghost.UnitTests` project.
|
|
||||||
|
|
||||||
## Critical Considerations
|
|
||||||
|
|
||||||
- Performance: Ensure that the AssetDB operations are efficient, especially for large projects with many assets.
|
|
||||||
- Stability: The meta data files should be the only source of truth for asset information.
|
|
||||||
The AssetDB should be able to recover from inconsistencies by re-generating data from the meta files.
|
|
||||||
We still need to trust the meta data files even if they are corrupted or missing by regenerating them when necessary.
|
|
||||||
Database is used for caching and quick lookup only.
|
|
||||||
- Asynchronous patterns: Consider using asynchronous operations for file I/O and database operations to avoid blocking the main thread.
|
|
||||||
Packages like `Microsoft.Data.Sqlite` support async operations.
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new asset at the specified path.
|
|
||||||
/// Generates metadata and adds it to the database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="assetPath">Path to create the asset at.</param>
|
|
||||||
/// <param name="content">Content to write to the asset file.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public async ValueTask<Result> CreateAssetAsync(string assetPath, ReadOnlyMemory<byte> content, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assetPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Result.Failure("Asset path must be within the Assets directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(assetPath))
|
|
||||||
{
|
|
||||||
return Result.Failure("Asset already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(assetPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var fs = File.Create(assetPath);
|
|
||||||
await fs.WriteAsync(content, token);
|
|
||||||
|
|
||||||
// GenerateMetaFileAsync will be called automatically by the file watcher
|
|
||||||
// But we'll call it directly to ensure it's created immediately
|
|
||||||
await GenerateMetaFileAsync(assetPath, token);
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to create asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create an empty asset at the specified path.
|
|
||||||
/// Generates metadata and adds it to the database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="assetPath">Path to create the asset at.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public ValueTask<Result> CreateAssetAsync(string assetPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return CreateAssetAsync(assetPath, ReadOnlyMemory<byte>.Empty, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete an asset and its metadata.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset to delete.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public async ValueTask<Result> DeleteAssetAsync(Guid guid, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var pathResult = GuidToPath(guid);
|
|
||||||
if (pathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(pathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPathResult = GetFullPath(pathResult.Value);
|
|
||||||
if (fullPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(fullPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var assetPath = fullPathResult.Value;
|
|
||||||
|
|
||||||
// Delete the asset file
|
|
||||||
if (File.Exists(assetPath))
|
|
||||||
{
|
|
||||||
File.Delete(assetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the .gmeta file
|
|
||||||
var metaPath = assetPath + Utilities.FileExtensions.META_FILE_EXTENSION;
|
|
||||||
if (File.Exists(metaPath))
|
|
||||||
{
|
|
||||||
File.Delete(metaPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from database
|
|
||||||
await RemoveAssetFromDatabaseAsync(guid, token);
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to delete asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete an asset and its metadata by path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="assetPath">Path to the asset to delete.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public ValueTask<Result> DeleteAssetAsync(string assetPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var guidResult = PathToGuid(assetPath);
|
|
||||||
if (guidResult.IsFailure)
|
|
||||||
{
|
|
||||||
return new ValueTask<Result>(Task.FromResult(Result.Failure(guidResult.Message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return DeleteAssetAsync(guidResult.Value, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Move an asset to a new location.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset to move.</param>
|
|
||||||
/// <param name="newPath">New path for the asset (relative or absolute).</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public async ValueTask<Result> MoveAssetAsync(Guid guid, string newPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var oldPathResult = GuidToPath(guid);
|
|
||||||
if (oldPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(oldPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldFullPathResult = GetFullPath(oldPathResult.Value);
|
|
||||||
if (oldFullPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(oldFullPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure new path is absolute and within assets directory
|
|
||||||
if (!Path.IsPathRooted(newPath))
|
|
||||||
{
|
|
||||||
newPath = Path.Combine(AssetsDirectory.FullName, newPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Result.Failure("New path must be within the Assets directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(newPath))
|
|
||||||
{
|
|
||||||
return Result.Failure("A file already exists at the new path");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(newPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read metadata and calculate hash before moving
|
|
||||||
var metaResult = await ReadMetaFileAsync(oldFullPathResult.Value, token);
|
|
||||||
if (metaResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(metaResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileHash = await CalculateFileHashAsync(oldFullPathResult.Value, token);
|
|
||||||
|
|
||||||
// Move the asset file
|
|
||||||
File.Move(oldFullPathResult.Value, newPath);
|
|
||||||
|
|
||||||
// Move the .gmeta file
|
|
||||||
var oldMetaPath = oldFullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION;
|
|
||||||
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
|
|
||||||
if (File.Exists(oldMetaPath))
|
|
||||||
{
|
|
||||||
File.Move(oldMetaPath, newMetaPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database directly (bypassing file watcher)
|
|
||||||
await UpsertAssetAsync(newPath, metaResult.Value, fileHash, null, token);
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to move asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Move an asset to a new location by path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="oldPath">CurrentApplication path of the asset.</param>
|
|
||||||
/// <param name="newPath">New path for the asset (relative or absolute).</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public ValueTask<Result> MoveAssetAsync(string oldPath, string newPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var guidResult = PathToGuid(oldPath);
|
|
||||||
if (guidResult.IsFailure)
|
|
||||||
{
|
|
||||||
return ValueTask.FromResult(Result.Failure(guidResult.Message));
|
|
||||||
}
|
|
||||||
|
|
||||||
return MoveAssetAsync(guidResult.Value, newPath, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Copy an asset to a new location with a new GUID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset to copy.</param>
|
|
||||||
/// <param name="newPath">New path for the copied asset (relative or absolute).</param>
|
|
||||||
/// <returns>Result containing the new asset's GUID.</returns>
|
|
||||||
public async ValueTask<Result<Guid>> CopyAssetAsync(Guid guid, string newPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var oldPathResult = GuidToPath(guid);
|
|
||||||
if (oldPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure(oldPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldFullPathResult = GetFullPath(oldPathResult.Value);
|
|
||||||
if (oldFullPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure(oldFullPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure new path is absolute and within assets directory
|
|
||||||
if (!Path.IsPathRooted(newPath))
|
|
||||||
{
|
|
||||||
newPath = Path.Combine(AssetsDirectory.FullName, newPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure("New path must be within the Assets directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(newPath))
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure("A file already exists at the new path");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(newPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var oldFs = File.OpenRead(oldFullPathResult.Value);
|
|
||||||
await using var newFs = File.Create(newPath);
|
|
||||||
await oldFs.CopyToAsync(newFs, token);
|
|
||||||
|
|
||||||
// Generate new metadata with new GUID
|
|
||||||
await GenerateMetaFileAsync(newPath, token);
|
|
||||||
|
|
||||||
// Get the new GUID
|
|
||||||
var newGuidResult = PathToGuid(newPath);
|
|
||||||
if (newGuidResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure(newGuidResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newGuidResult.Value;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure($"Failed to copy asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Copy an asset to a new location by path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="sourcePath">Path of the asset to copy.</param>
|
|
||||||
/// <param name="destPath">New path for the copied asset (relative or absolute).</param>
|
|
||||||
/// <returns>Result containing the new asset's GUID.</returns>
|
|
||||||
public ValueTask<Result<Guid>> CopyAssetAsync(string sourcePath, string destPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var guidResult = PathToGuid(sourcePath);
|
|
||||||
if (guidResult.IsFailure)
|
|
||||||
{
|
|
||||||
return new ValueTask<Result<Guid>>(Task.FromResult(Result<Guid>.Failure(guidResult.Message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return CopyAssetAsync(guidResult.Value, destPath, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mark an asset as dirty for re-importing (in-memory only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset to mark dirty.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public Result MarkDirtyAsync(Guid guid, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
MarkDirty(guid);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Import all dirty assets.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public async Task<Result> ImportDirtyAssetsAsync(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var dirtyGuids = GetDirtyAssets();
|
|
||||||
|
|
||||||
foreach (var guid in dirtyGuids)
|
|
||||||
{
|
|
||||||
var pathResult = GuidToPath(guid);
|
|
||||||
if (pathResult.IsFailure)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPathResult = GetFullPath(pathResult.Value);
|
|
||||||
if (fullPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await ImportAssetAsync(fullPathResult.Value, token);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
ClearDirty(guid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
private readonly Dictionary<Type, AssetImporter> _importerInstances = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Import an asset at the specified path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="assetPath">Full path to the asset file.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
private async ValueTask<Result> ImportAssetAsync(string assetPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var extension = Path.GetExtension(assetPath);
|
|
||||||
|
|
||||||
if (!_importerTypeLookup.TryGetValue(extension, out var importerType))
|
|
||||||
{
|
|
||||||
// No importer registered for this file type
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create importer instance
|
|
||||||
if (!_importerInstances.TryGetValue(importerType, out var importerInstance))
|
|
||||||
{
|
|
||||||
importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
|
|
||||||
if (importerInstance is null)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to create importer instance for type {importerType.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_importerInstances[importerType] = importerInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read metadata
|
|
||||||
var metaResult = await ReadMetaFileAsync(assetPath, token);
|
|
||||||
if (metaResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to read asset metadata: {metaResult.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await importerInstance.ImportAsync(assetPath, metaResult.Value, this, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the importer type for a specific file extension.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="extension">File extension (e.g., ".png").</param>
|
|
||||||
/// <returns>The importer type if found, otherwise null.</returns>
|
|
||||||
public Type? GetImporterType(string extension)
|
|
||||||
{
|
|
||||||
_importerTypeLookup.TryGetValue(extension, out var importerType);
|
|
||||||
return importerType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get all registered importer types and their supported extensions.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Dictionary mapping extensions to importer types.</returns>
|
|
||||||
public Dictionary<string, Type> GetAllImporters()
|
|
||||||
{
|
|
||||||
return new Dictionary<string, Type>(_importerTypeLookup);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Export in-memory asset data to disk.
|
|
||||||
/// The importer will serialize the data into a format it can later import.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Type of asset data to export.</typeparam>
|
|
||||||
/// <param name="assetPath">Full path where the asset should be saved.</param>
|
|
||||||
/// <param name="assetData">In-memory asset data to export.</param>
|
|
||||||
/// <returns>Result with the GUID of the exported asset.</returns>
|
|
||||||
public async ValueTask<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData, CancellationToken token = default)
|
|
||||||
where T : class
|
|
||||||
{
|
|
||||||
var extension = Path.GetExtension(assetPath);
|
|
||||||
|
|
||||||
if (!_importerTypeLookup.TryGetValue(extension, out var importerType))
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure($"No importer registered for extension {extension}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create importer instance
|
|
||||||
if (!_importerInstances.TryGetValue(importerType, out var importerInstance))
|
|
||||||
{
|
|
||||||
importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
|
|
||||||
if (importerInstance is null)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure($"Failed to create importer instance for type {importerType.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_importerInstances[importerType] = importerInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate metadata for the new asset
|
|
||||||
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);
|
|
||||||
if (metaResult.IsFailure)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
// Asset cache - stores loaded assets by GUID
|
|
||||||
private readonly ConcurrentDictionary<Guid, Asset> _assetCache = new();
|
|
||||||
|
|
||||||
// LRU tracking - stores access time for each cached asset
|
|
||||||
private readonly ConcurrentDictionary<Guid, DateTime> _assetAccessTime = new();
|
|
||||||
|
|
||||||
// Maximum number of cached assets before eviction starts
|
|
||||||
private const int _MAX_CACHED_ASSETS = 1000;
|
|
||||||
|
|
||||||
// Percentage of cache to evict when limit is reached (evict oldest 20%)
|
|
||||||
private const float _CACHE_EVICTION_PERCENTAGE = 0.2f;
|
|
||||||
|
|
||||||
private Result<string> GetImportedAssetsDirectory()
|
|
||||||
{
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result<string>.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, EditorApplication.CACHES_FOLDER_NAME, "ImportedAssets");
|
|
||||||
if (!Directory.Exists(cacheDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(cacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cacheDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result<string> GetImportedAssetPath(Guid guid)
|
|
||||||
{
|
|
||||||
var importedDirResult = GetImportedAssetsDirectory();
|
|
||||||
if (importedDirResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<string>.Failure(importedDirResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store imported assets as {GUID}.asset
|
|
||||||
var assetDataPath = Path.Combine(importedDirResult.Value, $"{guid}.asset");
|
|
||||||
return assetDataPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset
|
|
||||||
{
|
|
||||||
// Check cache first
|
|
||||||
if (_assetCache.TryGetValue(guid, out var cachedAsset))
|
|
||||||
{
|
|
||||||
// Update access time for LRU
|
|
||||||
_assetAccessTime[guid] = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (cachedAsset is T typedAsset)
|
|
||||||
{
|
|
||||||
return typedAsset;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return Result<T>.Failure($"Cached asset is of type {cachedAsset.GetType().Name}, expected {typeof(T).Name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asset not in cache, load from disk
|
|
||||||
var assetPathResult = GetImportedAssetPath(guid);
|
|
||||||
if (assetPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<T>.Failure(assetPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var assetDataPath = assetPathResult.Value;
|
|
||||||
if (!File.Exists(assetDataPath))
|
|
||||||
{
|
|
||||||
return Result<T>.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Read and deserialize asset data
|
|
||||||
var json = File.ReadAllText(assetDataPath);
|
|
||||||
var asset = JsonSerializer.Deserialize<T>(json);
|
|
||||||
if (asset == null)
|
|
||||||
{
|
|
||||||
return Result<T>.Failure("Failed to deserialize asset data");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
CacheAsset(guid, asset);
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result<T>.Failure($"Failed to load asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset
|
|
||||||
{
|
|
||||||
var guidResult = PathToGuid(assetPath);
|
|
||||||
if (guidResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<T>.Failure(guidResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return LoadAsset<T>(guidResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CacheAsset(Guid guid, Asset asset)
|
|
||||||
{
|
|
||||||
// Check if we need to evict old assets
|
|
||||||
if (_assetCache.Count >= _MAX_CACHED_ASSETS)
|
|
||||||
{
|
|
||||||
EvictOldestAssets();
|
|
||||||
}
|
|
||||||
|
|
||||||
_assetCache[guid] = asset;
|
|
||||||
_assetAccessTime[guid] = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EvictOldestAssets()
|
|
||||||
{
|
|
||||||
var evictionCount = (int)(_MAX_CACHED_ASSETS * _CACHE_EVICTION_PERCENTAGE);
|
|
||||||
|
|
||||||
// Sort by access time and remove oldest entries
|
|
||||||
var oldestAssets = _assetAccessTime
|
|
||||||
.OrderBy(kvp => kvp.Value)
|
|
||||||
.Take(evictionCount)
|
|
||||||
.Select(kvp => kvp.Key)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var guid in oldestAssets)
|
|
||||||
{
|
|
||||||
_assetCache.TryRemove(guid, out _);
|
|
||||||
_assetAccessTime.TryRemove(guid, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unload a specific asset from cache.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset to unload.</param>
|
|
||||||
public void UnloadAsset(Guid guid)
|
|
||||||
{
|
|
||||||
_assetCache.TryRemove(guid, out _);
|
|
||||||
_assetAccessTime.TryRemove(guid, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unload all assets from cache.
|
|
||||||
/// </summary>
|
|
||||||
public void UnloadAllAssets()
|
|
||||||
{
|
|
||||||
_assetCache.Clear();
|
|
||||||
_assetAccessTime.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if an asset is currently loaded in cache.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset.</param>
|
|
||||||
/// <returns>True if the asset is in cache.</returns>
|
|
||||||
public bool IsAssetLoaded(Guid guid)
|
|
||||||
{
|
|
||||||
return _assetCache.ContainsKey(guid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get cache statistics.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Tuple of (current cache size, max cache size).</returns>
|
|
||||||
public (int currentSize, int maxSize) GetCacheStats()
|
|
||||||
{
|
|
||||||
return (_assetCache.Count, _MAX_CACHED_ASSETS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save an imported asset to disk for later loading.
|
|
||||||
/// This should be called by importers after processing the source file.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Type of asset data.</typeparam>
|
|
||||||
/// <param name="guid">GUID of the asset.</param>
|
|
||||||
/// <param name="assetData">Processed asset data to save.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public Result SaveImportedAsset<T>(Guid guid, T assetData)
|
|
||||||
where T : Asset
|
|
||||||
{
|
|
||||||
var assetPathResult = GetImportedAssetPath(guid);
|
|
||||||
if (assetPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(assetPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(assetData, _defaultJsonOptions);
|
|
||||||
File.WriteAllText(assetPathResult.Value, json);
|
|
||||||
|
|
||||||
// Invalidate cache for this asset so it gets reloaded next time
|
|
||||||
UnloadAsset(guid);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to save imported asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Get the relative path from the assets directory.
|
|
||||||
/// </summary>
|
|
||||||
private Result<string> GetRelativePath(string fullPath)
|
|
||||||
{
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result<string>.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fullPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Result<string>.Failure("Path is not within assets directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.GetRelativePath(AssetsDirectory.FullName, fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the full path from a relative path.
|
|
||||||
/// </summary>
|
|
||||||
private Result<string> GetFullPath(string relativePath)
|
|
||||||
{
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result<string>.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.Combine(AssetsDirectory.FullName, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Find GUID by asset path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="assetPath">Full or relative path to the asset.</param>
|
|
||||||
/// <returns>The GUID of the asset if found.</returns>
|
|
||||||
public Result<Guid> PathToGuid(string assetPath)
|
|
||||||
{
|
|
||||||
var relativePath = assetPath;
|
|
||||||
|
|
||||||
// Convert to relative path if it's a full path
|
|
||||||
if (Path.IsPathRooted(assetPath))
|
|
||||||
{
|
|
||||||
var relResult = GetRelativePath(assetPath);
|
|
||||||
if (relResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<Guid>.Failure(relResult.Message);
|
|
||||||
}
|
|
||||||
relativePath = relResult.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize path separators
|
|
||||||
relativePath = relativePath.Replace('\\', '/');
|
|
||||||
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
if (_pathAssetLookup.TryGetValue(relativePath, out var guid))
|
|
||||||
{
|
|
||||||
return guid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result<Guid>.Failure("Asset not found in database");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Find path by GUID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset.</param>
|
|
||||||
/// <returns>The relative path to the asset if found.</returns>
|
|
||||||
public Result<string> GuidToPath(Guid guid)
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
if (_assetPathLookup.TryGetValue(guid, out var path))
|
|
||||||
{
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result<string>.Failure("Asset GUID not found in database");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Load asset by GUID with caching.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Type of asset to load.</typeparam>
|
|
||||||
/// <param name="guid">GUID of the asset.</param>
|
|
||||||
/// <returns>The loaded asset.</returns>
|
|
||||||
public Result<T> LoadAsset<T>(Guid guid) where T : Asset
|
|
||||||
{
|
|
||||||
// Implemented in AssetService.Loader.cs
|
|
||||||
return LoadAssetInternal<T>(guid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get asset tags by GUID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset.</param>
|
|
||||||
/// <returns>List of tags associated with the asset.</returns>
|
|
||||||
public async ValueTask<Result<List<string>>> GetAssetTagsAsync(Guid guid, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var pathResult = GuidToPath(guid);
|
|
||||||
if (pathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<List<string>>.Failure(pathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPathResult = GetFullPath(pathResult.Value);
|
|
||||||
if (fullPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<List<string>>.Failure(fullPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var metaResult = await ReadMetaFileAsync(fullPathResult.Value, token);
|
|
||||||
if (metaResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<List<string>>.Failure(metaResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return metaResult.Value.Tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set asset tags by GUID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="guid">GUID of the asset.</param>
|
|
||||||
/// <param name="tags">New tags for the asset.</param>
|
|
||||||
/// <returns>Result indicating success or failure.</returns>
|
|
||||||
public async ValueTask<Result> SetAssetTagsAsync(Guid guid, List<string> tags, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var pathResult = GuidToPath(guid);
|
|
||||||
if (pathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(pathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPathResult = GetFullPath(pathResult.Value);
|
|
||||||
if (fullPathResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(fullPathResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var metaResult = await ReadMetaFileAsync(fullPathResult.Value, token);
|
|
||||||
if (metaResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(metaResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
metaResult.Value.Tags = tags;
|
|
||||||
|
|
||||||
// Write updated metadata to .gmeta file
|
|
||||||
var writeResult = await WriteMetaFileAsync(fullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION, metaResult.Value, token);
|
|
||||||
if (writeResult.IsFailure)
|
|
||||||
{
|
|
||||||
return writeResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database with new tags
|
|
||||||
var fileHash = await CalculateFileHashAsync(fullPathResult.Value, token);
|
|
||||||
return await UpsertAssetAsync(fullPathResult.Value, metaResult.Value, fileHash, null, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Search assets by name pattern.
|
|
||||||
/// Supports SQL LIKE wildcards: * (any characters) and ? (single character).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="namePattern">Search pattern (e.g., "*.txt", "player?", "test*").</param>
|
|
||||||
/// <returns>List of matching asset GUIDs.</returns>
|
|
||||||
public async Task<List<Guid>> FindAssetsByNameAsync(string namePattern, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return await GetAssetsByNameAsync(namePattern, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Find assets by tag.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tag">Tag to search for.</param>
|
|
||||||
/// <returns>List of asset GUIDs with the specified tag.</returns>
|
|
||||||
public async Task<List<Guid>> FindAssetsByTagAsync(string tag, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return await GetAssetsByTagAsync(tag, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get all assets in the database.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Dictionary mapping GUIDs to relative paths.</returns>
|
|
||||||
public IReadOnlyDictionary<Guid, string> GetAllAssets()
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
return _assetPathLookup.AsReadOnly();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
private readonly Dictionary<string, Type> _importerTypeLookup = new();
|
|
||||||
|
|
||||||
private void InitializeMetaData()
|
|
||||||
{
|
|
||||||
if (_watcher == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("AssetDatabase is not initialized. Ensure that Initialize() is called before registering asset importers.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var importerTypes = TypeCache.GetTypes().Where(t => t.GetCustomAttribute<AssetImporterAttribute>() != null);
|
|
||||||
foreach (var type in importerTypes)
|
|
||||||
{
|
|
||||||
var attribute = type.GetCustomAttribute<AssetImporterAttribute>()!;
|
|
||||||
foreach (var extension in attribute.SupportedExtensions)
|
|
||||||
{
|
|
||||||
_importerTypeLookup[extension] = type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_watcher.Created += OnFSEvent;
|
|
||||||
_watcher.Deleted += OnFSEvent;
|
|
||||||
_watcher.Changed += OnFSEvent;
|
|
||||||
_watcher.Renamed += OnAssetRenamed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result<string> GetMetaFilePath(string assetPath)
|
|
||||||
{
|
|
||||||
if (Directory.Exists(assetPath))
|
|
||||||
{
|
|
||||||
return Result<string>.Failure("Cannot create metadata for directories");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Result<string>.Failure("Cannot create metadata for metadata files");
|
|
||||||
}
|
|
||||||
|
|
||||||
return assetPath + FileExtensions.META_FILE_EXTENSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ImporterSettings? GetDefaultSettingsForAsset(string assetPath)
|
|
||||||
{
|
|
||||||
var extension = Path.GetExtension(assetPath);
|
|
||||||
|
|
||||||
if (_importerTypeLookup.TryGetValue(extension, out var importerType))
|
|
||||||
{
|
|
||||||
var settingsType = importerType.BaseType?.GetGenericArguments()[0];
|
|
||||||
if (settingsType == null || !typeof(ImporterSettings).IsAssignableFrom(settingsType))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (ImporterSettings?)Activator.CreateInstance(settingsType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculate SHA256 hash of a file for change detection.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> CalculateFileHashAsync(string filePath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var stream = File.OpenRead(filePath);
|
|
||||||
var hash = await SHA256.HashDataAsync(stream, token);
|
|
||||||
return Convert.ToHexString(hash);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Result> WriteMetaFileAsync(string metaFilePath, AssetMeta metaData, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var fileStream = File.Create(metaFilePath);
|
|
||||||
await JsonSerializer.SerializeAsync(fileStream, metaData, _defaultJsonOptions, token);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read metadata from a .gmeta file.
|
|
||||||
/// </summary>
|
|
||||||
private async ValueTask<Result<AssetMeta>> ReadMetaFileAsync(string assetPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var metaFileResult = GetMetaFilePath(assetPath);
|
|
||||||
if (metaFileResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result<AssetMeta>.Failure(metaFileResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(metaFileResult.Value))
|
|
||||||
{
|
|
||||||
return Result<AssetMeta>.Failure("Metadata file does not exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var fileStream = File.OpenRead(metaFileResult.Value);
|
|
||||||
var meta = await JsonSerializer.DeserializeAsync<AssetMeta>(fileStream, _defaultJsonOptions, token);
|
|
||||||
if (meta == null)
|
|
||||||
{
|
|
||||||
return Result<AssetMeta>.Failure("Failed to deserialize metadata");
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result<AssetMeta>.Failure($"Failed to read metadata: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async ValueTask<Result> GenerateMetaFileAsync(string assetPath, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
Result r;
|
|
||||||
|
|
||||||
var metaFileResult = GetMetaFilePath(assetPath);
|
|
||||||
if (metaFileResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(metaFileResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(metaFileResult.Value))
|
|
||||||
{
|
|
||||||
var existingMetaResult = await ReadMetaFileAsync(assetPath, token);
|
|
||||||
if (existingMetaResult.IsSuccess)
|
|
||||||
{
|
|
||||||
var existingMeta = existingMetaResult.Value;
|
|
||||||
if (_assetPathLookup.TryGetValue(existingMeta.Guid, out var path))
|
|
||||||
{
|
|
||||||
var relResult = GetRelativePath(assetPath);
|
|
||||||
if (relResult.IsSuccess && assetPath != path)
|
|
||||||
{
|
|
||||||
// GUID conflict - regenerate
|
|
||||||
existingMeta.Guid = Guid.NewGuid();
|
|
||||||
r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta, token);
|
|
||||||
if (r.IsFailure)
|
|
||||||
{
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate file hash and update database
|
|
||||||
var fileHash = await CalculateFileHashAsync(assetPath, token);
|
|
||||||
await UpsertAssetAsync(assetPath, existingMeta, fileHash, null, token);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate initial file hash
|
|
||||||
var fileHash2 = await CalculateFileHashAsync(assetPath, token);
|
|
||||||
|
|
||||||
var defaultSettings = GetDefaultSettingsForAsset(assetPath);
|
|
||||||
var metaData = new AssetMeta
|
|
||||||
{
|
|
||||||
Guid = Guid.NewGuid()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (defaultSettings != null)
|
|
||||||
{
|
|
||||||
metaData.SetImporterSettings(defaultSettings.GetType().Name, defaultSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
r = await WriteMetaFileAsync(metaFileResult.Value, metaData, token);
|
|
||||||
if (r.IsFailure)
|
|
||||||
{
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to database
|
|
||||||
await UpsertAssetAsync(assetPath, metaData, fileHash2, null, token);
|
|
||||||
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
private bool IsMetaFile(string path)
|
|
||||||
{
|
|
||||||
return Path.GetExtension(path).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnFSEvent(object sender, FileSystemEventArgs e)
|
|
||||||
{
|
|
||||||
if (IsMetaFile(e.FullPath))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var type = e.ChangeType switch
|
|
||||||
{
|
|
||||||
WatcherChangeTypes.Created => AssetCommandType.FileCreated,
|
|
||||||
WatcherChangeTypes.Deleted => AssetCommandType.FileDeleted,
|
|
||||||
WatcherChangeTypes.Changed => AssetCommandType.FileModified,
|
|
||||||
_ => throw new InvalidOperationException("Unsupported file system event type")
|
|
||||||
};
|
|
||||||
|
|
||||||
await PostCommandAsync(new AssetCommand(type, e.FullPath, Timestamp: DateTime.UtcNow));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnAssetRenamed(object sender, RenamedEventArgs e)
|
|
||||||
{
|
|
||||||
if (IsMetaFile(e.FullPath))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PostCommandAsync(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mark all assets that depend on the specified asset as dirty.
|
|
||||||
/// </summary>
|
|
||||||
private async Task MarkDependentAssetsDirtyAsync(Guid assetGuid)
|
|
||||||
{
|
|
||||||
// TODO: We should have a reverse dependency lookup in the database to avoid scanning all assets.
|
|
||||||
|
|
||||||
// Query database for all assets and check their dependencies
|
|
||||||
var allAssets = GetAllAssets();
|
|
||||||
|
|
||||||
foreach (var kvp in allAssets)
|
|
||||||
{
|
|
||||||
var dependencies = await GetDependenciesAsync(kvp.Key, CancellationToken.None);
|
|
||||||
if (dependencies.Contains(assetGuid))
|
|
||||||
{
|
|
||||||
MarkDirty(kvp.Key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
private readonly Dictionary<string, Action<string>> _assetOpenHandlers = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private void InitializeAssetHandle()
|
|
||||||
{
|
|
||||||
var methods = TypeCache.GetTypes()
|
|
||||||
.SelectMany(t => t.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
|
|
||||||
.Where(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>() != null &&
|
|
||||||
m.GetParameters().Length == 1 &&
|
|
||||||
m.GetParameters()[0].ParameterType == typeof(string));
|
|
||||||
|
|
||||||
foreach (var method in methods)
|
|
||||||
{
|
|
||||||
var attr = method.GetCustomAttribute<AssetOpenHandlerAttribute>()!;
|
|
||||||
var del = (Action<string>)Delegate.CreateDelegate(typeof(Action<string>), method);
|
|
||||||
foreach (var ext in attr.Extensions)
|
|
||||||
{
|
|
||||||
if (_assetOpenHandlers.ContainsKey(ext))
|
|
||||||
{
|
|
||||||
Logger.LogError($"Duplicate asset open handler for extension '{ext}' found in method '{method.Name}'. Existing handler will be overwritten.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_assetOpenHandlers[ext] = del;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OpenAsset(string path)
|
|
||||||
{
|
|
||||||
var extension = Path.GetExtension(path);
|
|
||||||
if (_assetOpenHandlers.TryGetValue(extension, out var handler))
|
|
||||||
{
|
|
||||||
handler(path);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo(path)
|
|
||||||
{
|
|
||||||
UseShellExecute = true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public partial class AssetService
|
|
||||||
{
|
|
||||||
private SqliteConnection? _dbConnection;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Init the SQLite database for asset caching.
|
|
||||||
/// </summary>
|
|
||||||
private async Task InitializeDatabaseAsync(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("AssetsDirectory is not set. Initialize() must be called first.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var dbPath = Path.Combine(AssetsDirectory.Parent!.FullName, EditorApplication.CACHES_FOLDER_NAME, "AssetDatabase.db");
|
|
||||||
var cacheDir = Path.GetDirectoryName(dbPath);
|
|
||||||
if (!Directory.Exists(cacheDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(cacheDir!);
|
|
||||||
}
|
|
||||||
|
|
||||||
var connectionString = new SqliteConnectionStringBuilder
|
|
||||||
{
|
|
||||||
DataSource = dbPath,
|
|
||||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
|
||||||
Cache = SqliteCacheMode.Shared
|
|
||||||
}.ToString();
|
|
||||||
|
|
||||||
_dbConnection = new SqliteConnection(connectionString);
|
|
||||||
await _dbConnection.OpenAsync(token);
|
|
||||||
|
|
||||||
// Create tables
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = @"
|
|
||||||
CREATE TABLE IF NOT EXISTS Assets (
|
|
||||||
Guid TEXT PRIMARY KEY,
|
|
||||||
Path TEXT NOT NULL,
|
|
||||||
Version INTEGER NOT NULL,
|
|
||||||
Tags TEXT,
|
|
||||||
FileHash TEXT,
|
|
||||||
DependencyGuids TEXT,
|
|
||||||
LastModified INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path);
|
|
||||||
";
|
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add or update an asset in the database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="assetPath">Full path to the asset file.</param>
|
|
||||||
/// <param name="meta">Asset metadata from .gmeta file.</param>
|
|
||||||
/// <param name="fileHash">SHA256 hash of the asset file content.</param>
|
|
||||||
/// <param name="dependencies">List of GUIDs this asset depends on (extracted during import).</param>
|
|
||||||
private async ValueTask<Result> UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List<Guid>? dependencies = null, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return Result.Failure("Database not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
var relativePath = GetRelativePath(assetPath);
|
|
||||||
if (relativePath.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure(relativePath.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
// If this GUID already exists with a different path, remove the old path mapping
|
|
||||||
if (_assetPathLookup.TryGetValue(meta.Guid, out var oldPath) && oldPath != relativePath.Value)
|
|
||||||
{
|
|
||||||
_pathAssetLookup.Remove(oldPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update lookups with new path (normalize path separators for consistency)
|
|
||||||
var normalizedPath = relativePath.Value.Replace('\\', '/');
|
|
||||||
_assetPathLookup[meta.Guid] = normalizedPath;
|
|
||||||
_pathAssetLookup[normalizedPath] = meta.Guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = @"
|
|
||||||
INSERT OR REPLACE INTO Assets (Guid, Path, Version, Tags, FileHash, DependencyGuids, LastModified)
|
|
||||||
VALUES (@guid, @path, @version, @tags, @fileHash, @deps, @modified)
|
|
||||||
";
|
|
||||||
cmd.Parameters.AddWithValue("@guid", meta.Guid.ToString());
|
|
||||||
cmd.Parameters.AddWithValue("@path", relativePath.Value);
|
|
||||||
cmd.Parameters.AddWithValue("@version", meta.Version);
|
|
||||||
cmd.Parameters.AddWithValue("@tags", JsonSerializer.Serialize(meta.Tags));
|
|
||||||
cmd.Parameters.AddWithValue("@fileHash", fileHash);
|
|
||||||
cmd.Parameters.AddWithValue("@deps", JsonSerializer.Serialize(dependencies ?? new List<Guid>()));
|
|
||||||
cmd.Parameters.AddWithValue("@modified", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync(token);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to upsert asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove an asset from the database.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<Result> RemoveAssetFromDatabaseAsync(Guid guid, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return Result.Failure("Database not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
if (_assetPathLookup.TryGetValue(guid, out var path))
|
|
||||||
{
|
|
||||||
_assetPathLookup.Remove(guid);
|
|
||||||
_pathAssetLookup.Remove(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM Assets WHERE Guid = @guid";
|
|
||||||
cmd.Parameters.AddWithValue("@guid", guid.ToString());
|
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync(token);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to remove asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Load all assets from the database into memory cache.
|
|
||||||
/// </summary>
|
|
||||||
private async Task LoadAssetCacheFromDatabaseAsync(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT Guid, Path FROM Assets";
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(token);
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var guidStr = reader.GetString(0);
|
|
||||||
var path = reader.GetString(1);
|
|
||||||
|
|
||||||
if (Guid.TryParse(guidStr, out var guid))
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
_assetPathLookup[guid] = path;
|
|
||||||
_pathAssetLookup[path] = guid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError($"Failed to load asset cache: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get assets by tag.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<List<Guid>> GetAssetsByTagAsync(string tag, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var result = new List<Guid>();
|
|
||||||
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT Guid, Tags FROM Assets";
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(token);
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var guidStr = reader.GetString(0);
|
|
||||||
var tagsJson = reader.GetString(1);
|
|
||||||
|
|
||||||
if (Guid.TryParse(guidStr, out var guid))
|
|
||||||
{
|
|
||||||
var tags = JsonSerializer.Deserialize<List<string>>(tagsJson);
|
|
||||||
if (tags != null && tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
result.Add(guid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the file hash for an asset from the database.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string?> GetFileHashAsync(Guid guid, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT FileHash FROM Assets WHERE Guid = @guid";
|
|
||||||
cmd.Parameters.AddWithValue("@guid", guid.ToString());
|
|
||||||
|
|
||||||
var result = await cmd.ExecuteScalarAsync(token);
|
|
||||||
return result?.ToString();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the dependencies for an asset from the database.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<List<Guid>> GetDependenciesAsync(Guid guid, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return new List<Guid>();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT DependencyGuids FROM Assets WHERE Guid = @guid";
|
|
||||||
cmd.Parameters.AddWithValue("@guid", guid.ToString());
|
|
||||||
|
|
||||||
var result = await cmd.ExecuteScalarAsync(token);
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
var json = result.ToString();
|
|
||||||
return JsonSerializer.Deserialize<List<Guid>>(json ?? "[]") ?? new List<Guid>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
|
|
||||||
return new List<Guid>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Find assets by name pattern using database query with wildcards.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="namePattern">Pattern supporting * (any chars) and ? (single char).</param>
|
|
||||||
private async Task<List<Guid>> GetAssetsByNameAsync(string namePattern, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var results = new List<Guid>();
|
|
||||||
|
|
||||||
if (_dbConnection == null)
|
|
||||||
{
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Convert wildcard pattern to SQL LIKE pattern
|
|
||||||
var sqlPattern = namePattern.Replace('*', '%').Replace('?', '_');
|
|
||||||
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
|
|
||||||
// Extract just the filename from the path for matching
|
|
||||||
// SQLite doesn't have a built-in path manipulation, so we search in the full path
|
|
||||||
// and filter by checking if the pattern matches the filename part
|
|
||||||
cmd.CommandText = @"
|
|
||||||
SELECT Guid, Path FROM Assets
|
|
||||||
WHERE Path LIKE '%' || @pattern || '%'
|
|
||||||
";
|
|
||||||
cmd.Parameters.AddWithValue("@pattern", sqlPattern);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(token);
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var guidStr = reader.GetString(0);
|
|
||||||
var path = reader.GetString(1);
|
|
||||||
|
|
||||||
// Extract filename and check if it matches the pattern
|
|
||||||
var fileName = Path.GetFileName(path);
|
|
||||||
|
|
||||||
// Convert pattern to regex for proper matching
|
|
||||||
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(namePattern)
|
|
||||||
.Replace("\\*", ".*")
|
|
||||||
.Replace("\\?", ".") + "$";
|
|
||||||
|
|
||||||
if (System.Text.RegularExpressions.Regex.IsMatch(fileName, regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
|
||||||
{
|
|
||||||
if (Guid.TryParse(guidStr, out var guid))
|
|
||||||
{
|
|
||||||
results.Add(guid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove orphaned entries from database (assets that no longer exist on disk).
|
|
||||||
/// </summary>
|
|
||||||
private async Task RemoveOrphanedEntriesAsync(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_dbConnection == null || AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var orphanedGuids = new List<Guid>();
|
|
||||||
|
|
||||||
await using var cmd = _dbConnection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT Guid, Path FROM Assets";
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(token);
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var guidStr = reader.GetString(0);
|
|
||||||
var path = reader.GetString(1);
|
|
||||||
|
|
||||||
if (Guid.TryParse(guidStr, out var guid))
|
|
||||||
{
|
|
||||||
// Check if file exists
|
|
||||||
var fullPath = Path.Combine(AssetsDirectory.FullName, path);
|
|
||||||
if (!File.Exists(fullPath))
|
|
||||||
{
|
|
||||||
orphanedGuids.Add(guid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove orphaned entries
|
|
||||||
foreach (var guid in orphanedGuids)
|
|
||||||
{
|
|
||||||
await RemoveAssetFromDatabaseAsync(guid, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Silently fail - cleanup is best effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading.Channels;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command types for asset database operations.
|
|
||||||
/// </summary>
|
|
||||||
internal enum AssetCommandType
|
|
||||||
{
|
|
||||||
FileCreated,
|
|
||||||
FileModified,
|
|
||||||
FileDeleted,
|
|
||||||
FileRenamed,
|
|
||||||
ManualRefresh
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a command to process an asset operation.
|
|
||||||
/// </summary>
|
|
||||||
internal readonly record struct AssetCommand(
|
|
||||||
AssetCommandType Type,
|
|
||||||
string Path,
|
|
||||||
string? OldPath = null,
|
|
||||||
DateTime Timestamp = default
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Centralized asset database that manages all assets in the project.
|
|
||||||
/// Handles asset registration, lookup, importing, and dependency management.
|
|
||||||
/// Uses SQLite for persistent storage and efficient querying.
|
|
||||||
/// </summary>
|
|
||||||
public partial class AssetService : IAssetService
|
|
||||||
{
|
|
||||||
private FileSystemWatcher? _watcher;
|
|
||||||
private readonly Lock _dbLock = new();
|
|
||||||
private readonly Dictionary<Guid, string> _assetPathLookup = new();
|
|
||||||
private readonly Dictionary<string, Guid> _pathAssetLookup = new();
|
|
||||||
|
|
||||||
// In-memory dirty asset tracking (for runtime modifications only)
|
|
||||||
// TODO: We do not handle the reimporting of dirty assets yet
|
|
||||||
private readonly HashSet<Guid> _dirtyAssets = new();
|
|
||||||
|
|
||||||
// Command buffer pattern - Channel for file system event commands
|
|
||||||
private Channel<AssetCommand>? _commandChannel;
|
|
||||||
private Timer? _commandProcessorTimer;
|
|
||||||
private readonly ConcurrentQueue<AssetCommand> _waitingCommands = new(); // Commands waiting for manual refresh
|
|
||||||
private bool _autoRefreshEnabled = true;
|
|
||||||
|
|
||||||
// Initialization guard
|
|
||||||
private readonly Lock _initializationLock = new();
|
|
||||||
private bool _initialized = false;
|
|
||||||
|
|
||||||
private readonly TimeSpan _debounceDelay = TimeSpan.FromMilliseconds(100);
|
|
||||||
private readonly ManualResetEventSlim _resetEventSlim = new(false);
|
|
||||||
|
|
||||||
private readonly JsonSerializerOptions _defaultJsonOptions = new()
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
Converters =
|
|
||||||
{
|
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public DirectoryInfo? AssetsDirectory
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
private set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Init the asset database.
|
|
||||||
/// Must be called after project is loaded.
|
|
||||||
/// </summary>
|
|
||||||
internal async Task Init(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
lock (_initializationLock)
|
|
||||||
{
|
|
||||||
if (_initialized)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
AssetsDirectory = new DirectoryInfo(Path.Combine(EditorApplication.CurrentProjectPath, EditorApplication.ASSETS_FOLDER_NAME));
|
|
||||||
|
|
||||||
_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
|
|
||||||
{
|
|
||||||
SingleReader = false,
|
|
||||||
SingleWriter = false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Init command processor timer (starts disabled, triggered by events)
|
|
||||||
_commandProcessorTimer = new Timer(ProcessPendingCommands, null, Timeout.Infinite, Timeout.Infinite);
|
|
||||||
|
|
||||||
await InitializeDatabaseAsync(token);
|
|
||||||
await LoadAssetCacheFromDatabaseAsync(token);
|
|
||||||
|
|
||||||
_watcher = new FileSystemWatcher
|
|
||||||
{
|
|
||||||
Path = AssetsDirectory.FullName,
|
|
||||||
IncludeSubdirectories = true,
|
|
||||||
EnableRaisingEvents = true,
|
|
||||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
|
|
||||||
};
|
|
||||||
|
|
||||||
InitializeAssetHandle();
|
|
||||||
InitializeMetaData();
|
|
||||||
|
|
||||||
// TODO: Timestamp fake instead of full scan.
|
|
||||||
await ValidateAndFixDatabaseAsync(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validate the asset database and fix any inconsistencies.
|
|
||||||
/// Checks for missing/corrupted assets and regenerates metadata as needed.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<Result> ValidateAndFixDatabaseAsync(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (AssetsDirectory == null)
|
|
||||||
{
|
|
||||||
return Result.Failure("AssetsDirectory not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Scan all files in assets directory
|
|
||||||
var allFiles = Directory.EnumerateFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories)
|
|
||||||
.Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
// Ensure all files have metadata
|
|
||||||
foreach (var file in allFiles)
|
|
||||||
{
|
|
||||||
var metaPath = file + Utilities.FileExtensions.META_FILE_EXTENSION;
|
|
||||||
if (!File.Exists(metaPath))
|
|
||||||
{
|
|
||||||
await GenerateMetaFileAsync(file, token);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Validate and update database
|
|
||||||
var metaResult = await ReadMetaFileAsync(file, token);
|
|
||||||
if (metaResult.IsSuccess)
|
|
||||||
{
|
|
||||||
var fileHash = await CalculateFileHashAsync(file, token);
|
|
||||||
await UpsertAssetAsync(file, metaResult.Value, fileHash, null, token);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Corrupted meta file - regenerate
|
|
||||||
await GenerateMetaFileAsync(file, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove orphaned entries from database (files that no longer exist)
|
|
||||||
await RemoveOrphanedEntriesAsync(token);
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to validate database: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refresh the asset database manually.
|
|
||||||
/// Scans the project directory for changes and processes any queued file system events.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<Result> RefreshAsync(CancellationToken token = default)
|
|
||||||
{
|
|
||||||
// Flush waiting commands to channel
|
|
||||||
while (_waitingCommands.TryDequeue(out var cmd))
|
|
||||||
{
|
|
||||||
_commandChannel?.Writer.TryWrite(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
_resetEventSlim.Reset();
|
|
||||||
_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
|
|
||||||
_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
|
||||||
|
|
||||||
await Task.Run(_resetEventSlim.Wait, token);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mark an asset as dirty (modified in memory but not yet saved).
|
|
||||||
/// This state is NOT persisted and will be lost on application restart.
|
|
||||||
/// </summary>
|
|
||||||
public void MarkDirty(Guid assetGuid)
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
_dirtyAssets.Add(assetGuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if an asset is marked as dirty.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsDirty(Guid assetGuid)
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
return _dirtyAssets.Contains(assetGuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get all dirty assets.
|
|
||||||
/// </summary>
|
|
||||||
public Guid[] GetDirtyAssets()
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
return _dirtyAssets.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear dirty flag for an asset (typically after saving).
|
|
||||||
/// </summary>
|
|
||||||
public void ClearDirty(Guid assetGuid)
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
_dirtyAssets.Remove(assetGuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear all dirty flags.
|
|
||||||
/// </summary>
|
|
||||||
public void ClearAllDirty()
|
|
||||||
{
|
|
||||||
lock (_dbLock)
|
|
||||||
{
|
|
||||||
_dirtyAssets.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enable or disable automatic asset database refresh.
|
|
||||||
/// When disabled, file system events are queued and processed only when RefreshAsync() is called.
|
|
||||||
/// </summary>
|
|
||||||
public void SetAutoRefresh(bool enabled)
|
|
||||||
{
|
|
||||||
_autoRefreshEnabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void FlushPendingCommands()
|
|
||||||
{
|
|
||||||
// Stop timer temporarily
|
|
||||||
_commandProcessorTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
|
||||||
|
|
||||||
// Give a tiny bit of time for any in-flight file watcher events to post to channel
|
|
||||||
Thread.Sleep(50);
|
|
||||||
|
|
||||||
// Process all commands now
|
|
||||||
ProcessPendingCommands(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask PostCommandAsync(AssetCommand command, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (_commandChannel == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_autoRefreshEnabled)
|
|
||||||
{
|
|
||||||
await _commandChannel.Writer.WriteAsync(command, token);
|
|
||||||
_commandProcessorTimer?.Change(_debounceDelay, Timeout.InfiniteTimeSpan);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_waitingCommands.Enqueue(command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void ProcessPendingCommands(object? state)
|
|
||||||
{
|
|
||||||
if (_commandChannel == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// // Collect all pending commands
|
|
||||||
// var commands = new List<AssetCommand>();
|
|
||||||
//
|
|
||||||
// while (_commandChannel.Reader.TryRead(out var cmd))
|
|
||||||
// {
|
|
||||||
// commands.Add(cmd);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Group commands by path (last command wins)
|
|
||||||
// var commandsByPath = new Dictionary<string, AssetCommand>();
|
|
||||||
// foreach (var cmd in commands)
|
|
||||||
// {
|
|
||||||
// commandsByPath[cmd.Path] = cmd;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// NOTE: We handle the temp file filtering in each command handler now
|
|
||||||
// We should able to remove this allocation heavy code
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// NOTE: We many don't need to collect all commands first, just process as we read.
|
|
||||||
// Channel in c# is thread-safe for multiple readers/writers.
|
|
||||||
//await foreach (var cmd in _commandChannel.Reader.ReadAllAsync())
|
|
||||||
//{
|
|
||||||
// await ExecuteCommandAsync(cmd);
|
|
||||||
//}
|
|
||||||
|
|
||||||
while (_commandChannel.Reader.TryRead(out var cmd))
|
|
||||||
{
|
|
||||||
await ExecuteCommandAsync(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ImportDirtyAssetsAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError($"Error processing commands: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_resetEventSlim.Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask ExecuteCommandAsync(AssetCommand command)
|
|
||||||
{
|
|
||||||
switch (command.Type)
|
|
||||||
{
|
|
||||||
case AssetCommandType.FileCreated:
|
|
||||||
await HandleFileCreatedAsync(command.Path);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AssetCommandType.FileModified:
|
|
||||||
await HandleFileModifiedAsync(command.Path);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AssetCommandType.FileDeleted:
|
|
||||||
await HandleFileDeletedAsync(command.Path);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AssetCommandType.FileRenamed:
|
|
||||||
if (command.OldPath != null)
|
|
||||||
{
|
|
||||||
await HandleFileRenamedAsync(command.OldPath, command.Path);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AssetCommandType.ManualRefresh:
|
|
||||||
await ValidateAndFixDatabaseAsync(CancellationToken.None);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask HandleFileCreatedAsync(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await GenerateMetaFileAsync(path, CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask HandleFileModifiedAsync(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file hash changed
|
|
||||||
var metaResult = await ReadMetaFileAsync(path, CancellationToken.None);
|
|
||||||
if (metaResult.IsFailure)
|
|
||||||
{
|
|
||||||
// No .gmeta file - treat this as a new file creation
|
|
||||||
await HandleFileCreatedAsync(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var newHash = await CalculateFileHashAsync(path, CancellationToken.None);
|
|
||||||
var oldHash = await GetFileHashAsync(metaResult.Value.Guid, CancellationToken.None);
|
|
||||||
|
|
||||||
if (oldHash != newHash)
|
|
||||||
{
|
|
||||||
// File changed - update database and mark as dirty
|
|
||||||
await UpsertAssetAsync(path, metaResult.Value, newHash, null, CancellationToken.None);
|
|
||||||
MarkDirty(metaResult.Value.Guid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask HandleFileDeletedAsync(string path)
|
|
||||||
{
|
|
||||||
var metaFileResult = GetMetaFilePath(path);
|
|
||||||
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var metaResult = await ReadMetaFileAsync(path, CancellationToken.None);
|
|
||||||
if (metaResult.IsSuccess)
|
|
||||||
{
|
|
||||||
var meta = metaResult.Value;
|
|
||||||
|
|
||||||
// Remove from database
|
|
||||||
await RemoveAssetFromDatabaseAsync(meta.Guid, CancellationToken.None);
|
|
||||||
|
|
||||||
// Mark dependent assets as dirty
|
|
||||||
await MarkDependentAssetsDirtyAsync(meta.Guid);
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Delete(metaFileResult.Value);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError($"Error deleting asset metadata: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask HandleFileRenamedAsync(string oldPath, string newPath)
|
|
||||||
{
|
|
||||||
var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION;
|
|
||||||
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
|
|
||||||
|
|
||||||
if (File.Exists(newMetaPath))
|
|
||||||
{
|
|
||||||
// Validate and update
|
|
||||||
await GenerateMetaFileAsync(newPath, CancellationToken.None);
|
|
||||||
}
|
|
||||||
else if (File.Exists(oldMetaPath))
|
|
||||||
{
|
|
||||||
// Move meta file
|
|
||||||
File.Move(oldMetaPath, newMetaPath);
|
|
||||||
|
|
||||||
// Update database with new path and recalculated hash
|
|
||||||
var metaResult = await ReadMetaFileAsync(newPath, CancellationToken.None);
|
|
||||||
if (metaResult.IsSuccess)
|
|
||||||
{
|
|
||||||
var fileHash = await CalculateFileHashAsync(newPath, CancellationToken.None);
|
|
||||||
await UpsertAssetAsync(newPath, metaResult.Value, fileHash, null, CancellationToken.None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Generate new meta file
|
|
||||||
await GenerateMetaFileAsync(newPath, CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old meta if it still exists
|
|
||||||
if (File.Exists(oldMetaPath) && oldMetaPath != newMetaPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Delete(oldMetaPath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void Shutdown()
|
|
||||||
{
|
|
||||||
lock (_initializationLock)
|
|
||||||
{
|
|
||||||
if (!_initialized)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_watcher?.Dispose();
|
|
||||||
_watcher = null;
|
|
||||||
|
|
||||||
_commandProcessorTimer?.Dispose();
|
|
||||||
_commandProcessorTimer = null;
|
|
||||||
|
|
||||||
_dbConnection?.Close();
|
|
||||||
_dbConnection?.Dispose();
|
|
||||||
_dbConnection = null;
|
|
||||||
|
|
||||||
_assetPathLookup.Clear();
|
|
||||||
_pathAssetLookup.Clear();
|
|
||||||
_dirtyAssets.Clear();
|
|
||||||
_waitingCommands.Clear();
|
|
||||||
_importerInstances.Clear();
|
|
||||||
_importerTypeLookup.Clear();
|
|
||||||
|
|
||||||
_initialized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# Asset Database Architecture
|
|
||||||
|
|
||||||
This document details the architectural design and data flow of the `AssetHandle` module in Ghost Editor.
|
|
||||||
|
|
||||||
## System Overview
|
|
||||||
|
|
||||||
The Asset Database acts as the bridge between the raw file system (Source Assets) and the runtime engine (Imported Assets). It maintains a consistent state using a dual-storage approach:
|
|
||||||
1. **File System**: The source of truth. Contains source files (e.g., `.png`, `.fbx`) and metadata files (`.gmeta`).
|
|
||||||
2. **SQLite Database**: An acceleration layer (cache) for fast lookups, dependency tracking, and searching.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### 1. Asset Discovery & Registration
|
|
||||||
When the editor starts or a file changes:
|
|
||||||
1. **FileSystemWatcher** detects the change (Create/Delete/Modify/Rename).
|
|
||||||
2. **Event Handler** queues an `AssetCommand` (debounce mechanism prevents event storms).
|
|
||||||
3. **Command Processor** executes the command:
|
|
||||||
* **New File**: Generates a `.gmeta` file with a new GUID and default settings. Adds to SQLite.
|
|
||||||
* **Modified File**: Checks hash. If changed, marks asset as "Dirty" and updates SQLite.
|
|
||||||
* **Deleted File**: Removes from SQLite and marks dependents as "Dirty".
|
|
||||||
|
|
||||||
### 2. Import Pipeline
|
|
||||||
The import process converts source formats into engine-ready data.
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. `AssetDatabase.ImportDirtyAssetsAsync()` or direct `ImportAssetAsync` is called.
|
|
||||||
2. System looks up the registered `AssetImporter` for the file extension.
|
|
||||||
3. `AssetImporter.ImportAsync` is invoked with the source path and metadata.
|
|
||||||
4. Importer reads source file and settings from metadata.
|
|
||||||
5. Importer processes data (e.g., compiles shaders, compresses textures).
|
|
||||||
6. Importer calls `AssetDatabase.SaveImportedAsset(guid, data)`.
|
|
||||||
7. Data is serialized to JSON (or binary) in the `Cache/ImportedAssets` directory as `{GUID}.asset`.
|
|
||||||
|
|
||||||
### 3. Loading Pipeline
|
|
||||||
When the engine requests an asset:
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. `AssetDatabase.LoadAsset<T>(guid)` is called.
|
|
||||||
2. **Memory Cache Check**:
|
|
||||||
* Checks `s_assetCache` (ConcurrentDictionary).
|
|
||||||
* If found: Updates LRU timestamp and returns object.
|
|
||||||
* If not found: Proceeds to disk load.
|
|
||||||
3. **Disk Load**:
|
|
||||||
* Locates `{GUID}.asset` in `Cache/ImportedAssets`.
|
|
||||||
* Deserializes the data into the target runtime type (e.g., `TextureAsset`).
|
|
||||||
4. **Cache Update**:
|
|
||||||
* Adds new object to `s_assetCache`.
|
|
||||||
* If cache size > `MAX_CACHED_ASSETS` (1000), evicts oldest 20% based on access time.
|
|
||||||
|
|
||||||
## Key Components Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
User[Editor / User] -->|File Ops| API[AssetDatabase API]
|
|
||||||
FS[File System] -->|Events| Watcher[FileSystemWatcher]
|
|
||||||
|
|
||||||
subgraph AssetDatabase
|
|
||||||
API --> DB[SQLite Database]
|
|
||||||
API --> Meta[Meta Handler]
|
|
||||||
API --> Loader[Asset Loader]
|
|
||||||
API --> Importer[Import System]
|
|
||||||
|
|
||||||
Watcher -->|Queue| Cmd[Command Processor]
|
|
||||||
Cmd --> Meta
|
|
||||||
Cmd --> DB
|
|
||||||
|
|
||||||
Importer -->|Read| FS
|
|
||||||
Importer -->|Write| Cache[Imported Assets Cache]
|
|
||||||
|
|
||||||
Loader -->|Read| Cache
|
|
||||||
Loader -->|Check| MemCache[Memory LRU Cache]
|
|
||||||
end
|
|
||||||
|
|
||||||
Meta -->|Read/Write| FS
|
|
||||||
DB -->|Index| FS
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema (SQLite)
|
|
||||||
|
|
||||||
The `AssetDatabase.db` contains a single `Assets` table:
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| **Guid** | TEXT (PK) | The unique identifier of the asset. |
|
|
||||||
| **Path** | TEXT | Relative path from `Assets/` folder. Indexed for fast lookup. |
|
|
||||||
| **Version** | INTEGER | Importer version for migration support. |
|
|
||||||
| **Tags** | TEXT | JSON array of string tags. |
|
|
||||||
| **FileHash** | TEXT | SHA256 hash of the source file content. |
|
|
||||||
| **DependencyGuids** | TEXT | JSON array of GUIDs this asset depends on. |
|
|
||||||
| **LastModified** | INTEGER | Unix timestamp of last modification. |
|
|
||||||
|
|
||||||
## Detailed Subsystems
|
|
||||||
|
|
||||||
### Metadata System (`.gmeta`)
|
|
||||||
* **Format**: JSON.
|
|
||||||
* **Content**: GUID, Version, Tags, ImporterSettings (per importer type).
|
|
||||||
* **Strategy**: The `.gmeta` file is the *only* place the persistent GUID lives. If the database is corrupted, it can be rebuilt entirely by scanning the file system and reading `.gmeta` files.
|
|
||||||
|
|
||||||
### Threading & Safety
|
|
||||||
* **Locks**:
|
|
||||||
* `s_dbLock`: Protects in-memory dictionaries (`s_assetPathLookup`) and dirty tracking.
|
|
||||||
* `s_commandLock`: Protects the command queue for file events.
|
|
||||||
* **Async**: Heavy I/O operations (DB access, File I/O) are async.
|
|
||||||
* **Channels**: Uses `System.Threading.Channels` to decouple high-frequency file system events from database processing.
|
|
||||||
|
|
||||||
### Importer Registry
|
|
||||||
* Uses `TypeCache` and reflection to find classes with `[AssetImporter]`.
|
|
||||||
* Mappings are stored in `s_importerTypeLookup` (Extension -> Type).
|
|
||||||
* Importers are stateless (instantiated on demand or cached as singletons depending on implementation, currently cached in `s_importerInstances`).
|
|
||||||
|
|
||||||
## Future Improvements / Known Limitations
|
|
||||||
|
|
||||||
1. **Binary Formats**: Currently, imported assets are stored as JSON. For large assets (textures, models), a binary format is required for performance.
|
|
||||||
2. **Dependency Graph**: While dependencies are stored, a full graph traversal for complex invalidation (e.g., if A changes, re-import B which depends on A) is partial.
|
|
||||||
3. **Cross-Process Locking**: SQLite is file-based; concurrent access from multiple editor instances needs careful file locking mode configuration.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# Asset Database Documentation
|
|
||||||
|
|
||||||
The Asset Database is a core component of the Ghost Editor responsible for managing the lifecycle, storage, import, and retrieval of project assets. It provides a unified API for interacting with assets, ensuring that metadata (GUIDs, tags, settings) stays synchronized with files on disk.
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- **GUID-based Asset Identification**: Every asset is uniquely identified by a stable GUID, stored in a sidecar `.gmeta` file.
|
|
||||||
- **Automatic Importing**: Monitors the file system for changes and automatically imports assets using registered importers.
|
|
||||||
- **Dependency Tracking**: Tracks dependencies between assets to ensure validity and trigger re-imports when dependencies change.
|
|
||||||
- **Caching**: Implements an LRU (Least Recently Used) cache for loaded assets to optimize performance.
|
|
||||||
- **SQLite Backed**: Uses a local SQLite database for fast lookups (Path <-> GUID) and metadata queries.
|
|
||||||
- **Metadata Management**: Handles `.gmeta` files automatically, including generation, validation, and cleanup.
|
|
||||||
|
|
||||||
## usage
|
|
||||||
|
|
||||||
### Initialization
|
|
||||||
The Asset Database must be initialized after the project is loaded.
|
|
||||||
```csharp
|
|
||||||
await AssetDatabase.Initialize(cancellationToken);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading Assets
|
|
||||||
Assets can be loaded by GUID or by Path.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Load by Path
|
|
||||||
var result = AssetDatabase.LoadAssetAtPath<TextureAsset>("Assets/Textures/my_texture.png");
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
var texture = result.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load by GUID
|
|
||||||
var guid = ...;
|
|
||||||
var result = AssetDatabase.LoadAsset<TextureAsset>(guid);
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Operations
|
|
||||||
Always use the `AssetDatabase` API for file operations to ensure metadata is preserved.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Create
|
|
||||||
await AssetDatabase.CreateAssetAsync("Assets/Data/config.json", dataBytes);
|
|
||||||
|
|
||||||
// Move
|
|
||||||
await AssetDatabase.MoveAssetAsync("Assets/Old/file.txt", "Assets/New/file.txt");
|
|
||||||
|
|
||||||
// Copy
|
|
||||||
await AssetDatabase.CopyAssetAsync("Assets/template.txt", "Assets/instance.txt");
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
await AssetDatabase.DeleteAssetAsync("Assets/garbage.tmp");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Searching
|
|
||||||
Find assets using wildcards or tags.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Find all PNGs
|
|
||||||
var guids = await AssetDatabase.FindAssetsByNameAsync("*.png");
|
|
||||||
|
|
||||||
// Find assets with a specific tag
|
|
||||||
var enemyAssets = await AssetDatabase.FindAssetsByTagAsync("Enemy");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tags
|
|
||||||
Manage asset tags for organization.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Get tags
|
|
||||||
var tagsResult = await AssetDatabase.GetAssetTagsAsync(guid);
|
|
||||||
|
|
||||||
// Set tags
|
|
||||||
await AssetDatabase.SetAssetTagsAsync(guid, new List<string> { "Level1", "Prop" });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Opening Assets
|
|
||||||
Open an asset using its registered handler or the system default.
|
|
||||||
```csharp
|
|
||||||
AssetDatabase.OpenAsset("Assets/Docs/readme.txt");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extending the Asset Database
|
|
||||||
|
|
||||||
### Creating a New Importer
|
|
||||||
To support a new file type, create a class that inherits from `AssetImporter<T>` and decorate it with the `[AssetImporter]` attribute.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[AssetImporter(".myfmt")]
|
|
||||||
internal class MyFormatImporter : AssetImporter<MyFormatSettings>
|
|
||||||
{
|
|
||||||
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
|
|
||||||
{
|
|
||||||
var settings = GetSettings(meta);
|
|
||||||
|
|
||||||
// 1. Read source file
|
|
||||||
// 2. Process data
|
|
||||||
// 3. Save imported data using AssetDatabase.SaveImportedAsset
|
|
||||||
|
|
||||||
var myAsset = new MyAsset(meta.Guid) { ... };
|
|
||||||
return AssetDatabase.SaveImportedAsset(meta.Guid, myAsset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class MyFormatSettings : ImporterSettings
|
|
||||||
{
|
|
||||||
public float Scale { get; set; } = 1.0f;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating an Open Handler
|
|
||||||
To define custom behavior when an asset is opened (e.g., double-clicked in the editor), use the `[AssetOpenHandler]` attribute.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
internal static class MyHandlers
|
|
||||||
{
|
|
||||||
[AssetOpenHandler(".myfmt")]
|
|
||||||
private static void OpenMyFormat(string path)
|
|
||||||
{
|
|
||||||
// Open custom editor window
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Internal Architecture
|
|
||||||
|
|
||||||
- **AssetDatabase.cs**: Core initialization and event coordination.
|
|
||||||
- **AssetDatabase.SQLite.cs**: Database table management and queries.
|
|
||||||
- **AssetDatabase.Meta.cs**: `.gmeta` file handling and file system watcher events.
|
|
||||||
- **AssetDatabase.Importer.cs**: Importer discovery and execution.
|
|
||||||
- **AssetDatabase.Loader.cs**: Asset loading and caching logic.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
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 ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Export in-memory asset data to disk.
|
|
||||||
/// Override this method to support creating assets from code.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Type of asset data to export.</typeparam>
|
|
||||||
/// <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 ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
|
|
||||||
where T : class
|
|
||||||
{
|
|
||||||
return ValueTask.FromResult(Result.Failure("This importer does not support exporting assets."));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validate dependencies referenced by this asset.
|
|
||||||
/// Dependencies are extracted from asset content during import and stored in the database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param>
|
|
||||||
/// <param name="assetService">The asset service instance.</param>
|
|
||||||
/// <returns>Result indicating if all dependencies are valid.</returns>
|
|
||||||
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies, IAssetService assetService, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
foreach (var dependencyGuid in dependencies)
|
|
||||||
{
|
|
||||||
var path = assetService.GuidToPath(dependencyGuid);
|
|
||||||
if (path.IsFailure)
|
|
||||||
{
|
|
||||||
return ValueTask.FromResult(Result.Failure($"Missing dependency: {dependencyGuid}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(path.Value))
|
|
||||||
{
|
|
||||||
return ValueTask.FromResult(Result.Failure($"Dependency file does not exist: {path.Value}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Metadata for an asset, stored in .gmeta files.
|
|
||||||
/// Contains GUID, version, tags, and importer settings.
|
|
||||||
/// FileHash and Dependencies are stored in the database only, not in .gmeta files.
|
|
||||||
/// </summary>
|
|
||||||
public class AssetMeta
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Unique identifier for the asset.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("Guid")]
|
|
||||||
public Guid Guid
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Version of the asset pipeline (not the asset itself).
|
|
||||||
/// Used for migration when the asset pipeline is redesigned.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("Version")]
|
|
||||||
public int Version
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tags for categorizing and searching assets.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("Tags")]
|
|
||||||
public List<string> Tags
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Importer settings specific to this asset.
|
|
||||||
/// The key is the importer type name, and the value is a JSON element containing the settings.
|
|
||||||
/// Use GetImporterSettings<T>() and SetImporterSettings<T>() to work with strongly-typed settings.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("ImporterSettings")]
|
|
||||||
public Dictionary<string, JsonElement> ImporterSettings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get importer settings of a specific type.
|
|
||||||
/// </summary>
|
|
||||||
public T? GetImporterSettings<T>(string importerName) where T : ImporterSettings
|
|
||||||
{
|
|
||||||
if (ImporterSettings.TryGetValue(importerName, out var element))
|
|
||||||
{
|
|
||||||
return element.Deserialize<T>();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set importer settings.
|
|
||||||
/// </summary>
|
|
||||||
public void SetImporterSettings<T>(string importerName, T settings) where T : ImporterSettings
|
|
||||||
{
|
|
||||||
var element = JsonSerializer.SerializeToElement(settings);
|
|
||||||
ImporterSettings[importerName] = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set importer settings (non-generic overload).
|
|
||||||
/// </summary>
|
|
||||||
internal void SetImporterSettings(string importerName, ImporterSettings settings)
|
|
||||||
{
|
|
||||||
var element = JsonSerializer.SerializeToElement(settings, settings.GetType());
|
|
||||||
ImporterSettings[importerName] = element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
public abstract class ImporterSettings
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle.Importers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Example importer settings for text assets.
|
|
||||||
/// </summary>
|
|
||||||
internal class TextImporterSettings : ImporterSettings
|
|
||||||
{
|
|
||||||
public string Encoding
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = "UTF-8";
|
|
||||||
|
|
||||||
public bool TrimWhitespace
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Example importer for text files (.txt, .md).
|
|
||||||
/// This is a simple test importer to demonstrate the asset import system.
|
|
||||||
/// </summary>
|
|
||||||
[AssetImporter(".txt", ".md")]
|
|
||||||
internal class TextImporter : AssetImporter<TextImporterSettings>
|
|
||||||
{
|
|
||||||
public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var settings = GetSettings(meta);
|
|
||||||
|
|
||||||
// Text files typically don't have dependencies
|
|
||||||
// If they did, you would extract them from the content here
|
|
||||||
var dependencies = new List<Guid>();
|
|
||||||
|
|
||||||
// Validate dependencies
|
|
||||||
var depResult = await ValidateDependenciesAsync(dependencies, assetService, token);
|
|
||||||
if (depResult.IsFailure)
|
|
||||||
{
|
|
||||||
return depResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Read the file
|
|
||||||
var content = await File.ReadAllTextAsync(assetPath, token);
|
|
||||||
|
|
||||||
if (settings.TrimWhitespace)
|
|
||||||
{
|
|
||||||
content = content.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Process the text content
|
|
||||||
// For example:
|
|
||||||
// - Convert to a specific format
|
|
||||||
// - Extract metadata
|
|
||||||
// - Generate assets
|
|
||||||
// - Save to output folder
|
|
||||||
|
|
||||||
// For now, just report success
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to import text asset: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle.Importers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Importer settings for texture assets.
|
|
||||||
/// </summary>
|
|
||||||
internal class TextureImporterSettings : ImporterSettings
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to generate mipmaps for the texture.
|
|
||||||
/// </summary>
|
|
||||||
public bool GenerateMipmaps
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the texture uses sRGB color space.
|
|
||||||
/// </summary>
|
|
||||||
public bool SRGB
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum texture size. Images larger than this will be downscaled.
|
|
||||||
/// </summary>
|
|
||||||
public uint MaxSize
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = 2048;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Texture compression format.
|
|
||||||
/// Options: "None", "BC1", "BC3", "BC7"
|
|
||||||
/// </summary>
|
|
||||||
public string CompressionFormat
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = "None";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Texture filter mode.
|
|
||||||
/// Options: "Point", "Bilinear", "Trilinear"
|
|
||||||
/// </summary>
|
|
||||||
public string FilterMode
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = "Bilinear";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Texture wrap mode.
|
|
||||||
/// Options: "Repeat", "Clamp", "Mirror"
|
|
||||||
/// </summary>
|
|
||||||
public string WrapMode
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
} = "Repeat";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Importer for texture files (.png, .jpg, .jpeg, .dds, .tga, .bmp).
|
|
||||||
/// Processes image files and converts them into engine-ready texture assets.
|
|
||||||
/// </summary>
|
|
||||||
[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")]
|
|
||||||
internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
|
||||||
{
|
|
||||||
public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var settings = GetSettings(meta);
|
|
||||||
|
|
||||||
// Textures typically don't reference other assets as dependencies
|
|
||||||
//var dependencies = new List<Guid>();
|
|
||||||
|
|
||||||
//// Validate dependencies
|
|
||||||
//var depResult = await ValidateDependenciesAsync(dependencies, assetService, token);
|
|
||||||
//if (depResult.IsFailure)
|
|
||||||
//{
|
|
||||||
// return depResult;
|
|
||||||
//}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Check if file exists
|
|
||||||
if (!File.Exists(assetPath))
|
|
||||||
{
|
|
||||||
return Result.Failure($"Source texture file not found: {assetPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get image dimensions (simplified - in real implementation would use image library)
|
|
||||||
var (width, height) = GetImageDimensions(assetPath);
|
|
||||||
|
|
||||||
if (width == 0 || height == 0)
|
|
||||||
{
|
|
||||||
return Result.Failure("Failed to read image dimensions");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply max size constraint
|
|
||||||
if (width > settings.MaxSize || height > settings.MaxSize)
|
|
||||||
{
|
|
||||||
var scale = Math.Min(settings.MaxSize / (float)width, settings.MaxSize / (float)height);
|
|
||||||
width = (uint)(width * scale);
|
|
||||||
height = (uint)(height * scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate mipmap count
|
|
||||||
uint mipLevels = 1;
|
|
||||||
if (settings.GenerateMipmaps)
|
|
||||||
{
|
|
||||||
mipLevels = CalculateMipLevels(width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine format
|
|
||||||
var format = settings.CompressionFormat == "None" ? "RGBA8" : settings.CompressionFormat;
|
|
||||||
|
|
||||||
// Create texture asset
|
|
||||||
var textureAsset = new TextureAsset(meta.Guid, Path.GetFileNameWithoutExtension(assetPath))
|
|
||||||
{
|
|
||||||
Width = width,
|
|
||||||
Height = height,
|
|
||||||
MipLevels = mipLevels,
|
|
||||||
Format = format,
|
|
||||||
IsSRGB = settings.SRGB,
|
|
||||||
SourcePath = assetPath
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save the imported asset data
|
|
||||||
var saveResult = assetService.SaveImportedAsset(meta.Guid, textureAsset);
|
|
||||||
if (saveResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to save texture asset: {saveResult.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real implementation, you would:
|
|
||||||
// 1. Load the image using a library like ImageSharp or StbImageSharp
|
|
||||||
// 2. Resize if needed
|
|
||||||
// 3. Generate mipmaps
|
|
||||||
// 4. Compress if needed
|
|
||||||
// 5. Save the processed texture data to the ImportedAssets folder
|
|
||||||
// 6. Update the hash in database
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to import texture: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get image dimensions from file.
|
|
||||||
/// Simplified implementation - in production, use an image library.
|
|
||||||
/// </summary>
|
|
||||||
private static (uint width, uint height) GetImageDimensions(string imagePath)
|
|
||||||
{
|
|
||||||
// This is a placeholder implementation
|
|
||||||
// In a real implementation, you would use a library like:
|
|
||||||
// - ImageSharp
|
|
||||||
// - StbImageSharp
|
|
||||||
// - DirectXTex (for DDS files)
|
|
||||||
|
|
||||||
var extension = Path.GetExtension(imagePath).ToLowerInvariant();
|
|
||||||
|
|
||||||
if (extension == ".dds")
|
|
||||||
{
|
|
||||||
// For DDS files, read the header
|
|
||||||
// DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
|
|
||||||
return ReadDDSHeader(imagePath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// For PNG/JPG/etc, we would use an image library
|
|
||||||
// For now, return placeholder values
|
|
||||||
return (1024, 1024);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read DDS file header to get dimensions.
|
|
||||||
/// </summary>
|
|
||||||
private static (uint width, uint height) ReadDDSHeader(string ddsPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var stream = File.OpenRead(ddsPath);
|
|
||||||
using var reader = new BinaryReader(stream);
|
|
||||||
|
|
||||||
// Read magic number (should be "DDS ")
|
|
||||||
var magic = reader.ReadUInt32();
|
|
||||||
if (magic != 0x20534444) // "DDS " in little-endian
|
|
||||||
{
|
|
||||||
return (0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read header size (should be 124)
|
|
||||||
var headerSize = reader.ReadUInt32();
|
|
||||||
if (headerSize != 124)
|
|
||||||
{
|
|
||||||
return (0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip flags
|
|
||||||
reader.ReadUInt32();
|
|
||||||
|
|
||||||
// Read height and width
|
|
||||||
var height = reader.ReadUInt32();
|
|
||||||
var width = reader.ReadUInt32();
|
|
||||||
|
|
||||||
return (width, height);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return (0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Export a texture asset from memory to disk.
|
|
||||||
/// </summary>
|
|
||||||
public override async ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (assetData is not TextureAsset textureAsset)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Asset data is not a TextureAsset, got {typeof(T).Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// In a real implementation, you would:
|
|
||||||
// 1. Convert the texture data to the appropriate format
|
|
||||||
// 2. Write the image file (PNG, DDS, etc.)
|
|
||||||
// 3. Save metadata
|
|
||||||
|
|
||||||
// For now, just save metadata as JSON
|
|
||||||
var json = JsonSerializer.Serialize(textureAsset, new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true
|
|
||||||
});
|
|
||||||
|
|
||||||
await File.WriteAllTextAsync(assetPath, json, token);
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to export texture: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculate number of mipmap levels for a given texture size.
|
|
||||||
/// </summary>
|
|
||||||
private static uint CalculateMipLevels(uint width, uint height)
|
|
||||||
{
|
|
||||||
if (width == 0 || height == 0)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint count = 1;
|
|
||||||
while (width > 1 || height > 1)
|
|
||||||
{
|
|
||||||
width >>= 1;
|
|
||||||
height >>= 1;
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The base class for all asset types in the Ghost Editor.
|
|
||||||
/// </summary>
|
|
||||||
public abstract class Asset
|
|
||||||
{
|
|
||||||
public abstract string Name
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Asset(Guid id)
|
|
||||||
{
|
|
||||||
ID = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
namespace Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a texture asset.
|
|
||||||
/// </summary>
|
|
||||||
public class TextureAsset : Asset
|
|
||||||
{
|
|
||||||
public override string Name
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Width of the texture in pixels.
|
|
||||||
/// </summary>
|
|
||||||
public uint Width
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Height of the texture in pixels.
|
|
||||||
/// </summary>
|
|
||||||
public uint Height
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of mipmap levels.
|
|
||||||
/// </summary>
|
|
||||||
public uint MipLevels
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Texture format (e.g., "RGBA8", "BC1", "BC7").
|
|
||||||
/// </summary>
|
|
||||||
public string Format
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the texture uses sRGB color space.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsSRGB
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Relative path to the source image file.
|
|
||||||
/// </summary>
|
|
||||||
public string SourcePath
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TextureAsset(Guid id, string name) : base(id)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
Format = "RGBA8";
|
|
||||||
IsSRGB = true;
|
|
||||||
SourcePath = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.AssetHandle;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Contracts;
|
|
||||||
|
|
||||||
public interface IAssetService
|
|
||||||
{
|
|
||||||
DirectoryInfo? AssetsDirectory { get; }
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
Task<Result> RefreshAsync(CancellationToken token = default);
|
|
||||||
|
|
||||||
// Dirty tracking
|
|
||||||
void MarkDirty(Guid assetGuid);
|
|
||||||
bool IsDirty(Guid assetGuid);
|
|
||||||
Guid[] GetDirtyAssets();
|
|
||||||
void ClearDirty(Guid assetGuid);
|
|
||||||
void ClearAllDirty();
|
|
||||||
void SetAutoRefresh(bool enabled);
|
|
||||||
|
|
||||||
// Path <-> GUID lookup
|
|
||||||
Result<Guid> PathToGuid(string assetPath);
|
|
||||||
Result<string> GuidToPath(Guid guid);
|
|
||||||
|
|
||||||
// Asset loading
|
|
||||||
Result<T> LoadAsset<T>(Guid guid) where T : Asset;
|
|
||||||
Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset;
|
|
||||||
void UnloadAsset(Guid guid);
|
|
||||||
void UnloadAllAssets();
|
|
||||||
bool IsAssetLoaded(Guid guid);
|
|
||||||
(int currentSize, int maxSize) GetCacheStats();
|
|
||||||
Result SaveImportedAsset<T>(Guid guid, T assetData) where T : Asset;
|
|
||||||
|
|
||||||
// Asset tags
|
|
||||||
ValueTask<Result<List<string>>> GetAssetTagsAsync(Guid guid, CancellationToken token = default);
|
|
||||||
ValueTask<Result> SetAssetTagsAsync(Guid guid, List<string> tags, CancellationToken token = default);
|
|
||||||
|
|
||||||
// Asset search
|
|
||||||
Task<List<Guid>> FindAssetsByNameAsync(string namePattern, CancellationToken token = default);
|
|
||||||
Task<List<Guid>> FindAssetsByTagAsync(string tag, CancellationToken token = default);
|
|
||||||
IReadOnlyDictionary<Guid, string> GetAllAssets();
|
|
||||||
|
|
||||||
// Asset file operations
|
|
||||||
ValueTask<Result> CreateAssetAsync(string assetPath, ReadOnlyMemory<byte> content, CancellationToken token = default);
|
|
||||||
ValueTask<Result> CreateAssetAsync(string assetPath, CancellationToken token = default);
|
|
||||||
ValueTask<Result> DeleteAssetAsync(Guid guid, CancellationToken token = default);
|
|
||||||
ValueTask<Result> DeleteAssetAsync(string assetPath, CancellationToken token = default);
|
|
||||||
ValueTask<Result> MoveAssetAsync(Guid guid, string newPath, CancellationToken token = default);
|
|
||||||
ValueTask<Result> MoveAssetAsync(string oldPath, string newPath, CancellationToken token = default);
|
|
||||||
ValueTask<Result<Guid>> CopyAssetAsync(Guid guid, string newPath, CancellationToken token = default);
|
|
||||||
ValueTask<Result<Guid>> CopyAssetAsync(string sourcePath, string destPath, CancellationToken token = default);
|
|
||||||
Result MarkDirtyAsync(Guid guid, CancellationToken token = default);
|
|
||||||
Task<Result> ImportDirtyAssetsAsync(CancellationToken token = default);
|
|
||||||
|
|
||||||
// Importer management
|
|
||||||
Type? GetImporterType(string extension);
|
|
||||||
Dictionary<string, Type> GetAllImporters();
|
|
||||||
ValueTask<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData, CancellationToken token = default) where T : class;
|
|
||||||
|
|
||||||
// Asset opening
|
|
||||||
void OpenAsset(string path);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Ghost.Entities\Ghost.Entities.csproj" />
|
|
||||||
<ProjectReference Include="..\Ghost.Test.Core\Ghost.Test.Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
using Ghost.Entities.Test;
|
|
||||||
using Ghost.Test.Core;
|
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
|
||||||
|
|
||||||
AllocationManager.EnableDebugLayer();
|
|
||||||
TestRunner.Run<SerializationTest>();
|
|
||||||
AllocationManager.Dispose();
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<Solution>
|
|
||||||
<Configurations>
|
|
||||||
<Platform Name="ARM64" />
|
|
||||||
<Platform Name="x64" />
|
|
||||||
<Platform Name="x86" />
|
|
||||||
</Configurations>
|
|
||||||
<Folder Name="/Editor/">
|
|
||||||
<Project Path="Ghost.Editor.Core/Ghost.Editor.Core.csproj" />
|
|
||||||
<Project Path="Ghost.Editor/Ghost.Editor.csproj">
|
|
||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
|
||||||
<Platform Solution="*|x86" Project="x86" />
|
|
||||||
<Deploy />
|
|
||||||
</Project>
|
|
||||||
</Folder>
|
|
||||||
<Folder Name="/Library/">
|
|
||||||
<Project Path="Ghost.FMOD/Ghost.FMOD.csproj" />
|
|
||||||
<Project Path="Ghost.Zeux.MeshOptimizer/Ghost.Zeux.MeshOptimizer.csproj" />
|
|
||||||
</Folder>
|
|
||||||
<Folder Name="/Runtime/">
|
|
||||||
<Project Path="Ghost.Core/Ghost.Core.csproj" />
|
|
||||||
<Project Path="Ghost.Engine/Ghost.Engine.csproj" />
|
|
||||||
<Project Path="Ghost.Entities/Ghost.Entities.csproj" />
|
|
||||||
<Project Path="Ghost.Generator/Ghost.Generator.csproj" />
|
|
||||||
<Project Path="Ghost.Graphics/Ghost.Graphics.csproj" />
|
|
||||||
</Folder>
|
|
||||||
<Folder Name="/Test/">
|
|
||||||
<Project Path="Ghost.Entities.Test/Ghost.Entities.Test.csproj" />
|
|
||||||
<Project Path="Ghost.Graphics.Test/Ghost.Graphics.Test.csproj">
|
|
||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
|
||||||
<Platform Solution="*|x86" Project="x86" />
|
|
||||||
<Deploy />
|
|
||||||
</Project>
|
|
||||||
<Project Path="Ghost.MicroTest/Ghost.MicroTest.csproj" Id="8c8ffa4b-e1e4-46a1-9221-7b508a109edd" />
|
|
||||||
<Project Path="Ghost.Shader.Test/Ghost.Shader.Test.csproj" />
|
|
||||||
<Project Path="Ghost.Test.Core/Ghost.Test.Core.csproj" />
|
|
||||||
<Project Path="Ghost.UnitTest/Ghost.UnitTest.csproj" Id="4da45668-456b-4dcc-acd8-6bfe154e6837">
|
|
||||||
<Platform Solution="Debug|x64" Project="x64" />
|
|
||||||
</Project>
|
|
||||||
</Folder>
|
|
||||||
<Project Path="Ghost.DSL/Ghost.DSL.csproj" />
|
|
||||||
</Solution>
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Ghost.Core/Ghost.Core.csproj" />
|
<ProjectReference Include="../../Runtime/Ghost.Core/Ghost.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Core.Graphics;
|
using Ghost.Core.Graphics;
|
||||||
using Ghost.DSL.ShaderParser;
|
using Ghost.DSL.ShaderParser;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace Ghost.DSL.ShaderCompiler;
|
namespace Ghost.DSL.ShaderCompiler;
|
||||||
@@ -44,28 +45,31 @@ internal static class DSLShaderCompiler
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static uint CalculateCBufferSize(ReadOnlySpan<PropertyDescriptor> properties)
|
private static int LayoutCBufferProperties(Span<PropertyDescriptor> properties)
|
||||||
{
|
{
|
||||||
if (properties.IsEmpty)
|
if (properties.IsEmpty)
|
||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentOffset = 0u;
|
var currentOffset = 0;
|
||||||
|
|
||||||
foreach (var prop in properties)
|
foreach (ref var prop in properties)
|
||||||
{
|
{
|
||||||
var size = prop.type.GetSize();
|
var size = prop.type.GetSize();
|
||||||
|
|
||||||
if ((currentOffset % 16) + size > 16)
|
if ((currentOffset % 16) + size > 16)
|
||||||
{
|
{
|
||||||
currentOffset = (currentOffset + 15u) & ~15u;
|
currentOffset = (currentOffset + 15) & ~15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prop.offset = currentOffset;
|
||||||
|
prop.size = size;
|
||||||
|
|
||||||
currentOffset += size;
|
currentOffset += size;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (currentOffset + 15u) & ~15u;
|
return (currentOffset + 15) & ~15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
||||||
@@ -98,7 +102,7 @@ internal static class DSLShaderCompiler
|
|||||||
|
|
||||||
descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty<PropertyDescriptor>();
|
descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty<PropertyDescriptor>();
|
||||||
descriptor.properties = shaderLocalProperties ?? Array.Empty<PropertyDescriptor>();
|
descriptor.properties = shaderLocalProperties ?? Array.Empty<PropertyDescriptor>();
|
||||||
descriptor.cbufferSize = CalculateCBufferSize(descriptor.properties);
|
descriptor.cbufferSize = LayoutCBufferProperties(descriptor.properties);
|
||||||
|
|
||||||
if (semantics.passes != null)
|
if (semantics.passes != null)
|
||||||
{
|
{
|
||||||
@@ -264,7 +268,7 @@ internal static class DSLShaderCompiler
|
|||||||
#ifndef {fileDefine}
|
#ifndef {fileDefine}
|
||||||
#define {fileDefine}
|
#define {fileDefine}
|
||||||
|
|
||||||
#include ""F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Includes/Common.hlsl""");
|
#include ""F:/csharp/GhostEngine/src/Runtime//Ghost.Graphics/Shaders/Includes/Common.hlsl""");
|
||||||
|
|
||||||
sb.Append(@"
|
sb.Append(@"
|
||||||
struct PerMaterialData
|
struct PerMaterialData
|
||||||
@@ -303,7 +307,7 @@ struct PerMaterialData
|
|||||||
#ifndef GLOBALDATA_G_HLSL
|
#ifndef GLOBALDATA_G_HLSL
|
||||||
#define GLOBALDATA_G_HLSL
|
#define GLOBALDATA_G_HLSL
|
||||||
|
|
||||||
#include ""F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Includes/Common.hlsl""
|
#include ""F:/csharp/GhostEngine/src/Runtime//Ghost.Graphics/Shaders/Includes/Common.hlsl""
|
||||||
|
|
||||||
struct GlobalData
|
struct GlobalData
|
||||||
{");
|
{");
|
||||||
185
src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandler;
|
||||||
|
|
||||||
|
public abstract class Asset
|
||||||
|
{
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract Guid TypeID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid[] Dependencies
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Asset(Guid id, Guid[] dependencies, IAssetSettings? settings)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
Dependencies = dependencies;
|
||||||
|
Settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not change the order of the fields in this struct, as it is used for binary serialization/deserialization.
|
||||||
|
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
|
||||||
|
internal struct AssetMetadata
|
||||||
|
{
|
||||||
|
public const int CURRENT_FORMAT_VERSION = 1;
|
||||||
|
public const int SIZE = 128; // Fixed size for metadata header. We choose 128 bytes to allow future expansion without breaking compatibility.
|
||||||
|
|
||||||
|
public AssetMetadata(Guid id, Guid typeID)
|
||||||
|
{
|
||||||
|
FormatVersion = CURRENT_FORMAT_VERSION;
|
||||||
|
ID = id;
|
||||||
|
TypeID = typeID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FormatVersion
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TypeID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int HandlerVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int DependencyCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long DependenciesOffset
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long SettingsOffset
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long SettingsSize
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long ContentOffset
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long ContentSize
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteToStream(Stream stream, scoped ref readonly AssetMetadata metadata)
|
||||||
|
{
|
||||||
|
var buffer = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in metadata, 1));
|
||||||
|
stream.Write(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AssetMetadata ReadFromStream(Stream stream)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[SIZE];
|
||||||
|
stream.ReadExactly(buffer);
|
||||||
|
return Unsafe.ReadUnaligned<AssetMetadata>(ref MemoryMarshal.GetReference(buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
|
||||||
|
public readonly struct DependencyInfo
|
||||||
|
{
|
||||||
|
public const int SIZE = 16;
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get; init;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly ReadOnlySpan<byte> AsBytes()
|
||||||
|
{
|
||||||
|
return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct AssetReference : IEquatable<AssetReference>
|
||||||
|
{
|
||||||
|
private readonly int _value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The index of the asset in the dependency list.
|
||||||
|
/// </summary>
|
||||||
|
public int Index
|
||||||
|
{
|
||||||
|
get => Math.Abs(_value) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AssetReference Null => default;
|
||||||
|
|
||||||
|
public readonly bool IsInternal => _value >= 0;
|
||||||
|
public readonly bool IsExternal => _value < 0;
|
||||||
|
|
||||||
|
public bool Equals(AssetReference other)
|
||||||
|
{
|
||||||
|
return _value == other._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
return _value.GetHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
return obj is AssetReference reference && Equals(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator ==(AssetReference left, AssetReference right)
|
||||||
|
{
|
||||||
|
return left.Equals(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator !=(AssetReference left, AssetReference right)
|
||||||
|
{
|
||||||
|
return !(left == right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAssetSettings
|
||||||
|
{
|
||||||
|
ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default);
|
||||||
|
ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default);
|
||||||
|
}
|
||||||
66
src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandler;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public sealed class CustomAssetHandlerAttribute : Attribute
|
||||||
|
{
|
||||||
|
public required string ID
|
||||||
|
{
|
||||||
|
get; init;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AllowCaching
|
||||||
|
{
|
||||||
|
get; init;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
public required string[] SupportedExtensions
|
||||||
|
{
|
||||||
|
get; init;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DependencyUpdateType
|
||||||
|
{
|
||||||
|
Add,
|
||||||
|
Remove
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAssetExportOptions;
|
||||||
|
|
||||||
|
public interface IAssetHandler
|
||||||
|
{
|
||||||
|
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default);
|
||||||
|
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IImportableAssetHandler : IAssetHandler
|
||||||
|
{
|
||||||
|
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default);
|
||||||
|
ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AssetHandlerExtensions
|
||||||
|
{
|
||||||
|
public static async ValueTask<Result> ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
return await handler.ImportAsync(sourceStream, targetStream, id, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<Result> ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
return await handler.ExportAsync(assetStream, targetStream, options, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<Result<Asset>> ReadAsync(this IAssetHandler handler, string assetFilePath, IAssetRegistry assetDatabase, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
return await handler.LoadAsync(sourceStream, assetDatabase, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
378
src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Graphics.Core;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.Image;
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandler;
|
||||||
|
|
||||||
|
public enum TextureType : uint
|
||||||
|
{
|
||||||
|
Default,
|
||||||
|
Normal,
|
||||||
|
Lightmap,
|
||||||
|
SingleChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureShape : uint
|
||||||
|
{
|
||||||
|
Texture2D,
|
||||||
|
Texture3D,
|
||||||
|
TextureCube
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureSize : uint
|
||||||
|
{
|
||||||
|
Size256 = 256,
|
||||||
|
Size512 = 512,
|
||||||
|
Size1024 = 1024,
|
||||||
|
Size2048 = 2048,
|
||||||
|
Size4096 = 4096,
|
||||||
|
Size8192 = 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureCompressionLevel : uint
|
||||||
|
{
|
||||||
|
Low,
|
||||||
|
Normal,
|
||||||
|
High
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureCompressionEffort : uint
|
||||||
|
{
|
||||||
|
Fastest,
|
||||||
|
Normal,
|
||||||
|
Production
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MipmapFilter : uint
|
||||||
|
{
|
||||||
|
Box,
|
||||||
|
Triangle,
|
||||||
|
Kaiser,
|
||||||
|
MitchellNetravali
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TextureAsset : Asset
|
||||||
|
{
|
||||||
|
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
|
||||||
|
|
||||||
|
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
|
||||||
|
|
||||||
|
public override Guid TypeID => s_typeGuid;
|
||||||
|
|
||||||
|
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings)
|
||||||
|
: base(id, dependencies, settings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TextureAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
public struct BasicSettings()
|
||||||
|
{
|
||||||
|
public TextureType TextureType
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureType.Default;
|
||||||
|
|
||||||
|
public TextureShape TextureShape
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureShape.Texture2D;
|
||||||
|
|
||||||
|
public int Columns
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public int Rows
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public bool IsSRGB
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AdvancedSettings()
|
||||||
|
{
|
||||||
|
public bool StretchToPowerOfTwo
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
public bool VirtualTexture
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public bool GenerateMipmaps
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
public uint MipmapLevelCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 0; // 0 means generate full mipmap levels.
|
||||||
|
|
||||||
|
public bool GammaCorrection
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
public bool PremultiplyAlpha
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public MipmapFilter MipmapFilter
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = MipmapFilter.Kaiser;
|
||||||
|
|
||||||
|
public TextureCompressionLevel CompressionLevel
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureCompressionLevel.Normal;
|
||||||
|
|
||||||
|
public TextureCompressionEffort CompressionEffort
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureCompressionEffort.Normal;
|
||||||
|
|
||||||
|
public bool UseBorderColor
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public Color32 BorderColor
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new Color32(0, 0, 0, 0);
|
||||||
|
|
||||||
|
public bool ZeroAlphaBorder
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public bool CutoutAlpha
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public byte CutoutAlphaThreshold
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 127;
|
||||||
|
|
||||||
|
public bool ScaleAlphaForMipCoverage
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public byte ScaleAlphaForMipCoverageThreshold
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 127;
|
||||||
|
|
||||||
|
public bool MipmapStreaming
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SamplerSettings()
|
||||||
|
{
|
||||||
|
public TextureSize MaxSize
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureSize.Size2048;
|
||||||
|
|
||||||
|
public TextureFilterMode FilterMode
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureFilterMode.Anisotropic;
|
||||||
|
|
||||||
|
public TextureAddressMode WrapMode
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureAddressMode.Repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BasicSettings Basic
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new BasicSettings();
|
||||||
|
|
||||||
|
public AdvancedSettings Advanced
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new AdvancedSettings();
|
||||||
|
|
||||||
|
public SamplerSettings Sampler
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new SamplerSettings();
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
|
||||||
|
var tempArray = ArrayPool<byte>.Shared.Rent(size);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ref byte address = ref MemoryMarshal.GetReference(tempArray);
|
||||||
|
Unsafe.WriteUnaligned(ref address, Basic);
|
||||||
|
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()), Advanced);
|
||||||
|
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()), Sampler);
|
||||||
|
|
||||||
|
await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Result.Success<long>(size);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(tempArray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
|
||||||
|
var tempArray = ArrayPool<byte>.Shared.Rent(size);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ref byte address = ref MemoryMarshal.GetReference(tempArray);
|
||||||
|
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
|
||||||
|
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref address);
|
||||||
|
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()));
|
||||||
|
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()));
|
||||||
|
|
||||||
|
var settings = new TextureAssetSettings
|
||||||
|
{
|
||||||
|
Basic = basic,
|
||||||
|
Advanced = advanced,
|
||||||
|
Sampler = sampler
|
||||||
|
};
|
||||||
|
|
||||||
|
return Result.Success<IAssetSettings>(settings);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(tempArray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class TextureAssetHandler : IImportableAssetHandler
|
||||||
|
{
|
||||||
|
private const int _CURRENT_VERSION = 1;
|
||||||
|
|
||||||
|
public ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var info = ImageInfo.FromStream(sourceStream);
|
||||||
|
if (info.BitsPerChannel <= 0)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ref byte pData = ref Unsafe.NullRef<byte>();
|
||||||
|
var imageSize = 0ul;
|
||||||
|
var isFloat = info.BitsPerChannel > 8;
|
||||||
|
|
||||||
|
if (isFloat)
|
||||||
|
{
|
||||||
|
using var image = ImageResultFloat.FromStream(sourceStream, info.ColorComponents);
|
||||||
|
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
|
||||||
|
imageSize = image.Size;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var image = ImageResult.FromStream(sourceStream, info.ColorComponents);
|
||||||
|
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
|
||||||
|
imageSize = image.Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
|
||||||
|
{
|
||||||
|
HandlerVersion = _CURRENT_VERSION,
|
||||||
|
SettingsOffset = AssetMetadata.SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
targetStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
AssetMetadata.WriteToStream(targetStream, ref header);
|
||||||
|
|
||||||
|
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
|
||||||
|
var settings = new TextureAssetSettings();
|
||||||
|
var sizeResult = await settings.WriteToStreamAsync(targetStream, token).ConfigureAwait(false);
|
||||||
|
if (sizeResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
header.SettingsSize = sizeResult.Value;
|
||||||
|
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
|
||||||
|
header.ContentSize = (long)imageSize;
|
||||||
|
|
||||||
|
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
var tempArray = ArrayPool<byte>.Shared.Rent((int)Math.Min(imageSize, 40960ul));
|
||||||
|
var remaining = imageSize;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (remaining > 0)
|
||||||
|
{
|
||||||
|
var chunkSize = (int)Math.Min(remaining, (ulong)tempArray.Length);
|
||||||
|
Unsafe.CopyBlockUnaligned(ref tempArray[0], ref Unsafe.Add(ref pData, offset), (uint)chunkSize);
|
||||||
|
|
||||||
|
await targetStream.WriteAsync(tempArray.AsMemory(0, chunkSize), token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
offset += chunkSize;
|
||||||
|
remaining -= (ulong)chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to write texture asset content to stream: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(tempArray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.AssetHandler;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
public enum AssetChangeType
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Created,
|
||||||
|
Deleted,
|
||||||
|
Modified,
|
||||||
|
Renamed,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AssetChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public string AssetPath
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? OldAssetPath
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetChangeType ChangeType
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal AssetChangedEventArgs(string assetPath, string? oldAssetPath, AssetChangeType changeType)
|
||||||
|
{
|
||||||
|
AssetPath = assetPath;
|
||||||
|
OldAssetPath = oldAssetPath;
|
||||||
|
ChangeType = changeType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAssetRegistry : IDisposable
|
||||||
|
{
|
||||||
|
string? GetAssetPath(Guid id);
|
||||||
|
Guid GetAssetGuid(string assetPath);
|
||||||
|
|
||||||
|
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
|
||||||
|
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
|
||||||
|
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default);
|
||||||
|
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default);
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Ghost.Core\Ghost.Core.csproj" />
|
<ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
|
||||||
<ProjectReference Include="..\Ghost.Engine\Ghost.Engine.csproj" />
|
<ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace TestProject.AssetDB;
|
||||||
|
|
||||||
|
internal partial class AssetRegistry
|
||||||
|
{
|
||||||
|
// TODO: Sqlite backend implementation
|
||||||
|
}
|
||||||
510
src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.AssetHandler;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace TestProject.AssetDB;
|
||||||
|
|
||||||
|
internal class PathComparer : IEqualityComparer<string>
|
||||||
|
{
|
||||||
|
private static string ToCanonicalPath(string? path)
|
||||||
|
{
|
||||||
|
return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(string? x, string? y)
|
||||||
|
{
|
||||||
|
return string.Equals(
|
||||||
|
ToCanonicalPath(x),
|
||||||
|
ToCanonicalPath(y),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetHashCode(string str)
|
||||||
|
{
|
||||||
|
return ToCanonicalPath(str).GetHashCode(StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Path based locking for multi-threaded access?
|
||||||
|
// Is it actually necessary since this is mostly used in editor environment where single-threaded access is common (99.999%)?
|
||||||
|
internal partial class AssetRegistry : IAssetRegistry
|
||||||
|
{
|
||||||
|
public const string ASSET_EXTENSION = ".gasset";
|
||||||
|
public const string TEMP_EXTENSION = ".gtemp";
|
||||||
|
|
||||||
|
private readonly string _rootDirectory;
|
||||||
|
private readonly FileSystemWatcher _watcher;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, Guid> _pathToGuid;
|
||||||
|
private readonly ConcurrentDictionary<Guid, string> _guidToPath;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander;
|
||||||
|
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
|
||||||
|
|
||||||
|
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph;
|
||||||
|
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _cacheSlim;
|
||||||
|
private readonly Lock _pathLock;
|
||||||
|
|
||||||
|
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
|
||||||
|
|
||||||
|
public AssetRegistry(string rootDirectory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(rootDirectory))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException("The specified root directory does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Path.IsPathFullyQualified(rootDirectory))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The specified root directory must be an absolute path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_rootDirectory = rootDirectory;
|
||||||
|
_watcher = new FileSystemWatcher(rootDirectory)
|
||||||
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
EnableRaisingEvents = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer());
|
||||||
|
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512);
|
||||||
|
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16);
|
||||||
|
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512);
|
||||||
|
|
||||||
|
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
|
||||||
|
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
|
||||||
|
|
||||||
|
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
_cacheSlim = new SemaphoreSlim(1, 1);
|
||||||
|
_pathLock = new Lock();
|
||||||
|
|
||||||
|
LoadExistingAssets();
|
||||||
|
|
||||||
|
_watcher.Created += OnFileSystemOp;
|
||||||
|
_watcher.Deleted += OnFileSystemOp;
|
||||||
|
_watcher.Changed += OnFileSystemOp;
|
||||||
|
_watcher.Renamed += OnFileSystemRenameOp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: DB Cache
|
||||||
|
private unsafe void LoadExistingAssets()
|
||||||
|
{
|
||||||
|
Span<byte> guidBuffer = stackalloc byte[sizeof(Guid)];
|
||||||
|
foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(_rootDirectory, filePath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fs.Seek(4, SeekOrigin.Begin); // Skip format version
|
||||||
|
fs.ReadExactly(guidBuffer);
|
||||||
|
|
||||||
|
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(guidBuffer));
|
||||||
|
UpdatePathMapping(relativePath, guid);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
fs.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception
|
||||||
|
#if DEBUG
|
||||||
|
ex
|
||||||
|
#endif
|
||||||
|
)
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
System.Diagnostics.Debugger.BreakForUserUnhandledException(ex);
|
||||||
|
#endif
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateGraph(Guid assetId, IEnumerable<Guid> newDependencies)
|
||||||
|
{
|
||||||
|
// 1. Clean up old references (reverse)
|
||||||
|
if (_dependencyCache.TryGetValue(assetId, out var oldDeps))
|
||||||
|
{
|
||||||
|
foreach (var dep in oldDeps)
|
||||||
|
{
|
||||||
|
if (_referencerGraph.TryGetValue(dep, out var refs))
|
||||||
|
{
|
||||||
|
refs.Remove(assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set new forward dependencies
|
||||||
|
var newDepSet = new HashSet<Guid>(newDependencies);
|
||||||
|
_dependencyCache[assetId] = newDepSet;
|
||||||
|
|
||||||
|
// 3. Add new references (reverse)
|
||||||
|
foreach (var dep in newDepSet)
|
||||||
|
{
|
||||||
|
ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists);
|
||||||
|
if (!exists || referencers is null)
|
||||||
|
{
|
||||||
|
referencers = new HashSet<Guid>();
|
||||||
|
}
|
||||||
|
|
||||||
|
referencers.Add(assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePathMapping(string relativePath, Guid guid)
|
||||||
|
{
|
||||||
|
lock (_pathLock)
|
||||||
|
{
|
||||||
|
_pathToGuid[relativePath] = guid;
|
||||||
|
_guidToPath[guid] = relativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RemovePathMappingByPath(string relativePath)
|
||||||
|
{
|
||||||
|
lock (_pathLock)
|
||||||
|
{
|
||||||
|
if (_pathToGuid.Remove(relativePath, out var guid))
|
||||||
|
{
|
||||||
|
return _guidToPath.TryRemove(guid, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
|
||||||
|
{
|
||||||
|
if (_ignoreFileChanges.TryRemove(e.FullPath, out _))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||||
|
var ext = Path.GetExtension(relativePath);
|
||||||
|
|
||||||
|
var changeType = AssetChangeType.None;
|
||||||
|
var fireEvent = false;
|
||||||
|
var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal);
|
||||||
|
var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
switch (e.ChangeType)
|
||||||
|
{
|
||||||
|
case WatcherChangeTypes.Created:
|
||||||
|
changeType = AssetChangeType.Created;
|
||||||
|
if (!isAsset && !isTemp)
|
||||||
|
{
|
||||||
|
var handler = GetAssetHandlerForExtension(ext);
|
||||||
|
if (handler is IImportableAssetHandler importableHandler)
|
||||||
|
{
|
||||||
|
var assetPath = string.Create(e.FullPath.Length - ext.Length + ASSET_EXTENSION.Length, e.FullPath, (destSpan, source) =>
|
||||||
|
{
|
||||||
|
source.AsSpan(0, source.Length - ext.Length).CopyTo(destSpan);
|
||||||
|
ASSET_EXTENSION.AsSpan().CopyTo(destSpan.Slice(source.Length - ext.Length));
|
||||||
|
});
|
||||||
|
|
||||||
|
var newGuid = Guid.NewGuid();
|
||||||
|
await using var sourceStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read);
|
||||||
|
await using var targetStream = new FileStream(assetPath, FileMode.Create, FileAccess.Write);
|
||||||
|
await importableHandler.ImportAsync(sourceStream, targetStream, newGuid);
|
||||||
|
|
||||||
|
File.Delete(assetPath);
|
||||||
|
UpdatePathMapping(relativePath, newGuid);
|
||||||
|
|
||||||
|
fireEvent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WatcherChangeTypes.Deleted:
|
||||||
|
changeType = AssetChangeType.Deleted;
|
||||||
|
if (isAsset)
|
||||||
|
{
|
||||||
|
fireEvent = RemovePathMappingByPath(relativePath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WatcherChangeTypes.Changed:
|
||||||
|
changeType = AssetChangeType.Modified;
|
||||||
|
fireEvent = isAsset;
|
||||||
|
break;
|
||||||
|
case WatcherChangeTypes.All:
|
||||||
|
// Can this even happen?
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fireEvent)
|
||||||
|
{
|
||||||
|
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(e.FullPath);
|
||||||
|
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
|
||||||
|
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||||
|
|
||||||
|
if (_pathToGuid.Remove(oldRelativePath, out var guid))
|
||||||
|
{
|
||||||
|
UpdatePathMapping(newRelativePath, guid);
|
||||||
|
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetAssetPath(Guid id)
|
||||||
|
{
|
||||||
|
lock (_pathLock)
|
||||||
|
{
|
||||||
|
if (_guidToPath.TryGetValue(id, out var path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid GetAssetGuid(string path)
|
||||||
|
{
|
||||||
|
lock (_pathLock)
|
||||||
|
{
|
||||||
|
if (_pathToGuid.TryGetValue(path, out var guid))
|
||||||
|
{
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAssetHandler GetAssetHandler(Type type)
|
||||||
|
{
|
||||||
|
var typeHandle = type.TypeHandle.Value;
|
||||||
|
if (_cachedHander.TryGetValue(typeHandle, out var handler))
|
||||||
|
{
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = Activator.CreateInstance(type);
|
||||||
|
if (obj is not IAssetHandler newHandler)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||||
|
if (attr is null || attr.AllowCaching)
|
||||||
|
{
|
||||||
|
_cachedHander[typeHandle] = newHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAssetHandler? GetAssetHandlerForExtension(string extension)
|
||||||
|
{
|
||||||
|
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.SelectMany(assembly => assembly.GetTypes())
|
||||||
|
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
||||||
|
{
|
||||||
|
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||||
|
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return GetAssetHandler(handlerType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId)
|
||||||
|
{
|
||||||
|
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.SelectMany(assembly => assembly.GetTypes())
|
||||||
|
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
||||||
|
{
|
||||||
|
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||||
|
if (attr is not null && new Guid(attr.ID) == typeId)
|
||||||
|
{
|
||||||
|
return GetAssetHandler(handlerType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourceFilePath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Source file not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(sourceFilePath);
|
||||||
|
var handler = GetAssetHandlerForExtension(ext);
|
||||||
|
if (handler is not IImportableAssetHandler importableHandler)
|
||||||
|
{
|
||||||
|
return Result.Failure("No importable asset handler found for the given file extension.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var guid = Guid.NewGuid();
|
||||||
|
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory);
|
||||||
|
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset import failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatePathMapping(targetAssetPath, guid);
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var assetPath = GetAssetPath(assetId);
|
||||||
|
if (string.IsNullOrEmpty(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset not found in DB");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullAssetPath = Path.GetFullPath(assetPath, _rootDirectory);
|
||||||
|
|
||||||
|
// 2. Identify the Handler
|
||||||
|
// (You might want to store SourcePath in metadata later so you don't need to pass it here)
|
||||||
|
var ext = Path.GetExtension(sourceFilePath);
|
||||||
|
var handler = GetAssetHandlerForExtension(ext);
|
||||||
|
if (handler is not IImportableAssetHandler importableHandler)
|
||||||
|
{
|
||||||
|
return Result.Failure("No importable asset handler found for the given file extension.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ignoreFileChanges[fullAssetPath] = true;
|
||||||
|
|
||||||
|
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read);
|
||||||
|
await using var targetStream = new FileStream(fullAssetPath, FileMode.Create, FileAccess.Write);
|
||||||
|
|
||||||
|
await importableHandler.ImportAsync(sourceStream, targetStream, assetId, token);
|
||||||
|
if (_loadedAssets.TryGetValue(assetId, out var weakRef) && weakRef.TryGetTarget(out var liveAsset))
|
||||||
|
{
|
||||||
|
await liveAsset.RefreshAsync(this, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
// TODO: weakRef based locking instead of global lock for better concurrency.
|
||||||
|
// We should use GetOrAdd here.
|
||||||
|
if (_loadedAssets.TryGetValue(id, out var weakRef)
|
||||||
|
&& weakRef.TryGetTarget(out var existingAsset))
|
||||||
|
{
|
||||||
|
return existingAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _cacheSlim.WaitAsync(token);
|
||||||
|
|
||||||
|
// Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting.
|
||||||
|
if (_loadedAssets.TryGetValue(id, out weakRef)
|
||||||
|
&& weakRef.TryGetTarget(out existingAsset))
|
||||||
|
{
|
||||||
|
return existingAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = GetAssetPath(id);
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetPath = Path.GetFullPath(path, _rootDirectory);
|
||||||
|
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
int sizeofGuid;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
sizeofGuid = sizeof(Guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
|
||||||
|
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
|
||||||
|
fs.ReadExactly(typeIdBuffer);
|
||||||
|
|
||||||
|
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
|
||||||
|
var handler = GetAssetHandlerForTypeId(guid);
|
||||||
|
if (handler == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetResult = await handler.LoadAsync(fs, this, token);
|
||||||
|
if (assetResult.IsFailure)
|
||||||
|
{
|
||||||
|
return assetResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var asset = assetResult.Value;
|
||||||
|
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(asset), (key, oldRef) =>
|
||||||
|
{
|
||||||
|
// If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected.
|
||||||
|
// If the assetResult haven't been loaded before, we are in the addValue path, not here.
|
||||||
|
// If the previous reference has been collected, we can just replace it with the new one.
|
||||||
|
// Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time.
|
||||||
|
oldRef.SetTarget(asset);
|
||||||
|
return oldRef;
|
||||||
|
});
|
||||||
|
|
||||||
|
return assetResult;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cacheSlim.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var path = GetAssetPath(asset.ID);
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = GetAssetHandlerForTypeId(asset.TypeID);
|
||||||
|
if (handler == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("No asset handler found for the given asset type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(path, _rootDirectory);
|
||||||
|
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
||||||
|
return await handler.SaveAsync(asset, fs, this, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cacheSlim.Dispose();
|
||||||
|
_watcher.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Ghost.Editor.Core.AssetHandler;
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class AssetHandlerUtility
|
||||||
|
{
|
||||||
|
public static async ValueTask SerializeAssetAsync<TSetting>(Stream stream, Guid id, Guid typeID, int handlerVersion, ReadOnlyMemory<Guid> dependencies, IAssetSettings? settings, ReadOnlyMemory<byte> contents, CancellationToken token = default)
|
||||||
|
where TSetting : IAssetSettings
|
||||||
|
{
|
||||||
|
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
|
||||||
|
{
|
||||||
|
HandlerVersion = handlerVersion,
|
||||||
|
DependenciesOffset = AssetMetadata.SIZE,
|
||||||
|
DependencyCount = dependencies.Length,
|
||||||
|
};
|
||||||
|
|
||||||
|
var tempArray = ArrayPool<byte>.Shared.Rent(4096);
|
||||||
|
|
||||||
|
if (dependencies.Length > 0)
|
||||||
|
{
|
||||||
|
stream.Seek(header.DependenciesOffset, SeekOrigin.Begin);
|
||||||
|
for (var i = 0; i < dependencies.Length; i++)
|
||||||
|
{
|
||||||
|
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(tempArray.AsSpan(0, 16)), dependencies.Span[i]);
|
||||||
|
await stream.WriteAsync(tempArray.AsMemory(0, 16), token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header.SettingsOffset = stream.Position;
|
||||||
|
|
||||||
|
// TODO: We can use source generator to generate optimized serializer for settings.
|
||||||
|
// For now, we just use reflection for simplicity.
|
||||||
|
|
||||||
|
if (settings is not null)
|
||||||
|
{
|
||||||
|
var properties = typeof(TSetting).GetProperties();
|
||||||
|
|
||||||
|
if (properties.Length > 0)
|
||||||
|
{
|
||||||
|
using var bw = new BinaryWriter(stream);
|
||||||
|
|
||||||
|
for (var i = 0; (i < properties.Length); i++)
|
||||||
|
{
|
||||||
|
var property = properties[i];
|
||||||
|
var value = property.GetValue(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 453 B After Width: | Height: | Size: 453 B |
|
Before Width: | Height: | Size: 869 B After Width: | Height: | Size: 869 B |
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 465 B |
|
Before Width: | Height: | Size: 884 B After Width: | Height: | Size: 884 B |
|
Before Width: | Height: | Size: 727 B After Width: | Height: | Size: 727 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |