Compare commits
139 Commits
main
...
9bae3e647e
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
*.userprefs
|
*.userprefs
|
||||||
@@ -35,6 +36,7 @@ bld/
|
|||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
.vs/
|
.vs/
|
||||||
|
.vscode/
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
#wwwroot/
|
#wwwroot/
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
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(Api.nvttIsCudaSupported());
|
||||||
|
|
||||||
|
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
|
<Application
|
||||||
x:Class="Ghost.Editor.App"
|
x:Class="Ghost.Editor.App"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:core="using:Ghost.Editor.Core.Controls"
|
||||||
xmlns:local="using:Ghost.Editor">
|
xmlns:local="using:Ghost.Editor">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||||
<!-- Other merged dictionaries here -->
|
<ResourceDictionary Source="/Themes/Generic.xaml" />
|
||||||
|
<core:ControlsDictionary />
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
<!-- Other app resources here -->
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</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 |
22
src/Editor/Ghost.Editor/Assets/icon.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" width="48px" height="48px">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: url(#Безымянный_градиент_199);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #f7c13a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<linearGradient id="Безымянный_градиент_199" data-name="Безымянный градиент 199" x1="11.80415" y1="-22.237" x2="29.6085" y2="45.263" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#6d6d6d"/>
|
||||||
|
<stop offset="0.12552" stop-color="#626262"/>
|
||||||
|
<stop offset="0.987" stop-color="#464646"/>
|
||||||
|
<stop offset="0.998" stop-color="#454545"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect class="cls-1" x="6" y="6" width="36" height="36" rx="2"/>
|
||||||
|
<path class="cls-2" d="M22,27v1.5a.5.5,0,0,1-.5.5H17a8,8,0,0,1-8-8V19.5a.5.5,0,0,1,.5-.5h4.00612a.501.501,0,0,1,.49758.49688C14.05815,23.13441,14.70582,26,15.5,26c.73344,0,1.33761-2.43083,1.46835-5.65979a.50624.50624,0,0,1,.74437-.42711A7.99072,7.99072,0,0,1,22,27Z"/>
|
||||||
|
<path class="cls-2" d="M39,19.5V21a8,8,0,0,1-8,8H26.5a.5.5,0,0,1-.5-.5V27a7.99072,7.99072,0,0,1,4.28728-7.0869.50624.50624,0,0,1,.74437.42711C31.16239,23.56917,31.76656,26,32.5,26c.79418,0,1.44185-2.86559,1.4963-6.50312A.501.501,0,0,1,34.49388,19H38.5A.5.5,0,0,1,39,19.5Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/Editor/Ghost.Editor/Assets/icon.targetsize-16.png
Normal file
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 580 B |