Compare commits
117 Commits
main
...
feature/as
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fcf06dbe4 | |||
| 6505099667 | |||
| d263f0c7e1 | |||
| 9f05944d81 | |||
| e71851550b | |||
| 8a5795069f | |||
| b505c7c1c0 | |||
| 8d82c0a750 | |||
| 8df0b46960 | |||
| 06a150b899 | |||
| 49f54c6b43 | |||
| fdf831630b | |||
| ba5dc2159e | |||
| 0201f0fc33 | |||
| 364fbf9208 | |||
| e11a9ebb52 | |||
| 4173ff2432 | |||
| 139312d73b | |||
| 92b966fe0d | |||
| 1c155f962c | |||
| ac36bbf8c7 | |||
| 02df8d7732 | |||
| 954e3756aa | |||
| 1fc9df1812 | |||
| 87e315a588 | |||
| d71bdb3fc9 | |||
| 6a041f75ba | |||
| c9be05fc60 | |||
| f988c34b3d | |||
| a89719bfc9 | |||
| b8ce824292 | |||
| aa3d9c749b | |||
| d23e701f0a | |||
| 2881fda112 | |||
| 840cf7dd5a | |||
| 00b4e82ded | |||
| 3118021272 | |||
| 756727dc06 | |||
| 7613b5087e | |||
| 70cdd981aa | |||
| 05843fd665 | |||
| 7db4be1e6e | |||
| a3863c1263 | |||
| 856fa4f07d | |||
| 21e85e0c02 | |||
| 99c1a1980e | |||
| 97d1118caa | |||
| 5e276b289d | |||
| f44208b502 | |||
| 02084c1e47 | |||
| 30c1d99959 | |||
| 224b2b2dd5 | |||
| f9db047a5f | |||
| 93bc8e55a3 | |||
| 3bbf485fce | |||
| 948fae4401 | |||
| 63a70f1a74 | |||
| 95cb9af16f | |||
| 9d991bf316 | |||
| e3be5d0087 | |||
| 1fc4ff3f39 | |||
| 3af1d8c3bd | |||
| 676f8bb74c | |||
| 85280c746d | |||
| 0ec318a9ab | |||
| bd97d233cb | |||
| 0720444c2c | |||
| dfe786a2aa | |||
| 5c4e1a3350 | |||
| d91d6f6e57 | |||
| 708b8cd065 | |||
| 6cf2e35a9b | |||
| 6f786a0698 | |||
| fb003da26a | |||
| 56f73e774b | |||
| 15aca9aefb | |||
| b3eeb8d366 | |||
| 3bcf0ad539 | |||
| ad36250979 | |||
| 4dc98d6ed8 | |||
| 2612e19d35 | |||
| 017153aa02 | |||
| a8d7cd8828 | |||
| 9dc4f63e40 | |||
| 28c386b0bb | |||
| d2d9f5feb7 | |||
| 6d1b510ac1 | |||
| 682200cbf1 | |||
| 01a850ff94 | |||
| a39f377533 | |||
| 6a504cefc8 | |||
| 74bb2ccda5 | |||
| 1dfed83e38 | |||
| 1b0ef03728 | |||
| 78cc64b1d2 | |||
| 5385141f14 | |||
| eafbfb2fa1 | |||
| 1284bb17de | |||
| eed1b9d3d0 | |||
| 261afa4133 | |||
| 5ae4128baf | |||
| 300ae7251b | |||
| 8fd1222780 | |||
| 4110c166cf | |||
| 1724072f7e | |||
| fc44c73ca8 | |||
| ff14c0f49a | |||
| 40d333b004 | |||
| bab3be2508 | |||
| 61bbb1bc68 | |||
| 67b6040b5e | |||
| 0cf3104a6a | |||
| 56a21bab2b | |||
| 7cd881b7d4 | |||
| 62fe30ff2b | |||
| 02b3edcd7a | |||
| 23a08bc8e0 |
13
.editorconfig
Normal file
13
.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[*]
|
||||||
|
max_line_length = 400
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
csharp_new_line_before_open_brace = all
|
||||||
|
csharp_preserve_single_line_statements = true
|
||||||
|
csharp_preserve_single_line_blocks = true
|
||||||
|
csharp_style_prefer_primary_constructors = false
|
||||||
|
dotnet_sort_system_directives_first = false
|
||||||
|
dotnet_separate_import_directive_groups = false
|
||||||
|
|
||||||
|
dotnet_style_prefer_collection_expression = false
|
||||||
|
dotnet_style_collection_initializer = false
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ bld/
|
|||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
.vs/
|
.vs/
|
||||||
|
.vscode/
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
#wwwroot/
|
#wwwroot/
|
||||||
|
|
||||||
|
|||||||
297
AGENTS.md
Normal file
297
AGENTS.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# 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
|
||||||
7
Ghost.Core/AssemblyInfo.cs
Normal file
7
Ghost.Core/AssemblyInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Graphics")]
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Engine")]
|
||||||
|
|
||||||
|
[assembly: EngineAssembly]
|
||||||
6
Ghost.Core/Attributes/EngineAssemblyAttribute.cs
Normal file
6
Ghost.Core/Attributes/EngineAssemblyAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Ghost.Core.Attributes;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Assembly)]
|
||||||
|
public sealed class EngineAssemblyAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
11
Ghost.Core/Contracts/ICloneable.cs
Normal file
11
Ghost.Core/Contracts/ICloneable.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Ghost.Core.Contracts;
|
||||||
|
|
||||||
|
public interface ICloneable
|
||||||
|
{
|
||||||
|
object Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICloneable<T>
|
||||||
|
{
|
||||||
|
T Clone();
|
||||||
|
}
|
||||||
6
Ghost.Core/Contracts/IReleasable.cs
Normal file
6
Ghost.Core/Contracts/IReleasable.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Ghost.Core.Contracts;
|
||||||
|
|
||||||
|
internal interface IReleasable
|
||||||
|
{
|
||||||
|
void InternalRelease();
|
||||||
|
}
|
||||||
32
Ghost.Core/Ghost.Core.csproj
Normal file
32
Ghost.Core/Ghost.Core.csproj
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<IsAotCompatible>True</IsAotCompatible>
|
||||||
|
<DefineConstants>$(DefineConstants);PLATEFORME_WIN64</DefineConstants>
|
||||||
|
<IsTrimmable>True</IsTrimmable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<IsAotCompatible>True</IsAotCompatible>
|
||||||
|
<DefineConstants>$(DefineConstants);PLATEFORME_WIN64</DefineConstants>
|
||||||
|
<IsTrimmable>True</IsTrimmable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Misaki.HighPerformance" Version="1.0.4" />
|
||||||
|
<PackageReference Include="Misaki.HighPerformance.Jobs" Version="1.2.2" />
|
||||||
|
<PackageReference Include="Misaki.HighPerformance.LowLevel" Version="1.3.3" />
|
||||||
|
<PackageReference Include="Misaki.HighPerformance.Mathematics" Version="1.3.1" />
|
||||||
|
<PackageReference Include="System.IO.Hashing" Version="10.0.1" />
|
||||||
|
<PackageReference Include="TerraFX.Interop.Windows" Version="10.0.26100.6" />
|
||||||
|
<PackageReference Include="ZLinq" Version="1.5.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
110
Ghost.Core/Graphics/PipelineState.cs
Normal file
110
Ghost.Core/Graphics/PipelineState.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
namespace Ghost.Core.Graphics;
|
||||||
|
|
||||||
|
public enum ZTest : byte
|
||||||
|
{
|
||||||
|
Disabled,
|
||||||
|
Less,
|
||||||
|
LessEqual,
|
||||||
|
Equal,
|
||||||
|
GreaterEqual,
|
||||||
|
Greater,
|
||||||
|
NotEqual,
|
||||||
|
Always
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ZWrite : byte
|
||||||
|
{
|
||||||
|
Off,
|
||||||
|
On
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Cull : byte
|
||||||
|
{
|
||||||
|
Off,
|
||||||
|
Front,
|
||||||
|
Back
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Blend : byte
|
||||||
|
{
|
||||||
|
Opaque,
|
||||||
|
Alpha,
|
||||||
|
Additive,
|
||||||
|
Multiply,
|
||||||
|
PremultipliedAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum ColorWriteMask : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Red = 1 << 0,
|
||||||
|
Green = 1 << 1,
|
||||||
|
Blue = 1 << 2,
|
||||||
|
Alpha = 1 << 3,
|
||||||
|
All = Red | Green | Blue | Alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PipelineState
|
||||||
|
{
|
||||||
|
public ZTest ZTest
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZWrite ZWrite
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Cull Cull
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Blend Blend
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColorWriteMask ColorMask
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static PipelineState Default => new PipelineState
|
||||||
|
{
|
||||||
|
ZTest = ZTest.LessEqual,
|
||||||
|
ZWrite = ZWrite.On,
|
||||||
|
Cull = Cull.Back,
|
||||||
|
Blend = Blend.Opaque,
|
||||||
|
ColorMask = ColorWriteMask.All
|
||||||
|
};
|
||||||
|
|
||||||
|
public readonly ulong GetHashCode64()
|
||||||
|
{
|
||||||
|
// 32-bit packed key for states controlled by material / overrides.
|
||||||
|
// layout:
|
||||||
|
// 0..3 Blend (4 bits)
|
||||||
|
// 4..6 Cull (3 bits)
|
||||||
|
// 7..10 DeafaultState (4 bits)
|
||||||
|
// 11 ZWrite (1 bit)
|
||||||
|
// 12..15 ColorMask (4 bits)
|
||||||
|
|
||||||
|
var key = 0u;
|
||||||
|
key |= ((uint)Blend & 0xFu) << 0;
|
||||||
|
key |= ((uint)Cull & 0x7u) << 4;
|
||||||
|
key |= ((uint)ZTest & 0xFu) << 7;
|
||||||
|
key |= ((uint)ZWrite & 0x1u) << 11;
|
||||||
|
key |= ((uint)ColorMask & 0xFu) << 12;
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override readonly int GetHashCode()
|
||||||
|
{
|
||||||
|
var code64 = GetHashCode64();
|
||||||
|
return ((int)code64) ^ (int)(code64 >> 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Ghost.Core/Graphics/ShaderDescriptor.cs
Normal file
100
Ghost.Core/Graphics/ShaderDescriptor.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
namespace Ghost.Core.Graphics;
|
||||||
|
|
||||||
|
public enum KeywordSpace
|
||||||
|
{
|
||||||
|
Local,
|
||||||
|
Global,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ShaderPropertyType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Float, Float2, Float3, Float4,
|
||||||
|
Float4x4,
|
||||||
|
Int, Int2, Int3, Int4,
|
||||||
|
UInt, UInt2, UInt3, UInt4,
|
||||||
|
Bool, Bool2, Bool3, Bool4,
|
||||||
|
Texture2D, Texture3D, TextureCube,
|
||||||
|
Texture2DArray, TextureCubeArray,
|
||||||
|
Sampler
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ShaderEntryPoint
|
||||||
|
{
|
||||||
|
public string entry;
|
||||||
|
public string shader;
|
||||||
|
|
||||||
|
public readonly bool IsCreated => !string.IsNullOrEmpty(entry) && !string.IsNullOrEmpty(shader);
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct KeywordsGroup
|
||||||
|
{
|
||||||
|
public KeywordSpace space;
|
||||||
|
public List<string> keywords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PropertyDescriptor
|
||||||
|
{
|
||||||
|
public ShaderPropertyType type;
|
||||||
|
public string name;
|
||||||
|
public object? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PassDescriptor
|
||||||
|
{
|
||||||
|
public string identifier;
|
||||||
|
public string name;
|
||||||
|
|
||||||
|
public ShaderEntryPoint taskShader;
|
||||||
|
public ShaderEntryPoint meshShader;
|
||||||
|
public ShaderEntryPoint pixelShader;
|
||||||
|
public string[] defines;
|
||||||
|
public string[] includes;
|
||||||
|
public KeywordsGroup[] keywords;
|
||||||
|
public PipelineState localPipeline;
|
||||||
|
public string? hlsl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ShaderDescriptor
|
||||||
|
{
|
||||||
|
public string name = string.Empty;
|
||||||
|
public uint cbufferSize;
|
||||||
|
public PropertyDescriptor[] globalProperties = null!;
|
||||||
|
public PropertyDescriptor[] properties = null!;
|
||||||
|
public PassDescriptor[] passes = null!;
|
||||||
|
public string? hlsl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ShaderDescriptorExtensions
|
||||||
|
{
|
||||||
|
public static uint GetSize(this ShaderPropertyType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
ShaderPropertyType.Float => 4,
|
||||||
|
ShaderPropertyType.Float2 => 8,
|
||||||
|
ShaderPropertyType.Float3 => 12,
|
||||||
|
ShaderPropertyType.Float4 => 16,
|
||||||
|
ShaderPropertyType.Float4x4 => 64,
|
||||||
|
ShaderPropertyType.Int => 4,
|
||||||
|
ShaderPropertyType.Int2 => 8,
|
||||||
|
ShaderPropertyType.Int3 => 12,
|
||||||
|
ShaderPropertyType.Int4 => 16,
|
||||||
|
ShaderPropertyType.UInt => 4,
|
||||||
|
ShaderPropertyType.UInt2 => 8,
|
||||||
|
ShaderPropertyType.UInt3 => 12,
|
||||||
|
ShaderPropertyType.UInt4 => 16,
|
||||||
|
ShaderPropertyType.Bool => 4,
|
||||||
|
ShaderPropertyType.Bool2 => 8,
|
||||||
|
ShaderPropertyType.Bool3 => 12,
|
||||||
|
ShaderPropertyType.Bool4 => 16,
|
||||||
|
ShaderPropertyType.Texture2D => 4, // Bindless resource use uint32
|
||||||
|
ShaderPropertyType.Texture3D => 4,
|
||||||
|
ShaderPropertyType.TextureCube => 4,
|
||||||
|
ShaderPropertyType.Texture2DArray => 4,
|
||||||
|
ShaderPropertyType.TextureCubeArray => 4,
|
||||||
|
ShaderPropertyType.Sampler => 4,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
242
Ghost.Core/Handle.cs
Normal file
242
Ghost.Core/Handle.cs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
namespace Ghost.Core;
|
||||||
|
|
||||||
|
public readonly struct Handle<T> : IEquatable<Handle<T>>
|
||||||
|
{
|
||||||
|
public int ID
|
||||||
|
{
|
||||||
|
get => field - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Generation
|
||||||
|
{
|
||||||
|
get => field - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Handle(int id, int generation)
|
||||||
|
{
|
||||||
|
ID = id + 1;
|
||||||
|
Generation = generation + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Handle<T> Invalid => default;
|
||||||
|
|
||||||
|
public readonly bool IsValid => this != Invalid;
|
||||||
|
public readonly bool IsInvalid => this == Invalid;
|
||||||
|
|
||||||
|
public readonly override int GetHashCode()
|
||||||
|
{
|
||||||
|
return ID + (Generation << 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
return obj is Handle<T> id && Equals(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Handle<{typeof(T).Name}>({ID}, {Generation})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly bool Equals(Handle<T> other)
|
||||||
|
{
|
||||||
|
return ID == other.ID && Generation == other.Generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly int CompareTo(Handle<T> other)
|
||||||
|
{
|
||||||
|
return ID.CompareTo(other.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator ==(Handle<T> a, Handle<T> b)
|
||||||
|
{
|
||||||
|
return a.Equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator !=(Handle<T> a, Handle<T> b)
|
||||||
|
{
|
||||||
|
return !a.Equals(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct Identifier<T> : IEquatable<Identifier<T>>
|
||||||
|
{
|
||||||
|
public int Value
|
||||||
|
{
|
||||||
|
get => field - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Identifier(int value)
|
||||||
|
{
|
||||||
|
Value = value + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Identifier<T> Invalid => default;
|
||||||
|
|
||||||
|
public readonly bool IsValid => this != Invalid;
|
||||||
|
public readonly bool IsInvalid => this == Invalid;
|
||||||
|
|
||||||
|
public readonly override int GetHashCode()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
return obj is Identifier<T> id && Equals(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Identifier<{typeof(T).Name}>({Value})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly bool Equals(Identifier<T> other)
|
||||||
|
{
|
||||||
|
return Value == other.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly int CompareTo(Identifier<T> other)
|
||||||
|
{
|
||||||
|
return Value.CompareTo(other.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator ==(Identifier<T> a, Identifier<T> b)
|
||||||
|
{
|
||||||
|
return a.Equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator !=(Identifier<T> a, Identifier<T> b)
|
||||||
|
{
|
||||||
|
return !a.Equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator <(Identifier<T> a, Identifier<T> b)
|
||||||
|
{
|
||||||
|
return a.Value < b.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator >(Identifier<T> a, Identifier<T> b)
|
||||||
|
{
|
||||||
|
return a.Value > b.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator <=(Identifier<T> a, Identifier<T> b)
|
||||||
|
{
|
||||||
|
return a.Value <= b.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator >=(Identifier<T> a, Identifier<T> b)
|
||||||
|
{
|
||||||
|
return a.Value >= b.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator int(Identifier<T> id) => id.Value;
|
||||||
|
public static implicit operator Identifier<T>(int value) => new Identifier<T>(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct Key64<T> : IEquatable<Key64<T>>
|
||||||
|
{
|
||||||
|
public ulong Value
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Key64(ulong value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Key64<T> Invalid => new(0);
|
||||||
|
|
||||||
|
public bool IsValid => this != Invalid;
|
||||||
|
public bool IsInvalid => this == Invalid;
|
||||||
|
|
||||||
|
public readonly override int GetHashCode()
|
||||||
|
{
|
||||||
|
return Value.GetHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly bool Equals(Key64<T> other)
|
||||||
|
{
|
||||||
|
return Value == other.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly int CompareTo(Key64<T> other)
|
||||||
|
{
|
||||||
|
return Value.CompareTo(other.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
return obj is Key64<T> id && Equals(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Value.ToString("X16");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator ==(Key64<T> a, Key64<T> b)
|
||||||
|
{
|
||||||
|
return a.Equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator !=(Key64<T> a, Key64<T> b)
|
||||||
|
{
|
||||||
|
return !a.Equals(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct Key128<T> : IEquatable<Key128<T>>
|
||||||
|
{
|
||||||
|
public UInt128 Value
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Key128(UInt128 value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Key128<T> Invalid => new(0);
|
||||||
|
|
||||||
|
public bool IsValid => this != Invalid;
|
||||||
|
public bool IsInvalid => this == Invalid;
|
||||||
|
|
||||||
|
public readonly override int GetHashCode()
|
||||||
|
{
|
||||||
|
return Value.GetHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly bool Equals(Key128<T> other)
|
||||||
|
{
|
||||||
|
return Value == other.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly int CompareTo(Key128<T> other)
|
||||||
|
{
|
||||||
|
return Value.CompareTo(other.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
return obj is Key128<T> id && Equals(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Value.ToString("X16");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator ==(Key128<T> a, Key128<T> b)
|
||||||
|
{
|
||||||
|
return a.Equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator !=(Key128<T> a, Key128<T> b)
|
||||||
|
{
|
||||||
|
return !a.Equals(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Ghost.Core/Logging.cs
Normal file
214
Ghost.Core/Logging.cs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Ghost.Core;
|
||||||
|
|
||||||
|
public enum LogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct LogMessage
|
||||||
|
{
|
||||||
|
public LogLevel Level
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Message
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? StackTrace
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime Timestamp
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LogMessage(LogLevel level, string message, string? stackTrace = null)
|
||||||
|
{
|
||||||
|
Level = level;
|
||||||
|
Message = message;
|
||||||
|
StackTrace = stackTrace;
|
||||||
|
Timestamp = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
if (StackTrace != null)
|
||||||
|
{
|
||||||
|
return $"{Timestamp:HH:mm:ss} [{Level}] {Message}\n{StackTrace}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{Timestamp:HH:mm:ss} [{Level}] {Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ILogger
|
||||||
|
{
|
||||||
|
ReadOnlyObservableCollection<LogMessage> Logs
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Log(string message, LogLevel level);
|
||||||
|
void Log(Exception exception);
|
||||||
|
void Assert(bool condition, string message);
|
||||||
|
void Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Logger
|
||||||
|
{
|
||||||
|
// TODO: Add file logging.
|
||||||
|
private class LoggerImpl : ILogger
|
||||||
|
{
|
||||||
|
private readonly ObservableCollection<LogMessage> _logs = new();
|
||||||
|
private readonly ReadOnlyObservableCollection<LogMessage> _readOnly;
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
|
public ReadOnlyObservableCollection<LogMessage> Logs => _readOnly;
|
||||||
|
|
||||||
|
public LoggerImpl()
|
||||||
|
{
|
||||||
|
_readOnly = new ReadOnlyObservableCollection<LogMessage>(_logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public void Log(string message, LogLevel level)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_logs.Add(new LogMessage(level, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public void Log(Exception exception)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_logs.Add(new LogMessage(LogLevel.Error, exception.Message, exception.StackTrace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public void Assert(bool condition, string message)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
{
|
||||||
|
Log(message, LogLevel.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_logs.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly ILogger s_logger = new LoggerImpl();
|
||||||
|
|
||||||
|
public static ReadOnlyObservableCollection<LogMessage> Logs => s_logger.Logs;
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void Log(LogLevel level, object? message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message?.ToString() ?? "null", level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void Log(LogLevel level, string message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void Log(LogLevel level, string format, params object?[] args)
|
||||||
|
{
|
||||||
|
s_logger.Log(string.Format(format, args), level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogInfo(object? message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message?.ToString() ?? "null", LogLevel.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogInfo(string message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message, LogLevel.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogInfo(string format, params object?[] args)
|
||||||
|
{
|
||||||
|
s_logger.Log(string.Format(format, args), LogLevel.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogWarning(object? message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message?.ToString() ?? "null", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogWarning(string message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message, LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogWarning(string format, params object?[] args)
|
||||||
|
{
|
||||||
|
s_logger.Log(string.Format(format, args), LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogError(object? message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message?.ToString() ?? "null", LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogError(string message)
|
||||||
|
{
|
||||||
|
s_logger.Log(message, LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogError(string format, params object?[] args)
|
||||||
|
{
|
||||||
|
s_logger.Log(string.Format(format, args), LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void LogError(Exception ex)
|
||||||
|
{
|
||||||
|
s_logger.Log(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StackTraceHidden]
|
||||||
|
public static void Assert(bool condition, string message)
|
||||||
|
{
|
||||||
|
s_logger.Assert(condition, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
s_logger.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
407
Ghost.Core/Result.cs
Normal file
407
Ghost.Core/Result.cs
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
using Misaki.HighPerformance.LowLevel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Core;
|
||||||
|
|
||||||
|
public readonly struct Result
|
||||||
|
{
|
||||||
|
private readonly string? _message;
|
||||||
|
private readonly bool _isSuccess;
|
||||||
|
|
||||||
|
public readonly string? Message => _message;
|
||||||
|
public readonly bool IsSuccess => _isSuccess;
|
||||||
|
public readonly bool IsFailure => !IsSuccess;
|
||||||
|
|
||||||
|
public Result(bool success, string? message = null)
|
||||||
|
{
|
||||||
|
_isSuccess = success;
|
||||||
|
_message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result Success()
|
||||||
|
{
|
||||||
|
return new Result(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result Failure(string? message = null)
|
||||||
|
{
|
||||||
|
return new Result(false, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result Failure(Error status)
|
||||||
|
{
|
||||||
|
return new Result(false, status.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Success<T>(T value)
|
||||||
|
{
|
||||||
|
return Result<T>.Success(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Failure<T>(string? message = null)
|
||||||
|
{
|
||||||
|
return Result<T>.Failure(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Failure<T>(Error status)
|
||||||
|
{
|
||||||
|
return Result<T>.Failure(status.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deconstruct(out bool success, out string? message)
|
||||||
|
{
|
||||||
|
success = IsSuccess;
|
||||||
|
message = Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => IsSuccess ? "OK" : $"Error: {Message}";
|
||||||
|
|
||||||
|
public static implicit operator bool(Result result) => result.IsSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct Result<T>
|
||||||
|
{
|
||||||
|
private readonly T _value;
|
||||||
|
private readonly string? _message;
|
||||||
|
private readonly bool _isSuccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the value. Undefined if the result is a failure.
|
||||||
|
/// </summary>
|
||||||
|
public T Value
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
#if DEBUG || GHOST_EDITOR
|
||||||
|
if (IsFailure)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cannot access Value when Result is a failure. {_message}");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return _value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly string? Message => _message;
|
||||||
|
public readonly bool IsSuccess => _isSuccess;
|
||||||
|
public readonly bool IsFailure => !IsSuccess;
|
||||||
|
|
||||||
|
public Result(bool success, T value, string? message = null)
|
||||||
|
{
|
||||||
|
_isSuccess = success;
|
||||||
|
_value = value;
|
||||||
|
_message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Success(T value)
|
||||||
|
{
|
||||||
|
return new Result<T>(true, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Failure(string? message = null)
|
||||||
|
{
|
||||||
|
return new Result<T>(false, default!, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deconstruct(out bool success, out T value, out string? message)
|
||||||
|
{
|
||||||
|
success = IsSuccess;
|
||||||
|
value = Value;
|
||||||
|
message = Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => IsSuccess ? $"OK: {Value}" : $"Error: {Message}";
|
||||||
|
|
||||||
|
public static implicit operator Result<T>(T? data) => data is not null ? Success(data) : Failure(null);
|
||||||
|
public static implicit operator Result<T>(Result result) => result.IsSuccess ? Success(default!) : Failure(result.Message);
|
||||||
|
public static implicit operator bool(Result<T> result) => result.IsSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Error : byte
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
NotFound,
|
||||||
|
InvalidArgument,
|
||||||
|
InvalidState,
|
||||||
|
InternalError,
|
||||||
|
PermissionDenied,
|
||||||
|
NotSupported,
|
||||||
|
OutOfMemory,
|
||||||
|
Timeout,
|
||||||
|
Cancelled,
|
||||||
|
UnknownError,
|
||||||
|
|
||||||
|
Success = None,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct Result<T, E>
|
||||||
|
where E : struct, Enum
|
||||||
|
{
|
||||||
|
private readonly T _value;
|
||||||
|
private readonly E _error;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the value. Undefined if the result is a failure.
|
||||||
|
/// </summary>
|
||||||
|
public T Value
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
#if DEBUG || GHOST_EDITOR
|
||||||
|
if (IsFailure)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cannot access Value when Result is a failure. Error: {_error}");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return _value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public E Error => _error;
|
||||||
|
public bool IsSuccess => EqualityComparer<E>.Default.Equals(_error, default);
|
||||||
|
public bool IsFailure => !IsSuccess;
|
||||||
|
|
||||||
|
public Result(T value, E status)
|
||||||
|
{
|
||||||
|
_value = value;
|
||||||
|
_error = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T, E> Success(T value)
|
||||||
|
{
|
||||||
|
return new Result<T, E>(value, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T, E> Failure(E status)
|
||||||
|
{
|
||||||
|
return new Result<T, E>(default!, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deconstruct(out T value, out E status)
|
||||||
|
{
|
||||||
|
value = Value;
|
||||||
|
status = Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"Value: {_value}, Status: {_error}";
|
||||||
|
|
||||||
|
public static implicit operator Result<T, E>(T data) => new(data, default);
|
||||||
|
public static implicit operator Result<T, E>(E status) => new(default!, status);
|
||||||
|
public static implicit operator bool(Result<T, E> result) => result.IsSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly ref struct RefResult<T, E>
|
||||||
|
where E : struct, Enum
|
||||||
|
{
|
||||||
|
private readonly ref T _value;
|
||||||
|
private readonly E _error;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a reference to the value. Undefined if the result is a failure.
|
||||||
|
/// </summary>
|
||||||
|
public ref T Value
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
#if DEBUG || GHOST_EDITOR
|
||||||
|
if (IsFailure)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cannot access Value when Result is a failure. Error: {_error}");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return ref _value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public E Error => _error;
|
||||||
|
public bool IsSuccess => EqualityComparer<E>.Default.Equals(_error, default);
|
||||||
|
public bool IsFailure => !IsSuccess;
|
||||||
|
|
||||||
|
public RefResult(ref T value, E error)
|
||||||
|
{
|
||||||
|
_value = ref value;
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RefResult<T, E> Success(ref T value)
|
||||||
|
{
|
||||||
|
return new RefResult<T, E>(ref value, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RefResult<T, E> Failure(E error)
|
||||||
|
{
|
||||||
|
return new RefResult<T, E>(ref Unsafe.NullRef<T>(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deconstruct(out bool success, out Ref<T> value, out E status)
|
||||||
|
{
|
||||||
|
success = IsSuccess;
|
||||||
|
value = new Ref<T>(ref Value);
|
||||||
|
status = Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"Value: {_value}, Status: {_error}";
|
||||||
|
|
||||||
|
public static implicit operator RefResult<T, E>(Ref<T> data) => new(ref data.Get(), default);
|
||||||
|
public static implicit operator RefResult<T, E>(E error) => new(ref Unsafe.NullRef<T>(), error);
|
||||||
|
public static implicit operator bool(RefResult<T, E> result) => result.IsSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ResultExtensions
|
||||||
|
{
|
||||||
|
public static void ThrowIfFailed(this Error result, [CallerArgumentExpression(nameof(result))] string? op = null)
|
||||||
|
{
|
||||||
|
if (result != Error.None)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{op} failed: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ThrowIfFailed(this Result result, [CallerArgumentExpression(nameof(result))] string? op = null)
|
||||||
|
{
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{op} failed: {result.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T GetValueOrThrow<T>(this Result<T> result, [CallerArgumentExpression(nameof(result))] string? op = null)
|
||||||
|
{
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{op} failed: {result.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T GetValueOrThrow<T, S>(this Result<T, S> result, [CallerArgumentExpression(nameof(result))] string? op = null)
|
||||||
|
where S : struct, Enum
|
||||||
|
{
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{op} failed: status {result.Error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T? GetValueOrDefault<T>(this Result<T> result, T? defaultValue = default)
|
||||||
|
{
|
||||||
|
return result.IsSuccess ? result.Value : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T? GetValueOrDefault<T, S>(this Result<T, S> result, T? defaultValue = default)
|
||||||
|
where S : struct, Enum
|
||||||
|
{
|
||||||
|
return result.IsSuccess ? result.Value : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetValue<T>(this Result<T> result, out T value)
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
value = result.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetValue<T, S>(this Result<T, S> result, out T value)
|
||||||
|
where S : struct, Enum
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
value = result.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result OnSuccess(this Result result, Action action)
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> OnSuccess<T>(this Result<T> result, Action<T> action)
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
action(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T, E> OnSuccess<T, E>(this Result<T, E> result, Action<T> action)
|
||||||
|
where E : struct, Enum
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
action(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result OnFailed(this Result result, Action<string?> action)
|
||||||
|
{
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
action(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> OnFailed<T>(this Result<T> result, Action<string?> action)
|
||||||
|
{
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
action(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T, E> OnFailed<T, E>(this Result<T, E> result, Action<E> action)
|
||||||
|
where E : struct, Enum
|
||||||
|
{
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
action(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<U> Then<T, U>(this Result<T> result, Func<T, Result<U>> func)
|
||||||
|
{
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<U>.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<U, E> Then<T, U, E>(this Result<T, E> result, Func<T, Result<U, E>> func)
|
||||||
|
where E : struct, Enum
|
||||||
|
{
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<U, E>.Failure(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Ghost.Core/TypeHandle.cs
Normal file
67
Ghost.Core/TypeHandle.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Core;
|
||||||
|
|
||||||
|
public readonly struct TypeHandle
|
||||||
|
{
|
||||||
|
public readonly IntPtr Value
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TypeHandle(IntPtr value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the space handle for the specified space.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The space to get the handle for.</param>
|
||||||
|
/// <returns>The space handle as a nint.</returns>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static TypeHandle Get(Type type) => new TypeHandle(type.TypeHandle.Value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the space handle for the specified space.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The space to get the handle for.</typeparam>
|
||||||
|
/// <returns>The space handle as a nint.</returns>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static TypeHandle Get<T>() => Get(typeof(T));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a TypeHandle to a Type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handle">The TypeHandle to convert.</param>
|
||||||
|
/// <returns>The corresponding Type.</returns>
|
||||||
|
public Type? ToType()
|
||||||
|
{
|
||||||
|
return Type.GetTypeFromHandle(RuntimeTypeHandle.FromIntPtr(Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
return Value.GetHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator TypeHandle(IntPtr value)
|
||||||
|
{
|
||||||
|
return new TypeHandle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator IntPtr(TypeHandle handle)
|
||||||
|
{
|
||||||
|
return handle.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator TypeHandle(Type type)
|
||||||
|
{
|
||||||
|
return Get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator Type?(TypeHandle handle)
|
||||||
|
{
|
||||||
|
return handle.ToType();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Ghost.Core/Utilities/CollectionPool.cs
Normal file
22
Ghost.Core/Utilities/CollectionPool.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using Misaki.HighPerformance.Buffer;
|
||||||
|
|
||||||
|
namespace Ghost.Core.Utilities;
|
||||||
|
|
||||||
|
public class CollectionPool<TCollection, TItem>
|
||||||
|
where TCollection : class, ICollection<TItem>, new()
|
||||||
|
{
|
||||||
|
internal static readonly ObjectPool<TCollection> s_pool = new ObjectPool<TCollection>(() => new TCollection(), null, 1);
|
||||||
|
|
||||||
|
public static TCollection Rent()
|
||||||
|
{
|
||||||
|
return s_pool.Rent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Return(TCollection collection)
|
||||||
|
{
|
||||||
|
collection.Clear();
|
||||||
|
s_pool.Return(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListPool<T> : CollectionPool<List<T>, T>;
|
||||||
9
Ghost.Core/Utilities/EnumUtility.cs
Normal file
9
Ghost.Core/Utilities/EnumUtility.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Ghost.Core.Utilities;
|
||||||
|
|
||||||
|
internal class EnumUtility
|
||||||
|
{
|
||||||
|
}
|
||||||
44
Ghost.Core/Utilities/Hash.cs
Normal file
44
Ghost.Core/Utilities/Hash.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Core.Utilities;
|
||||||
|
|
||||||
|
public static class Hash
|
||||||
|
{
|
||||||
|
private const ulong _PRIME1 = 0xfa517d6985796b7bul;
|
||||||
|
private const ulong _PRIME2 = 0x589578278297b985ul;
|
||||||
|
private const ulong _PRIME3 = 0x221147a447814b73ul;
|
||||||
|
private const ulong _PRIME4 = 0x9e3779b97f4a7c15ul; // Golden Ratio
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static ulong Hash64(ulong a, ulong b)
|
||||||
|
{
|
||||||
|
return a ^ (b * _PRIME4 + (a << 6) + (a >> 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static ulong Hash64(ulong a, ulong b, ulong c)
|
||||||
|
{
|
||||||
|
ulong h1 = a * _PRIME1;
|
||||||
|
ulong h2 = b * _PRIME2;
|
||||||
|
ulong h3 = c * _PRIME3;
|
||||||
|
|
||||||
|
ulong h = h1 ^ h2 ^ h3;
|
||||||
|
|
||||||
|
h = (h ^ (h >> 33)) * _PRIME4;
|
||||||
|
return h ^ (h >> 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static ulong Hash64(ulong a, ulong b, ulong c, ulong d)
|
||||||
|
{
|
||||||
|
ulong h1 = a * _PRIME1;
|
||||||
|
ulong h2 = b * _PRIME2;
|
||||||
|
ulong h3 = c * _PRIME3;
|
||||||
|
ulong h4 = d * _PRIME4;
|
||||||
|
|
||||||
|
ulong h = h1 ^ h2 ^ h3 ^ h4;
|
||||||
|
|
||||||
|
h = (h ^ (h >> 33)) * _PRIME1;
|
||||||
|
return h ^ (h >> 29);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Ghost.Core/Utilities/InternalResource.cs
Normal file
12
Ghost.Core/Utilities/InternalResource.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Ghost.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Core.Utilities;
|
||||||
|
|
||||||
|
internal static class InternalResource
|
||||||
|
{
|
||||||
|
public static void Release<T>(ref T? resource)
|
||||||
|
where T : IReleasable
|
||||||
|
{
|
||||||
|
resource?.InternalRelease();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Ghost.Core/Utilities/Win32Utility.cs
Normal file
115
Ghost.Core/Utilities/Win32Utility.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Misaki.HighPerformance.LowLevel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using TerraFX.Interop.Windows;
|
||||||
|
|
||||||
|
namespace Ghost.Core.Utilities;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows10.0.19041.0")]
|
||||||
|
internal static unsafe partial class Win32Utility
|
||||||
|
{
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public readonly ref struct IID_PPV
|
||||||
|
{
|
||||||
|
public readonly Guid* iid;
|
||||||
|
public readonly void** ppv;
|
||||||
|
|
||||||
|
public IID_PPV(Guid* iid, void** ppv)
|
||||||
|
{
|
||||||
|
this.iid = iid;
|
||||||
|
this.ppv = ppv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deconstruct(out Guid* iid, out void** ppv)
|
||||||
|
{
|
||||||
|
iid = this.iid;
|
||||||
|
ppv = this.ppv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Guid* IID_NULL
|
||||||
|
{
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
get => (Guid*)Unsafe.AsPointer(ref Unsafe.AsRef(in IID.IID_NULL));
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static IID_PPV IID_PPV_ARGS<T>(ComPtr<T>* comPtr)
|
||||||
|
where T : unmanaged, IUnknown.Interface
|
||||||
|
{
|
||||||
|
return new IID_PPV(Windows.__uuidof<T>(), (void**)comPtr);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static void Attach<T>(ref this UniquePtr<T> uPtr, T* other)
|
||||||
|
where T : unmanaged, IUnknown.Interface
|
||||||
|
{
|
||||||
|
var ptr = uPtr.Get();
|
||||||
|
if (ptr != null)
|
||||||
|
{
|
||||||
|
var refCount = ptr->Release();
|
||||||
|
Debug.Assert((refCount != 0) || (ptr != other));
|
||||||
|
}
|
||||||
|
|
||||||
|
uPtr = new UniquePtr<T>(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static void Dispose<T>(ref this UniquePtr<T> uPtr)
|
||||||
|
where T : unmanaged, IUnknown.Interface
|
||||||
|
{
|
||||||
|
var ptr = uPtr.Detach();
|
||||||
|
if (ptr != null)
|
||||||
|
{
|
||||||
|
ptr->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static Result ToResult(this HRESULT hr, [CallerArgumentExpression(nameof(hr))] string? op = null)
|
||||||
|
{
|
||||||
|
if (hr.SUCCEEDED)
|
||||||
|
{
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Failure($"{op} failed with code {hr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static void** ReleaseAndGetVoidAddressOf<T>(ref this ComPtr<T> comPtr)
|
||||||
|
where T : unmanaged, IUnknown.Interface
|
||||||
|
{
|
||||||
|
return (void**)comPtr.ReleaseAndGetAddressOf();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static ComPtr<T> Move<T>(ref this ComPtr<T> comPtr)
|
||||||
|
where T : unmanaged, IUnknown.Interface
|
||||||
|
{
|
||||||
|
var copy = default(ComPtr<T>);
|
||||||
|
comPtr.Swap(ref copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static bool HasFlag<T>(this uint flags, T flag)
|
||||||
|
where T : Enum
|
||||||
|
{
|
||||||
|
return (flags & Unsafe.As<T, uint>(ref flag)) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension(MemoryLeakException)
|
||||||
|
{
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static void ThrowIfRefCountNonZero(uint count)
|
||||||
|
{
|
||||||
|
if (count != 0)
|
||||||
|
{
|
||||||
|
throw new MemoryLeakException($"Reference count is not zero: {count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
Ghost.DSL/AssemblyInfo.cs
Normal file
4
Ghost.DSL/AssemblyInfo.cs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Shader.Test")]
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Graphics")]
|
||||||
305
Ghost.DSL/Generator/ShaderStructGenerator.cs
Normal file
305
Ghost.DSL/Generator/ShaderStructGenerator.cs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Ghost.DSL.Generator;
|
||||||
|
|
||||||
|
public enum PackingRules
|
||||||
|
{
|
||||||
|
Exact,
|
||||||
|
Aligned,
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Enum)]
|
||||||
|
public class GenerateHLSLAttribute : Attribute
|
||||||
|
{
|
||||||
|
private readonly PackingRules _packingRules;
|
||||||
|
private readonly string? _outputSource;
|
||||||
|
|
||||||
|
public GenerateHLSLAttribute(PackingRules packingRules, string? outputSource)
|
||||||
|
{
|
||||||
|
_packingRules = packingRules;
|
||||||
|
_outputSource = outputSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static partial class ShaderStructGenerator
|
||||||
|
{
|
||||||
|
private struct ShaderFieldInfo
|
||||||
|
{
|
||||||
|
public string name;
|
||||||
|
public Type fieldType;
|
||||||
|
|
||||||
|
public ShaderFieldInfo(string name, Type fieldType)
|
||||||
|
{
|
||||||
|
this.name = name;
|
||||||
|
this.fieldType = fieldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShaderFieldInfo(FieldInfo fieldInfo)
|
||||||
|
: this(fieldInfo.Name, fieldInfo.FieldType)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int _HLSL_VECTOR_REGISTER_SIZE = 16; // 16 bytes (128 bits) for float4
|
||||||
|
|
||||||
|
private static void GenerateEnumHLSL(Type type, StringBuilder sb)
|
||||||
|
{
|
||||||
|
if (!type.IsEnum)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Type {type.FullName} is not an enum.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var enumName = type.Name;
|
||||||
|
//var underlyingType = Enum.GetUnderlyingType(space);
|
||||||
|
//var underlyingTypeName = underlyingType switch
|
||||||
|
//{
|
||||||
|
// Type t when t == typeof(byte) || t == typeof(short) || t == typeof(int) => "int",
|
||||||
|
// Type t when t == typeof(sbyte) || t == typeof(ushort) || t == typeof(uint) => "uint",
|
||||||
|
// _ => throw new InvalidOperationException($"Unsupported underlying space {underlyingType.FullName} for enum {enumName}."),
|
||||||
|
//};
|
||||||
|
|
||||||
|
// sb.Append(@$"
|
||||||
|
//enum {enumName} : {underlyingTypeName}
|
||||||
|
//{{");
|
||||||
|
var names = Enum.GetNames(type);
|
||||||
|
var values = Enum.GetValuesAsUnderlyingType(type);
|
||||||
|
for (var i = 0; i < names.Length; i++)
|
||||||
|
{
|
||||||
|
var name = $"{CamelCaseToUnderscoreRegex().Replace(enumName, "_$1")}_{names[i]}";
|
||||||
|
var value = values.GetValue(i);
|
||||||
|
// sb.Append(@$"
|
||||||
|
//{name} = {Value},");
|
||||||
|
sb.Append(@$"
|
||||||
|
#define {name.ToUpperInvariant()} {value}"); // Use #define for capability. Enum is only support for newer HLSL versions.
|
||||||
|
}
|
||||||
|
// sb.AppendLine(@"
|
||||||
|
//};");
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int FindNextFieldThatFits(FieldInfo[] fields, bool[] looked, int startIndex, int size, out int foundIndex)
|
||||||
|
{
|
||||||
|
if (size <= 0)
|
||||||
|
{
|
||||||
|
foundIndex = -1;
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestFitIndex = -1;
|
||||||
|
var bestFitSize = 0;
|
||||||
|
|
||||||
|
for (var j = startIndex; j < fields.Length; j++)
|
||||||
|
{
|
||||||
|
if (looked[j])
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextField = fields[j];
|
||||||
|
var nextSize = Marshal.SizeOf(nextField.FieldType);
|
||||||
|
if (nextSize <= size)
|
||||||
|
{
|
||||||
|
if (nextSize == size)
|
||||||
|
{
|
||||||
|
foundIndex = j;
|
||||||
|
return nextSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSize > bestFitSize)
|
||||||
|
{
|
||||||
|
bestFitSize = nextSize;
|
||||||
|
bestFitIndex = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestFitIndex != -1)
|
||||||
|
{
|
||||||
|
foundIndex = bestFitIndex;
|
||||||
|
return bestFitSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
foundIndex = -1;
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateStructHLSL(Type type, PackingRules packingRules, StringBuilder sb)
|
||||||
|
{
|
||||||
|
if (!type.IsValueType || type.IsPrimitive)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Type {type.FullName} is not a struct.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var structName = type.Name;
|
||||||
|
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Where(static f => f.FieldType.IsValueType).ToArray();
|
||||||
|
|
||||||
|
var shaderFields = new ShaderFieldInfo[fields.Length];
|
||||||
|
if (packingRules == PackingRules.Aligned)
|
||||||
|
{
|
||||||
|
var sortedFields = new List<ShaderFieldInfo>(fields.Length);
|
||||||
|
var looked = new bool[fields.Length];
|
||||||
|
var paddingIndex = 0;
|
||||||
|
|
||||||
|
// Sort the fields to align them to HLSL vector registers (16 bytes)
|
||||||
|
for (var i = 0; i < fields.Length; i++)
|
||||||
|
{
|
||||||
|
if (looked[i])
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = fields[i];
|
||||||
|
var size = Marshal.SizeOf(field.FieldType);
|
||||||
|
|
||||||
|
sortedFields.Add(new ShaderFieldInfo(field));
|
||||||
|
|
||||||
|
var registerRemaining = _HLSL_VECTOR_REGISTER_SIZE - (size % _HLSL_VECTOR_REGISTER_SIZE);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var nextSize = FindNextFieldThatFits(fields, looked, i + 1, registerRemaining, out var nextIndex);
|
||||||
|
if (nextSize == 0 || nextIndex == -1)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
looked[i] = true;
|
||||||
|
looked[nextIndex] = true;
|
||||||
|
|
||||||
|
sortedFields.Add(new ShaderFieldInfo(fields[nextIndex]));
|
||||||
|
|
||||||
|
registerRemaining -= nextSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerRemaining != 0)
|
||||||
|
{
|
||||||
|
// Add padding if necessary
|
||||||
|
var count = registerRemaining / sizeof(float);
|
||||||
|
for (var p = 0; p < count; p++)
|
||||||
|
{
|
||||||
|
sortedFields.Add(new ShaderFieldInfo($"_padding{paddingIndex++}", typeof(float)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shaderFields = sortedFields.ToArray();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (var i = 0; i < fields.Length; i++)
|
||||||
|
{
|
||||||
|
shaderFields[i] = new ShaderFieldInfo(fields[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(@$"
|
||||||
|
struct {structName}
|
||||||
|
{{");
|
||||||
|
foreach (var field in shaderFields)
|
||||||
|
{
|
||||||
|
var fieldType = field.fieldType;
|
||||||
|
var fieldName = field.name;
|
||||||
|
|
||||||
|
string hlslType;
|
||||||
|
switch (fieldType)
|
||||||
|
{
|
||||||
|
case Type t when t == typeof(float):
|
||||||
|
hlslType = "float";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(double):
|
||||||
|
hlslType = "double";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(int):
|
||||||
|
hlslType = "int";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(uint):
|
||||||
|
hlslType = "uint";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(bool):
|
||||||
|
hlslType = "bool";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(Vector2):
|
||||||
|
hlslType = "float2";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(Vector3):
|
||||||
|
hlslType = "float3";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(Vector4):
|
||||||
|
hlslType = "float4";
|
||||||
|
break;
|
||||||
|
case Type t when t == typeof(Matrix4x4):
|
||||||
|
hlslType = "float4x4";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
if (fieldType.Namespace == typeof(float2).Namespace)
|
||||||
|
{
|
||||||
|
if (fieldType.Name.StartsWith("float")
|
||||||
|
|| fieldType.Name.StartsWith("double")
|
||||||
|
|| fieldType.Name.StartsWith("int")
|
||||||
|
|| fieldType.Name.StartsWith("uint")
|
||||||
|
|| fieldType.Name.StartsWith("bool"))
|
||||||
|
{
|
||||||
|
hlslType = fieldType.Name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unsupported field type: {fieldType.FullName} in struct {structName}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(@$"
|
||||||
|
{hlslType} {fieldName};");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(@"
|
||||||
|
};");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void GenerateHLSL(ReadOnlySpan<Type> types, PackingRules packingRules, string outputSource)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(Path.GetDirectoryName(outputSource)))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException($"The directory for the output source '{outputSource}' does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlslDefine = $"{Path.GetFileNameWithoutExtension(outputSource).ToUpperInvariant().Replace('.', '_')}_HLSL";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine(@$"// Auto-generated HLSL code, please do not edit this file directly.
|
||||||
|
|
||||||
|
#ifndef {hlslDefine}
|
||||||
|
#define {hlslDefine}");
|
||||||
|
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
if (type.IsEnum)
|
||||||
|
{
|
||||||
|
GenerateEnumHLSL(type, sb);
|
||||||
|
}
|
||||||
|
else if (type.IsValueType && !type.IsPrimitive)
|
||||||
|
{
|
||||||
|
GenerateStructHLSL(type, packingRules, sb);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append(@"
|
||||||
|
#endif");
|
||||||
|
|
||||||
|
var hlslCode = sb.ToString();
|
||||||
|
File.WriteAllText(outputSource, hlslCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("(?<=[a-z])([A-Z])")]
|
||||||
|
private static partial Regex CamelCaseToUnderscoreRegex();
|
||||||
|
}
|
||||||
31
Ghost.DSL/Ghost.DSL.csproj
Normal file
31
Ghost.DSL/Ghost.DSL.csproj
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Antlr4.Runtime.Standard" Version="4.13.1" />
|
||||||
|
<PackageReference Include="Antlr4BuildTasks" Version="12.11.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Antlr4 Include="Grammar\GhostShaderLexer.g4">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<Listener>false</Listener>
|
||||||
|
<Visitor>true</Visitor>
|
||||||
|
</Antlr4>
|
||||||
|
<Antlr4 Include="Grammar\GhostShaderParser.g4">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<Listener>false</Listener>
|
||||||
|
<Visitor>true</Visitor>
|
||||||
|
</Antlr4>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../Ghost.Core/Ghost.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
38
Ghost.DSL/Grammar/GhostShaderLexer.g4
Normal file
38
Ghost.DSL/Grammar/GhostShaderLexer.g4
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
lexer grammar GhostShaderLexer;
|
||||||
|
|
||||||
|
// Keywords
|
||||||
|
SHADER: 'shader';
|
||||||
|
PROPERTIES: 'properties';
|
||||||
|
PIPELINE: 'pipeline';
|
||||||
|
PASS: 'pass';
|
||||||
|
DEFINES: 'defines';
|
||||||
|
KEYWORDS: 'keywords';
|
||||||
|
INCLUDES: 'includes';
|
||||||
|
GLOBAL: 'global';
|
||||||
|
LOCAL: 'local';
|
||||||
|
HLSL: 'hlsl';
|
||||||
|
|
||||||
|
// Punctuation
|
||||||
|
LBRACE: '{';
|
||||||
|
RBRACE: '}';
|
||||||
|
LPAREN: '(';
|
||||||
|
RPAREN: ')';
|
||||||
|
LBRACK: '[';
|
||||||
|
RBRACK: ']';
|
||||||
|
SEMICOLON: ';';
|
||||||
|
COMMA: ',';
|
||||||
|
EQUALS: '=';
|
||||||
|
COLON: ':';
|
||||||
|
|
||||||
|
// Literals
|
||||||
|
STRING_LITERAL: '"' (~["\r\n] | '\\' .)* '"';
|
||||||
|
NUMBER: [0-9]+ ('.' [0-9]+)? | '.' [0-9]+;
|
||||||
|
IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]*;
|
||||||
|
|
||||||
|
// Whitespace and Comments
|
||||||
|
WS: [ \t\r\n]+ -> skip;
|
||||||
|
LINE_COMMENT: '//' ~[\r\n]* -> skip;
|
||||||
|
BLOCK_COMMENT: '/*' .*? '*/' -> skip;
|
||||||
|
|
||||||
|
|
||||||
|
ANY_CHAR: . ;
|
||||||
99
Ghost.DSL/Grammar/GhostShaderParser.g4
Normal file
99
Ghost.DSL/Grammar/GhostShaderParser.g4
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
parser grammar GhostShaderParser;
|
||||||
|
|
||||||
|
options {
|
||||||
|
tokenVocab = GhostShaderLexer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level rule
|
||||||
|
shaderFile: shader+ EOF;
|
||||||
|
|
||||||
|
shader:
|
||||||
|
SHADER STRING_LITERAL LBRACE
|
||||||
|
shaderBody
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
shaderBody:
|
||||||
|
(propertiesBlock | pipelineBlock | passBlock | functionCall)*;
|
||||||
|
|
||||||
|
// Properties block
|
||||||
|
propertiesBlock:
|
||||||
|
PROPERTIES LBRACE
|
||||||
|
propertyDeclaration*
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
propertyDeclaration:
|
||||||
|
scope? IDENTIFIER IDENTIFIER (EQUALS LBRACE propertyInitializer RBRACE)? SEMICOLON;
|
||||||
|
|
||||||
|
scope:
|
||||||
|
GLOBAL | LOCAL;
|
||||||
|
|
||||||
|
propertyInitializer:
|
||||||
|
(NUMBER | IDENTIFIER) (COMMA (NUMBER | IDENTIFIER))*;
|
||||||
|
|
||||||
|
// Pipeline block
|
||||||
|
pipelineBlock:
|
||||||
|
PIPELINE LBRACE
|
||||||
|
pipelineStatement*
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
pipelineStatement:
|
||||||
|
IDENTIFIER EQUALS IDENTIFIER SEMICOLON;
|
||||||
|
|
||||||
|
// Pass block
|
||||||
|
passBlock:
|
||||||
|
PASS STRING_LITERAL LBRACE
|
||||||
|
passBody
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
// Template
|
||||||
|
passBody:
|
||||||
|
(definesBlock | includesBlock | keywordsBlock | pipelineBlock | hlslBlock | shaderEntry)*;
|
||||||
|
|
||||||
|
definesBlock:
|
||||||
|
DEFINES LBRACE
|
||||||
|
defineStatement*
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
defineStatement:
|
||||||
|
IDENTIFIER SEMICOLON;
|
||||||
|
|
||||||
|
includesBlock:
|
||||||
|
INCLUDES LBRACE
|
||||||
|
includeStatement*
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
includeStatement:
|
||||||
|
STRING_LITERAL SEMICOLON;
|
||||||
|
|
||||||
|
keywordsBlock:
|
||||||
|
KEYWORDS LBRACE
|
||||||
|
keywordStatement*
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
keywordStatement:
|
||||||
|
scope? IDENTIFIER (COMMA IDENTIFIER)* SEMICOLON;
|
||||||
|
|
||||||
|
hlslBlock:
|
||||||
|
HLSL LBRACE
|
||||||
|
hlslBody
|
||||||
|
RBRACE;
|
||||||
|
|
||||||
|
// Recursively matches content, ensuring braces are balanced.
|
||||||
|
hlslBody:
|
||||||
|
(
|
||||||
|
~(LBRACE | RBRACE) // Match ANY token except open/close braces
|
||||||
|
|
|
||||||
|
LBRACE hlslBody RBRACE // Or match a nested block recursively
|
||||||
|
)*;
|
||||||
|
|
||||||
|
shaderEntry:
|
||||||
|
IDENTIFIER STRING_LITERAL COLON STRING_LITERAL SEMICOLON;
|
||||||
|
|
||||||
|
functionCall:
|
||||||
|
IDENTIFIER LPAREN functionArguments? RPAREN SEMICOLON;
|
||||||
|
|
||||||
|
functionArguments:
|
||||||
|
functionArgument (COMMA functionArgument)*;
|
||||||
|
|
||||||
|
functionArgument:
|
||||||
|
STRING_LITERAL | NUMBER | IDENTIFIER;
|
||||||
323
Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs
Normal file
323
Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.DSL.ShaderParser;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Ghost.DSL.ShaderCompiler;
|
||||||
|
|
||||||
|
public struct DSLShaderError
|
||||||
|
{
|
||||||
|
public string message;
|
||||||
|
public int line;
|
||||||
|
public int column;
|
||||||
|
|
||||||
|
public override readonly string ToString()
|
||||||
|
{
|
||||||
|
return $"Error at {line}:{column} - {message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DSLShaderCompiler
|
||||||
|
{
|
||||||
|
private const string _GLOBAL_PROPERTY_FILE_NAME = "GlobalData.g.hlsl";
|
||||||
|
private const string _GENERATED_FILE_HEADER = "// Auto-generated shader file. Please do not edit this file directly.";
|
||||||
|
|
||||||
|
private static string GetPassUniqueId(DSLShaderSemantics shader, PassSemantic pass)
|
||||||
|
{
|
||||||
|
return $"{shader.name}_{pass.name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
||||||
|
{
|
||||||
|
if (semantic == null)
|
||||||
|
{
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PipelineState
|
||||||
|
{
|
||||||
|
ZTest = semantic.zTest ?? parent.ZTest,
|
||||||
|
ZWrite = semantic.zWrite ?? parent.ZWrite,
|
||||||
|
Cull = semantic.cull ?? parent.Cull,
|
||||||
|
Blend = semantic.blend ?? parent.Blend,
|
||||||
|
ColorMask = semantic.colorMask ?? parent.ColorMask
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint CalculateCBufferSize(ReadOnlySpan<PropertyDescriptor> properties)
|
||||||
|
{
|
||||||
|
if (properties.IsEmpty)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentOffset = 0u;
|
||||||
|
|
||||||
|
foreach (var prop in properties)
|
||||||
|
{
|
||||||
|
var size = prop.type.GetSize();
|
||||||
|
|
||||||
|
if ((currentOffset % 16) + size > 16)
|
||||||
|
{
|
||||||
|
currentOffset = (currentOffset + 15u) & ~15u;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffset += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (currentOffset + 15u) & ~15u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
||||||
|
// Currently, we just ignore inheritance.
|
||||||
|
public static ShaderDescriptor ResolveShader(DSLShaderSemantics semantics)
|
||||||
|
{
|
||||||
|
var descriptor = new ShaderDescriptor
|
||||||
|
{
|
||||||
|
name = semantics.name,
|
||||||
|
hlsl = semantics.hlsl
|
||||||
|
};
|
||||||
|
|
||||||
|
var shaderGlobalProperties = semantics.properties?
|
||||||
|
.Where(p => p.scope == PropertyScope.Global)
|
||||||
|
.Select(p => new PropertyDescriptor
|
||||||
|
{
|
||||||
|
name = p.name,
|
||||||
|
type = p.type,
|
||||||
|
defaultValue = p.defaultValue
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
var shaderLocalProperties = semantics.properties?
|
||||||
|
.Where(p => p.scope == PropertyScope.Local)
|
||||||
|
.Select(p => new PropertyDescriptor
|
||||||
|
{
|
||||||
|
name = p.name,
|
||||||
|
type = p.type,
|
||||||
|
defaultValue = p.defaultValue
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty<PropertyDescriptor>();
|
||||||
|
descriptor.properties = shaderLocalProperties ?? Array.Empty<PropertyDescriptor>();
|
||||||
|
descriptor.cbufferSize = CalculateCBufferSize(descriptor.properties);
|
||||||
|
|
||||||
|
if (semantics.passes != null)
|
||||||
|
{
|
||||||
|
descriptor.passes = new PassDescriptor[semantics.passes.Count];
|
||||||
|
for (int i = 0; i < semantics.passes.Count; i++)
|
||||||
|
{
|
||||||
|
var pass = semantics.passes[i];
|
||||||
|
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
|
||||||
|
descriptor.passes[i] = new PassDescriptor
|
||||||
|
{
|
||||||
|
identifier = GetPassUniqueId(semantics, pass),
|
||||||
|
name = pass.name,
|
||||||
|
taskShader = pass.taskShader,
|
||||||
|
meshShader = pass.meshShader,
|
||||||
|
pixelShader = pass.pixelShader,
|
||||||
|
localPipeline = localPipeline,
|
||||||
|
defines = pass.defines?.ToArray() ?? Array.Empty<string>(),
|
||||||
|
includes = pass.includes?.ToArray() ?? Array.Empty<string>(),
|
||||||
|
keywords = pass.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>(),
|
||||||
|
hlsl = pass.hlsl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
descriptor.passes = Array.Empty<PassDescriptor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<ShaderDescriptor> CompileShader(string shaderPath, string generatedOutputDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(shaderPath);
|
||||||
|
|
||||||
|
// Use ANTLR4 parser
|
||||||
|
var shaderModels = AntlrShaderCompiler.ParseShaders(source, out var parseErrors);
|
||||||
|
|
||||||
|
if (parseErrors.Count != 0)
|
||||||
|
{
|
||||||
|
var errorMessages = new StringBuilder();
|
||||||
|
foreach (var error in parseErrors)
|
||||||
|
{
|
||||||
|
errorMessages.AppendLine(error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Failure("Failed to parse shader due to errors:\n" + errorMessages.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shaderModels.Count == 0)
|
||||||
|
{
|
||||||
|
return Result.Failure("No shader found in the provided file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to semantics
|
||||||
|
var model = AntlrShaderCompiler.ConvertToSemantics(shaderModels[0], out var errors);
|
||||||
|
|
||||||
|
if (errors.Count != 0 || model == null)
|
||||||
|
{
|
||||||
|
var errorMessages = new StringBuilder();
|
||||||
|
foreach (var error in errors)
|
||||||
|
{
|
||||||
|
errorMessages.AppendLine(error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Failure("Failed to compile shader due to errors:\n" + errorMessages.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
var desc = ResolveShader(model);
|
||||||
|
var globalPropResult = GenerateGlobalProperties(desc.globalProperties, generatedOutputDirectory);
|
||||||
|
if (globalPropResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to generate global properties: " + globalPropResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var generatedResult = GenerateShaderCode(desc, generatedOutputDirectory);
|
||||||
|
if (generatedResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to generate pass files: " + generatedResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (ref var pass in desc.passes.AsSpan())
|
||||||
|
{
|
||||||
|
if (pass.includes == null)
|
||||||
|
{
|
||||||
|
pass.includes = new string[2];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Array.Resize(ref pass.includes, pass.includes.Length + 2);
|
||||||
|
// Shift existing includes to make room for the two new includes at the front.
|
||||||
|
pass.includes.AsSpan(0, pass.includes.Length - 2).CopyTo(pass.includes.AsSpan(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
pass.includes[0] = globalPropResult.Value;
|
||||||
|
pass.includes[1] = generatedResult.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to compile shader: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShaderPropertyTypeToHLSLType(ShaderPropertyType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
ShaderPropertyType.Float => "float",
|
||||||
|
ShaderPropertyType.Float2 => "float2",
|
||||||
|
ShaderPropertyType.Float3 => "float3",
|
||||||
|
ShaderPropertyType.Float4 => "float4",
|
||||||
|
ShaderPropertyType.Int => "int",
|
||||||
|
ShaderPropertyType.Int2 => "int2",
|
||||||
|
ShaderPropertyType.Int3 => "int3",
|
||||||
|
ShaderPropertyType.Int4 => "int4",
|
||||||
|
ShaderPropertyType.UInt => "uint",
|
||||||
|
ShaderPropertyType.UInt2 => "uint2",
|
||||||
|
ShaderPropertyType.UInt3 => "uint3",
|
||||||
|
ShaderPropertyType.UInt4 => "uint4",
|
||||||
|
ShaderPropertyType.Bool => "bool",
|
||||||
|
ShaderPropertyType.Bool2 => "bool2",
|
||||||
|
ShaderPropertyType.Bool3 => "bool3",
|
||||||
|
ShaderPropertyType.Bool4 => "bool4",
|
||||||
|
// NOTE: Textures here are bindless, represented as uint (descriptor index).
|
||||||
|
ShaderPropertyType.Texture2D => "TEXTURE2D",
|
||||||
|
ShaderPropertyType.Texture3D => "TEXTURE3D",
|
||||||
|
ShaderPropertyType.TextureCube => "TEXTURECUBE",
|
||||||
|
ShaderPropertyType.Texture2DArray => "TEXTURE2D_ARRAY",
|
||||||
|
ShaderPropertyType.TextureCubeArray => "TEXTURECUBE_ARRAY",
|
||||||
|
ShaderPropertyType.Sampler => "SAMPLER",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported shader property type: {type}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<string> GenerateShaderCode(ShaderDescriptor descriptor, string targetDirectory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(targetDirectory))
|
||||||
|
{
|
||||||
|
return Result.Failure("Target directory does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputFileName = descriptor.name.Replace('/', '_');
|
||||||
|
var outputFilePath = Path.Combine(targetDirectory, outputFileName + ".g.hlsl");
|
||||||
|
var outputDirectory = Path.GetDirectoryName(outputFilePath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(outputDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory!);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var fileStream = File.CreateText(outputFilePath);
|
||||||
|
var fileDefine = outputFileName.Replace('/', '_').ToUpperInvariant() + "_G_HLSL";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.AppendLine(_GENERATED_FILE_HEADER);
|
||||||
|
sb.AppendLine(@$"
|
||||||
|
#ifndef {fileDefine}
|
||||||
|
#define {fileDefine}
|
||||||
|
|
||||||
|
#include ""F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Includes/Common.hlsl""");
|
||||||
|
|
||||||
|
sb.Append(@"
|
||||||
|
struct PerMaterialData
|
||||||
|
{");
|
||||||
|
foreach (var prop in descriptor.properties)
|
||||||
|
{
|
||||||
|
sb.Append($@"
|
||||||
|
{ShaderPropertyTypeToHLSLType(prop.type)} {prop.name};");
|
||||||
|
}
|
||||||
|
sb.Append(@"
|
||||||
|
};");
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(@$"
|
||||||
|
#endif // {fileDefine}");
|
||||||
|
|
||||||
|
fileStream.Write(sb.ToString());
|
||||||
|
|
||||||
|
return outputFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<string> GenerateGlobalProperties(ReadOnlySpan<PropertyDescriptor> globalProperties, string targetDirectory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(targetDirectory))
|
||||||
|
{
|
||||||
|
return Result.Failure("Target directory does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalFilePath = Path.Combine(targetDirectory, _GLOBAL_PROPERTY_FILE_NAME);
|
||||||
|
using var globalFileStream = File.CreateText(globalFilePath);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.AppendLine(_GENERATED_FILE_HEADER);
|
||||||
|
sb.Append(@"
|
||||||
|
#ifndef GLOBALDATA_G_HLSL
|
||||||
|
#define GLOBALDATA_G_HLSL
|
||||||
|
|
||||||
|
#include ""F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Includes/Common.hlsl""
|
||||||
|
|
||||||
|
struct GlobalData
|
||||||
|
{");
|
||||||
|
foreach (var prop in globalProperties)
|
||||||
|
{
|
||||||
|
sb.Append($@"
|
||||||
|
{ShaderPropertyTypeToHLSLType(prop.type)} {prop.name};");
|
||||||
|
}
|
||||||
|
sb.AppendLine(@"
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // GLOBALDATA_G_HLSL");
|
||||||
|
globalFileStream.Write(sb.ToString());
|
||||||
|
|
||||||
|
return globalFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Ghost.DSL/ShaderCompiler/DSLShaderSemantics.cs
Normal file
48
Ghost.DSL/ShaderCompiler/DSLShaderSemantics.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Ghost.Core.Graphics;
|
||||||
|
|
||||||
|
namespace Ghost.DSL.ShaderCompiler;
|
||||||
|
|
||||||
|
public enum PropertyScope
|
||||||
|
{
|
||||||
|
Global,
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PropertySemantic
|
||||||
|
{
|
||||||
|
public PropertyScope scope;
|
||||||
|
public ShaderPropertyType type;
|
||||||
|
public string name = string.Empty;
|
||||||
|
public object? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PipelineSemantic
|
||||||
|
{
|
||||||
|
public ZTest? zTest;
|
||||||
|
public ZWrite? zWrite;
|
||||||
|
public Cull? cull;
|
||||||
|
public Blend? blend;
|
||||||
|
public ColorWriteMask? colorMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PassSemantic
|
||||||
|
{
|
||||||
|
public string name = string.Empty;
|
||||||
|
public ShaderEntryPoint taskShader;
|
||||||
|
public ShaderEntryPoint meshShader;
|
||||||
|
public ShaderEntryPoint pixelShader;
|
||||||
|
public string? hlsl;
|
||||||
|
public List<string>? defines;
|
||||||
|
public List<string>? includes;
|
||||||
|
public List<KeywordsGroup>? keywords;
|
||||||
|
public PipelineSemantic? localPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DSLShaderSemantics
|
||||||
|
{
|
||||||
|
public string name = string.Empty;
|
||||||
|
public string? hlsl;
|
||||||
|
public List<PropertySemantic>? properties;
|
||||||
|
public PipelineSemantic? pipeline;
|
||||||
|
public List<PassSemantic>? passes;
|
||||||
|
}
|
||||||
383
Ghost.DSL/ShaderParser/AntlrShaderCompiler.cs
Normal file
383
Ghost.DSL/ShaderParser/AntlrShaderCompiler.cs
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
using Antlr4.Runtime;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.DSL.ShaderCompiler;
|
||||||
|
using Ghost.DSL.ShaderParser.Model;
|
||||||
|
|
||||||
|
namespace Ghost.DSL.ShaderParser;
|
||||||
|
|
||||||
|
public class AntlrShaderCompiler
|
||||||
|
{
|
||||||
|
public static List<ShaderModel> ParseShaders(string source, out List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
errors = new List<DSLShaderError>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inputStream = new AntlrInputStream(source);
|
||||||
|
var lexer = new GhostShaderLexer(inputStream);
|
||||||
|
|
||||||
|
// Capture lexer errors
|
||||||
|
lexer.RemoveErrorListeners();
|
||||||
|
var lexerErrorListener = new ErrorListener(errors);
|
||||||
|
lexer.AddErrorListener(lexerErrorListener);
|
||||||
|
|
||||||
|
var tokenStream = new CommonTokenStream(lexer);
|
||||||
|
var parser = new GhostShaderParser(tokenStream);
|
||||||
|
|
||||||
|
// Capture parser errors
|
||||||
|
parser.RemoveErrorListeners();
|
||||||
|
var parserErrorListener = new ErrorListener(errors);
|
||||||
|
parser.AddErrorListener(parserErrorListener);
|
||||||
|
|
||||||
|
var tree = parser.shaderFile();
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
return new List<ShaderModel>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var visitor = new ShaderVisitor();
|
||||||
|
visitor.Visit(tree);
|
||||||
|
|
||||||
|
return visitor.Shaders;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Unexpected error during parsing: {ex.Message}",
|
||||||
|
line = -1,
|
||||||
|
column = -1
|
||||||
|
});
|
||||||
|
return new List<ShaderModel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DSLShaderSemantics? ConvertToSemantics(ShaderModel model, out List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
errors = new List<DSLShaderError>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(model.Name))
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = "Shader name cannot be empty.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var semantics = new DSLShaderSemantics
|
||||||
|
{
|
||||||
|
name = model.Name,
|
||||||
|
properties = ConvertProperties(model.Properties, errors),
|
||||||
|
pipeline = ConvertPipeline(model.Pipeline, errors)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pass in model.Passes)
|
||||||
|
{
|
||||||
|
var passSemantic = ConvertPass(pass, errors);
|
||||||
|
if (passSemantic != null)
|
||||||
|
{
|
||||||
|
semantics.passes ??= new List<PassSemantic>();
|
||||||
|
semantics.passes.Add(passSemantic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return semantics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<PropertySemantic>? ConvertProperties(PropertiesBlockModel? properties, List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
if (properties == null || properties.Properties.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<PropertySemantic>();
|
||||||
|
var usedNames = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var prop in properties.Properties)
|
||||||
|
{
|
||||||
|
if (usedNames.Contains(prop.Name))
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Duplicate property name '{prop.Name}'.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var semantic = new PropertySemantic
|
||||||
|
{
|
||||||
|
name = prop.Name,
|
||||||
|
scope = prop.Scope?.ToLower() == "global" ? PropertyScope.Global : PropertyScope.Local,
|
||||||
|
type = ParsePropertyType(prop.Type, errors)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prop.Initializer.Count > 0)
|
||||||
|
{
|
||||||
|
semantic.defaultValue = ParsePropertyValue(semantic.type, prop.Initializer, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.Add(prop.Name);
|
||||||
|
result.Add(semantic);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShaderPropertyType ParsePropertyType(string type, List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
return type.ToLower() switch
|
||||||
|
{
|
||||||
|
"float" => ShaderPropertyType.Float,
|
||||||
|
"float2" => ShaderPropertyType.Float2,
|
||||||
|
"float3" => ShaderPropertyType.Float3,
|
||||||
|
"float4" => ShaderPropertyType.Float4,
|
||||||
|
"float4x4" => ShaderPropertyType.Float4x4,
|
||||||
|
"int" => ShaderPropertyType.Int,
|
||||||
|
"int2" => ShaderPropertyType.Int2,
|
||||||
|
"int3" => ShaderPropertyType.Int3,
|
||||||
|
"int4" => ShaderPropertyType.Int4,
|
||||||
|
"uint" => ShaderPropertyType.UInt,
|
||||||
|
"uint2" => ShaderPropertyType.UInt2,
|
||||||
|
"uint3" => ShaderPropertyType.UInt3,
|
||||||
|
"uint4" => ShaderPropertyType.UInt4,
|
||||||
|
"bool" => ShaderPropertyType.Bool,
|
||||||
|
"bool2" => ShaderPropertyType.Bool2,
|
||||||
|
"bool3" => ShaderPropertyType.Bool3,
|
||||||
|
"bool4" => ShaderPropertyType.Bool4,
|
||||||
|
"tex2d" => ShaderPropertyType.Texture2D,
|
||||||
|
"tex3d" => ShaderPropertyType.Texture3D,
|
||||||
|
"texcube" => ShaderPropertyType.TextureCube,
|
||||||
|
"texcube_arr" => ShaderPropertyType.TextureCubeArray,
|
||||||
|
"tex2d_arr" => ShaderPropertyType.Texture2DArray,
|
||||||
|
"sampler" => ShaderPropertyType.Sampler,
|
||||||
|
_ => ShaderPropertyType.None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ParsePropertyValue(ShaderPropertyType type, List<string> values, List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
// For textures, the value is an identifier (e.g., "white", "black")
|
||||||
|
if (type is ShaderPropertyType.Texture2D or ShaderPropertyType.Texture3D or ShaderPropertyType.TextureCube)
|
||||||
|
{
|
||||||
|
return values.Count > 0 ? values[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For samplers, no default value
|
||||||
|
if (type == ShaderPropertyType.Sampler)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For numeric types, parse the values
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
ShaderPropertyType.Float => values.Count > 0 ? float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0f,
|
||||||
|
ShaderPropertyType.Float2 => values.Count >= 2 ? new Misaki.HighPerformance.Mathematics.float2(
|
||||||
|
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||||
|
ShaderPropertyType.Float3 => values.Count >= 3 ? new Misaki.HighPerformance.Mathematics.float3(
|
||||||
|
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
float.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||||
|
ShaderPropertyType.Float4 => values.Count >= 4 ? new Misaki.HighPerformance.Mathematics.float4(
|
||||||
|
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
float.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
float.Parse(values[3], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||||
|
ShaderPropertyType.Int => values.Count > 0 ? int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0,
|
||||||
|
ShaderPropertyType.Int2 => values.Count >= 2 ? new Misaki.HighPerformance.Mathematics.int2(
|
||||||
|
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||||
|
ShaderPropertyType.Int3 => values.Count >= 3 ? new Misaki.HighPerformance.Mathematics.int3(
|
||||||
|
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||||
|
ShaderPropertyType.Int4 => values.Count >= 4 ? new Misaki.HighPerformance.Mathematics.int4(
|
||||||
|
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(values[3], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||||
|
ShaderPropertyType.UInt => values.Count > 0 ? uint.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0u,
|
||||||
|
ShaderPropertyType.Bool => values.Count > 0 && (values[0] == "1" || values[0].ToLower() == "true"),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Failed to parse property value: {ex.Message}",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PipelineSemantic? ConvertPipeline(PipelineBlockModel? pipeline, List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
if (pipeline == null || pipeline.Statements.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var semantic = new PipelineSemantic();
|
||||||
|
|
||||||
|
foreach (var (key, value) in pipeline.Statements)
|
||||||
|
{
|
||||||
|
switch (key.ToLower())
|
||||||
|
{
|
||||||
|
case "ztest":
|
||||||
|
semantic.zTest = value.ToLower() switch
|
||||||
|
{
|
||||||
|
"disabled" => ZTest.Disabled,
|
||||||
|
"less" => ZTest.Less,
|
||||||
|
"lessequal" => ZTest.LessEqual,
|
||||||
|
"equal" => ZTest.Equal,
|
||||||
|
"greaterequal" => ZTest.GreaterEqual,
|
||||||
|
"greater" => ZTest.Greater,
|
||||||
|
"notequal" => ZTest.NotEqual,
|
||||||
|
"always" => ZTest.Always,
|
||||||
|
_ => ZTest.Disabled
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "zwrite":
|
||||||
|
semantic.zWrite = value.ToLower() == "on" ? ZWrite.On : ZWrite.Off;
|
||||||
|
break;
|
||||||
|
case "cull":
|
||||||
|
semantic.cull = value.ToLower() switch
|
||||||
|
{
|
||||||
|
"off" => Cull.Off,
|
||||||
|
"front" => Cull.Front,
|
||||||
|
"back" => Cull.Back,
|
||||||
|
_ => Cull.Off
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "blend":
|
||||||
|
semantic.blend = value.ToLower() switch
|
||||||
|
{
|
||||||
|
"opaque" => Blend.Opaque,
|
||||||
|
"alpha" => Blend.Alpha,
|
||||||
|
"additive" => Blend.Additive,
|
||||||
|
"multiply" => Blend.Multiply,
|
||||||
|
"premultipliedalpha" => Blend.PremultipliedAlpha,
|
||||||
|
_ => Blend.Opaque
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "color_mask":
|
||||||
|
semantic.colorMask = value.ToLower() == "all" ? ColorWriteMask.All : ColorWriteMask.None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return semantic;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PassSemantic? ConvertPass(PassBlockModel pass, List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
var semantic = new PassSemantic
|
||||||
|
{
|
||||||
|
name = pass.Name,
|
||||||
|
hlsl = pass.Hlsl?.Code,
|
||||||
|
defines = pass.Defines?.Defines,
|
||||||
|
includes = pass.Includes?.Includes,
|
||||||
|
localPipeline = ConvertPipeline(pass.LocalPipeline, errors)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pass.Keywords != null)
|
||||||
|
{
|
||||||
|
semantic.keywords = new List<KeywordsGroup>();
|
||||||
|
foreach (var group in pass.Keywords.Groups)
|
||||||
|
{
|
||||||
|
var keywordGroup = new KeywordsGroup
|
||||||
|
{
|
||||||
|
space = group.Scope?.ToLower() == "global" ? KeywordSpace.Global : KeywordSpace.Local,
|
||||||
|
keywords = group.Keywords
|
||||||
|
};
|
||||||
|
semantic.keywords.Add(keywordGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in pass.ShaderEntries)
|
||||||
|
{
|
||||||
|
var entryType = entry.EntryType.ToLower();
|
||||||
|
var shaderEntry = new ShaderEntryPoint
|
||||||
|
{
|
||||||
|
shader = entry.ShaderPath,
|
||||||
|
entry = entry.EntryPoint
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (entryType)
|
||||||
|
{
|
||||||
|
case "mesh" or "ms":
|
||||||
|
semantic.meshShader = shaderEntry;
|
||||||
|
break;
|
||||||
|
case "pixel" or "ps":
|
||||||
|
semantic.pixelShader = shaderEntry;
|
||||||
|
break;
|
||||||
|
case "task" or "ts":
|
||||||
|
semantic.taskShader = shaderEntry;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Unknown shader entry type '{entry.EntryType}'.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semantic.meshShader.shader == null || semantic.pixelShader.shader == null)
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Pass '{pass.Name}' must contain a mesh/ms shader and a pixel/ps shader declaration.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return semantic;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ErrorListener : BaseErrorListener, IAntlrErrorListener<int>, IAntlrErrorListener<IToken>
|
||||||
|
{
|
||||||
|
private readonly List<DSLShaderError> _errors;
|
||||||
|
|
||||||
|
public ErrorListener(List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
_errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e)
|
||||||
|
{
|
||||||
|
_errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = msg,
|
||||||
|
line = line,
|
||||||
|
column = charPositionInLine
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public new void SyntaxError(TextWriter output, IRecognizer recognizer, IToken offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e)
|
||||||
|
{
|
||||||
|
_errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = msg,
|
||||||
|
line = line,
|
||||||
|
column = charPositionInLine
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
Ghost.DSL/ShaderParser/Model/ShaderModel.cs
Normal file
78
Ghost.DSL/ShaderParser/Model/ShaderModel.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
namespace Ghost.DSL.ShaderParser.Model;
|
||||||
|
|
||||||
|
public class ShaderModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public PropertiesBlockModel? Properties { get; set; }
|
||||||
|
public PipelineBlockModel? Pipeline { get; set; }
|
||||||
|
public List<PassBlockModel> Passes { get; set; } = new();
|
||||||
|
public List<FunctionCallModel> FunctionCalls { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PropertiesBlockModel
|
||||||
|
{
|
||||||
|
public List<PropertyDeclarationModel> Properties { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PropertyDeclarationModel
|
||||||
|
{
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<string> Initializer { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PipelineBlockModel
|
||||||
|
{
|
||||||
|
public Dictionary<string, string> Statements { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PassBlockModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public PipelineBlockModel? LocalPipeline { get; set; }
|
||||||
|
public DefinesBlockModel? Defines { get; set; }
|
||||||
|
public IncludesBlockModel? Includes { get; set; }
|
||||||
|
public KeywordsBlockModel? Keywords { get; set; }
|
||||||
|
public HlslBlockModel? Hlsl { get; set; }
|
||||||
|
public List<ShaderEntryModel> ShaderEntries { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DefinesBlockModel
|
||||||
|
{
|
||||||
|
public List<string> Defines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IncludesBlockModel
|
||||||
|
{
|
||||||
|
public List<string> Includes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KeywordsBlockModel
|
||||||
|
{
|
||||||
|
public List<KeywordGroupModel> Groups { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KeywordGroupModel
|
||||||
|
{
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
public List<string> Keywords { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HlslBlockModel
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ShaderEntryModel
|
||||||
|
{
|
||||||
|
public string EntryType { get; set; } = string.Empty; // "mesh", "pixel", "task", etc.
|
||||||
|
public string ShaderPath { get; set; } = string.Empty;
|
||||||
|
public string EntryPoint { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FunctionCallModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<string> Arguments { get; set; } = new();
|
||||||
|
}
|
||||||
261
Ghost.DSL/ShaderParser/ShaderVisitor.cs
Normal file
261
Ghost.DSL/ShaderParser/ShaderVisitor.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
using Antlr4.Runtime.Misc;
|
||||||
|
using Ghost.DSL.ShaderParser.Model;
|
||||||
|
|
||||||
|
namespace Ghost.DSL.ShaderParser;
|
||||||
|
|
||||||
|
public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
||||||
|
{
|
||||||
|
public List<ShaderModel> Shaders { get; } = new();
|
||||||
|
|
||||||
|
public override object VisitShaderFile([NotNull] GhostShaderParser.ShaderFileContext context)
|
||||||
|
{
|
||||||
|
foreach (var shaderContext in context.shader())
|
||||||
|
{
|
||||||
|
var shader = (ShaderModel)VisitShader(shaderContext);
|
||||||
|
Shaders.Add(shader);
|
||||||
|
}
|
||||||
|
return Shaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitShader([NotNull] GhostShaderParser.ShaderContext context)
|
||||||
|
{
|
||||||
|
var shader = new ShaderModel
|
||||||
|
{
|
||||||
|
Name = StripQuotes(context.STRING_LITERAL().GetText())
|
||||||
|
};
|
||||||
|
|
||||||
|
var shaderBody = context.shaderBody();
|
||||||
|
if (shaderBody != null)
|
||||||
|
{
|
||||||
|
foreach (var propBlock in shaderBody.propertiesBlock())
|
||||||
|
{
|
||||||
|
shader.Properties = (PropertiesBlockModel)VisitPropertiesBlock(propBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pipelineBlock in shaderBody.pipelineBlock())
|
||||||
|
{
|
||||||
|
shader.Pipeline = (PipelineBlockModel)VisitPipelineBlock(pipelineBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var passBlock in shaderBody.passBlock())
|
||||||
|
{
|
||||||
|
shader.Passes.Add((PassBlockModel)VisitPassBlock(passBlock));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var funcCall in shaderBody.functionCall())
|
||||||
|
{
|
||||||
|
shader.FunctionCalls.Add((FunctionCallModel)VisitFunctionCall(funcCall));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitPropertiesBlock([NotNull] GhostShaderParser.PropertiesBlockContext context)
|
||||||
|
{
|
||||||
|
var properties = new PropertiesBlockModel();
|
||||||
|
|
||||||
|
foreach (var propDecl in context.propertyDeclaration())
|
||||||
|
{
|
||||||
|
properties.Properties.Add((PropertyDeclarationModel)VisitPropertyDeclaration(propDecl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitPropertyDeclaration([NotNull] GhostShaderParser.PropertyDeclarationContext context)
|
||||||
|
{
|
||||||
|
var property = new PropertyDeclarationModel
|
||||||
|
{
|
||||||
|
Type = context.IDENTIFIER(0).GetText(),
|
||||||
|
Name = context.IDENTIFIER(1).GetText()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.scope() != null)
|
||||||
|
{
|
||||||
|
property.Scope = context.scope().GetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.propertyInitializer() != null)
|
||||||
|
{
|
||||||
|
var init = context.propertyInitializer();
|
||||||
|
foreach (var number in init.NUMBER())
|
||||||
|
{
|
||||||
|
property.Initializer.Add(number.GetText());
|
||||||
|
}
|
||||||
|
foreach (var identifier in init.IDENTIFIER())
|
||||||
|
{
|
||||||
|
property.Initializer.Add(identifier.GetText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return property;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitPipelineBlock([NotNull] GhostShaderParser.PipelineBlockContext context)
|
||||||
|
{
|
||||||
|
var pipeline = new PipelineBlockModel();
|
||||||
|
|
||||||
|
foreach (var statement in context.pipelineStatement())
|
||||||
|
{
|
||||||
|
var key = statement.IDENTIFIER(0).GetText();
|
||||||
|
var value = statement.IDENTIFIER(1).GetText();
|
||||||
|
pipeline.Statements[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitPassBlock([NotNull] GhostShaderParser.PassBlockContext context)
|
||||||
|
{
|
||||||
|
var pass = new PassBlockModel
|
||||||
|
{
|
||||||
|
Name = StripQuotes(context.STRING_LITERAL().GetText())
|
||||||
|
};
|
||||||
|
|
||||||
|
var passBody = context.passBody();
|
||||||
|
if (passBody != null)
|
||||||
|
{
|
||||||
|
foreach (var definesBlock in passBody.definesBlock())
|
||||||
|
{
|
||||||
|
pass.Defines = (DefinesBlockModel)VisitDefinesBlock(definesBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var includesBlock in passBody.includesBlock())
|
||||||
|
{
|
||||||
|
pass.Includes = (IncludesBlockModel)VisitIncludesBlock(includesBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var keywordsBlock in passBody.keywordsBlock())
|
||||||
|
{
|
||||||
|
pass.Keywords = (KeywordsBlockModel)VisitKeywordsBlock(keywordsBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pipelineBlock in passBody.pipelineBlock())
|
||||||
|
{
|
||||||
|
pass.LocalPipeline = (PipelineBlockModel)VisitPipelineBlock(pipelineBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var hlslBlock in passBody.hlslBlock())
|
||||||
|
{
|
||||||
|
pass.Hlsl = (HlslBlockModel)VisitHlslBlock(hlslBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var shaderEntry in passBody.shaderEntry())
|
||||||
|
{
|
||||||
|
pass.ShaderEntries.Add((ShaderEntryModel)VisitShaderEntry(shaderEntry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitDefinesBlock([NotNull] GhostShaderParser.DefinesBlockContext context)
|
||||||
|
{
|
||||||
|
var defines = new DefinesBlockModel();
|
||||||
|
|
||||||
|
foreach (var defineStmt in context.defineStatement())
|
||||||
|
{
|
||||||
|
defines.Defines.Add(defineStmt.IDENTIFIER().GetText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return defines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitIncludesBlock([NotNull] GhostShaderParser.IncludesBlockContext context)
|
||||||
|
{
|
||||||
|
var includes = new IncludesBlockModel();
|
||||||
|
|
||||||
|
foreach (var includeStmt in context.includeStatement())
|
||||||
|
{
|
||||||
|
includes.Includes.Add(StripQuotes(includeStmt.STRING_LITERAL().GetText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return includes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitKeywordsBlock([NotNull] GhostShaderParser.KeywordsBlockContext context)
|
||||||
|
{
|
||||||
|
var keywords = new KeywordsBlockModel();
|
||||||
|
|
||||||
|
foreach (var keywordStmt in context.keywordStatement())
|
||||||
|
{
|
||||||
|
var group = new KeywordGroupModel();
|
||||||
|
|
||||||
|
if (keywordStmt.scope() != null)
|
||||||
|
{
|
||||||
|
group.Scope = keywordStmt.scope().GetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var identifier in keywordStmt.IDENTIFIER())
|
||||||
|
{
|
||||||
|
group.Keywords.Add(identifier.GetText());
|
||||||
|
}
|
||||||
|
|
||||||
|
keywords.Groups.Add(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitHlslBlock([NotNull] GhostShaderParser.HlslBlockContext context)
|
||||||
|
{
|
||||||
|
var hlsl = new HlslBlockModel();
|
||||||
|
|
||||||
|
// Get the text between the braces
|
||||||
|
var start = context.LBRACE().Symbol.StopIndex + 1;
|
||||||
|
var stop = context.RBRACE().Symbol.StartIndex - 1;
|
||||||
|
|
||||||
|
if (stop >= start)
|
||||||
|
{
|
||||||
|
var input = context.Start.InputStream;
|
||||||
|
hlsl.Code = input.GetText(new Antlr4.Runtime.Misc.Interval(start, stop));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hlsl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitShaderEntry([NotNull] GhostShaderParser.ShaderEntryContext context)
|
||||||
|
{
|
||||||
|
var entry = new ShaderEntryModel
|
||||||
|
{
|
||||||
|
EntryType = context.IDENTIFIER().GetText(),
|
||||||
|
ShaderPath = StripQuotes(context.STRING_LITERAL(0).GetText()),
|
||||||
|
EntryPoint = StripQuotes(context.STRING_LITERAL(1).GetText())
|
||||||
|
};
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object VisitFunctionCall([NotNull] GhostShaderParser.FunctionCallContext context)
|
||||||
|
{
|
||||||
|
var funcCall = new FunctionCallModel
|
||||||
|
{
|
||||||
|
Name = context.IDENTIFIER().GetText()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.functionArguments() != null)
|
||||||
|
{
|
||||||
|
foreach (var arg in context.functionArguments().functionArgument())
|
||||||
|
{
|
||||||
|
var text = arg.GetText();
|
||||||
|
if (text.StartsWith('"'))
|
||||||
|
{
|
||||||
|
text = StripQuotes(text);
|
||||||
|
}
|
||||||
|
funcCall.Arguments.Add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return funcCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripQuotes(string text)
|
||||||
|
{
|
||||||
|
if (text.Length >= 2 && text.StartsWith('"') && text.EndsWith('"'))
|
||||||
|
{
|
||||||
|
return text.Substring(1, text.Length - 2);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Ghost.Data/AssemblyInfo.cs
Normal file
9
Ghost.Data/AssemblyInfo.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Editor")]
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Editor.Core")]
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.UnitTest")]
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.MicroTest")]
|
||||||
|
|
||||||
|
[assembly: EngineAssembly]
|
||||||
BIN
Ghost.Data/Assets/ProjectTemplates/Empty.zip
Normal file
BIN
Ghost.Data/Assets/ProjectTemplates/Empty.zip
Normal file
Binary file not shown.
33
Ghost.Data/Ghost.Data.csproj
Normal file
33
Ghost.Data/Ghost.Data.csproj
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<IsAotCompatible>True</IsAotCompatible>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<IsAotCompatible>True</IsAotCompatible>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
|
||||||
|
<PackageReference Include="System.Data.SQLite" Version="1.0.119" />
|
||||||
|
<PackageReference Include="System.Drawing.Common" Version="4.7.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="Assets\ProjectTemplates\Empty.zip">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Ghost.Core\Ghost.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
11
Ghost.Data/JsonContext.cs
Normal file
11
Ghost.Data/JsonContext.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using Ghost.Data.Models;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Data;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(TemplateInfo))]
|
||||||
|
[JsonSerializable(typeof(ProjectMetadata))]
|
||||||
|
internal partial class JsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
22
Ghost.Data/Models/ProjectInfo.cs
Normal file
22
Ghost.Data/Models/ProjectInfo.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Ghost.Data.Models;
|
||||||
|
|
||||||
|
internal class ProjectInfo
|
||||||
|
{
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public int ID
|
||||||
|
{
|
||||||
|
get; internal set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required string MetadataPath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Ghost.Data/Models/ProjectMetadata.cs
Normal file
53
Ghost.Data/Models/ProjectMetadata.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Ghost.Data.Models;
|
||||||
|
|
||||||
|
public class ProjectMetadata
|
||||||
|
{
|
||||||
|
public const string PROJECT_FILE_EXTENSION_NAME = "gproj";
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Version EngineVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime CreatedAt
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime LastOpened
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectMetadata(string name, Version engineVersion)
|
||||||
|
{
|
||||||
|
ID = Guid.NewGuid();
|
||||||
|
Name = name;
|
||||||
|
EngineVersion = engineVersion;
|
||||||
|
CreatedAt = DateTime.UtcNow;
|
||||||
|
LastOpened = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterless constructor for deserialization
|
||||||
|
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||||
|
public ProjectMetadata()
|
||||||
|
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct ProjectMetadataInfo(string path, ProjectMetadata metadata)
|
||||||
|
{
|
||||||
|
public readonly string Path => path;
|
||||||
|
public readonly ProjectMetadata Metadata => metadata;
|
||||||
|
}
|
||||||
44
Ghost.Data/Models/TemplateInfo.cs
Normal file
44
Ghost.Data/Models/TemplateInfo.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
namespace Ghost.Data.Models;
|
||||||
|
|
||||||
|
public class TemplateInfo
|
||||||
|
{
|
||||||
|
public required string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Description
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required Version TemplateVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required Version EngineVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TemplateData(string templatePath, TemplateInfo info)
|
||||||
|
{
|
||||||
|
private const string _ICON_NAME = "icon.png";
|
||||||
|
private const string _PREVIEW_NAME = "preview.png";
|
||||||
|
|
||||||
|
public string directory = Path.GetDirectoryName(templatePath)!;
|
||||||
|
|
||||||
|
public readonly TemplateInfo Info => info;
|
||||||
|
|
||||||
|
public readonly Uri GetIconURI()
|
||||||
|
{
|
||||||
|
return new Uri(Path.Combine(directory, _ICON_NAME));
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly Uri GetPreviewURI()
|
||||||
|
{
|
||||||
|
return new Uri(Path.Combine(directory, _PREVIEW_NAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Ghost.Data/Repository/AssetsRepository.cs
Normal file
6
Ghost.Data/Repository/AssetsRepository.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Ghost.Data.Repository;
|
||||||
|
|
||||||
|
internal class AssetsRepository
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
172
Ghost.Data/Repository/ProjectRepository.cs
Normal file
172
Ghost.Data/Repository/ProjectRepository.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using Ghost.Data.Models;
|
||||||
|
using Ghost.Data.Resources;
|
||||||
|
using System.Data.SQLite;
|
||||||
|
|
||||||
|
namespace Ghost.Data.Repository;
|
||||||
|
|
||||||
|
internal static class ProjectRepository
|
||||||
|
{
|
||||||
|
private static class Command
|
||||||
|
{
|
||||||
|
public const string CONNECTION_STRING = "Data Source={0}\\projects.db;Version=3;";
|
||||||
|
public const string CREATE_PROJECT_TABLE_STRING = "CREATE TABLE IF NOT EXISTS Projects (ID INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT, MetadataPath TEXT);";
|
||||||
|
public const string SELECT_PROJECT_STRING = "SELECT * FROM Projects";
|
||||||
|
public const string INSERT_PROJECT_STRING = "INSERT INTO Projects (Name, MetadataPath) VALUES (@Name, @MetadataPath);";
|
||||||
|
public const string REMOVE_PROJECT_STRING = "DELETE FROM Projects WHERE ID = @ID;";
|
||||||
|
public const string UPDATE_PROJECT_STRING = "UPDATE Projects SET Name = @Name, MetadataPath = @MetadataPath WHERE ID = @ID;";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureTableCreatedAsync(SQLiteConnection connection)
|
||||||
|
{
|
||||||
|
using var createCommand = connection.CreateCommand();
|
||||||
|
createCommand.CommandText = Command.CREATE_PROJECT_TABLE_STRING;
|
||||||
|
await createCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async IAsyncEnumerable<ProjectInfo> GetAllProjectsAsync()
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
await EnsureTableCreatedAsync(connection);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.SELECT_PROJECT_STRING;
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
var project = new ProjectInfo
|
||||||
|
{
|
||||||
|
ID = reader.GetInt32(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
MetadataPath = reader.GetString(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
yield return project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ProjectInfo?> GetProjectByIdAsync(int id)
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
await EnsureTableCreatedAsync(connection);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.SELECT_PROJECT_STRING + " WHERE ID = @ID;";
|
||||||
|
|
||||||
|
command.Parameters.AddWithValue("@ID", id);
|
||||||
|
|
||||||
|
using var reader = await command.ExecuteReaderAsync();
|
||||||
|
|
||||||
|
if (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
return new ProjectInfo
|
||||||
|
{
|
||||||
|
ID = reader.GetInt32(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
MetadataPath = reader.GetString(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ProjectInfo?> GetProjectByNameAsync(string name)
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
await EnsureTableCreatedAsync(connection);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.SELECT_PROJECT_STRING + " WHERE Name = @Name;";
|
||||||
|
|
||||||
|
command.Parameters.AddWithValue("@Name", name);
|
||||||
|
|
||||||
|
using var reader = await command.ExecuteReaderAsync();
|
||||||
|
if (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
return new ProjectInfo
|
||||||
|
{
|
||||||
|
ID = reader.GetInt32(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
MetadataPath = reader.GetString(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ProjectInfo?> GetProjectByMetadataPathAsync(string metadataPath)
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
await EnsureTableCreatedAsync(connection);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.SELECT_PROJECT_STRING + " WHERE MetadataPath = @MetadataPath;";
|
||||||
|
|
||||||
|
command.Parameters.AddWithValue("@MetadataPath", metadataPath);
|
||||||
|
|
||||||
|
using var reader = await command.ExecuteReaderAsync();
|
||||||
|
if (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
return new ProjectInfo
|
||||||
|
{
|
||||||
|
ID = reader.GetInt32(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
MetadataPath = reader.GetString(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task AddProjectAsync(ProjectInfo project)
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
await EnsureTableCreatedAsync(connection);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.INSERT_PROJECT_STRING;
|
||||||
|
|
||||||
|
command.Parameters.AddWithValue("@Name", project.Name);
|
||||||
|
command.Parameters.AddWithValue("@MetadataPath", project.MetadataPath);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RemoveProjectAsync(ProjectInfo project)
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.REMOVE_PROJECT_STRING;
|
||||||
|
|
||||||
|
command.Parameters.AddWithValue("@ID", project.ID);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task UpdateProjectAsync(ProjectInfo project)
|
||||||
|
{
|
||||||
|
using var connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, DataPath.s_applicationDataFolder));
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Command.UPDATE_PROJECT_STRING;
|
||||||
|
|
||||||
|
command.Parameters.AddWithValue("@Name", project.Name);
|
||||||
|
command.Parameters.AddWithValue("@MetadataPath", project.MetadataPath);
|
||||||
|
command.Parameters.AddWithValue("@ID", project.ID);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Ghost.Data/Resources/AssetsPath.cs
Normal file
8
Ghost.Data/Resources/AssetsPath.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Ghost.Data.Resources;
|
||||||
|
|
||||||
|
public static class AssetsPath
|
||||||
|
{
|
||||||
|
public const string ASSETS_FOLDER = "Assets";
|
||||||
|
|
||||||
|
public readonly static string s_appIconPath = Path.Combine(AppContext.BaseDirectory, $"{ASSETS_FOLDER}/Icon-256.ico");
|
||||||
|
}
|
||||||
9
Ghost.Data/Resources/DataPath.cs
Normal file
9
Ghost.Data/Resources/DataPath.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Ghost.Data.Resources;
|
||||||
|
|
||||||
|
public class DataPath
|
||||||
|
{
|
||||||
|
public const string ENGINE_DATA_FOLDER_NAME = "GhostEngine";
|
||||||
|
|
||||||
|
public readonly static string s_applicationDataFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ENGINE_DATA_FOLDER_NAME);
|
||||||
|
public readonly static string s_projectTemplateFolder = Path.Combine(s_applicationDataFolder, "ProjectTemplates");
|
||||||
|
}
|
||||||
226
Ghost.Data/Services/ProjectService.cs
Normal file
226
Ghost.Data/Services/ProjectService.cs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Data.Models;
|
||||||
|
using Ghost.Data.Repository;
|
||||||
|
using Ghost.Data.Resources;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Data.Services;
|
||||||
|
|
||||||
|
internal partial class ProjectService
|
||||||
|
{
|
||||||
|
private const string _TEMPLATE_CONTENT_FILE = "content.zip";
|
||||||
|
|
||||||
|
public const string ASSETS_FOLDER = "Assets";
|
||||||
|
public const string CACHE_FOLDER = "Caches";
|
||||||
|
public const string CONFIG_FOLDER = "Configs";
|
||||||
|
|
||||||
|
public static ProjectMetadataInfo CurrentProject
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void EnsureDefaultTemplate()
|
||||||
|
{
|
||||||
|
var templates = Directory.GetFiles(DataPath.s_projectTemplateFolder, "template.json", SearchOption.AllDirectories);
|
||||||
|
if (templates.Length > 0)
|
||||||
|
{
|
||||||
|
return; // Default template already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultTemplatePath = Path.Combine(AppContext.BaseDirectory, "Assets/ProjectTemplates/Empty.zip");
|
||||||
|
ZipFile.ExtractToDirectory(defaultTemplatePath, DataPath.s_projectTemplateFolder, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async IAsyncEnumerable<(string path, TemplateInfo info)> GetProjectTemplatesAsync()
|
||||||
|
{
|
||||||
|
var templatesFolder = DataPath.s_projectTemplateFolder;
|
||||||
|
if (!Directory.Exists(templatesFolder))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates = Directory.GetFiles(DataPath.s_projectTemplateFolder, "template.json", SearchOption.AllDirectories);
|
||||||
|
foreach (var templatePath in templates)
|
||||||
|
{
|
||||||
|
var fileStream = File.OpenRead(templatePath);
|
||||||
|
var templateInfo = await JsonSerializer.DeserializeAsync<TemplateInfo>(fileStream, JsonContext.Default.TemplateInfo);
|
||||||
|
if (templateInfo == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return (templatePath, templateInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task CreateMetadataFileAsync(string path, ProjectMetadata metadata)
|
||||||
|
{
|
||||||
|
await using var fileStream = File.Create(path);
|
||||||
|
await JsonSerializer.SerializeAsync(fileStream, metadata, JsonContext.Default.ProjectMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ProjectMetadata?> LoadMetadataAsync(string ghostprojPath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(ghostprojPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Project metadata file not found.", ghostprojPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var fileStream = File.OpenRead(ghostprojPath);
|
||||||
|
return await JsonSerializer.DeserializeAsync<ProjectMetadata>(fileStream, JsonContext.Default.ProjectMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Result<ProjectMetadataInfo>> ValidateProjectDirectoryAsync(string? projectDirectory)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(projectDirectory) || !Directory.Exists(projectDirectory))
|
||||||
|
{
|
||||||
|
return Result<ProjectMetadataInfo>.Failure("Project directory is invalid or does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectAssetsPath = Path.Combine(projectDirectory, ASSETS_FOLDER);
|
||||||
|
var projectConfigPath = Path.Combine(projectDirectory, CONFIG_FOLDER);
|
||||||
|
if (!Directory.Exists(projectAssetsPath) || !Directory.Exists(projectConfigPath))
|
||||||
|
{
|
||||||
|
return Result<ProjectMetadataInfo>.Failure("Project folder structure is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadataPath = Directory.GetFiles(projectDirectory, $"*.{ProjectMetadata.PROJECT_FILE_EXTENSION_NAME}", SearchOption.TopDirectoryOnly).FirstOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(metadataPath) || !File.Exists(metadataPath))
|
||||||
|
{
|
||||||
|
return Result<ProjectMetadataInfo>.Failure("Project metadata file not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = await LoadMetadataAsync(metadataPath);
|
||||||
|
if (metadata == null)
|
||||||
|
{
|
||||||
|
return Result<ProjectMetadataInfo>.Failure("Project metadata file is corrupted or invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProjectMetadataInfo(metadataPath, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async ValueTask SetupRequestFolderAsync(string projectDirectory, string templateDirectory)
|
||||||
|
{
|
||||||
|
var projectAssetsPath = Path.Combine(projectDirectory, ASSETS_FOLDER);
|
||||||
|
var projectConfigPath = Path.Combine(projectDirectory, CONFIG_FOLDER);
|
||||||
|
var templateContentPath = Path.Combine(templateDirectory, _TEMPLATE_CONTENT_FILE);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(projectAssetsPath);
|
||||||
|
if (File.Exists(templateContentPath))
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ZipFile.ExtractToDirectory(templateContentPath, projectAssetsPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(projectConfigPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class ProjectService
|
||||||
|
{
|
||||||
|
public Task AddProjectAsync(ProjectInfo project)
|
||||||
|
{
|
||||||
|
return ProjectRepository.AddProjectAsync(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProjectInfo> AddProjectAsync(string name, string path)
|
||||||
|
{
|
||||||
|
var project = new ProjectInfo
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
MetadataPath = path,
|
||||||
|
};
|
||||||
|
await ProjectRepository.AddProjectAsync(project);
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RemoveProjectAsync(ProjectInfo project)
|
||||||
|
{
|
||||||
|
return ProjectRepository.RemoveProjectAsync(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateProjectAsync(ProjectInfo project)
|
||||||
|
{
|
||||||
|
return ProjectRepository.UpdateProjectAsync(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> HasProjectAsync(string path)
|
||||||
|
{
|
||||||
|
return await ProjectRepository.GetProjectByMetadataPathAsync(path) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ProjectInfo> GetAllProjectAsync()
|
||||||
|
{
|
||||||
|
var badProjectList = new List<ProjectInfo>();
|
||||||
|
await foreach (var project in ProjectRepository.GetAllProjectsAsync())
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(project.MetadataPath) || !File.Exists(project.MetadataPath))
|
||||||
|
{
|
||||||
|
badProjectList.Add(project);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var badProject in badProjectList)
|
||||||
|
{
|
||||||
|
await ProjectRepository.RemoveProjectAsync(badProject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ProjectMetadataInfo>> CreateProjectAsync(string projectName, string projectDirectory, Version engineVersion, string templatePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var projectPath = Path.Combine(projectDirectory, projectName);
|
||||||
|
if (!Directory.Exists(projectPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(projectPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Check if folder is empty
|
||||||
|
if (Directory.EnumerateFiles(projectPath, "*", SearchOption.AllDirectories).Any())
|
||||||
|
{
|
||||||
|
return Result.Failure("Directory is not empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = new ProjectMetadata(projectName, engineVersion);
|
||||||
|
var metadataPath = Path.Combine(projectPath, $"{projectName}.{ProjectMetadata.PROJECT_FILE_EXTENSION_NAME}");
|
||||||
|
await CreateMetadataFileAsync(metadataPath, metadata);
|
||||||
|
await SetupRequestFolderAsync(projectPath, templatePath);
|
||||||
|
|
||||||
|
var info = await AddProjectAsync(projectName, metadataPath);
|
||||||
|
return Result.Success(new ProjectMetadataInfo(metadataPath, metadata));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to create project: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ProjectMetadataInfo>> AddProjectFromDirectoryAsync(string projectDirectory)
|
||||||
|
{
|
||||||
|
var result = await ValidateProjectDirectoryAsync(projectDirectory);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await HasProjectAsync(result.Value.Path))
|
||||||
|
{
|
||||||
|
return Result.Failure("Project already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await AddProjectAsync(result.Value.Metadata.Name, result.Value.Path);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Ghost.Editor.Core/AppState/AppStateMachine.cs
Normal file
97
Ghost.Editor.Core/AppState/AppStateMachine.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AppState;
|
||||||
|
|
||||||
|
internal partial class AppStateMachine : IDisposable, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private Dictionary<StateKey, Lazy<IAppState>> _states = new();
|
||||||
|
private IAppState? _current;
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public void RegisterState(StateKey key, Func<IAppState> stateFactory)
|
||||||
|
{
|
||||||
|
_states[key] = new(stateFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> TransitionToAsync(StateKey stateKey, object? parameter = null)
|
||||||
|
{
|
||||||
|
var previous = _current;
|
||||||
|
if (!_states.TryGetValue(stateKey, out var next))
|
||||||
|
{
|
||||||
|
return Result.Failure($"State '{stateKey}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Result result;
|
||||||
|
if (previous != null)
|
||||||
|
{
|
||||||
|
result = await previous.OnExitingAsync();
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await next.Value.OnEnteringAsync(parameter);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
if (previous != null)
|
||||||
|
{
|
||||||
|
await previous.OnEnteredAsync(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous != null)
|
||||||
|
{
|
||||||
|
result = await previous.OnExitedAsync();
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
await next.Value.OnExitedAsync();
|
||||||
|
await previous.OnEnteredAsync(parameter);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await next.Value.OnEnteredAsync(parameter);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
await next.Value.OnExitedAsync();
|
||||||
|
|
||||||
|
if (previous != null)
|
||||||
|
{
|
||||||
|
await previous.OnEnteredAsync(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_current = next.Value;
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeAsync().AsTask().Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_states.Clear();
|
||||||
|
if (_current != null)
|
||||||
|
{
|
||||||
|
await _current.OnExitingAsync();
|
||||||
|
await _current.OnExitedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_current = null;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Ghost.Editor.Core/AppState/IAppState.cs
Normal file
28
Ghost.Editor.Core/AppState/IAppState.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AppState;
|
||||||
|
|
||||||
|
internal interface IAppState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Called when exiting the state.
|
||||||
|
/// </summary>
|
||||||
|
public ValueTask<Result> OnExitingAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when entering the state, right after OnEnteringAsync.
|
||||||
|
/// <paramref name="parameter">can be used to pass data into the state, such as a project to load.</summary>
|
||||||
|
/// </summary>
|
||||||
|
public ValueTask<Result> OnEnteringAsync(object? parameter);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when exiting the state, specifically for pose transitions.
|
||||||
|
/// </summary>
|
||||||
|
public ValueTask<Result> OnExitedAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when entered the state, specifically after the state has been fully initialized and is ready for interaction.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parameter">can be used to pass data into the state, such as a project to load.</param>
|
||||||
|
public ValueTask<Result> OnEnteredAsync(object? parameter);
|
||||||
|
}
|
||||||
8
Ghost.Editor.Core/AppState/StateKey.cs
Normal file
8
Ghost.Editor.Core/AppState/StateKey.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Ghost.Editor.Core.AppState;
|
||||||
|
|
||||||
|
internal enum StateKey
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Landing,
|
||||||
|
EngineEditor,
|
||||||
|
}
|
||||||
7
Ghost.Editor.Core/AssemblyInfo.cs
Normal file
7
Ghost.Editor.Core/AssemblyInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.UnitTest")]
|
||||||
|
[assembly: InternalsVisibleTo("Ghost.Editor")]
|
||||||
|
|
||||||
|
[assembly: EngineAssembly]
|
||||||
134
Ghost.Editor.Core/AssetHandle/AssetDBPlan.md
Normal file
134
Ghost.Editor.Core/AssetHandle/AssetDBPlan.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# 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.
|
||||||
355
Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs
Normal file
355
Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
/// <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 static 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 static 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 static 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 static 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 static 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">Current 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 static 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 static 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 static 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 static 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 static 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
128
Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs
Normal file
128
Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, AssetImporter> s_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 static async ValueTask<Result> ImportAssetAsync(string assetPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(assetPath);
|
||||||
|
|
||||||
|
if (!s_importerTypeLookup.TryGetValue(extension, out var importerType))
|
||||||
|
{
|
||||||
|
// No importer registered for this file type
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create importer instance
|
||||||
|
if (!s_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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
s_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, 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 static Type? GetImporterType(string extension)
|
||||||
|
{
|
||||||
|
s_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 static Dictionary<string, Type> GetAllImporters()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, Type>(s_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 static async ValueTask<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData, CancellationToken token = default) where T : class
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(assetPath);
|
||||||
|
|
||||||
|
if (!s_importerTypeLookup.TryGetValue(extension, out var importerType))
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure($"No importer registered for extension {extension}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create importer instance
|
||||||
|
if (!s_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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
s_importerInstances[importerType] = importerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and invoke the ExportAsync method
|
||||||
|
var exportMethod = importerType.GetMethod("ExportAsync", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
if (exportMethod == null)
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure($"ExportAsync method not found on importer {importerType.Name}. This importer does not support exporting.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs
Normal file
212
Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Data.Services;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
// Asset cache - stores loaded assets by GUID
|
||||||
|
private static readonly ConcurrentDictionary<Guid, Asset> s_assetCache = new();
|
||||||
|
|
||||||
|
// LRU tracking - stores access time for each cached asset
|
||||||
|
private static readonly ConcurrentDictionary<Guid, DateTime> s_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 static Result<string> GetImportedAssetsDirectory()
|
||||||
|
{
|
||||||
|
if (AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return Result<string>.Failure("AssetsDirectory not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets");
|
||||||
|
if (!Directory.Exists(cacheDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 static Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset
|
||||||
|
{
|
||||||
|
// Check cache first
|
||||||
|
if (s_assetCache.TryGetValue(guid, out var cachedAsset))
|
||||||
|
{
|
||||||
|
// Update access time for LRU
|
||||||
|
s_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 static 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 static void CacheAsset(Guid guid, Asset asset)
|
||||||
|
{
|
||||||
|
// Check if we need to evict old assets
|
||||||
|
if (s_assetCache.Count >= MAX_CACHED_ASSETS)
|
||||||
|
{
|
||||||
|
EvictOldestAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
s_assetCache[guid] = asset;
|
||||||
|
s_assetAccessTime[guid] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvictOldestAssets()
|
||||||
|
{
|
||||||
|
var evictionCount = (int)(MAX_CACHED_ASSETS * _CACHE_EVICTION_PERCENTAGE);
|
||||||
|
|
||||||
|
// Sort by access time and remove oldest entries
|
||||||
|
var oldestAssets = s_assetAccessTime
|
||||||
|
.OrderBy(kvp => kvp.Value)
|
||||||
|
.Take(evictionCount)
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var guid in oldestAssets)
|
||||||
|
{
|
||||||
|
s_assetCache.TryRemove(guid, out _);
|
||||||
|
s_assetAccessTime.TryRemove(guid, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unload a specific asset from cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset to unload.</param>
|
||||||
|
public static void UnloadAsset(Guid guid)
|
||||||
|
{
|
||||||
|
s_assetCache.TryRemove(guid, out _);
|
||||||
|
s_assetAccessTime.TryRemove(guid, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unload all assets from cache.
|
||||||
|
/// </summary>
|
||||||
|
public static void UnloadAllAssets()
|
||||||
|
{
|
||||||
|
s_assetCache.Clear();
|
||||||
|
s_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 static bool IsAssetLoaded(Guid guid)
|
||||||
|
{
|
||||||
|
return s_assetCache.ContainsKey(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get cache statistics.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Tuple of (current cache size, max cache size).</returns>
|
||||||
|
public static (int currentSize, int maxSize) GetCacheStats()
|
||||||
|
{
|
||||||
|
return (s_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 static 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, s_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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs
Normal file
203
Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get the relative path from the assets directory.
|
||||||
|
/// </summary>
|
||||||
|
private static 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 static 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 static 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 (s_dbLock)
|
||||||
|
{
|
||||||
|
if (s_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 static Result<string> GuidToPath(Guid guid)
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
if (s_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 static Result<T> LoadAsset<T>(Guid guid) where T : Asset
|
||||||
|
{
|
||||||
|
// Implemented in AssetDatabase.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 static 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 static 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 static 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 static 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 static IReadOnlyDictionary<Guid, string> GetAllAssets()
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
return s_assetPathLookup.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
251
Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs
Normal file
251
Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
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 static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, Type> s_importerTypeLookup = new();
|
||||||
|
|
||||||
|
private static void InitializeMetaData()
|
||||||
|
{
|
||||||
|
if (s_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)
|
||||||
|
{
|
||||||
|
s_importerTypeLookup[extension] = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s_watcher.Created += OnFSEvent;
|
||||||
|
s_watcher.Deleted += OnFSEvent;
|
||||||
|
s_watcher.Changed += OnFSEvent;
|
||||||
|
s_watcher.Renamed += OnAssetRenamed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 static ImporterSettings? GetDefaultSettingsForAsset(string assetPath)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(assetPath);
|
||||||
|
|
||||||
|
if (s_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 static 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 static async Task<Result> WriteMetaFileAsync(string metaFilePath, AssetMeta metaData, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var fileStream = File.Create(metaFilePath);
|
||||||
|
await JsonSerializer.SerializeAsync(fileStream, metaData, s_defaultJsonOptions, token);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read metadata from a .gmeta file.
|
||||||
|
/// </summary>
|
||||||
|
private static 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, s_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 static 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 (s_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 static bool IsMetaFile(string path)
|
||||||
|
{
|
||||||
|
return Path.GetExtension(path).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 static 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 static 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Ghost.Editor.Core/AssetHandle/AssetDatabase.Open.cs
Normal file
51
Ghost.Editor.Core/AssetHandle/AssetDatabase.Open.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, Action<string>> s_assetOpenHandlers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static 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 (s_assetOpenHandlers.ContainsKey(ext))
|
||||||
|
{
|
||||||
|
Logger.LogError($"Duplicate asset open handler for extension '{ext}' found in method '{method.Name}'. Existing handler will be overwritten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
s_assetOpenHandlers[ext] = del;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OpenAsset(string path)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
if (s_assetOpenHandlers.TryGetValue(extension, out var handler))
|
||||||
|
{
|
||||||
|
handler(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(path)
|
||||||
|
{
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs
Normal file
390
Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Data.Services;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
private static SqliteConnection? s_dbConnection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the SQLite database for asset caching.
|
||||||
|
/// </summary>
|
||||||
|
private static 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, ProjectService.CACHE_FOLDER, "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();
|
||||||
|
|
||||||
|
s_dbConnection = new SqliteConnection(connectionString);
|
||||||
|
await s_dbConnection.OpenAsync(token);
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
await using var cmd = s_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 static async ValueTask<Result> UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List<Guid>? dependencies = null, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Database not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = GetRelativePath(assetPath);
|
||||||
|
if (relativePath.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(relativePath.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
// If this GUID already exists with a different path, remove the old path mapping
|
||||||
|
if (s_assetPathLookup.TryGetValue(meta.Guid, out var oldPath) && oldPath != relativePath.Value)
|
||||||
|
{
|
||||||
|
s_pathAssetLookup.Remove(oldPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lookups with new path (normalize path separators for consistency)
|
||||||
|
var normalizedPath = relativePath.Value.Replace('\\', '/');
|
||||||
|
s_assetPathLookup[meta.Guid] = normalizedPath;
|
||||||
|
s_pathAssetLookup[normalizedPath] = meta.Guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var cmd = s_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 static async Task<Result> RemoveAssetFromDatabaseAsync(Guid guid, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Database not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
if (s_assetPathLookup.TryGetValue(guid, out var path))
|
||||||
|
{
|
||||||
|
s_assetPathLookup.Remove(guid);
|
||||||
|
s_pathAssetLookup.Remove(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var cmd = s_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 static async Task LoadAssetCacheFromDatabaseAsync(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = s_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 (s_dbLock)
|
||||||
|
{
|
||||||
|
s_assetPathLookup[guid] = path;
|
||||||
|
s_pathAssetLookup[path] = guid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError($"Failed to load asset cache: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get assets by tag.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<List<Guid>> GetAssetsByTagAsync(string tag, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var result = new List<Guid>();
|
||||||
|
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = s_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 static async Task<string?> GetFileHashAsync(Guid guid, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = s_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 static async Task<List<Guid>> GetDependenciesAsync(Guid guid, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return new List<Guid>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = s_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 static async Task<List<Guid>> GetAssetsByNameAsync(string namePattern, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var results = new List<Guid>();
|
||||||
|
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Convert wildcard pattern to SQL LIKE pattern
|
||||||
|
var sqlPattern = namePattern.Replace('*', '%').Replace('?', '_');
|
||||||
|
|
||||||
|
await using var cmd = s_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 static async Task RemoveOrphanedEntriesAsync(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null || AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var orphanedGuids = new List<Guid>();
|
||||||
|
|
||||||
|
await using var cmd = s_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
531
Ghost.Editor.Core/AssetHandle/AssetDatabase.cs
Normal file
531
Ghost.Editor.Core/AssetHandle/AssetDatabase.cs
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Data.Services;
|
||||||
|
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 static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
private static FileSystemWatcher? s_watcher;
|
||||||
|
private static readonly Lock s_dbLock = new();
|
||||||
|
private static readonly Dictionary<Guid, string> s_assetPathLookup = new();
|
||||||
|
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
|
||||||
|
|
||||||
|
// In-memory dirty asset tracking (for runtime modifications only)
|
||||||
|
// TODO: We do not handle the reimporting of dirty assets yet
|
||||||
|
private static readonly HashSet<Guid> s_dirtyAssets = new();
|
||||||
|
|
||||||
|
// Command buffer pattern - Channel for file system event commands
|
||||||
|
private static Channel<AssetCommand>? s_commandChannel;
|
||||||
|
private static Timer? s_commandProcessorTimer;
|
||||||
|
private static readonly Lock s_commandLock = new();
|
||||||
|
private static readonly ConcurrentQueue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
|
||||||
|
private static bool s_autoRefreshEnabled = true;
|
||||||
|
|
||||||
|
// Initialization guard
|
||||||
|
private static readonly Lock s_initializationLock = new();
|
||||||
|
private static bool s_initialized = false;
|
||||||
|
|
||||||
|
private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100);
|
||||||
|
private static ManualResetEventSlim s_resetEventSlim = new(false);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions s_defaultJsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static DirectoryInfo? AssetsDirectory
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
private set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the asset database.
|
||||||
|
/// Must be called after project is loaded.
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
|
internal static async Task Initialize(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
lock (s_initializationLock)
|
||||||
|
{
|
||||||
|
if (s_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ProjectService.CurrentProject.Metadata == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Project metadata is not initialized. Ensure that the project is loaded before accessing the AssetDatabase.");
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetsDirectory = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(ProjectService.CurrentProject.Path)!, ProjectService.ASSETS_FOLDER));
|
||||||
|
|
||||||
|
s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = false,
|
||||||
|
SingleWriter = false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize command processor timer (starts disabled, triggered by events)
|
||||||
|
s_commandProcessorTimer = new Timer(ProcessPendingCommands, null, Timeout.Infinite, Timeout.Infinite);
|
||||||
|
|
||||||
|
await InitializeDatabaseAsync(token);
|
||||||
|
await LoadAssetCacheFromDatabaseAsync(token);
|
||||||
|
|
||||||
|
s_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 static 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 static async Task<Result> RefreshAsync(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
// Flush waiting commands to channel
|
||||||
|
while (s_waitingCommands.TryDequeue(out var cmd))
|
||||||
|
{
|
||||||
|
s_commandChannel?.Writer.TryWrite(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
s_resetEventSlim.Reset();
|
||||||
|
s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
|
||||||
|
s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
||||||
|
|
||||||
|
await Task.Run(s_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 static void MarkDirty(Guid assetGuid)
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
s_dirtyAssets.Add(assetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an asset is marked as dirty.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsDirty(Guid assetGuid)
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
return s_dirtyAssets.Contains(assetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all dirty assets.
|
||||||
|
/// </summary>
|
||||||
|
public static Guid[] GetDirtyAssets()
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
return s_dirtyAssets.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear dirty flag for an asset (typically after saving).
|
||||||
|
/// </summary>
|
||||||
|
public static void ClearDirty(Guid assetGuid)
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
s_dirtyAssets.Remove(assetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all dirty flags.
|
||||||
|
/// </summary>
|
||||||
|
public static void ClearAllDirty()
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
s_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 static void SetAutoRefresh(bool enabled)
|
||||||
|
{
|
||||||
|
s_autoRefreshEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void FlushPendingCommands()
|
||||||
|
{
|
||||||
|
// Stop timer temporarily
|
||||||
|
s_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 static async ValueTask PostCommandAsync(AssetCommand command, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (s_commandChannel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_autoRefreshEnabled)
|
||||||
|
{
|
||||||
|
await s_commandChannel.Writer.WriteAsync(command, token);
|
||||||
|
s_commandProcessorTimer?.Change(s_debounceDelay, Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
s_waitingCommands.Enqueue(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async void ProcessPendingCommands(object? state)
|
||||||
|
{
|
||||||
|
if (s_commandChannel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// // Collect all pending commands
|
||||||
|
// var commands = new List<AssetCommand>();
|
||||||
|
//
|
||||||
|
// while (s_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 s_commandChannel.Reader.ReadAllAsync())
|
||||||
|
//{
|
||||||
|
// await ExecuteCommandAsync(cmd);
|
||||||
|
//}
|
||||||
|
|
||||||
|
while (s_commandChannel.Reader.TryRead(out var cmd))
|
||||||
|
{
|
||||||
|
await ExecuteCommandAsync(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ImportDirtyAssetsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError($"Error processing commands: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
s_resetEventSlim.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 static async ValueTask HandleFileCreatedAsync(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await GenerateMetaFileAsync(path, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 static 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 static 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 static void Shutdown()
|
||||||
|
{
|
||||||
|
lock (s_initializationLock)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_watcher?.Dispose();
|
||||||
|
s_watcher = null;
|
||||||
|
|
||||||
|
s_commandProcessorTimer?.Dispose();
|
||||||
|
s_commandProcessorTimer = null;
|
||||||
|
|
||||||
|
s_dbConnection?.Close();
|
||||||
|
s_dbConnection?.Dispose();
|
||||||
|
s_dbConnection = null;
|
||||||
|
|
||||||
|
s_assetPathLookup.Clear();
|
||||||
|
s_pathAssetLookup.Clear();
|
||||||
|
s_dirtyAssets.Clear();
|
||||||
|
s_waitingCommands.Clear();
|
||||||
|
s_importerInstances.Clear();
|
||||||
|
s_importerTypeLookup.Clear();
|
||||||
|
|
||||||
|
s_initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Ghost.Editor.Core/AssetHandle/AssetDatabase_Architecture.md
Normal file
115
Ghost.Editor.Core/AssetHandle/AssetDatabase_Architecture.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 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.
|
||||||
131
Ghost.Editor.Core/AssetHandle/AssetDatabase_Documentation.md
Normal file
131
Ghost.Editor.Core/AssetHandle/AssetDatabase_Documentation.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# 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.
|
||||||
81
Ghost.Editor.Core/AssetHandle/AssetImporter.cs
Normal file
81
Ghost.Editor.Core/AssetHandle/AssetImporter.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
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, 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>
|
||||||
|
/// <returns>Result indicating if all dependencies are valid.</returns>
|
||||||
|
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
foreach (var dependencyGuid in dependencies)
|
||||||
|
{
|
||||||
|
var path = AssetDatabase.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Ghost.Editor.Core/AssetHandle/AssetImporterAttribute.cs
Normal file
15
Ghost.Editor.Core/AssetHandle/AssetImporterAttribute.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
||||||
|
internal class AssetImporterAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string[] SupportedExtensions
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetImporterAttribute(params string[] supportedExtensions)
|
||||||
|
{
|
||||||
|
SupportedExtensions = supportedExtensions;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Ghost.Editor.Core/AssetHandle/AssetMeta.cs
Normal file
85
Ghost.Editor.Core/AssetHandle/AssetMeta.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Ghost.Editor.Core/AssetHandle/AssetOpenHandlerAttribute .cs
Normal file
15
Ghost.Editor.Core/AssetHandle/AssetOpenHandlerAttribute .cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
|
public class AssetOpenHandlerAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string[] Extensions
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetOpenHandlerAttribute(params string[] extensions)
|
||||||
|
{
|
||||||
|
Extensions = extensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : '.' + e.ToLowerInvariant()).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Ghost.Editor.Core/AssetHandle/ImporterSettings.cs
Normal file
5
Ghost.Editor.Core/AssetHandle/ImporterSettings.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public abstract class ImporterSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
70
Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs
Normal file
70
Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
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, 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);
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs
Normal file
279
Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
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, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var settings = GetSettings(meta);
|
||||||
|
|
||||||
|
// Textures typically don't reference other assets as dependencies
|
||||||
|
// If they did (e.g., normal maps referencing base textures), extract here
|
||||||
|
var dependencies = new List<Guid>();
|
||||||
|
|
||||||
|
// Validate dependencies
|
||||||
|
var depResult = await ValidateDependenciesAsync(dependencies, 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 = AssetDatabase.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Ghost.Editor.Core/AssetHandle/Models/Asset.cs
Normal file
22
Ghost.Editor.Core/AssetHandle/Models/Asset.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Ghost.Editor.Core/AssetHandle/Models/TextureAsset.cs
Normal file
75
Ghost.Editor.Core/AssetHandle/Models/TextureAsset.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Ghost.Editor.Core/Contracts/INavigationAware.cs
Normal file
7
Ghost.Editor.Core/Contracts/INavigationAware.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
public interface INavigationAware
|
||||||
|
{
|
||||||
|
public void OnNavigatedTo(object? parameter);
|
||||||
|
public void OnNavigatedFrom();
|
||||||
|
}
|
||||||
69
Ghost.Editor.Core/Controls/BasicInput/Float3Field.cs
Normal file
69
Ghost.Editor.Core/Controls/BasicInput/Float3Field.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
[TemplatePart(Name = "XComponent", Type = typeof(NumberBox))]
|
||||||
|
[TemplatePart(Name = "YComponent", Type = typeof(NumberBox))]
|
||||||
|
[TemplatePart(Name = "ZComponent", Type = typeof(NumberBox))]
|
||||||
|
public sealed partial class Float3Field : ValueControl<float3>
|
||||||
|
{
|
||||||
|
private NumberBox? _xComponent;
|
||||||
|
private NumberBox? _yComponent;
|
||||||
|
private NumberBox? _zComponent;
|
||||||
|
|
||||||
|
public Float3Field()
|
||||||
|
{
|
||||||
|
DefaultStyleKey = typeof(Float3Field);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ValueChanged(float3 oldValue, float3 newValue)
|
||||||
|
{
|
||||||
|
SyncFromValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnApplyTemplate()
|
||||||
|
{
|
||||||
|
base.OnApplyTemplate();
|
||||||
|
|
||||||
|
_xComponent?.ValueChanged -= OnComponentChanged;
|
||||||
|
_yComponent?.ValueChanged -= OnComponentChanged;
|
||||||
|
_zComponent?.ValueChanged -= OnComponentChanged;
|
||||||
|
|
||||||
|
_xComponent = GetTemplateChild("XComponent") as NumberBox;
|
||||||
|
_yComponent = GetTemplateChild("YComponent") as NumberBox;
|
||||||
|
_zComponent = GetTemplateChild("ZComponent") as NumberBox;
|
||||||
|
|
||||||
|
SyncFromValue();
|
||||||
|
|
||||||
|
_xComponent?.ValueChanged += OnComponentChanged;
|
||||||
|
_yComponent?.ValueChanged += OnComponentChanged;
|
||||||
|
_zComponent?.ValueChanged += OnComponentChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncFromValue()
|
||||||
|
{
|
||||||
|
SuppressChangedEvent = true;
|
||||||
|
_xComponent?.Value = Value.x;
|
||||||
|
_yComponent?.Value = Value.y;
|
||||||
|
_zComponent?.Value = Value.z;
|
||||||
|
SuppressChangedEvent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnComponentChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (SuppressChangedEvent)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newValue = new float3(
|
||||||
|
(float)(_xComponent?.Value ?? 0),
|
||||||
|
(float)(_yComponent?.Value ?? 0),
|
||||||
|
(float)(_zComponent?.Value ?? 0));
|
||||||
|
|
||||||
|
RiseChangedEvent(Value, newValue);
|
||||||
|
Value = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml
Normal file
41
Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:Ghost.Editor.Core.Controls">
|
||||||
|
|
||||||
|
<Style TargetType="local:Float3Field">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="local:Float3Field">
|
||||||
|
<Grid ColumnSpacing="4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="X" />
|
||||||
|
<NumberBox x:Name="XComponent" Grid.Column="1" />
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="2"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="Y" />
|
||||||
|
<NumberBox x:Name="YComponent" Grid.Column="3" />
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="4"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="Z" />
|
||||||
|
<NumberBox x:Name="ZComponent" Grid.Column="5" />
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
143
Ghost.Editor.Core/Controls/BasicInput/PropertyField.cs
Normal file
143
Ghost.Editor.Core/Controls/BasicInput/PropertyField.cs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||||
|
using Microsoft.UI.Xaml.Data;
|
||||||
|
using System.Reflection;
|
||||||
|
using Windows.Globalization.NumberFormatting;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public sealed partial class PropertyField : ContentControl
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, DependencyProperty> _valueProperties = new()
|
||||||
|
{
|
||||||
|
{ typeof(TextBox), TextBox.TextProperty },
|
||||||
|
{ typeof(NumberBox), NumberBox.ValueProperty },
|
||||||
|
{ typeof(ToggleButton), ToggleButton.IsCheckedProperty },
|
||||||
|
{ typeof(ToggleSwitch), ToggleSwitch.IsOnProperty },
|
||||||
|
{ typeof(ComboBox), Selector.SelectedValueProperty },
|
||||||
|
{ typeof(RangeBase), RangeBase.ValueProperty },
|
||||||
|
};
|
||||||
|
|
||||||
|
private object? _sourceObject;
|
||||||
|
internal FieldInfo? _propertyInfo;
|
||||||
|
internal Type? _fieldType;
|
||||||
|
|
||||||
|
private object? _lastValue;
|
||||||
|
|
||||||
|
public event Action<PropertyField>? OnValueChanged;
|
||||||
|
|
||||||
|
public string Label
|
||||||
|
{
|
||||||
|
get => (string)GetValue(LabelProperty);
|
||||||
|
set => SetValue(LabelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty LabelProperty = DependencyProperty.Register(
|
||||||
|
nameof(Label),
|
||||||
|
typeof(string),
|
||||||
|
typeof(PropertyField),
|
||||||
|
new PropertyMetadata(default(string)));
|
||||||
|
|
||||||
|
public PropertyField()
|
||||||
|
{
|
||||||
|
DefaultStyleKey = typeof(PropertyField);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DependencyProperty? GetValueProperty(Type? fieldType)
|
||||||
|
{
|
||||||
|
while (fieldType != null)
|
||||||
|
{
|
||||||
|
if (_valueProperties.TryGetValue(fieldType, out var dp))
|
||||||
|
{
|
||||||
|
return dp;
|
||||||
|
}
|
||||||
|
fieldType = fieldType.BaseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TField ConfigureField<TField>(PropertyField propertyField, FieldInfo fieldInfo, object sourceObject, Func<TField> factory)
|
||||||
|
where TField : FrameworkElement
|
||||||
|
{
|
||||||
|
propertyField._sourceObject = sourceObject;
|
||||||
|
propertyField._propertyInfo = fieldInfo;
|
||||||
|
propertyField._fieldType = typeof(TField);
|
||||||
|
|
||||||
|
var field = factory();
|
||||||
|
|
||||||
|
var dp = GetValueProperty(typeof(TField));
|
||||||
|
field.SetBinding(dp, new Binding
|
||||||
|
{
|
||||||
|
Source = sourceObject,
|
||||||
|
Path = new PropertyPath(fieldInfo.Name),
|
||||||
|
Mode = BindingMode.TwoWay,
|
||||||
|
});
|
||||||
|
|
||||||
|
field.RegisterPropertyChangedCallback(dp, (s, e) =>
|
||||||
|
{
|
||||||
|
propertyField.OnValueChanged?.Invoke(propertyField);
|
||||||
|
});
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyField Create(string label, FieldInfo fieldInfo, object sourceObject)
|
||||||
|
{
|
||||||
|
var propertyField = new PropertyField
|
||||||
|
{
|
||||||
|
Label = label
|
||||||
|
};
|
||||||
|
|
||||||
|
FrameworkElement content = fieldInfo.FieldType switch
|
||||||
|
{
|
||||||
|
Type t when t == typeof(string) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new TextBox()),
|
||||||
|
Type t when t == typeof(int) || t == typeof(float) || t == typeof(double) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new NumberBox
|
||||||
|
{
|
||||||
|
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Hidden,
|
||||||
|
AcceptsExpression = true,
|
||||||
|
NumberFormatter = new DecimalFormatter
|
||||||
|
{
|
||||||
|
FractionDigits = t == typeof(int) ? 0 : 9,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Type t when t == typeof(bool) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new ToggleSwitch()),
|
||||||
|
Type t when t == typeof(Enum) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new ComboBox
|
||||||
|
{
|
||||||
|
ItemsSource = Enum.GetValues(t),
|
||||||
|
SelectedValuePath = "Value",
|
||||||
|
}),
|
||||||
|
_ => new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"Unsupported type: {fieldInfo.FieldType.Name}",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Red)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
propertyField.Content = content;
|
||||||
|
return propertyField;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateValue()
|
||||||
|
{
|
||||||
|
if (_sourceObject == null || _propertyInfo == null || _fieldType == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentValue = _propertyInfo.GetValue(_sourceObject);
|
||||||
|
if (Equals(currentValue, _lastValue))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dp = GetValueProperty(_fieldType);
|
||||||
|
if (dp != null)
|
||||||
|
{
|
||||||
|
SetValue(dp, _propertyInfo.GetValue(_sourceObject));
|
||||||
|
_lastValue = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml
Normal file
33
Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:Ghost.Editor.Core.Controls">
|
||||||
|
|
||||||
|
<Style TargetType="local:PropertyField">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="local:PropertyField">
|
||||||
|
<Grid Height="32" Margin="2,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="125" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource BodyTextBlockStyle}"
|
||||||
|
Text="{TemplateBinding Label}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
|
<ContentPresenter
|
||||||
|
Grid.Column="1"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
13
Ghost.Editor.Core/Controls/ControlsDictionary.cs
Normal file
13
Ghost.Editor.Core/Controls/ControlsDictionary.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public partial class ControlsDictionary : ResourceDictionary
|
||||||
|
{
|
||||||
|
private const string _DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
||||||
|
|
||||||
|
public ControlsDictionary()
|
||||||
|
{
|
||||||
|
Source = new Uri(_DICTIONARY_PATH, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Ghost.Editor.Core/Controls/ControlsDictionary.xaml
Normal file
10
Ghost.Editor.Core/Controls/ControlsDictionary.xaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
|
||||||
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml" />
|
||||||
|
|
||||||
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/Internal/ComponentDataView.xaml" />
|
||||||
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/Internal/NavigationTabView.xaml" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
27
Ghost.Editor.Core/Controls/Internal/ComponentDataView.xaml
Normal file
27
Ghost.Editor.Core/Controls/Internal/ComponentDataView.xaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:Ghost.Editor.Core.Controls.Internal">
|
||||||
|
|
||||||
|
<Style TargetType="local:ComponentView">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="local:ComponentView">
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<Border
|
||||||
|
Padding="8"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
Background="{ThemeResource SolidBackgroundFillColorSecondaryBrush}">
|
||||||
|
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{TemplateBinding HeaderText}" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel
|
||||||
|
x:Name="ContentContainer"
|
||||||
|
Margin="8,2,2,0"
|
||||||
|
Spacing="2" />
|
||||||
|
</StackPanel>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
157
Ghost.Editor.Core/Controls/Internal/ComponentView.cs
Normal file
157
Ghost.Editor.Core/Controls/Internal/ComponentView.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Editor.Core.Resources;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls.Internal;
|
||||||
|
|
||||||
|
internal sealed unsafe partial class ComponentView : Control
|
||||||
|
{
|
||||||
|
private delegate void EditorUpdate();
|
||||||
|
|
||||||
|
private StackPanel? _contentContainer;
|
||||||
|
|
||||||
|
private readonly World? _world;
|
||||||
|
private readonly Entity _entity = Entity.Invalid;
|
||||||
|
private readonly Type? _componentType;
|
||||||
|
private readonly ComponentInfo _componentInfo;
|
||||||
|
|
||||||
|
private object? _managedInstance;
|
||||||
|
private void* _pComponentData;
|
||||||
|
|
||||||
|
private ComponentEditor? _customEditor;
|
||||||
|
private PropertyField[]? _propertyFields;
|
||||||
|
private EditorUpdate? _editorUpdate;
|
||||||
|
|
||||||
|
public string HeaderText
|
||||||
|
{
|
||||||
|
get => (string)GetValue(HeaderTextProperty);
|
||||||
|
set => SetValue(HeaderTextProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty HeaderTextProperty =
|
||||||
|
DependencyProperty.Register(nameof(HeaderText), typeof(string), typeof(ComponentView), new PropertyMetadata(string.Empty));
|
||||||
|
|
||||||
|
internal ComponentView()
|
||||||
|
{
|
||||||
|
DefaultStyleKey = typeof(ComponentView);
|
||||||
|
|
||||||
|
Unloaded += (s, e) =>
|
||||||
|
{
|
||||||
|
_customEditor?.Destroy();
|
||||||
|
|
||||||
|
_contentContainer = null;
|
||||||
|
_customEditor = null;
|
||||||
|
_propertyFields = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentView(string header, World world, Entity entity, Type componentType) : this()
|
||||||
|
{
|
||||||
|
HeaderText = header;
|
||||||
|
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
_componentType = componentType;
|
||||||
|
_componentInfo = ComponentRegistry.GetComponentInfo(componentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnApplyTemplate()
|
||||||
|
{
|
||||||
|
_contentContainer = (StackPanel)GetTemplateChild("ContentContainer");
|
||||||
|
|
||||||
|
base.OnApplyTemplate();
|
||||||
|
ReBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReflectionUpdate()
|
||||||
|
{
|
||||||
|
if (_propertyFields == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var propertyField in _propertyFields)
|
||||||
|
{
|
||||||
|
propertyField.UpdateValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CustomEditorUpdate()
|
||||||
|
{
|
||||||
|
_customEditor?.Update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReBuild()
|
||||||
|
{
|
||||||
|
if (_contentContainer == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contentContainer.Children.Clear();
|
||||||
|
if (_world == null || _componentType == null || _entity == Entity.Invalid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_propertyFields != null)
|
||||||
|
{
|
||||||
|
foreach (var propertyField in _propertyFields)
|
||||||
|
{
|
||||||
|
propertyField.OnValueChanged -= OnPropertyValueChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentObject = new ComponentObject(_world, _entity);
|
||||||
|
var editorType = TypeCache.GetTypes().FirstOrDefault(t =>
|
||||||
|
typeof(ComponentEditor).IsAssignableFrom(t) &&
|
||||||
|
t.GetCustomAttribute<CustomEditorAttribute>()?.TargetType.IsAssignableFrom(_componentType) == true);
|
||||||
|
|
||||||
|
if (editorType != null)
|
||||||
|
{
|
||||||
|
_customEditor = (ComponentEditor)Activator.CreateInstance(editorType)!;
|
||||||
|
_customEditor.Initialize(componentObject);
|
||||||
|
_customEditor.Create(_contentContainer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var fields = _componentType.GetFields(StaticResource.ComponentPropertyBindingFlags);
|
||||||
|
_propertyFields = new PropertyField[fields.Length];
|
||||||
|
|
||||||
|
_pComponentData = _world.EntityManager.GetComponent(_entity, _componentInfo.id);
|
||||||
|
_managedInstance = Marshal.PtrToStructure((nint)_pComponentData, _componentType);
|
||||||
|
if (_managedInstance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < fields.Length; i++)
|
||||||
|
{
|
||||||
|
var field = fields[i];
|
||||||
|
var propertyField = PropertyField.Create(field.Name, field, _managedInstance);
|
||||||
|
propertyField.OnValueChanged += OnPropertyValueChanged;
|
||||||
|
|
||||||
|
_propertyFields[i] = propertyField;
|
||||||
|
_contentContainer.Children.Add(propertyField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_editorUpdate = _customEditor == null ? ReflectionUpdate : CustomEditorUpdate;
|
||||||
|
_editorUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyValueChanged(PropertyField field)
|
||||||
|
{
|
||||||
|
if (_managedInstance == null || _pComponentData == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(_managedInstance, (nint)_pComponentData, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Ghost.Editor.Core/Controls/Internal/NavigationTabView.cs
Normal file
42
Ghost.Editor.Core/Controls/Internal/NavigationTabView.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Controls.Internal;
|
||||||
|
|
||||||
|
public partial class NavigationTabPage : TabViewItem, INavigationAware
|
||||||
|
{
|
||||||
|
public virtual void OnNavigatedTo(object? parameter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void OnNavigatedFrom()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class NavigationTabView : TabView
|
||||||
|
{
|
||||||
|
public NavigationTabView()
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||||
|
VerticalAlignment = VerticalAlignment.Stretch;
|
||||||
|
SelectionChanged += NavigationTabView_SelectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigationTabView_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var oldItem in e.RemovedItems)
|
||||||
|
{
|
||||||
|
if (oldItem is NavigationTabPage oldPage)
|
||||||
|
{
|
||||||
|
oldPage.OnNavigatedFrom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SelectedItem is NavigationTabPage newPage)
|
||||||
|
{
|
||||||
|
newPage.OnNavigatedTo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:Ghost.Editor.Controls.Internal" />
|
||||||
70
Ghost.Editor.Core/Controls/ValueControl.cs
Normal file
70
Ghost.Editor.Core/Controls/ValueControl.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Ghost.Editor.Core.Event;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public partial class ValueControl<T> : Control
|
||||||
|
{
|
||||||
|
private bool _suppressChangedEvent;
|
||||||
|
|
||||||
|
protected bool SuppressChangedEvent
|
||||||
|
{
|
||||||
|
get => _suppressChangedEvent;
|
||||||
|
set => _suppressChangedEvent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Value
|
||||||
|
{
|
||||||
|
get => (T)GetValue(ValueProperty);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(Value, value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetValue(ValueProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty ValueProperty =
|
||||||
|
DependencyProperty.Register(nameof(Value), typeof(T), typeof(ValueControl<T>), new PropertyMetadata(default(T), ChangedCallback));
|
||||||
|
|
||||||
|
public event ValueChangedEventHandler<T>? OnValueChanged;
|
||||||
|
|
||||||
|
private static void ChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (d is ValueControl<T> valueControl)
|
||||||
|
{
|
||||||
|
valueControl.ValueChanged((T)e.OldValue, (T)e.NewValue);
|
||||||
|
|
||||||
|
if (!valueControl._suppressChangedEvent)
|
||||||
|
{
|
||||||
|
valueControl.OnValueChanged?.Invoke(valueControl, new((T)e.OldValue, (T)e.NewValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void ValueChanged(T oldValue, T newValue)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void RiseChangedEvent(T oldValue, T newValue)
|
||||||
|
{
|
||||||
|
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the _value without notifying the change event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new _value to set.</param>
|
||||||
|
/// <remarks>This method only suppresses the change event notification, not the <see cref="ValueChanged(T, T)"/> method.
|
||||||
|
/// Useful when you need to change the _value programmatically without triggering the change event.</remarks>
|
||||||
|
public void SetValueWithoutNotifying(T value)
|
||||||
|
{
|
||||||
|
_suppressChangedEvent = true;
|
||||||
|
SetValue(ValueProperty, value);
|
||||||
|
_suppressChangedEvent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Ghost.Editor.Core/Event/ValueChangedEventHandler.cs
Normal file
22
Ghost.Editor.Core/Event/ValueChangedEventHandler.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Ghost.Editor.Core.Event;
|
||||||
|
|
||||||
|
public delegate void ValueChangedEventHandler<T>(object? sender, ValueChangedEventArgs<T> args);
|
||||||
|
|
||||||
|
public class ValueChangedEventArgs<T> : EventArgs
|
||||||
|
{
|
||||||
|
public T OldValue
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T NewValue
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueChangedEventArgs(T oldValue, T newValue)
|
||||||
|
{
|
||||||
|
OldValue = oldValue;
|
||||||
|
NewValue = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Ghost.Editor.Core/Ghost.Editor.Core.csproj
Normal file
46
Ghost.Editor.Core/Ghost.Editor.Core.csproj
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
|
||||||
|
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
<RootNamespace>Ghost.Editor.Core</RootNamespace>
|
||||||
|
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||||
|
<UseWinUI>true</UseWinUI>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
|
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages -->
|
||||||
|
<langversion>preview</langversion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7463" />
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260101001" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Ghost.Data\Ghost.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\Ghost.Core\Ghost.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Ghost.Engine\Ghost.Engine.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Page Update="Controls\BasicInput\PropertyField.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</Page>
|
||||||
|
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</Page>
|
||||||
|
<Page Update="Controls\ControlsDictionary.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</Page>
|
||||||
|
<Page Update="Controls\Internal\ComponentDataView.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</Page>
|
||||||
|
<Page Update="Controls\Internal\NavigationTabView.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</Page>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
40
Ghost.Editor.Core/Inspector/ComponentEditor.cs
Normal file
40
Ghost.Editor.Core/Inspector/ComponentEditor.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
public abstract class ComponentEditor
|
||||||
|
{
|
||||||
|
private ComponentObject _componentObject;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the underlying component object used by this class to manage its functionality.
|
||||||
|
/// </summary>
|
||||||
|
protected ComponentObject ComponentObject => _componentObject;
|
||||||
|
|
||||||
|
internal void Initialize(ComponentObject componentObject)
|
||||||
|
{
|
||||||
|
_componentObject = componentObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the component editor is created.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="container">The container to add the editor controls to.</param>
|
||||||
|
public virtual void Create(StackPanel container)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the component editor needs to update its UI based on the current state of the component data.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void Update()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the component editor is destroyed.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void Destroy()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Ghost.Editor.Core/Inspector/ComponentObject.cs
Normal file
27
Ghost.Editor.Core/Inspector/ComponentObject.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
public readonly struct ComponentObject
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
private readonly Entity _entity;
|
||||||
|
|
||||||
|
internal ComponentObject(World world, Entity entity)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ref T GetData<T>()
|
||||||
|
where T : unmanaged, IComponent
|
||||||
|
{
|
||||||
|
return ref _world.EntityManager.GetComponent<T>(_entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetData<T>(in T data)
|
||||||
|
where T : unmanaged, IComponent
|
||||||
|
{
|
||||||
|
_world.EntityManager.SetComponent(_entity, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Ghost.Editor.Core/Inspector/CustomEditorAttribute.cs
Normal file
10
Ghost.Editor.Core/Inspector/CustomEditorAttribute.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public class CustomEditorAttribute(Type targetType) : Attribute
|
||||||
|
{
|
||||||
|
internal Type TargetType
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = targetType;
|
||||||
|
}
|
||||||
13
Ghost.Editor.Core/Inspector/IInspectable.cs
Normal file
13
Ghost.Editor.Core/Inspector/IInspectable.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
public interface IInspectable
|
||||||
|
{
|
||||||
|
public IconSource? CreateIcon();
|
||||||
|
|
||||||
|
public UIElement? CreateHeader();
|
||||||
|
|
||||||
|
public UIElement? CreateInspector();
|
||||||
|
}
|
||||||
12
Ghost.Editor.Core/Inspector/IInspectorService.cs
Normal file
12
Ghost.Editor.Core/Inspector/IInspectorService.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
internal interface IInspectorService
|
||||||
|
{
|
||||||
|
public IInspectable? SelectedInspectable
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? OnSelectionChanged;
|
||||||
|
}
|
||||||
19
Ghost.Editor.Core/Inspector/InspectorService.cs
Normal file
19
Ghost.Editor.Core/Inspector/InspectorService.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
public class InspectorService : IInspectorService
|
||||||
|
{
|
||||||
|
public IInspectable? SelectedInspectable
|
||||||
|
{
|
||||||
|
get => field;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (field != value)
|
||||||
|
{
|
||||||
|
field = value;
|
||||||
|
OnSelectionChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? OnSelectionChanged;
|
||||||
|
}
|
||||||
9
Ghost.Editor.Core/Notifications/INotificationService.cs
Normal file
9
Ghost.Editor.Core/Notifications/INotificationService.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using CommunityToolkit.WinUI.Behaviors;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
public void ShowNotification(string? message, MessageType type, int duration = 5, string? title = null);
|
||||||
|
public void ShowNotification(Notification notification);
|
||||||
|
}
|
||||||
9
Ghost.Editor.Core/Notifications/MessageType.cs
Normal file
9
Ghost.Editor.Core/Notifications/MessageType.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Ghost.Editor.Core.Notifications;
|
||||||
|
|
||||||
|
public enum MessageType
|
||||||
|
{
|
||||||
|
Informational,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Error
|
||||||
|
}
|
||||||
49
Ghost.Editor.Core/Notifications/NotificationService.cs
Normal file
49
Ghost.Editor.Core/Notifications/NotificationService.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using CommunityToolkit.WinUI.Behaviors;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Notifications;
|
||||||
|
|
||||||
|
public class NotificationService : INotificationService
|
||||||
|
{
|
||||||
|
private InfoBar? _infoBar;
|
||||||
|
private StackedNotificationsBehavior? _notificationQueue;
|
||||||
|
|
||||||
|
internal void SetReference(InfoBar infoBar, StackedNotificationsBehavior notificationQueue)
|
||||||
|
{
|
||||||
|
_infoBar = infoBar;
|
||||||
|
_notificationQueue = notificationQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowNotification(string? message, MessageType type, int duration = 5, string? title = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification = new Notification
|
||||||
|
{
|
||||||
|
Message = message,
|
||||||
|
Severity = (InfoBarSeverity)type,
|
||||||
|
Duration = TimeSpan.FromSeconds(duration),
|
||||||
|
Title = title
|
||||||
|
};
|
||||||
|
|
||||||
|
ShowNotification(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowNotification(Notification notification)
|
||||||
|
{
|
||||||
|
_notificationQueue?.Show(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ClearReference()
|
||||||
|
{
|
||||||
|
if (_infoBar != null)
|
||||||
|
{
|
||||||
|
_infoBar.IsOpen = false;
|
||||||
|
}
|
||||||
|
_infoBar = null;
|
||||||
|
_notificationQueue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Ghost.Editor.Core/Progress/IProgressService.cs
Normal file
9
Ghost.Editor.Core/Progress/IProgressService.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Ghost.Editor.Core.Progress;
|
||||||
|
|
||||||
|
public interface IProgressService
|
||||||
|
{
|
||||||
|
public void ShowProgress(string message, double progress = 0.0);
|
||||||
|
public void ShowIndeterminateProgress(string message);
|
||||||
|
public void SetProgress(double progress);
|
||||||
|
public void HideProgress();
|
||||||
|
}
|
||||||
74
Ghost.Editor.Core/Progress/ProgressService.cs
Normal file
74
Ghost.Editor.Core/Progress/ProgressService.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using CommunityToolkit.WinUI;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Progress;
|
||||||
|
|
||||||
|
public class ProgressService : IProgressService
|
||||||
|
{
|
||||||
|
private Grid? _progressBarContainer;
|
||||||
|
private TextBlock? _progressMessage;
|
||||||
|
private ProgressBar? _progressBar;
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private bool IsInitialized()
|
||||||
|
{
|
||||||
|
return _progressBarContainer != null && _progressMessage != null && _progressBar != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetReference(Grid progressBarContainer)
|
||||||
|
{
|
||||||
|
_progressBarContainer = progressBarContainer;
|
||||||
|
_progressMessage = _progressBarContainer.FindChild<TextBlock>();
|
||||||
|
_progressBar = _progressBarContainer.FindChild<ProgressBar>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowProgress(string message, double progress = 0.0)
|
||||||
|
{
|
||||||
|
if (!IsInitialized())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_progressBarContainer!.Visibility = Visibility.Visible;
|
||||||
|
_progressMessage!.Text = message;
|
||||||
|
_progressBar!.Value = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowIndeterminateProgress(string message)
|
||||||
|
{
|
||||||
|
if (!IsInitialized())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_progressBarContainer!.Visibility = Visibility.Visible;
|
||||||
|
_progressMessage!.Text = message;
|
||||||
|
_progressBar!.IsIndeterminate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetProgress(double progress)
|
||||||
|
{
|
||||||
|
_progressBar!.Value = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HideProgress()
|
||||||
|
{
|
||||||
|
if (!IsInitialized())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_progressBarContainer!.Visibility = Visibility.Collapsed;
|
||||||
|
_progressMessage!.Text = string.Empty;
|
||||||
|
_progressBar!.Value = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ClearReference()
|
||||||
|
{
|
||||||
|
_progressBarContainer = null;
|
||||||
|
_progressMessage = null;
|
||||||
|
_progressBar = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Ghost.Editor.Core/Resources/EditorIconSource.cs
Normal file
18
Ghost.Editor.Core/Resources/EditorIconSource.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Resources;
|
||||||
|
|
||||||
|
public static class EditorIconSource
|
||||||
|
{
|
||||||
|
public static readonly IconSource scene_24 = new FontIconSource
|
||||||
|
{
|
||||||
|
Glyph = "\uF159",
|
||||||
|
FontSize = 24
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly IconSource entity_24 = new FontIconSource
|
||||||
|
{
|
||||||
|
Glyph = "\uF158",
|
||||||
|
FontSize = 24
|
||||||
|
};
|
||||||
|
}
|
||||||
8
Ghost.Editor.Core/Resources/StaticResource.cs
Normal file
8
Ghost.Editor.Core/Resources/StaticResource.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Resources;
|
||||||
|
|
||||||
|
internal static class StaticResource
|
||||||
|
{
|
||||||
|
public static readonly BindingFlags ComponentPropertyBindingFlags = BindingFlags.Public | BindingFlags.Instance;
|
||||||
|
}
|
||||||
45
Ghost.Editor.Core/SceneGraph/EntityNode.cs
Normal file
45
Ghost.Editor.Core/SceneGraph/EntityNode.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public sealed partial class EntityNode : SceneGraphNode
|
||||||
|
{
|
||||||
|
private readonly Entity _entity;
|
||||||
|
|
||||||
|
public Entity Entity => _entity;
|
||||||
|
|
||||||
|
public override IconSource? CreateIcon()
|
||||||
|
{
|
||||||
|
return new FontIconSource
|
||||||
|
{
|
||||||
|
Glyph = "\uF158"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UIElement? CreateHeader()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UIElement? CreateInspector()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DataTemplate GetSceneHierarchyTemplate()
|
||||||
|
{
|
||||||
|
var template = @"
|
||||||
|
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:Key=""EntityTemplate"" x:DataType=""sg:SceneGraphNode"">
|
||||||
|
<TreeViewItem AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}"" ItemsSource=""{x:Bind Children, Mode=OneWay}"">
|
||||||
|
<StackPanel Margin=""10,0"" Orientation=""Horizontal"">
|
||||||
|
<FontIcon FontSize=""14"" Glyph="""" />
|
||||||
|
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>";
|
||||||
|
|
||||||
|
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md
Normal file
87
Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Architecture Plan: Scene Graph and Scene Representation
|
||||||
|
|
||||||
|
The Scene Graph is a hierarchical structure that represents all the objects and entities within a 3D scene in the Ghost Editor.
|
||||||
|
|
||||||
|
## Scene Graph (Editor representation of runtime data)
|
||||||
|
|
||||||
|
There should be three main types of nodes in the Scene Graph for now:
|
||||||
|
|
||||||
|
1. **Scene Graph Node**: The base class for all nodes in the Scene Graph.
|
||||||
|
2. **Entity Node**: Represents an individual entity within a scene. Name stored here, not runtime component.
|
||||||
|
3. **Scene Node**: Represents a Scene object, which can contain multiple entities. Name stored here not runtime data.
|
||||||
|
|
||||||
|
### Editor World
|
||||||
|
|
||||||
|
Editor contains a different world compares to the runtime world. When user click the Play button, we will create a runtime world and load the scene data from the editor world to the runtime world.
|
||||||
|
This allows us to
|
||||||
|
|
||||||
|
1. Unload the runtime only systems like physics, rendering, etc when user stop playing.
|
||||||
|
2. Load editor only systems like gizmos, debug, etc when user stop playing.
|
||||||
|
3. Allow editor only entities like editor camera, editor lights, etc to exist in the editor world without affecting the runtime world.
|
||||||
|
|
||||||
|
### Editor Hierarchy
|
||||||
|
|
||||||
|
The Scene Graph should be represented as a tree structure in the editor (TreeView in WinUI 3), where:
|
||||||
|
|
||||||
|
- The top level nodes represents the loaded Scenes in the editor world.
|
||||||
|
- Levels below the Scene nodes represents the Entity nodes that belong to that scene.
|
||||||
|
- Each Entity node can have child Entity nodes representing parent-child relationships between entities.
|
||||||
|
|
||||||
|
An example hierarchy could look like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
- Scene 1
|
||||||
|
- Entity A
|
||||||
|
- Entity B
|
||||||
|
- Entity C
|
||||||
|
- Scene 2
|
||||||
|
- Entity D
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scene (The runtime representation)
|
||||||
|
|
||||||
|
A Scene is a collection of entities with SceneID component from a world that are grouped together. There can be multiple scenes in a world.
|
||||||
|
|
||||||
|
### Save a Scene
|
||||||
|
|
||||||
|
When save a scene, all entities with the SceneID component matching the scene's ID should be included in the saved data.
|
||||||
|
When an Entity references another Entity in the same scene, we should store the file local id instead of the global entity id.
|
||||||
|
For example, if Entity A (id: 10, 5th in scene) references Entity B (id: 20, 50th in scene) in the same scene, in the saved data for Entity A,
|
||||||
|
we should store 50 (the file local id) as the reference to Entity B instead of 20 (the global entity id).
|
||||||
|
|
||||||
|
> We does not allow cross-scene references for now because ideally it's not a good practice to have cross-scene references.
|
||||||
|
> We can use query or singleton pattern to access entities from other scenes if needed because they are in the same world.
|
||||||
|
|
||||||
|
### Load a Scene
|
||||||
|
|
||||||
|
When loading a scene, we need to reconstruct the entities and their relationships based on the saved data.
|
||||||
|
|
||||||
|
1. We allocate the entities in the world and assign them new global entity IDs.
|
||||||
|
2. We remap the file local IDs to the new global entity IDs and change the references accordingly.
|
||||||
|
For example if Entity A (file local id: 5) references Entity B (file local id: 50) in the saved data,
|
||||||
|
we need to find the new global entity IDs for both entities after loading and update the reference in Entity A to point to the new global entity ID of Entity B.
|
||||||
|
|
||||||
|
### Data format
|
||||||
|
|
||||||
|
The scene data should be stored in a structured format (JSON and binary) that includes:
|
||||||
|
|
||||||
|
- List of entities with their components and properties (Entities must in the order that file local id directly maps to the index in the list)
|
||||||
|
- References between entities using file local IDs
|
||||||
|
|
||||||
|
> The name of the saved scene file should match the name of the scene node in the editor.
|
||||||
|
|
||||||
|
JSON should only be used in the editor and JSON serialization/deserialization logic should also only exist in the editor codebase (Ghost.Editor.Core). Reflection is allowed here.
|
||||||
|
Binary format should be used in the runtime for better performance. The runtime codebase (Ghost.Engine) must be aot compatible.
|
||||||
|
|
||||||
|
Currently we strict the IComponent to must be unmanaged and blittable types.
|
||||||
|
However, we also support ManagedEntity and ManagedEntityRef with ScriptComponent to allow OOP like logic for common gameplay logic that DOD pattern is not suitable for.
|
||||||
|
Serializing/deserializing with those components will be tricky. We can use MemoryPack (already installed) for binary serialization/deserialization because it supports both unmanaged and managed types.
|
||||||
|
|
||||||
|
## What need to implement
|
||||||
|
|
||||||
|
- [ ] Scene type for the runtime representation if needed
|
||||||
|
- [ ] Scene Graph data structures (SceneNode, EntityNode)
|
||||||
|
- [ ] Editor World management (loading/unloading scenes, managing entities)
|
||||||
|
- [ ] Scene saving/loading logic with file local ID remapping
|
||||||
|
- [ ] Serialization/deserialization logic for scene data (JSON for editor, binary for runtime)
|
||||||
|
- [ ] UI integration for displaying and managing the Scene Graph in the editor with WinUI 3 TreeView
|
||||||
27
Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs
Normal file
27
Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<SceneGraphNode> Children
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
public abstract IconSource? CreateIcon();
|
||||||
|
public abstract UIElement? CreateHeader();
|
||||||
|
public abstract UIElement? CreateInspector();
|
||||||
|
|
||||||
|
public abstract DataTemplate GetSceneHierarchyTemplate();
|
||||||
|
}
|
||||||
45
Ghost.Editor.Core/SceneGraph/SceneNode.cs
Normal file
45
Ghost.Editor.Core/SceneGraph/SceneNode.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public sealed partial class SceneNode : SceneGraphNode
|
||||||
|
{
|
||||||
|
public override IconSource? CreateIcon()
|
||||||
|
{
|
||||||
|
return new FontIconSource
|
||||||
|
{
|
||||||
|
Glyph = "\uF156"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement custom header and inspector UI for the SceneNode
|
||||||
|
public override UIElement? CreateHeader()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UIElement? CreateInspector()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DataTemplate GetSceneHierarchyTemplate()
|
||||||
|
{
|
||||||
|
var template = @"
|
||||||
|
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:DataType=""sg:SceneGraphNode"">
|
||||||
|
<TreeViewItem
|
||||||
|
AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}""
|
||||||
|
Background=""{ThemeResource ControlSolidFillColorDefaultBrush}""
|
||||||
|
IsExpanded=""True""
|
||||||
|
ItemsSource=""{ x:Bind Children, Mode=OneWay}"" >
|
||||||
|
<StackPanel Orientation=""Horizontal"" >
|
||||||
|
<FontIcon FontSize=""14"" Glyph=""""/>
|
||||||
|
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>";
|
||||||
|
|
||||||
|
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Ghost.Editor.Core/Utilities/EditorApplication.cs
Normal file
26
Ghost.Editor.Core/Utilities/EditorApplication.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class EditorApplication
|
||||||
|
{
|
||||||
|
private static IServiceProvider? _serviceProvider;
|
||||||
|
|
||||||
|
public static Application Current => Application.Current;
|
||||||
|
|
||||||
|
internal static void Initialize(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T GetService<T>()
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (_serviceProvider?.GetService(typeof(T)) is not T service)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices within App.xaml.cs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Ghost.Editor.Core/Utilities/FileExtensions.cs
Normal file
15
Ghost.Editor.Core/Utilities/FileExtensions.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Ghost.Data.Models;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
internal static class FileExtensions
|
||||||
|
{
|
||||||
|
public const string META_FILE_EXTENSION = ".gmeta";
|
||||||
|
|
||||||
|
public const string PROJECT_FILE_EXTENSION = "." + ProjectMetadata.PROJECT_FILE_EXTENSION_NAME;
|
||||||
|
public const string TEMPLATE_FILE_EXTENSION = ".gtmpl";
|
||||||
|
public const string SCENE_FILE_EXTENSION = ".gscene";
|
||||||
|
public const string ASSET_FILE_EXTENSION = ".gasset";
|
||||||
|
public const string SHADER_FILE_EXTENSION = ".gshdr";
|
||||||
|
public const string MATERIAL_FILE_EXTENSION = ".gmat";
|
||||||
|
}
|
||||||
36
Ghost.Editor.Core/Utilities/TypeCache.cs
Normal file
36
Ghost.Editor.Core/Utilities/TypeCache.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class TypeCache
|
||||||
|
{
|
||||||
|
private static readonly TypeInfo[] s_types;
|
||||||
|
|
||||||
|
static TypeCache()
|
||||||
|
{
|
||||||
|
var loadableTypes = new List<Type>(512);
|
||||||
|
var assembliesToScan = AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Where(a => a.GetCustomAttribute<EngineAssemblyAttribute>() != null);
|
||||||
|
|
||||||
|
foreach (var assembly in assembliesToScan)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loadableTypes.AddRange(assembly.GetTypes());
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
var types = ex.Types.Where(t => t != null);
|
||||||
|
loadableTypes.AddRange(types!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s_types = loadableTypes.Select(t => t.GetTypeInfo()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Type[] GetTypes()
|
||||||
|
{
|
||||||
|
return s_types;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Ghost.Editor/ActivationHandler.cs
Normal file
30
Ghost.Editor/ActivationHandler.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Ghost.Data.Resources;
|
||||||
|
using Ghost.Data.Services;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor;
|
||||||
|
|
||||||
|
internal static class ActivationHandler
|
||||||
|
{
|
||||||
|
private static void FolderInitialization()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(DataPath.s_applicationDataFolder))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(DataPath.s_applicationDataFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(DataPath.s_projectTemplateFolder))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(DataPath.s_projectTemplateFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Handle(LaunchActivatedEventArgs args)
|
||||||
|
{
|
||||||
|
FolderInitialization();
|
||||||
|
ProjectService.EnsureDefaultTemplate();
|
||||||
|
|
||||||
|
EditorApplication.Initialize(((App)(Application.Current)).Host.Services);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user