Compare commits
142 Commits
main
...
85a000e5c4
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a000e5c4 | |||
| 301a6d1c45 | |||
| e831b71a79 | |||
| 9bae3e647e | |||
| 6cadd8edeb | |||
| 3e4084c42a | |||
| cce1cf7256 | |||
| 254b08bc81 | |||
| 912b320d8f | |||
| 8a3b40b4f8 | |||
| 619720feee | |||
| bfe8588d76 | |||
| b8af6e8c3a | |||
| 5e42d699c3 | |||
| 6f802ac12b | |||
| 162b71f309 | |||
| 30090f84ab | |||
| 93c58fa7fb | |||
| 78e3b4ef31 | |||
| db8ca971a8 | |||
| 638417d4f0 | |||
| 426786397c | |||
| 9bbccfc8f8 | |||
| eadd13931f | |||
| 59991f47d5 | |||
| 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 |
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.dll filter=lfs diff=lfs merge=lfs -text
|
||||
2
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
AGENTS.md
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
@@ -35,6 +36,7 @@ bld/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
.vscode/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
|
||||
212
AGENT_GUIDELINES.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# GhostEngine — Agent Guidelines
|
||||
|
||||
## Repository Overview
|
||||
|
||||
GhostEngine is a C# game engine targeting .NET 10 / Windows, built around:
|
||||
- **ECS runtime** (`Ghost.Entities`, `Ghost.Core`) — high-performance, AOT-compatible
|
||||
- **Graphics** (`Ghost.Graphics`, `Ghost.Graphics.RHI`, `Ghost.Graphics.D3D12`) — D3D12 RHI
|
||||
- **Editor** (`Ghost.Editor`, `Ghost.Editor.Core`, `Ghost.DSL`, `Ghost.Data`) — WinUI 3 (WindowsAppSDK)
|
||||
- **Third-party bindings** (`Ghost.FMOD`, `Ghost.MeshOptimizer`, `Ghost.Nvtt`, `Ghost.Ufbx`)
|
||||
- **Tools** (`Ghost.NativeWrapperGen`)
|
||||
|
||||
Solution file: `src/GhostEngine.slnx`
|
||||
All commands below should be run from the `src/` directory unless noted.
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```shell
|
||||
# Build entire solution (x64, Debug)
|
||||
dotnet build GhostEngine.slnx -c Debug -p:Platform=x64
|
||||
|
||||
# Build entire solution (Release)
|
||||
dotnet build GhostEngine.slnx -c Release -p:Platform=x64
|
||||
|
||||
# Build a single project
|
||||
dotnet build Runtime/Ghost.Entities/Ghost.Entities.csproj
|
||||
|
||||
# Clean
|
||||
dotnet clean GhostEngine.slnx
|
||||
```
|
||||
|
||||
> **Note:** Editor projects (`Ghost.Editor`, `Ghost.Editor.Core`) require
|
||||
> `net10.0-windows10.0.22621.0` and the Windows App SDK. They will only build
|
||||
> on a Windows machine with the correct SDK installed. Platform-agnostic runtime
|
||||
> and test projects target plain `net10.0`.
|
||||
|
||||
---
|
||||
|
||||
## Test Commands
|
||||
|
||||
There are two test frameworks in use:
|
||||
|
||||
### MSTest — `Ghost.UnitTest`
|
||||
Standard `dotnet test` runner. Tests are parallelized at method level by default.
|
||||
Currently the integration test file is `#if false`-guarded until the asset
|
||||
service is fully wired.
|
||||
|
||||
```shell
|
||||
# Run all MSTest tests
|
||||
dotnet test Test/Ghost.UnitTest/Ghost.UnitTest.csproj -c Debug -p:Platform=x64
|
||||
|
||||
# Run a single test method by name
|
||||
dotnet test Test/Ghost.UnitTest/Ghost.UnitTest.csproj \
|
||||
--filter "FullyQualifiedName~TestAutoMetaGeneration_WhenFileCreated"
|
||||
|
||||
# Run a single test class
|
||||
dotnet test Test/Ghost.UnitTest/Ghost.UnitTest.csproj \
|
||||
--filter "ClassName~AssetDatabaseIntegrationTest"
|
||||
```
|
||||
|
||||
### Custom TestRunner — `Ghost.MicroTest` / `Ghost.Entities.Test`
|
||||
These are console executables driven by `Ghost.Test.Core.TestRunner`. There is
|
||||
no `dotnet test` integration; run them directly:
|
||||
|
||||
```shell
|
||||
# Micro tests (native binding smoke tests)
|
||||
dotnet run --project Test/Ghost.MicroTest/Ghost.MicroTest.csproj
|
||||
|
||||
# ECS benchmarks / manual tests
|
||||
dotnet run --project Test/Ghost.Entities.Test/Ghost.Entities.Test.csproj -c Release
|
||||
```
|
||||
|
||||
To run a specific `ITest` implementation, edit `Program.cs` in the respective
|
||||
project and call `TestRunner.Run<YourTestClass>()`.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### EditorConfig (enforced — `src/.editorconfig`)
|
||||
- Max line length: **200**
|
||||
- Opening braces always on a **new line** for all C# constructs
|
||||
- Single-line statements and blocks are **preserved** (not force-expanded)
|
||||
- **No** primary constructors (`csharp_style_prefer_primary_constructors = false`)
|
||||
- `System.*` using directives are **not** sorted first
|
||||
- Import directive groups are **not** separated by blank lines
|
||||
- Collection expressions and collection initializer syntax are **disabled**
|
||||
(`dotnet_style_prefer_collection_expression = false`)
|
||||
|
||||
### Language
|
||||
- C# `latest` (runtime/test projects) or `preview` (editor projects, for `field`
|
||||
keyword support in .NET 10)
|
||||
- Nullable reference types: **enabled** everywhere (`<Nullable>enable</Nullable>`)
|
||||
- Implicit usings: **enabled** (`<ImplicitUsings>enable</ImplicitUsings>`)
|
||||
- Unsafe blocks: **enabled** where needed (ECS, graphics, native bindings)
|
||||
|
||||
### Namespaces & File Layout
|
||||
- One type per file; file name matches type name exactly
|
||||
- Namespace matches folder structure: `Ghost.<Module>[.<SubFolder>]`
|
||||
- `partial` classes are split across files named `TypeName.Purpose.cs`
|
||||
(e.g. `EntityManager.cs`, `EntityManager.Managed.cs`)
|
||||
- `AssemblyInfo.cs` holds `[assembly: InternalsVisibleTo(...)]` and assembly
|
||||
attributes; do not scatter these across regular source files
|
||||
|
||||
### Naming Conventions
|
||||
| Symbol | Convention | Example |
|
||||
|--------|-----------|---------|
|
||||
| Private fields | `_camelCase` | `_jobScheduler` |
|
||||
| Private static fields | `s_camelCase` | `s_worlds`, `s_logger` |
|
||||
| Constants (public/private) | `UPPER_SNAKE_CASE` | `ASSET_EXTENSION`, `ASSETS_FOLDER_NAME` |
|
||||
| Properties & public members | `PascalCase` | `EntityManager`, `IsSuccess` |
|
||||
| Local variables / params | `camelCase` | `entityCapacity`, `signatureHash` |
|
||||
| Interfaces | `I` prefix | `IComponent`, `ISystem`, `ITest` |
|
||||
| Generic type parameters | `T`, `TKey`, `TValue`, `E` | |
|
||||
| Type-tagged structs (handles) | Generic param encodes context | `Handle<T>`, `Identifier<T>`, `Key64<T>` |
|
||||
|
||||
### Types & Structs
|
||||
- Prefer `readonly struct` for value types that are logically immutable.
|
||||
- Prefer `ref struct` / `readonly ref struct` for stack-only types
|
||||
(`RefResult<T,E>`, `SystemAPI`, `ChunkView`).
|
||||
- Use `partial class` to split large classes by concern.
|
||||
- Avoid primary constructors (disabled by editorconfig).
|
||||
- Use the `field` keyword (preview feature) for auto-property backing fields
|
||||
where it simplifies code — only in editor projects that opt in via
|
||||
`<langversion>preview</langversion>`.
|
||||
|
||||
### Imports
|
||||
- `using` directives at the top of each file, before the `namespace` declaration.
|
||||
- No blank line between `using` groups (enforced by editorconfig).
|
||||
- `System.*` namespaces may appear in any order alongside project namespaces.
|
||||
- Prefer specific `using` imports over global usings for clarity in low-level
|
||||
performance-critical files.
|
||||
|
||||
### Error Handling
|
||||
- **Return `Result` / `Result<T>` instead of throwing** for expected failures
|
||||
(file-not-found, invalid args, etc.).
|
||||
`Result.Success()` / `Result.Failure(message)` or `Result.Failure(Error.XXX)`.
|
||||
- Use the typed `Error` enum (`Ghost.Core.Error`) for structured error codes.
|
||||
- Use `result.ThrowIfFailed()` / `result.GetValueOrThrow()` extension methods at
|
||||
call sites that want throw-on-failure semantics.
|
||||
- **Throw exceptions** only for programming errors / invariant violations
|
||||
(corrupt state, null-ref on internal APIs).
|
||||
- In performance-critical paths, guard validation behind `#if DEBUG || GHOST_EDITOR`
|
||||
to eliminate overhead in release builds.
|
||||
- `Logger.LogError(...)` / `Logger.LogWarning(...)` for non-fatal operational
|
||||
issues; do not use `Console.WriteLine` in production library code.
|
||||
|
||||
### Performance Patterns
|
||||
- Annotate hot paths with `[MethodImpl(MethodImplOptions.AggressiveInlining)]`.
|
||||
- Annotate log/assert helpers with `[StackTraceHidden]`.
|
||||
- Prefer `stackalloc` + `Span<T>` over heap allocation for small temporary arrays.
|
||||
- Use the `Misaki.HighPerformance.*` allocation APIs (`AllocationManager`,
|
||||
`UnsafeList<T>`, `UnsafeHashMap<T,V>`, etc.) for long-lived unmanaged buffers.
|
||||
- All runtime/ECS types must be AOT-compatible and trimmable (set
|
||||
`<IsAotCompatible>True</IsAotCompatible>` and `<IsTrimmable>True</IsTrimmable>`
|
||||
in Release config).
|
||||
- Avoid LINQ in hot paths; use `for` loops or `foreach` over `Span<T>`.
|
||||
|
||||
### Attributes & Extensibility
|
||||
- Custom attributes for editor extension points inherit from
|
||||
`DiscoverableAttributeBase` (discovered at startup via `TypeCache`).
|
||||
- Use `[UpdateAfter(typeof(X))]` / `[UpdateBefore(typeof(X))]` to declare
|
||||
`ISystem` ordering dependencies; `SystemGroup.SortSystems()` topologically sorts
|
||||
them at startup.
|
||||
- Use `[EditorInjection(ServiceLifetime.Singleton)]` to register editor services
|
||||
via DI without manual wiring.
|
||||
|
||||
### XML Documentation
|
||||
- All public API surface should have `<summary>` doc-comments.
|
||||
- Use `<remarks>` for non-obvious behavior or threading constraints.
|
||||
- Document thread-safety expectations explicitly (see `EntityCommandBuffer` /
|
||||
`AssetRegistry` as reference).
|
||||
|
||||
### Preprocessor Defines
|
||||
| Define | Meaning |
|
||||
|--------|---------|
|
||||
| `DEBUG` | Standard debug build |
|
||||
| `GHOST_EDITOR` | Editor build (extra validation, reflection helpers) |
|
||||
| `PLATEFORME_WIN64` | Windows 64-bit platform target |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
GhostEngine.slnx # Solution
|
||||
.editorconfig # Formatting rules
|
||||
Runtime/
|
||||
Ghost.Core/ # Core types: Result, Handle, Logger, math helpers
|
||||
Ghost.Engine/ # Engine entry point & loop
|
||||
Ghost.Entities/ # ECS: World, Entity, Component, System
|
||||
Ghost.Generator/ # Source generators
|
||||
Ghost.Graphics/ # High-level graphics API
|
||||
Ghost.Graphics.RHI/ # Render hardware interface abstractions
|
||||
Ghost.Graphics.D3D12/ # D3D12 backend
|
||||
Editor/
|
||||
Ghost.Editor/ # WinUI 3 shell
|
||||
Ghost.Editor.Core/ # Editor services, asset registry, inspector
|
||||
Ghost.DSL/ # Shader DSL compiler
|
||||
Ghost.Data/ # Serialization / project data models
|
||||
ThridParty/ # Native binding wrappers (FMOD, MeshOptimizer, Nvtt, Ufbx)
|
||||
Test/
|
||||
Ghost.Test.Core/ # Shared ITest / TestRunner infrastructure
|
||||
Ghost.UnitTest/ # MSTest integration tests
|
||||
Ghost.MicroTest/ # Native binding smoke tests (console app)
|
||||
Ghost.Entities.Test/ # ECS benchmarks (BenchmarkDotNet, console app)
|
||||
Ghost.Shader.Test/ # Shader DSL manual tests (console app)
|
||||
Tools/
|
||||
Ghost.NativeWrapperGen/ # Code-gen tool for native wrappers
|
||||
```
|
||||
@@ -1,34 +0,0 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace Ghost.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched.
|
||||
/// </summary>
|
||||
/// <param name="args">Details about the launch request and process.</param>
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
m_window = new MainWindow();
|
||||
m_window.Activate();
|
||||
}
|
||||
|
||||
private Window? m_window;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Window
|
||||
x:Class="Ghost.Editor.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ghost.Editor"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="Ghost.Editor">
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Button x:Name="myButton" Click="myButton_Click">Click Me</Button>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -1,36 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace Ghost.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// An empty window that can be used on its own or navigated to within a Frame.
|
||||
/// </summary>
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
private void myButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
myButton.Content = "Clicked";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Ghost.Engine;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.35906.104
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ghost.Editor", "Ghost.Editor\Ghost.Editor.csproj", "{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ghost.Engine", "Ghost.Engine\Ghost.Engine.csproj", "{1ED62E09-8F36-4671-896B-16C1C1530202}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|ARM64 = Debug|ARM64
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|ARM64 = Release|ARM64
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|ARM64.Build.0 = Debug|ARM64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|ARM64.Deploy.0 = Debug|ARM64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|x64.Build.0 = Debug|x64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|x64.Deploy.0 = Debug|x64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|x86.ActiveCfg = Debug|x86
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|x86.Build.0 = Debug|x86
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Debug|x86.Deploy.0 = Debug|x86
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|ARM64.Build.0 = Release|ARM64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|ARM64.Deploy.0 = Release|ARM64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|x64.ActiveCfg = Release|x64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|x64.Build.0 = Release|x64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|x64.Deploy.0 = Release|x64
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|x86.ActiveCfg = Release|x86
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|x86.Build.0 = Release|x86
|
||||
{15AFE3A1-0CAF-4B36-8835-121C4D683BBF}.Release|x86.Deploy.0 = Release|x86
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Debug|ARM64.ActiveCfg = Debug|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Debug|ARM64.Build.0 = Debug|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Release|ARM64.ActiveCfg = Release|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Release|ARM64.Build.0 = Release|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1ED62E09-8F36-4671-896B-16C1C1530202}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {0C545827-2ED7-4597-BE3C-30E978C85B9E}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
19
README_julian.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Julian's Workspace: GhostEngine
|
||||
|
||||
Misaki has set up my environment to work from my own fork of "GhostEngine." Here's how I'll manage my workflow:
|
||||
|
||||
## What I'll Do
|
||||
|
||||
1. Write, commit, and push code changes directly to my local fork.
|
||||
2. Use the "tea" CLI tool when I'm ready to create PRs back to the original repository.
|
||||
|
||||
## Tools & Resources
|
||||
|
||||
- **Gitea:** The repository is hosted here. I can authenticate directly.
|
||||
- **Tea CLI:** Details for creating PRs can be found in the `gitea-pr` and `gitea` skill documentation.
|
||||
|
||||
## Collaboration Rules
|
||||
|
||||
We'll refine separately; I'll merge when...
|
||||
...the functionality is complete and tested.
|
||||
**PR Description Tip:** Include concise changelog markdown.
|
||||
62
WORK_SUMMARY.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ClusterLOD C# Translation — Work Summary
|
||||
|
||||
## ✅ Completed Work
|
||||
|
||||
I've successfully translated the C++ `clusterlod` library to C# using your GhostEngine infrastructure:
|
||||
|
||||
### Files Created
|
||||
|
||||
1. **ClodConfig.cs** — Configuration struct with fields (`camelCase`) and properties (`PascalCase`)
|
||||
2. **ClodMesh.cs** — Unsafe mesh data structure (pointers for high-performance access)
|
||||
3. **ClodBounds.cs** — Bounds representation with center, radius, and error
|
||||
4. **ClodBuilder.cs** — Main API entry point with full `clodBuild` implementation
|
||||
5. **ClodInternal.cs** — Clusterization using `meshopt_buildMeshlets*` bindings
|
||||
6. **ClodInternal_Partition.cs** — Spatial partitioning via `meshopt_partitionClusters`
|
||||
7. **ClodInternal_Boundary.cs** — Boundary locking to prevent mesh seams
|
||||
8. **ClodBoundsHelper.cs** — Bounds computation and merging
|
||||
9. **ClodSimplify.cs** — Full simplification pipeline (permissive + sloppy fallbacks)
|
||||
10. **AGENT_GUIDELINES.md** — Your naming conventions and architecture guide
|
||||
|
||||
### Features Implemented
|
||||
|
||||
✅ High-performance memory via `UnsafeList<T>` from `Misaki.HighPerformance`
|
||||
✅ Full cluster LOD hierarchy generation
|
||||
✅ Attribute-aware simplification
|
||||
✅ Error monotonicity tracking
|
||||
✅ Boundary preservation across simplification
|
||||
✅ Proper allocator handling and cleanup
|
||||
✅ Edge-length error limiting
|
||||
✅ Fallback simplification (permissive & sloppy modes)
|
||||
✅ C# naming conventions (fields camelCase, props PascalCase, consts UPPER_SNAKE_CASE)
|
||||
|
||||
### Changes to Native Bindings
|
||||
|
||||
- Renamed `NvttApi` → `MeshOptApi` in `meshopt.json` config
|
||||
- Re-ran code generator to produce `MeshOptApi.nativegen.cs` and mesh-related wrapper files
|
||||
|
||||
### Local Commits
|
||||
|
||||
```
|
||||
301a6d1 feat: translate clusterlod to C# and restructure to Ghost.Graphics.Meshlet
|
||||
```
|
||||
|
||||
## 📤 Next Steps (You)
|
||||
|
||||
The commit is ready locally. To push and create a PR:
|
||||
|
||||
```bash
|
||||
cd projects/GhostEngine
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
Then create a PR from `Julian/GhostEngine:develop` → `Misaki/GhostEngine:develop`.
|
||||
|
||||
## 🎯 What's Ready for Testing
|
||||
|
||||
- Full clusterization pipeline
|
||||
- Spatial/flex meshlet building
|
||||
- Simplification with all fallback modes
|
||||
- Bounds computation and hierarchy tracking
|
||||
- Ready to integrate with your graphics pipeline
|
||||
|
||||
Let me know if you'd like me to refine anything or add documentation!
|
||||
1125
doc/meshlet-architecture.md
Normal file
13
src/.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
[*]
|
||||
max_line_length = 200
|
||||
|
||||
[*.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
|
||||
4
src/Editor/Ghost.DSL/AssemblyInfo.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ghost.Shader.Test")]
|
||||
[assembly: InternalsVisibleTo("Ghost.Graphics")]
|
||||
305
src/Editor/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
src/Editor/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="../../Runtime/Ghost.Core/Ghost.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
38
src/Editor/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
src/Editor/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;
|
||||
326
src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
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 int LayoutCBufferProperties(Span<PropertyDescriptor> properties)
|
||||
{
|
||||
if (properties.IsEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var currentOffset = 0;
|
||||
|
||||
foreach (ref var prop in properties)
|
||||
{
|
||||
var size = prop.type.GetSize();
|
||||
|
||||
if ((currentOffset % 16) + size > 16)
|
||||
{
|
||||
currentOffset = (currentOffset + 15) & ~15;
|
||||
}
|
||||
|
||||
prop.offset = currentOffset;
|
||||
prop.size = size;
|
||||
|
||||
currentOffset += size;
|
||||
}
|
||||
|
||||
return (currentOffset + 15) & ~15;
|
||||
}
|
||||
|
||||
// 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 = LayoutCBufferProperties(descriptor.properties);
|
||||
|
||||
if (semantics.passes != null)
|
||||
{
|
||||
descriptor.passes = new PassDescriptor[semantics.passes.Count];
|
||||
for (var 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/src/Runtime//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/src/Runtime//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
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/Ghost.Data/Assets/ProjectTemplates/Empty.zip
Normal file
33
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/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));
|
||||
}
|
||||
}
|
||||
172
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/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;
|
||||
}
|
||||
}
|
||||
7
src/Editor/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]
|
||||
179
src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Ghost.Editor.Core.AssetHandler;
|
||||
|
||||
public abstract class Asset
|
||||
{
|
||||
public Guid ID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public abstract Guid TypeID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public Guid[] Dependencies
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public IAssetSettings? Settings
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
protected Asset(Guid id, Guid[] dependencies, IAssetSettings? settings)
|
||||
{
|
||||
ID = id;
|
||||
Dependencies = dependencies;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not change the order of the fields in this struct, as it is used for binary serialization/deserialization.
|
||||
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
|
||||
internal struct AssetMetadata
|
||||
{
|
||||
public const int CURRENT_FORMAT_VERSION = 1;
|
||||
public const int SIZE = 128; // Fixed size for metadata header. We choose 128 bytes to allow future expansion without breaking compatibility.
|
||||
|
||||
public AssetMetadata(Guid id, Guid typeID)
|
||||
{
|
||||
FormatVersion = CURRENT_FORMAT_VERSION;
|
||||
ID = id;
|
||||
TypeID = typeID;
|
||||
}
|
||||
|
||||
public int FormatVersion
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public Guid ID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public Guid TypeID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public int HandlerVersion
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public int DependencyCount
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public long DependenciesOffset
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public long SettingsOffset
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public long SettingsSize
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public long ContentOffset
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public long ContentSize
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public static void WriteToStream(Stream stream, scoped ref readonly AssetMetadata metadata)
|
||||
{
|
||||
var buffer = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in metadata, 1));
|
||||
stream.Write(buffer);
|
||||
}
|
||||
|
||||
public static AssetMetadata ReadFromStream(Stream stream)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[SIZE];
|
||||
stream.ReadExactly(buffer);
|
||||
return Unsafe.ReadUnaligned<AssetMetadata>(ref MemoryMarshal.GetReference(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
|
||||
public readonly struct DependencyInfo
|
||||
{
|
||||
public const int SIZE = 16;
|
||||
|
||||
public Guid ID
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public readonly ReadOnlySpan<byte> AsBytes()
|
||||
{
|
||||
return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct AssetReference : IEquatable<AssetReference>
|
||||
{
|
||||
private readonly int _value;
|
||||
|
||||
/// <summary>
|
||||
/// The index of the asset in the dependency list.
|
||||
/// </summary>
|
||||
public int Index
|
||||
{
|
||||
get => Math.Abs(_value) - 1;
|
||||
}
|
||||
|
||||
public static AssetReference Null => default;
|
||||
|
||||
public readonly bool IsInternal => _value >= 0;
|
||||
public readonly bool IsExternal => _value < 0;
|
||||
|
||||
public bool Equals(AssetReference other)
|
||||
{
|
||||
return _value == other._value;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return _value.GetHashCode();
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is AssetReference reference && Equals(reference);
|
||||
}
|
||||
|
||||
public static bool operator ==(AssetReference left, AssetReference right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(AssetReference left, AssetReference right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAssetSettings;
|
||||
60
src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
|
||||
namespace Ghost.Editor.Core.AssetHandler;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class CustomAssetHandlerAttribute : Attribute
|
||||
{
|
||||
public required string ID
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public required string[] SupportedExtensions
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public bool AllowCaching
|
||||
{
|
||||
get; init;
|
||||
} = true;
|
||||
}
|
||||
|
||||
public interface IAssetExportOptions;
|
||||
|
||||
public interface IAssetHandler
|
||||
{
|
||||
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default);
|
||||
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default);
|
||||
}
|
||||
|
||||
public interface IImportableAssetHandler : IAssetHandler
|
||||
{
|
||||
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default);
|
||||
ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default);
|
||||
}
|
||||
|
||||
public static class AssetHandlerExtensions
|
||||
{
|
||||
public static async ValueTask<Result> ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default)
|
||||
{
|
||||
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
return await handler.ImportAsync(sourceStream, targetStream, id, token);
|
||||
}
|
||||
|
||||
public static async ValueTask<Result> ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default)
|
||||
{
|
||||
await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
return await handler.ExportAsync(assetStream, targetStream, options, token);
|
||||
}
|
||||
|
||||
public static async ValueTask<Result<Asset>> LoadAsync(this IAssetHandler handler, string assetFilePath, IAssetRegistry assetDatabase, CancellationToken token = default)
|
||||
{
|
||||
await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return await handler.LoadAsync(sourceStream, assetDatabase, token);
|
||||
}
|
||||
}
|
||||
37
src/Editor/Ghost.Editor.Core/AssetHandler/AssetProcesser.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
|
||||
namespace Ghost.Editor.Core.AssetHandler;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class CustomAssetProcesserAttribute<T> : Attribute
|
||||
{
|
||||
public Type Type => typeof(T);
|
||||
}
|
||||
|
||||
public readonly struct AssetProcesserContext
|
||||
{
|
||||
public IAssetRegistry Registry
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public string AssetPath
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public Asset Asset
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public IAssetHandler Handler
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAssetProcesser
|
||||
{
|
||||
ValueTask ProcessAsync(AssetProcesserContext ctx);
|
||||
}
|
||||
397
src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs
Normal file
@@ -0,0 +1,397 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Graphics.RHI;
|
||||
using Misaki.HighPerformance.Image;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using static Ghost.Editor.Core.AssetHandler.TextureAssetSettings;
|
||||
|
||||
namespace Ghost.Editor.Core.AssetHandler;
|
||||
|
||||
public enum TextureType : uint
|
||||
{
|
||||
Default,
|
||||
Normal,
|
||||
Lightmap,
|
||||
SingleChannel
|
||||
}
|
||||
|
||||
public enum TextureShape : uint
|
||||
{
|
||||
Texture2D,
|
||||
Texture3D,
|
||||
TextureCube
|
||||
}
|
||||
|
||||
public enum TextureSize : uint
|
||||
{
|
||||
Size256 = 256,
|
||||
Size512 = 512,
|
||||
Size1024 = 1024,
|
||||
Size2048 = 2048,
|
||||
Size4096 = 4096,
|
||||
Size8192 = 8192
|
||||
}
|
||||
|
||||
public enum TextureCompressionLevel : uint
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High
|
||||
}
|
||||
|
||||
public enum MipmapFilter : uint
|
||||
{
|
||||
Box,
|
||||
Triangle,
|
||||
Kaiser,
|
||||
MitchellNetravali
|
||||
}
|
||||
|
||||
public class TextureAsset : Asset
|
||||
{
|
||||
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
|
||||
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
|
||||
|
||||
private readonly Handle<Texture> _texture;
|
||||
|
||||
public override Guid TypeID => s_typeGuid;
|
||||
public Handle<Texture> Texture => _texture;
|
||||
|
||||
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings, Handle<Texture> texture)
|
||||
: base(id, dependencies, settings)
|
||||
{
|
||||
_texture = texture;
|
||||
}
|
||||
}
|
||||
|
||||
public class TextureAssetSettings : IAssetSettings
|
||||
{
|
||||
public struct BasicSettings()
|
||||
{
|
||||
public TextureType TextureType
|
||||
{
|
||||
get; set;
|
||||
} = TextureType.Default;
|
||||
|
||||
public TextureShape TextureShape
|
||||
{
|
||||
get; set;
|
||||
} = TextureShape.Texture2D;
|
||||
|
||||
public int Columns
|
||||
{
|
||||
get; set;
|
||||
} = 1;
|
||||
|
||||
public int Rows
|
||||
{
|
||||
get; set;
|
||||
} = 1;
|
||||
|
||||
public bool IsSRGB
|
||||
{
|
||||
get; set;
|
||||
} = true;
|
||||
}
|
||||
|
||||
public struct AdvancedSettings()
|
||||
{
|
||||
public bool StretchToPowerOfTwo
|
||||
{
|
||||
get; set;
|
||||
} = true;
|
||||
|
||||
public bool VirtualTexture
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public bool GenerateMipmaps
|
||||
{
|
||||
get; set;
|
||||
} = true;
|
||||
|
||||
public uint MipmapLevelCount
|
||||
{
|
||||
get; set;
|
||||
} = 0; // 0 means generate full mipmap levels.
|
||||
|
||||
public bool GammaCorrection
|
||||
{
|
||||
get; set;
|
||||
} = true;
|
||||
|
||||
public bool PremultiplyAlpha
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public MipmapFilter MipmapFilter
|
||||
{
|
||||
get; set;
|
||||
} = MipmapFilter.Kaiser;
|
||||
|
||||
public TextureCompressionLevel CompressionLevel
|
||||
{
|
||||
get; set;
|
||||
} = TextureCompressionLevel.Normal;
|
||||
|
||||
public bool UseBorderColor
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public Color128 BorderColor
|
||||
{
|
||||
get; set;
|
||||
} = new Color128(0, 0, 0, 0);
|
||||
|
||||
public bool ZeroAlphaBorder
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public bool CutoutAlpha
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public byte CutoutAlphaThreshold
|
||||
{
|
||||
get; set;
|
||||
} = 127;
|
||||
|
||||
public bool ScaleAlphaForMipCoverage
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public byte ScaleAlphaForMipCoverageThreshold
|
||||
{
|
||||
get; set;
|
||||
} = 127;
|
||||
|
||||
public bool MipmapStreaming
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
}
|
||||
|
||||
public struct SamplerSettings()
|
||||
{
|
||||
public TextureSize MaxSize
|
||||
{
|
||||
get; set;
|
||||
} = TextureSize.Size2048;
|
||||
|
||||
public TextureFilterMode FilterMode
|
||||
{
|
||||
get; set;
|
||||
} = TextureFilterMode.Anisotropic;
|
||||
|
||||
public TextureAddressMode WrapMode
|
||||
{
|
||||
get; set;
|
||||
} = TextureAddressMode.Repeat;
|
||||
}
|
||||
|
||||
public BasicSettings Basic
|
||||
{
|
||||
get; set;
|
||||
} = new BasicSettings();
|
||||
|
||||
public AdvancedSettings Advanced
|
||||
{
|
||||
get; set;
|
||||
} = new AdvancedSettings();
|
||||
|
||||
public SamplerSettings Sampler
|
||||
{
|
||||
get; set;
|
||||
} = new SamplerSettings();
|
||||
}
|
||||
|
||||
[CustomAssetHandler(ID = TextureAsset._TYPE_ID, SupportedExtensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
|
||||
internal class TextureAssetHandler : IImportableAssetHandler
|
||||
{
|
||||
private const int _CURRENT_VERSION = 1;
|
||||
|
||||
private static async ValueTask<Result<long>> WriteSettingsToStreamAsync(TextureAssetSettings settings, Stream stream, CancellationToken token = default)
|
||||
{
|
||||
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
|
||||
var tempArray = ArrayPool<byte>.Shared.Rent(size);
|
||||
|
||||
try
|
||||
{
|
||||
ref var address = ref MemoryMarshal.GetReference(tempArray);
|
||||
Unsafe.WriteUnaligned(ref address, settings.Basic);
|
||||
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()), settings.Advanced);
|
||||
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()), settings.Sampler);
|
||||
|
||||
await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
|
||||
|
||||
return Result.Success<long>(size);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(tempArray);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<Result<IAssetSettings>> ReadSettingsFromStreamAsync(Stream stream, CancellationToken token = default)
|
||||
{
|
||||
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
|
||||
var tempArray = ArrayPool<byte>.Shared.Rent(size);
|
||||
|
||||
try
|
||||
{
|
||||
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
|
||||
|
||||
// Use index-based reads after the await to avoid 'ref across await' errors.
|
||||
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref tempArray[0]);
|
||||
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>()]);
|
||||
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()]);
|
||||
|
||||
var settings = new TextureAssetSettings
|
||||
{
|
||||
Basic = basic,
|
||||
Advanced = advanced,
|
||||
Sampler = sampler
|
||||
};
|
||||
|
||||
return Result.Success<IAssetSettings>(settings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(tempArray);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
|
||||
{
|
||||
var info = ImageInfo.FromStream(sourceStream);
|
||||
if (info.BitsPerChannel <= 0)
|
||||
{
|
||||
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
|
||||
}
|
||||
|
||||
var isFloat = info.BitsPerChannel > 8;
|
||||
var width = info.Width;
|
||||
var height = info.Height;
|
||||
var colorComponents = info.ColorComponents;
|
||||
|
||||
byte[] pixelBytes;
|
||||
|
||||
if (isFloat)
|
||||
{
|
||||
using var image = ImageResultFloat.FromStream(sourceStream, colorComponents);
|
||||
var span = MemoryMarshal.AsBytes(image.AsSpan());
|
||||
pixelBytes = ArrayPool<byte>.Shared.Rent(span.Length);
|
||||
span.CopyTo(pixelBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var image = ImageResult.FromStream(sourceStream, colorComponents);
|
||||
var span = image.AsSpan();
|
||||
pixelBytes = ArrayPool<byte>.Shared.Rent(span.Length);
|
||||
span.CopyTo(pixelBytes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new TextureAssetSettings();
|
||||
await Task.Run(() =>
|
||||
TextureProcessor.CompressToCache(
|
||||
EditorApplication.CachesFolderPath,
|
||||
id,
|
||||
pixelBytes,
|
||||
width,
|
||||
height,
|
||||
isFloat,
|
||||
colorComponents,
|
||||
settings),
|
||||
token).ConfigureAwait(false);
|
||||
|
||||
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
|
||||
{
|
||||
HandlerVersion = _CURRENT_VERSION,
|
||||
SettingsOffset = AssetMetadata.SIZE,
|
||||
};
|
||||
|
||||
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
|
||||
var sizeResult = await WriteSettingsToStreamAsync(settings, targetStream, token).ConfigureAwait(false);
|
||||
if (sizeResult.IsFailure)
|
||||
{
|
||||
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
|
||||
}
|
||||
|
||||
// Content layout (all little-endian):
|
||||
// int32 width
|
||||
// int32 height
|
||||
// byte isFloat (0 = byte, 1 = float)
|
||||
// int32 colorComponents (cast of ColorComponents enum)
|
||||
// byte[] pixelBytes
|
||||
const int _CONTENT_HEADER_SIZE = 4 + 4 + 1 + 4; // 13 bytes
|
||||
|
||||
header.SettingsSize = sizeResult.Value;
|
||||
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
|
||||
header.ContentSize = _CONTENT_HEADER_SIZE + pixelBytes.Length;
|
||||
|
||||
// Write raw image content
|
||||
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
|
||||
|
||||
var contentHeader = ArrayPool<byte>.Shared.Rent(_CONTENT_HEADER_SIZE);
|
||||
try
|
||||
{
|
||||
BitConverter.TryWriteBytes(contentHeader.AsSpan(0, 4), width);
|
||||
BitConverter.TryWriteBytes(contentHeader.AsSpan(4, 4), height);
|
||||
contentHeader[8] = isFloat ? (byte)1 : (byte)0;
|
||||
BitConverter.TryWriteBytes(contentHeader.AsSpan(9, 4), (int)colorComponents);
|
||||
|
||||
await targetStream.WriteAsync(contentHeader.AsMemory(0, _CONTENT_HEADER_SIZE), token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(contentHeader);
|
||||
}
|
||||
|
||||
await targetStream.WriteAsync(pixelBytes, token).ConfigureAwait(false);
|
||||
await targetStream.FlushAsync(token).ConfigureAwait(false);
|
||||
|
||||
// Patch header now that all sizes are known
|
||||
targetStream.Seek(0, SeekOrigin.Begin);
|
||||
AssetMetadata.WriteToStream(targetStream, ref header);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(pixelBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
228
src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using Ghost.Nvtt;
|
||||
using Misaki.HighPerformance.Image;
|
||||
using Misaki.HighPerformance.LowLevel;
|
||||
using System.IO.Hashing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Ghost.Editor.Core.AssetHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the NVTT compression + mipmap pipeline for a single texture asset.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// 1. Accept raw decoded pixel bytes + settings.
|
||||
/// 2. Determine the cache file path (<c>CachesFolderPath/TextureCache/<guid>_<hash>.dds</c>).
|
||||
/// 3. If the cache is already valid (hash matches), skip compression.
|
||||
/// 4. Otherwise run the full NVTT pipeline and write the DDS to the cache file.
|
||||
///
|
||||
/// The caller owns opening/closing all streams; this class only takes spans and paths.
|
||||
/// </summary>
|
||||
internal static unsafe class TextureProcessor
|
||||
{
|
||||
private const string _TEXTURE_CACHE_SUBFOLDER = "TextureCache";
|
||||
|
||||
/// <summary>
|
||||
/// Compresses <paramref name="pixelData"/> according to <paramref name="settings"/>
|
||||
/// and writes the result to the texture cache.
|
||||
///
|
||||
/// Returns the absolute path of the cache file on success.
|
||||
/// The cache file is skipped if it already exists with a matching content hash.
|
||||
/// </summary>
|
||||
public static string CompressToCache(
|
||||
string cachesFolderPath,
|
||||
Guid assetId,
|
||||
ReadOnlySpan<byte> pixelData,
|
||||
int width,
|
||||
int height,
|
||||
bool isFloat,
|
||||
ColorComponents colorComponents,
|
||||
TextureAssetSettings settings)
|
||||
{
|
||||
var cacheDir = Path.Combine(cachesFolderPath, _TEXTURE_CACHE_SUBFOLDER);
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var settingsHash = ComputeSettingsHash(settings);
|
||||
var cacheFileName = $"{assetId:N}_{settingsHash:X16}.dds";
|
||||
var cachePath = Path.Combine(cacheDir, cacheFileName);
|
||||
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
foreach (var stale in Directory.EnumerateFiles(cacheDir, $"{assetId:N}_*.dds"))
|
||||
{
|
||||
File.Delete(stale);
|
||||
}
|
||||
|
||||
RunNvttPipeline(cachePath, pixelData, width, height, isFloat, colorComponents, settings);
|
||||
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
private static void RunNvttPipeline(
|
||||
string outputPath,
|
||||
ReadOnlySpan<byte> pixelData,
|
||||
int width,
|
||||
int height,
|
||||
bool isFloat,
|
||||
ColorComponents colorComponents,
|
||||
TextureAssetSettings settings)
|
||||
{
|
||||
using var pSurface = new DisposablePtr<NvttSurface>(NvttSurface.Create());
|
||||
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
|
||||
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
|
||||
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
|
||||
|
||||
var inputFormat = isFloat
|
||||
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
|
||||
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
|
||||
|
||||
fixed (void* pData = pixelData)
|
||||
{
|
||||
pSurface.Get()->SetImageData(inputFormat, width, height, 1, pData, NvttBoolean.NVTT_True, null);
|
||||
}
|
||||
|
||||
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
|
||||
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
|
||||
if (!isFloat)
|
||||
{
|
||||
pSurface.Get()->Swizzle(2, 1, 0, 3, null);
|
||||
}
|
||||
|
||||
var maxExtent = (int)settings.Sampler.MaxSize;
|
||||
if (settings.Advanced.StretchToPowerOfTwo)
|
||||
{
|
||||
pSurface.Get()->ResizeMakeSquare(maxExtent,
|
||||
NvttRoundMode.NVTT_RoundMode_ToNearestPowerOfTwo,
|
||||
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
||||
}
|
||||
else if (pSurface.Get()->Width() > maxExtent || pSurface.Get()->Height() > maxExtent)
|
||||
{
|
||||
pSurface.Get()->ResizeMax(maxExtent,
|
||||
NvttRoundMode.NVTT_RoundMode_None,
|
||||
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
||||
}
|
||||
|
||||
if (settings.Advanced.UseBorderColor)
|
||||
{
|
||||
var c = settings.Advanced.BorderColor;
|
||||
pSurface.Get()->SetBorder(c.r, c.g, c.b, c.a, null);
|
||||
}
|
||||
else if (settings.Advanced.ZeroAlphaBorder)
|
||||
{
|
||||
pSurface.Get()->SetBorder(0f, 0f, 0f, 0f, null);
|
||||
}
|
||||
|
||||
if (settings.Basic.IsSRGB && settings.Advanced.GammaCorrection)
|
||||
{
|
||||
pSurface.Get()->ToLinearFromSrgb(null);
|
||||
}
|
||||
|
||||
if (settings.Advanced.PremultiplyAlpha)
|
||||
{
|
||||
pSurface.Get()->PremultiplyAlpha(null);
|
||||
}
|
||||
|
||||
pCompOpts.Get()->SetFormat(SelectFormat(settings));
|
||||
pCompOpts.Get()->SetQuality(SelectQuality(settings.Advanced.CompressionLevel));
|
||||
|
||||
if (settings.Advanced.CutoutAlpha)
|
||||
{
|
||||
pCompOpts.Get()->SetQuantization(false, false, true,
|
||||
settings.Advanced.CutoutAlphaThreshold);
|
||||
}
|
||||
|
||||
pOutOpts.Get()->SetOutputHeader(true);
|
||||
pOutOpts.Get()->SetSrgbFlag(settings.Basic.IsSRGB);
|
||||
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
|
||||
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(outputPath));
|
||||
|
||||
var nvttFilter = SelectMipmapFilter(settings.Advanced.MipmapFilter);
|
||||
|
||||
int mipmapCount;
|
||||
if (!settings.Advanced.GenerateMipmaps)
|
||||
{
|
||||
mipmapCount = 1;
|
||||
}
|
||||
else if (settings.Advanced.MipmapLevelCount == 0)
|
||||
{
|
||||
mipmapCount = pSurface.Get()->CountMipmaps(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
mipmapCount = (int)settings.Advanced.MipmapLevelCount;
|
||||
}
|
||||
|
||||
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
|
||||
|
||||
pCtx.Get()->OutputHeader(pSurface.Get(), mipmapCount, pCompOpts.Get(), pOutOpts.Get());
|
||||
|
||||
using var pMip = new DisposablePtr<NvttSurface>(pSurface.Get()->Clone());
|
||||
|
||||
for (var level = 0; level < mipmapCount; level++)
|
||||
{
|
||||
// Scale alpha for coverage on each pMip (if requested)
|
||||
if (settings.Advanced.ScaleAlphaForMipCoverage && level > 0)
|
||||
{
|
||||
var refCoverage = pMip.Get()->AlphaTestCoverage(
|
||||
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3);
|
||||
pMip.Get()->ScaleAlphaToCoverage(refCoverage,
|
||||
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3, null);
|
||||
}
|
||||
|
||||
pCtx.Get()->Compress(pMip.Get(), 0, level, pCompOpts.Get(), pOutOpts.Get());
|
||||
|
||||
if (level + 1 < mipmapCount)
|
||||
{
|
||||
pMip.Get()->BuildNextMipmapDefaults(nvttFilter, 1, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static NvttFormat SelectFormat(TextureAssetSettings settings)
|
||||
=> settings.Basic.TextureType switch
|
||||
{
|
||||
TextureType.Normal => NvttFormat.NVTT_Format_BC5, // RG normal map
|
||||
TextureType.SingleChannel => NvttFormat.NVTT_Format_BC4, // single channel
|
||||
TextureType.Lightmap => NvttFormat.NVTT_Format_BC6U, // HDR lightmap (unsigned)
|
||||
_ => NvttFormat.NVTT_Format_BC7, // default color
|
||||
};
|
||||
|
||||
private static NvttQuality SelectQuality(TextureCompressionLevel level)
|
||||
=> level switch
|
||||
{
|
||||
TextureCompressionLevel.Low => NvttQuality.NVTT_Quality_Fastest,
|
||||
TextureCompressionLevel.High => NvttQuality.NVTT_Quality_Production,
|
||||
_ => NvttQuality.NVTT_Quality_Normal,
|
||||
};
|
||||
|
||||
private static NvttMipmapFilter SelectMipmapFilter(MipmapFilter filter)
|
||||
=> filter switch
|
||||
{
|
||||
MipmapFilter.Box => NvttMipmapFilter.NVTT_MipmapFilter_Box,
|
||||
MipmapFilter.Triangle => NvttMipmapFilter.NVTT_MipmapFilter_Triangle,
|
||||
MipmapFilter.MitchellNetravali => NvttMipmapFilter.NVTT_MipmapFilter_Mitchell,
|
||||
_ => NvttMipmapFilter.NVTT_MipmapFilter_Kaiser,
|
||||
};
|
||||
|
||||
private static ulong ComputeSettingsHash(TextureAssetSettings s)
|
||||
{
|
||||
var basicSize = Unsafe.SizeOf<TextureAssetSettings.BasicSettings>();
|
||||
var advancedSize = Unsafe.SizeOf<TextureAssetSettings.AdvancedSettings>();
|
||||
var samplerSize = Unsafe.SizeOf<TextureAssetSettings.SamplerSettings>();
|
||||
var total = basicSize + advancedSize + samplerSize;
|
||||
|
||||
Span<byte> buf = stackalloc byte[total];
|
||||
var basic = s.Basic;
|
||||
var advanced = s.Advanced;
|
||||
var sampler = s.Sampler;
|
||||
MemoryMarshal.Write(buf, in basic);
|
||||
MemoryMarshal.Write(buf.Slice(basicSize), in advanced);
|
||||
MemoryMarshal.Write(buf.Slice(basicSize + advancedSize), in sampler);
|
||||
|
||||
return XxHash64.HashToUInt64(buf);
|
||||
}
|
||||
}
|
||||
102
src/Editor/Ghost.Editor.Core/Attributes.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
namespace Ghost.Editor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The base class for all attributes that can be discovered via <see cref="Utilities.TypeCache"/>.
|
||||
/// </summary>
|
||||
public abstract class DiscoverableAttributeBase : Attribute;
|
||||
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
|
||||
{
|
||||
public string[] Extensions
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public AssetOpenHandlerAttribute(params string[] extensions)
|
||||
{
|
||||
Extensions = extensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : '.' + e.ToLowerInvariant()).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
||||
internal class AssetImporterAttribute : DiscoverableAttributeBase
|
||||
{
|
||||
public string[] SupportedExtensions
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public AssetImporterAttribute(params string[] supportedExtensions)
|
||||
{
|
||||
SupportedExtensions = supportedExtensions;
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public class CustomEditorAttribute : DiscoverableAttributeBase
|
||||
{
|
||||
internal Type TargetType
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public CustomEditorAttribute(Type targetType)
|
||||
{
|
||||
TargetType = targetType;
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)]
|
||||
public class EditorInjectionAttribute : DiscoverableAttributeBase
|
||||
{
|
||||
public enum ServiceLifetime
|
||||
{
|
||||
Singleton,
|
||||
Transient,
|
||||
Scoped
|
||||
}
|
||||
|
||||
public ServiceLifetime Lifetime
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public Type? ImplementationType
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public EditorInjectionAttribute(ServiceLifetime lifetime, Type? implementationType = null)
|
||||
{
|
||||
Lifetime = lifetime;
|
||||
ImplementationType = implementationType;
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class ContextMenuItemAttribute : DiscoverableAttributeBase
|
||||
{
|
||||
public string Tag
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public int Group
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public ContextMenuItemAttribute(string tag, string name, int group = 0)
|
||||
{
|
||||
Tag = tag;
|
||||
Name = name;
|
||||
Group = group;
|
||||
}
|
||||
}
|
||||
49
src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public enum AssetChangeType
|
||||
{
|
||||
None = 0,
|
||||
Created,
|
||||
Deleted,
|
||||
Modified,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
public sealed class AssetChangedEventArgs : EventArgs
|
||||
{
|
||||
public string AssetPath
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public string? OldAssetPath
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public AssetChangeType ChangeType
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
internal AssetChangedEventArgs(string assetPath, string? oldAssetPath, AssetChangeType changeType)
|
||||
{
|
||||
AssetPath = assetPath;
|
||||
OldAssetPath = oldAssetPath;
|
||||
ChangeType = changeType;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAssetRegistry : IDisposable
|
||||
{
|
||||
string? GetAssetPath(Guid id);
|
||||
Guid GetAssetGuid(string assetPath);
|
||||
|
||||
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
|
||||
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
|
||||
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default);
|
||||
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default);
|
||||
}
|
||||
13
src/Editor/Ghost.Editor.Core/Contracts/IInspectable.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public interface IInspectable
|
||||
{
|
||||
IconSource? CreateIcon();
|
||||
|
||||
UIElement? CreateHeader();
|
||||
|
||||
UIElement? CreateInspector();
|
||||
}
|
||||
32
src/Editor/Ghost.Editor.Core/Contracts/IInspectorService.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public class InspectorSelectionChangedEventArgs : EventArgs
|
||||
{
|
||||
public object? Source
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public IInspectable? Selected
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public InspectorSelectionChangedEventArgs(object? source, IInspectable? selected)
|
||||
{
|
||||
Source = source;
|
||||
Selected = selected;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IInspectorService
|
||||
{
|
||||
IInspectable? Selected
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
event EventHandler<InspectorSelectionChangedEventArgs> OnSelectionChanged;
|
||||
|
||||
void SetSelected(IInspectable? inspectable, object? source);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public interface INavigationAware
|
||||
{
|
||||
void OnNavigatedTo(object? parameter);
|
||||
void OnNavigatedFrom();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using CommunityToolkit.WinUI.Behaviors;
|
||||
using Ghost.Editor.Core.Notifications;
|
||||
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
void ShowNotification(string? message, MessageType type, int duration = 5, string? title = null);
|
||||
void ShowNotification(Notification notification);
|
||||
}
|
||||
12
src/Editor/Ghost.Editor.Core/Contracts/IPreviewService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public enum IconSize
|
||||
{
|
||||
Small,
|
||||
Large
|
||||
}
|
||||
|
||||
public interface IPreviewService
|
||||
{
|
||||
string GetIconPath(string path, bool isDirectory, IconSize size);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public interface IProgressService
|
||||
{
|
||||
void ShowProgress(string message, double progress = 0.0);
|
||||
void ShowIndeterminateProgress(string message);
|
||||
void SetProgress(double progress);
|
||||
void HideProgress();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
src/Editor/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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/ComponentView.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
157
src/Editor/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 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);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,42 @@
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.Controls;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
214
src/Editor/Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using Ghost.Editor.Core.Utilities;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System.Reflection;
|
||||
|
||||
|
||||
namespace Ghost.Editor.Core.Controls;
|
||||
|
||||
public sealed partial class ContextFlyout : MenuFlyout
|
||||
{
|
||||
private class MenuNode
|
||||
{
|
||||
public required string Name
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
public MethodInfo? Method
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public List<MenuNode> Children
|
||||
{
|
||||
get;
|
||||
} = new();
|
||||
|
||||
public int RawGroup
|
||||
{
|
||||
get; set;
|
||||
} = int.MaxValue;
|
||||
|
||||
// The calculated group used for sorting (min of children for folders)
|
||||
public int EffectiveGroup
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isPopulated;
|
||||
|
||||
public string Tag
|
||||
{
|
||||
get; set;
|
||||
} = string.Empty;
|
||||
|
||||
public ContextFlyout()
|
||||
{
|
||||
Opening += ContextFlyout_Opening;
|
||||
}
|
||||
|
||||
// Recursively sorts nodes and calculates folder groups
|
||||
private static void PrepareNodes(List<MenuNode> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.Children.Count > 0)
|
||||
{
|
||||
// Go deep first
|
||||
PrepareNodes(node.Children);
|
||||
|
||||
// A folder's group is determined by its highest priority child (lowest group number).
|
||||
// This ensures a "File" folder (containing Group 0 items) sits at the top
|
||||
// alongside other Group 0 leaf items.
|
||||
node.EffectiveGroup = node.Children.Min(c => c.EffectiveGroup);
|
||||
}
|
||||
else
|
||||
{
|
||||
node.EffectiveGroup = node.RawGroup;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by Group, then by Name
|
||||
nodes.Sort((a, b) =>
|
||||
{
|
||||
var groupCompare = a.EffectiveGroup.CompareTo(b.EffectiveGroup);
|
||||
return groupCompare != 0
|
||||
? groupCompare
|
||||
: string.CompareOrdinal(a.Name, b.Name);
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively builds the UI elements
|
||||
private static void BuildNodes(List<MenuNode> nodes, IList<MenuFlyoutItemBase> targetCollection)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentGroup = nodes[0].EffectiveGroup;
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.EffectiveGroup != currentGroup)
|
||||
{
|
||||
targetCollection.Add(new MenuFlyoutSeparator());
|
||||
currentGroup = node.EffectiveGroup;
|
||||
}
|
||||
|
||||
if (node.Children.Count > 0)
|
||||
{
|
||||
var subItem = new MenuFlyoutSubItem
|
||||
{
|
||||
Text = node.Name
|
||||
};
|
||||
|
||||
// Recursively render children into the subitem
|
||||
BuildNodes(node.Children, subItem.Items);
|
||||
targetCollection.Add(subItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
var menuItem = new MenuFlyoutItem
|
||||
{
|
||||
Text = node.Name
|
||||
};
|
||||
|
||||
var methodToInvoke = node.Method;
|
||||
menuItem.Click += (_, _) =>
|
||||
{
|
||||
methodToInvoke?.Invoke(null, null);
|
||||
};
|
||||
|
||||
targetCollection.Add(menuItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateContextMenu()
|
||||
{
|
||||
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
||||
if (methods == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Build the Tree
|
||||
var rootNodes = new List<MenuNode>();
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var attr = method.GetCustomAttribute<ContextMenuItemAttribute>();
|
||||
if (attr == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter tags
|
||||
if (!string.Equals(attr.Tag, Tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameSpan = attr.Name.AsSpan();
|
||||
var pathParts = nameSpan.Split('/');
|
||||
|
||||
var currentLevel = rootNodes;
|
||||
MenuNode? currentNode = null;
|
||||
|
||||
foreach (var range in pathParts)
|
||||
{
|
||||
var part = nameSpan[range.Start..range.End];
|
||||
|
||||
MenuNode? foundNode = null;
|
||||
|
||||
// Try to find existing node in the current level
|
||||
foreach (var node in currentLevel)
|
||||
{
|
||||
if (part.Equals(node.Name.AsSpan(), StringComparison.Ordinal))
|
||||
{
|
||||
foundNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundNode == null)
|
||||
{
|
||||
foundNode = new MenuNode { Name = part.ToString() };
|
||||
currentLevel.Add(foundNode);
|
||||
}
|
||||
|
||||
currentNode = foundNode;
|
||||
|
||||
// If this is the last part, it's the executable item
|
||||
if (range.End.Value == nameSpan.Length)
|
||||
{
|
||||
currentNode.Method = method;
|
||||
currentNode.RawGroup = attr.Group;
|
||||
}
|
||||
|
||||
currentLevel = currentNode.Children;
|
||||
}
|
||||
}
|
||||
|
||||
PrepareNodes(rootNodes);
|
||||
BuildNodes(rootNodes, Items);
|
||||
}
|
||||
|
||||
private async void ContextFlyout_Opening(object? sender, object e)
|
||||
{
|
||||
if (_isPopulated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PopulateContextMenu();
|
||||
_isPopulated = true;
|
||||
}
|
||||
}
|
||||
70
src/Editor/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;
|
||||
}
|
||||
}
|
||||
70
src/Editor/Ghost.Editor.Core/EditorApplication.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace Ghost.Editor.Core;
|
||||
|
||||
public static class EditorApplication
|
||||
{
|
||||
public const string ASSETS_FOLDER_NAME = "Assets";
|
||||
public const string SOURCES_FOLDER_NAME = "Sources";
|
||||
public const string PACKAGES_FOLDER_NAME = "Packages";
|
||||
public const string CACHES_FOLDER_NAME = "Caches";
|
||||
public const string CONFIG_FOLDER_NAME = "Config";
|
||||
|
||||
private static IServiceProvider? s_serviceProvider;
|
||||
private static string s_currentProjectPath = string.Empty;
|
||||
private static string s_currentProjectName = string.Empty;
|
||||
|
||||
private static DispatcherQueue? s_dispatcherQueue;
|
||||
|
||||
internal static Application CurrentApplication => Application.Current;
|
||||
|
||||
public static string ProjectPath => s_currentProjectPath;
|
||||
public static string ProjectName => s_currentProjectName;
|
||||
|
||||
public static string AssetsFolderPath => Path.Combine(ProjectPath, ASSETS_FOLDER_NAME);
|
||||
public static string SourcesFolderPath => Path.Combine(ProjectPath, SOURCES_FOLDER_NAME);
|
||||
public static string PackagesFolderPath => Path.Combine(ProjectPath, PACKAGES_FOLDER_NAME);
|
||||
public static string CachesFolderPath => Path.Combine(ProjectPath, CACHES_FOLDER_NAME);
|
||||
public static string ConfigFolderPath => Path.Combine(ProjectPath, CONFIG_FOLDER_NAME);
|
||||
|
||||
public static DispatcherQueue DispatcherQueue
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_dispatcherQueue is null)
|
||||
{
|
||||
throw new InvalidOperationException("DispatcherQueue is not initialized.");
|
||||
}
|
||||
|
||||
return s_dispatcherQueue;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
||||
{
|
||||
s_serviceProvider = serviceProvider;
|
||||
s_currentProjectPath = projectPath;
|
||||
s_currentProjectName = projectName;
|
||||
}
|
||||
|
||||
internal static void SetDispatcherQueue(DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
s_dispatcherQueue = dispatcherQueue;
|
||||
}
|
||||
|
||||
public static T GetService<T>()
|
||||
where T : class
|
||||
{
|
||||
if (s_serviceProvider?.GetService(typeof(T)) is not T service)
|
||||
{
|
||||
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices.");
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
internal static void Shutdown()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
40
src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj
Normal file
@@ -0,0 +1,40 @@
|
||||
<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="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
|
||||
<ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.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\Internal\ComponentView.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
40
src/Editor/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
src/Editor/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Ghost.Editor.Core.Notifications;
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
Informational,
|
||||
Success,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
18
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/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
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
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
src/Editor/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TestProject.AssetDB;
|
||||
|
||||
internal partial class AssetRegistry
|
||||
{
|
||||
// TODO: Sqlite backend implementation
|
||||
}
|
||||
510
src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs
Normal file
@@ -0,0 +1,510 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace TestProject.AssetDB;
|
||||
|
||||
internal class PathComparer : IEqualityComparer<string>
|
||||
{
|
||||
private static string ToCanonicalPath(string? path)
|
||||
{
|
||||
return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty;
|
||||
}
|
||||
|
||||
public bool Equals(string? x, string? y)
|
||||
{
|
||||
return string.Equals(
|
||||
ToCanonicalPath(x),
|
||||
ToCanonicalPath(y),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(string str)
|
||||
{
|
||||
return ToCanonicalPath(str).GetHashCode(StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Path based locking for multi-threaded access?
|
||||
// Is it actually necessary since this is mostly used in editor environment where single-threaded access is common (99.999%)?
|
||||
internal partial class AssetRegistry : IAssetRegistry
|
||||
{
|
||||
public const string ASSET_EXTENSION = ".gasset";
|
||||
public const string TEMP_EXTENSION = ".gtemp";
|
||||
|
||||
private readonly string _rootDirectory;
|
||||
private readonly FileSystemWatcher _watcher;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Guid> _pathToGuid;
|
||||
private readonly ConcurrentDictionary<Guid, string> _guidToPath;
|
||||
|
||||
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander;
|
||||
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
|
||||
|
||||
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph;
|
||||
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache;
|
||||
|
||||
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
|
||||
|
||||
private readonly SemaphoreSlim _cacheSlim;
|
||||
private readonly Lock _pathLock;
|
||||
|
||||
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
|
||||
|
||||
public AssetRegistry(string rootDirectory)
|
||||
{
|
||||
if (!Directory.Exists(rootDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException("The specified root directory does not exist.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(rootDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("The specified root directory must be an absolute path.");
|
||||
}
|
||||
|
||||
_rootDirectory = rootDirectory;
|
||||
_watcher = new FileSystemWatcher(rootDirectory)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
|
||||
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer());
|
||||
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512);
|
||||
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16);
|
||||
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512);
|
||||
|
||||
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
|
||||
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
|
||||
|
||||
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_cacheSlim = new SemaphoreSlim(1, 1);
|
||||
_pathLock = new Lock();
|
||||
|
||||
LoadExistingAssets();
|
||||
|
||||
_watcher.Created += OnFileSystemOp;
|
||||
_watcher.Deleted += OnFileSystemOp;
|
||||
_watcher.Changed += OnFileSystemOp;
|
||||
_watcher.Renamed += OnFileSystemRenameOp;
|
||||
}
|
||||
|
||||
// TODO: DB Cache
|
||||
private unsafe void LoadExistingAssets()
|
||||
{
|
||||
Span<byte> guidBuffer = stackalloc byte[sizeof(Guid)];
|
||||
foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(_rootDirectory, filePath);
|
||||
|
||||
try
|
||||
{
|
||||
var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
try
|
||||
{
|
||||
fs.Seek(4, SeekOrigin.Begin); // Skip format version
|
||||
fs.ReadExactly(guidBuffer);
|
||||
|
||||
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(guidBuffer));
|
||||
UpdatePathMapping(relativePath, guid);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fs.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception
|
||||
#if DEBUG
|
||||
ex
|
||||
#endif
|
||||
)
|
||||
{
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debugger.BreakForUserUnhandledException(ex);
|
||||
#endif
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateGraph(Guid assetId, IEnumerable<Guid> newDependencies)
|
||||
{
|
||||
// 1. Clean up old references (reverse)
|
||||
if (_dependencyCache.TryGetValue(assetId, out var oldDeps))
|
||||
{
|
||||
foreach (var dep in oldDeps)
|
||||
{
|
||||
if (_referencerGraph.TryGetValue(dep, out var refs))
|
||||
{
|
||||
refs.Remove(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Set new forward dependencies
|
||||
var newDepSet = new HashSet<Guid>(newDependencies);
|
||||
_dependencyCache[assetId] = newDepSet;
|
||||
|
||||
// 3. Add new references (reverse)
|
||||
foreach (var dep in newDepSet)
|
||||
{
|
||||
ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists);
|
||||
if (!exists || referencers is null)
|
||||
{
|
||||
referencers = new HashSet<Guid>();
|
||||
}
|
||||
|
||||
referencers.Add(assetId);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePathMapping(string relativePath, Guid guid)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
_pathToGuid[relativePath] = guid;
|
||||
_guidToPath[guid] = relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
private bool RemovePathMappingByPath(string relativePath)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
if (_pathToGuid.Remove(relativePath, out var guid))
|
||||
{
|
||||
return _guidToPath.TryRemove(guid, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
if (_ignoreFileChanges.TryRemove(e.FullPath, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||
var ext = Path.GetExtension(relativePath);
|
||||
|
||||
var changeType = AssetChangeType.None;
|
||||
var fireEvent = false;
|
||||
var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal);
|
||||
var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal);
|
||||
|
||||
switch (e.ChangeType)
|
||||
{
|
||||
case WatcherChangeTypes.Created:
|
||||
changeType = AssetChangeType.Created;
|
||||
if (!isAsset && !isTemp)
|
||||
{
|
||||
var handler = GetAssetHandlerForExtension(ext);
|
||||
if (handler is IImportableAssetHandler importableHandler)
|
||||
{
|
||||
var assetPath = string.Create(e.FullPath.Length - ext.Length + ASSET_EXTENSION.Length, e.FullPath, (destSpan, source) =>
|
||||
{
|
||||
source.AsSpan(0, source.Length - ext.Length).CopyTo(destSpan);
|
||||
ASSET_EXTENSION.AsSpan().CopyTo(destSpan.Slice(source.Length - ext.Length));
|
||||
});
|
||||
|
||||
var newGuid = Guid.NewGuid();
|
||||
await using var sourceStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read);
|
||||
await using var targetStream = new FileStream(assetPath, FileMode.Create, FileAccess.Write);
|
||||
await importableHandler.ImportAsync(sourceStream, targetStream, newGuid);
|
||||
|
||||
File.Delete(assetPath);
|
||||
UpdatePathMapping(relativePath, newGuid);
|
||||
|
||||
fireEvent = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case WatcherChangeTypes.Deleted:
|
||||
changeType = AssetChangeType.Deleted;
|
||||
if (isAsset)
|
||||
{
|
||||
fireEvent = RemovePathMappingByPath(relativePath);
|
||||
}
|
||||
break;
|
||||
|
||||
case WatcherChangeTypes.Changed:
|
||||
changeType = AssetChangeType.Modified;
|
||||
fireEvent = isAsset;
|
||||
break;
|
||||
case WatcherChangeTypes.All:
|
||||
// Can this even happen?
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (fireEvent)
|
||||
{
|
||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
|
||||
{
|
||||
var ext = Path.GetExtension(e.FullPath);
|
||||
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
|
||||
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||
|
||||
if (_pathToGuid.Remove(oldRelativePath, out var guid))
|
||||
{
|
||||
UpdatePathMapping(newRelativePath, guid);
|
||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetAssetPath(Guid id)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
if (_guidToPath.TryGetValue(id, out var path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Guid GetAssetGuid(string path)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
if (_pathToGuid.TryGetValue(path, out var guid))
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
private IAssetHandler GetAssetHandler(Type type)
|
||||
{
|
||||
var typeHandle = type.TypeHandle.Value;
|
||||
if (_cachedHander.TryGetValue(typeHandle, out var handler))
|
||||
{
|
||||
return handler;
|
||||
}
|
||||
|
||||
var obj = Activator.CreateInstance(type);
|
||||
if (obj is not IAssetHandler newHandler)
|
||||
{
|
||||
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
|
||||
}
|
||||
|
||||
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||
if (attr is null || attr.AllowCaching)
|
||||
{
|
||||
_cachedHander[typeHandle] = newHandler;
|
||||
}
|
||||
|
||||
return newHandler;
|
||||
}
|
||||
|
||||
private IAssetHandler? GetAssetHandlerForExtension(string extension)
|
||||
{
|
||||
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => assembly.GetTypes())
|
||||
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
||||
{
|
||||
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetAssetHandler(handlerType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId)
|
||||
{
|
||||
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => assembly.GetTypes())
|
||||
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
||||
{
|
||||
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||
if (attr is not null && new Guid(attr.ID) == typeId)
|
||||
{
|
||||
return GetAssetHandler(handlerType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
|
||||
{
|
||||
if (!File.Exists(sourceFilePath))
|
||||
{
|
||||
return Result.Failure("Source file not found.");
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(sourceFilePath);
|
||||
var handler = GetAssetHandlerForExtension(ext);
|
||||
if (handler is not IImportableAssetHandler importableHandler)
|
||||
{
|
||||
return Result.Failure("No importable asset handler found for the given file extension.");
|
||||
}
|
||||
|
||||
var guid = Guid.NewGuid();
|
||||
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory);
|
||||
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
|
||||
{
|
||||
return Result.Failure("Asset import failed.");
|
||||
}
|
||||
|
||||
UpdatePathMapping(targetAssetPath, guid);
|
||||
return guid;
|
||||
}
|
||||
|
||||
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
|
||||
{
|
||||
var assetPath = GetAssetPath(assetId);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
return Result.Failure("Asset not found in DB");
|
||||
}
|
||||
|
||||
var fullAssetPath = Path.GetFullPath(assetPath, _rootDirectory);
|
||||
|
||||
// 2. Identify the Handler
|
||||
// (You might want to store SourcePath in metadata later so you don't need to pass it here)
|
||||
var ext = Path.GetExtension(sourceFilePath);
|
||||
var handler = GetAssetHandlerForExtension(ext);
|
||||
if (handler is not IImportableAssetHandler importableHandler)
|
||||
{
|
||||
return Result.Failure("No importable asset handler found for the given file extension.");
|
||||
}
|
||||
|
||||
_ignoreFileChanges[fullAssetPath] = true;
|
||||
|
||||
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read);
|
||||
await using var targetStream = new FileStream(fullAssetPath, FileMode.Create, FileAccess.Write);
|
||||
|
||||
await importableHandler.ImportAsync(sourceStream, targetStream, assetId, token);
|
||||
if (_loadedAssets.TryGetValue(assetId, out var weakRef) && weakRef.TryGetTarget(out var liveAsset))
|
||||
{
|
||||
await liveAsset.RefreshAsync(this, token);
|
||||
}
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public async ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default)
|
||||
{
|
||||
// TODO: weakRef based locking instead of global lock for better concurrency.
|
||||
// We should use GetOrAdd here.
|
||||
if (_loadedAssets.TryGetValue(id, out var weakRef)
|
||||
&& weakRef.TryGetTarget(out var existingAsset))
|
||||
{
|
||||
return existingAsset;
|
||||
}
|
||||
|
||||
await _cacheSlim.WaitAsync(token);
|
||||
|
||||
// Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting.
|
||||
if (_loadedAssets.TryGetValue(id, out weakRef)
|
||||
&& weakRef.TryGetTarget(out existingAsset))
|
||||
{
|
||||
return existingAsset;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = GetAssetPath(id);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var assetPath = Path.GetFullPath(path, _rootDirectory);
|
||||
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
|
||||
int sizeofGuid;
|
||||
unsafe
|
||||
{
|
||||
sizeofGuid = sizeof(Guid);
|
||||
}
|
||||
|
||||
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
|
||||
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
|
||||
fs.ReadExactly(typeIdBuffer);
|
||||
|
||||
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
|
||||
var handler = GetAssetHandlerForTypeId(guid);
|
||||
if (handler == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var assetResult = await handler.LoadAsync(fs, this, token);
|
||||
if (assetResult.IsFailure)
|
||||
{
|
||||
return assetResult;
|
||||
}
|
||||
|
||||
var asset = assetResult.Value;
|
||||
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(asset), (key, oldRef) =>
|
||||
{
|
||||
// If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected.
|
||||
// If the assetResult haven't been loaded before, we are in the addValue path, not here.
|
||||
// If the previous reference has been collected, we can just replace it with the new one.
|
||||
// Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time.
|
||||
oldRef.SetTarget(asset);
|
||||
return oldRef;
|
||||
});
|
||||
|
||||
return assetResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cacheSlim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default)
|
||||
{
|
||||
var path = GetAssetPath(asset.ID);
|
||||
if (path == null)
|
||||
{
|
||||
return Result.Failure("Asset not found.");
|
||||
}
|
||||
|
||||
var handler = GetAssetHandlerForTypeId(asset.TypeID);
|
||||
if (handler == null)
|
||||
{
|
||||
return Result.Failure("No asset handler found for the given asset type.");
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path, _rootDirectory);
|
||||
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
||||
return await handler.SaveAsync(asset, fs, this, token);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cacheSlim.Dispose();
|
||||
_watcher.Dispose();
|
||||
}
|
||||
}
|
||||
21
src/Editor/Ghost.Editor.Core/Services/InspectorService.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
|
||||
namespace Ghost.Editor.Core.Services;
|
||||
|
||||
public class InspectorService : IInspectorService
|
||||
{
|
||||
private IInspectable? _selected;
|
||||
|
||||
public IInspectable? Selected => _selected;
|
||||
|
||||
public event EventHandler<InspectorSelectionChangedEventArgs>? OnSelectionChanged;
|
||||
|
||||
public void SetSelected(IInspectable? inspectable, object? source)
|
||||
{
|
||||
if (_selected != inspectable)
|
||||
{
|
||||
_selected = inspectable;
|
||||
OnSelectionChanged?.Invoke(this, new InspectorSelectionChangedEventArgs(source, inspectable));
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/Editor/Ghost.Editor.Core/Services/NotificationService.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using CommunityToolkit.WinUI.Behaviors;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Editor.Core.Notifications;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.Core.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
35
src/Editor/Ghost.Editor.Core/Services/PreviewService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
|
||||
namespace Ghost.Editor.Core.Services;
|
||||
|
||||
internal class PreviewService : IPreviewService
|
||||
{
|
||||
public string GetIconPath(string path, bool isDirectory, IconSize size)
|
||||
{
|
||||
string iconPath;
|
||||
if (isDirectory)
|
||||
{
|
||||
iconPath = "ms-appx:///Assets/EditorIcons/folder-{0}.png";
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: Generate preview icons dynamically for known file types like images, meshes, materials, etc.
|
||||
var ext = Path.GetExtension(path);
|
||||
iconPath = ext switch
|
||||
{
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".tiff" or ".svg" => "ms-appx:///Assets/EditorIcons/image-{0}.png",
|
||||
_ => "ms-appx:///Assets/EditorIcons/document-{0}.png",
|
||||
};
|
||||
}
|
||||
|
||||
var sizeIndex = size switch
|
||||
{
|
||||
IconSize.Small => "0",
|
||||
IconSize.Large => "1",
|
||||
_ => "0"
|
||||
};
|
||||
|
||||
iconPath = string.Format(iconPath, sizeIndex);
|
||||
return iconPath;
|
||||
}
|
||||
}
|
||||
75
src/Editor/Ghost.Editor.Core/Services/ProgressService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using CommunityToolkit.WinUI;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Ghost.Editor.Core.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Ghost.Editor.Core.Utilities;
|
||||
|
||||
public static class AssetHandlerUtility
|
||||
{
|
||||
public static async ValueTask SerializeAssetAsync<TSetting>(Stream stream, Guid id, Guid typeID, int handlerVersion, ReadOnlyMemory<Guid> dependencies, IAssetSettings? settings, ReadOnlyMemory<byte> contents, CancellationToken token = default)
|
||||
where TSetting : IAssetSettings
|
||||
{
|
||||
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
|
||||
{
|
||||
HandlerVersion = handlerVersion,
|
||||
DependenciesOffset = AssetMetadata.SIZE,
|
||||
DependencyCount = dependencies.Length,
|
||||
};
|
||||
|
||||
var tempArray = ArrayPool<byte>.Shared.Rent(4096);
|
||||
|
||||
if (dependencies.Length > 0)
|
||||
{
|
||||
stream.Seek(header.DependenciesOffset, SeekOrigin.Begin);
|
||||
for (var i = 0; i < dependencies.Length; i++)
|
||||
{
|
||||
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(tempArray.AsSpan(0, 16)), dependencies.Span[i]);
|
||||
await stream.WriteAsync(tempArray.AsMemory(0, 16), token);
|
||||
}
|
||||
}
|
||||
|
||||
header.SettingsOffset = stream.Position;
|
||||
|
||||
// TODO: We can use source generator to generate optimized serializer for settings.
|
||||
// For now, we just use reflection for simplicity.
|
||||
|
||||
if (settings is not null)
|
||||
{
|
||||
var properties = typeof(TSetting).GetProperties();
|
||||
|
||||
if (properties.Length > 0)
|
||||
{
|
||||
using var bw = new BinaryWriter(stream);
|
||||
|
||||
for (var i = 0; (i < properties.Length); i++)
|
||||
{
|
||||
var property = properties[i];
|
||||
var value = property.GetValue(settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Editor/Ghost.Editor.Core/Utilities/FileExtensions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Ghost.Editor.Core.Utilities;
|
||||
|
||||
internal static class FileExtensions
|
||||
{
|
||||
public const string META_FILE_EXTENSION = ".gmeta";
|
||||
|
||||
public const string PROJECT_FILE_EXTENSION = ".gproj";
|
||||
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";
|
||||
}
|
||||
93
src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Ghost.Core.Attributes;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Ghost.Editor.Core.Utilities;
|
||||
|
||||
public static class TypeCache
|
||||
{
|
||||
private static TypeInfo[] s_types;
|
||||
private static Dictionary<nint, List<MethodInfo>> s_attributeMethodCache;
|
||||
|
||||
static TypeCache()
|
||||
{
|
||||
s_types = LoadTypes();
|
||||
s_attributeMethodCache = FindMethodWithAttribute();
|
||||
}
|
||||
|
||||
private static TypeInfo[] LoadTypes()
|
||||
{
|
||||
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!);
|
||||
}
|
||||
}
|
||||
|
||||
return loadableTypes.Select(t => t.GetTypeInfo()).ToArray();
|
||||
}
|
||||
|
||||
private static Dictionary<nint, List<MethodInfo>> FindMethodWithAttribute()
|
||||
{
|
||||
var dict = new Dictionary<nint, List<MethodInfo>>();
|
||||
foreach (var type in s_types)
|
||||
{
|
||||
foreach (var method in type.DeclaredMethods)
|
||||
{
|
||||
var attrs = method.GetCustomAttributes<DiscoverableAttributeBase>(false);
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
var key = attr.GetType().TypeHandle.Value;
|
||||
ref var methodList = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var exist);
|
||||
if (!exist)
|
||||
{
|
||||
methodList = new List<MethodInfo>();
|
||||
}
|
||||
|
||||
methodList!.Add(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
internal static void Init()
|
||||
{
|
||||
// Intentionally left blank.
|
||||
// This method exists to force the static constructor to run.
|
||||
}
|
||||
|
||||
internal static void Reload()
|
||||
{
|
||||
s_types = LoadTypes();
|
||||
s_attributeMethodCache = FindMethodWithAttribute();
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<TypeInfo> GetTypes()
|
||||
{
|
||||
return s_types;
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<MethodInfo>? GetMethodsWithAttribute<T>()
|
||||
where T : DiscoverableAttributeBase
|
||||
{
|
||||
var key = typeof(T).TypeHandle.Value;
|
||||
if (s_attributeMethodCache.TryGetValue(key, out var methods))
|
||||
{
|
||||
return methods;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
67
src/Editor/Ghost.Editor/ActivationHandler.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Ghost.Editor.Core.Utilities;
|
||||
using Ghost.Editor.Models;
|
||||
using Ghost.Engine;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Ghost.Editor;
|
||||
|
||||
internal static class ActivationHandler
|
||||
{
|
||||
public static LaunchArguments ParseArguments(ReadOnlySpan<char> args)
|
||||
{
|
||||
var arguments = new LaunchArguments();
|
||||
var properties = typeof(LaunchArguments).GetProperties();
|
||||
var split = args.Split(' ');
|
||||
|
||||
while (split.MoveNext())
|
||||
{
|
||||
var range = split.Current;
|
||||
var arg = args[range.Start..range.End];
|
||||
if (arg.Length > 2)
|
||||
{
|
||||
if (arg[0] == '-' && arg[1] == '-')
|
||||
{
|
||||
var argName = arg[2..];
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var propName = property.Name;
|
||||
var attr = property.GetCustomAttributes<ArgumentNameAttribute>(false).FirstOrDefault();
|
||||
if (attr != null)
|
||||
{
|
||||
propName = attr.Name;
|
||||
}
|
||||
|
||||
if (argName.Equals(propName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (split.MoveNext())
|
||||
{
|
||||
var valueRange = split.Current;
|
||||
var value = args[valueRange.Start..valueRange.End];
|
||||
var convertedValue = Convert.ChangeType(value.ToString(), property.PropertyType);
|
||||
|
||||
property.SetValue(arguments, convertedValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
public static async Task HandleAsync(LaunchArguments args)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
TypeCache.Init();
|
||||
((EngineCore)App.GetService<IEngineContext>()).Init();
|
||||
});
|
||||
|
||||
// await ((Core.AssetHandle.AssetService)App.GetService<IAssetService>()).Init();
|
||||
|
||||
// TODO: Init other subsystems here.
|
||||
// await Task.Delay(10000); // Wait 10 seconds to simulate work.
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Application
|
||||
x:Class="Ghost.Editor.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:core="using:Ghost.Editor.Core.Controls"
|
||||
xmlns:local="using:Ghost.Editor">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
<!-- Other merged dictionaries here -->
|
||||
<ResourceDictionary Source="/Themes/Generic.xaml" />
|
||||
<core:ControlsDictionary />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Other app resources here -->
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
162
src/Editor/Ghost.Editor/App.xaml.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Editor.Core.Services;
|
||||
using Ghost.Editor.View.Pages.EngineEditor;
|
||||
using Ghost.Editor.View.Windows;
|
||||
using Ghost.Editor.ViewModels.Controls;
|
||||
using Ghost.Editor.ViewModels.Pages.EngineEditor;
|
||||
using Ghost.Editor.ViewModels.Windows;
|
||||
using Ghost.Engine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using System.Diagnostics;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Ghost.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private Window? _window;
|
||||
|
||||
internal static Window? Window
|
||||
{
|
||||
get => (Current as App)!._window;
|
||||
set
|
||||
{
|
||||
if (Current is App app)
|
||||
{
|
||||
// HACK: As far as I can tell, there is no proper application shutdown event in WinUI 3.
|
||||
app._window?.Closed -= app.OnClosed;
|
||||
app._window = value;
|
||||
app._window?.Closed += app.OnClosed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal IHost Host
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
internal App()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
Host = Microsoft.Extensions.Hosting.Host.
|
||||
CreateDefaultBuilder().
|
||||
UseContentRoot(AppContext.BaseDirectory).
|
||||
ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddSingleton<IEngineContext, EngineCore>();
|
||||
|
||||
services.AddSingleton<INotificationService, NotificationService>();
|
||||
services.AddSingleton<IProgressService, ProgressService>();
|
||||
services.AddSingleton<IInspectorService, InspectorService>();
|
||||
services.AddSingleton<IPreviewService, PreviewService>();
|
||||
// services.AddSingleton<IAssetService, AssetService>();
|
||||
|
||||
services.AddSingleton<EngineEditorViewModel>();
|
||||
|
||||
services.AddTransient<ProjectBrowserViewModel>();
|
||||
|
||||
#region Should be deleted
|
||||
services.AddTransient<ScenePage>();
|
||||
|
||||
services.AddTransient<HierarchyPage>();
|
||||
services.AddTransient<HierarchyViewModel>();
|
||||
|
||||
services.AddTransient<ProjectPage>();
|
||||
services.AddTransient<ProjectViewModel>();
|
||||
|
||||
services.AddTransient<ConsolePage>();
|
||||
services.AddTransient<ConsoleViewModel>();
|
||||
|
||||
services.AddTransient<InspectorPage>();
|
||||
services.AddTransient<InspectorViewModel>();
|
||||
#endregion
|
||||
})
|
||||
.Build();
|
||||
|
||||
UnhandledException += App_UnhandledException;
|
||||
}
|
||||
|
||||
internal static IServiceScope CreateScope()
|
||||
{
|
||||
return (Current as App)!.Host.Services.CreateScope();
|
||||
}
|
||||
|
||||
public static T GetService<T>() where T : class
|
||||
{
|
||||
if ((Current as App)!.Host.Services.GetService(typeof(T)) is not T service)
|
||||
{
|
||||
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices within App.xaml.cs.");
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
protected override async void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
base.OnLaunched(args);
|
||||
|
||||
var arguments = ActivationHandler.ParseArguments("--project-path F:/GhostProject/Test2 --project-name Test2"); // args.Arguments
|
||||
if (!arguments.IsValid())
|
||||
{
|
||||
Exit();
|
||||
return;
|
||||
}
|
||||
|
||||
EditorApplication.Initialize(Host.Services, arguments.ProjectPath, arguments.ProjectName);
|
||||
|
||||
// NOTE: We must call DispatcherQueue.GetForCurrentThread() on the UI thread before any await.
|
||||
EditorApplication.SetDispatcherQueue(DispatcherQueue.GetForCurrentThread());
|
||||
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Activate();
|
||||
Window = splashWindow;
|
||||
|
||||
await Host.StartAsync();
|
||||
await ActivationHandler.HandleAsync(arguments);
|
||||
|
||||
splashWindow.Hide();
|
||||
|
||||
var editorWindow = new EngineEditorWindow();
|
||||
editorWindow.Activate();
|
||||
Window = editorWindow;
|
||||
|
||||
splashWindow.Close();
|
||||
}
|
||||
|
||||
private void OnClosed(object? sender, WindowEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
Host.StopAsync().GetAwaiter().GetResult();
|
||||
Host.Dispose();
|
||||
|
||||
EditorApplication.Shutdown();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debugger.BreakForUserUnhandledException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Logger.LogError(e.Exception);
|
||||
#if DEBUG
|
||||
Debugger.BreakForUserUnhandledException(e.Exception);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
6
src/Editor/Ghost.Editor/AssemblyInfo.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using Ghost.Core.Attributes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ghost.UnitTest")]
|
||||
|
||||
[assembly: EngineAssembly]
|
||||
BIN
src/Editor/Ghost.Editor/Assets/EditorIcons/document-0.png
Normal file
|
After Width: | Height: | Size: 453 B |
BIN
src/Editor/Ghost.Editor/Assets/EditorIcons/document-1.png
Normal file
|
After Width: | Height: | Size: 869 B |
BIN
src/Editor/Ghost.Editor/Assets/EditorIcons/folder-0.png
Normal file
|
After Width: | Height: | Size: 465 B |
BIN
src/Editor/Ghost.Editor/Assets/EditorIcons/folder-1.png
Normal file
|
After Width: | Height: | Size: 884 B |
BIN
src/Editor/Ghost.Editor/Assets/EditorIcons/image-0.png
Normal file
|
After Width: | Height: | Size: 727 B |
BIN
src/Editor/Ghost.Editor/Assets/EditorIcons/image-1.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 432 B After Width: | Height: | Size: 432 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 456 B After Width: | Height: | Size: 456 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.ico
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.scale-100.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.scale-125.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.scale-150.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.scale-200.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.scale-400.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |