Compare commits
48 Commits
0eaf7cd51d
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d4238e3086 | |||
| f552a4e9e1 | |||
| 5b34da6d6c | |||
| 1f1e21905e | |||
| acd8e60ffb | |||
| c6e58b057c | |||
| 34dc6fc8c9 | |||
| d9343a94dc | |||
| b84ee586bf | |||
| 52dfa12e84 | |||
| 326aee2b1c | |||
| 2cbe1ca789 | |||
| 1fe080dd87 | |||
| 211ea2254d | |||
| 914dde2448 | |||
| 5222e801b9 | |||
| a1c5ccf937 | |||
| 6b501efda0 | |||
| a40140cabd | |||
| 7dac1e4437 | |||
| e04c7eb6a7 | |||
| 84c936bb7a | |||
| 18505cdff6 | |||
| f85cf4edde | |||
| 982ce7d8e0 | |||
| cf5af7ee50 | |||
| 60b807abd7 | |||
| cbaa129d9e | |||
| bb07644580 | |||
| 314b0111f0 | |||
| a95ff01366 | |||
| 7e1db7b908 | |||
| 2ea3c509b0 | |||
| e2bc68d359 | |||
| 1cc65e8218 | |||
| d0076c852f | |||
| ba8694ed0c | |||
| 80e820a858 | |||
| b42398bbce | |||
| d052ca848f | |||
| 744b058e6a | |||
| 5de480e231 | |||
| 8d3e1c91d7 | |||
| bffe05f0ef | |||
| 220db828a0 | |||
| d2bf2f12a2 | |||
| e7fedfd35a | |||
| e384a2f38c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
.opencode/
|
.opencode/
|
||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
.github/instructions/
|
.antigravitycli/
|
||||||
|
|
||||||
ref/
|
ref/
|
||||||
docfx/
|
docfx/
|
||||||
|
|||||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright © 2026 Enjie Huang
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
7
src/.github/commit-instructions.md
vendored
7
src/.github/commit-instructions.md
vendored
@@ -1,7 +0,0 @@
|
|||||||
Use this instructions when writing a git commit message
|
|
||||||
|
|
||||||
The first line should be a single line with no more than 50 characters that summary the changes. The second line should be blank. Start at the third line for actual changes.
|
|
||||||
|
|
||||||
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
|
|
||||||
|
|
||||||
Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space. The type feat MUST be used when a commit adds a new feature to your application or library. The type fix MUST be used when a commit represents a bug fix for your application. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser) A description MUST immediately follow the colon and space after the typescope prefix. The description is a short summary of the code changes, e.g., fix array parsing issue when multiple spaces were contained in string. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description. A commit body is free-form and MAY consist of any number of newline separated paragraphs. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a or # separator, followed by a string value (this is inspired by the git trailer convention). A footer’s token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token. A footer’s value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer tokenseparator pair is observed. Breaking changes MUST be indicated in the typescope prefix of a commit, or as an entry in the footer. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE environment variables now take precedence over config files. If included in the typescope prefix, breaking changes MUST be indicated by a ! immediately before the . If ! is used, BREAKING CHANGE MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change. Types other than feat and fix MAY be used in your commit messages, e.g., docs update ref docs. The units of information that make up Conventional Commits MUST NOT be treated as case-sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.
|
|
||||||
17
src/Directory.Build.props
Normal file
17
src/Directory.Build.props
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Debug_Editor'">
|
||||||
|
<DefineConstants>$(DefineConstants);DEBUG;TRACE;GHOST_EDITOR;GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>false</Optimize>
|
||||||
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<DebugType>portable</DebugType>
|
||||||
|
<TieredCompilation>false</TieredCompilation>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release_Editor'">
|
||||||
|
<DefineConstants>$(DefineConstants);GHOST_EDITOR;GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release_Dev'">
|
||||||
|
<DefineConstants>$(DefineConstants);GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public struct DSLShaderError
|
|||||||
|
|
||||||
public static class DSLShaderCompiler
|
public static class DSLShaderCompiler
|
||||||
{
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
||||||
{
|
{
|
||||||
if (semantic == null)
|
if (semantic == null)
|
||||||
@@ -85,11 +86,13 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
||||||
// Currently, we just ignore inheritance.
|
// Currently, we just ignore inheritance.
|
||||||
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
||||||
{
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
{
|
{
|
||||||
propertyInfo = default;
|
propertyInfo = default;
|
||||||
@@ -101,7 +104,7 @@ public static class DSLShaderCompiler
|
|||||||
var pass = semantics.passes![i];
|
var pass = semantics.passes![i];
|
||||||
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
|
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
|
||||||
|
|
||||||
var result = BuildFinalShaderCode(pass.amplificationShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.code);
|
var result = BuildFinalShaderCode(pass.amplificationShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
||||||
@@ -109,7 +112,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
var amplificationShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.amplificationShader.entry };
|
var amplificationShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.amplificationShader.entry };
|
||||||
|
|
||||||
result = BuildFinalShaderCode(pass.meshShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.code);
|
result = BuildFinalShaderCode(pass.meshShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
||||||
@@ -117,7 +120,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
var meshShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.meshShader.entry };
|
var meshShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.meshShader.entry };
|
||||||
|
|
||||||
result = BuildFinalShaderCode(pass.pixelShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.code);
|
result = BuildFinalShaderCode(pass.pixelShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
||||||
@@ -142,7 +145,7 @@ public static class DSLShaderCompiler
|
|||||||
var descriptor = new GraphicsShaderDescriptor
|
var descriptor = new GraphicsShaderDescriptor
|
||||||
{
|
{
|
||||||
Name = semantics.name,
|
Name = semantics.name,
|
||||||
PropertyBufferSize = propertyInfo.size,
|
PropertyBufferSize = propertyInfo.Size,
|
||||||
|
|
||||||
ShaderModel = semantics.shaderModel,
|
ShaderModel = semantics.shaderModel,
|
||||||
Passes = passes
|
Passes = passes
|
||||||
@@ -154,6 +157,10 @@ public static class DSLShaderCompiler
|
|||||||
}
|
}
|
||||||
|
|
||||||
return descriptor;
|
return descriptor;
|
||||||
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
||||||
@@ -294,6 +301,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
|
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
|
||||||
{
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
{
|
{
|
||||||
propertyInfo = default;
|
propertyInfo = default;
|
||||||
@@ -302,7 +310,7 @@ public static class DSLShaderCompiler
|
|||||||
var shaderCodes = new ShaderCode[semantics.entryPoints.Count];
|
var shaderCodes = new ShaderCode[semantics.entryPoints.Count];
|
||||||
for (var i = 0; i < shaderCodes.Length; i++)
|
for (var i = 0; i < shaderCodes.Length; i++)
|
||||||
{
|
{
|
||||||
var result = BuildFinalShaderCode(semantics.entryPoints[i].shaderPath, semantics.includes.AsSpan(), semantics.hlsl, propertyInfo.code);
|
var result = BuildFinalShaderCode(semantics.entryPoints[i].shaderPath, semantics.includes.AsSpan(), semantics.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for entry point '{semantics.entryPoints[i].entry}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for entry point '{semantics.entryPoints[i].entry}': {result.Message}");
|
||||||
@@ -314,11 +322,14 @@ public static class DSLShaderCompiler
|
|||||||
return new ComputeShaderDescriptor
|
return new ComputeShaderDescriptor
|
||||||
{
|
{
|
||||||
Name = semantics.name,
|
Name = semantics.name,
|
||||||
PropertyBufferSize = propertyInfo.size,
|
PropertyBufferSize = propertyInfo.Size,
|
||||||
ShaderModel = semantics.shaderModel,
|
ShaderModel = semantics.shaderModel,
|
||||||
ShaderCodes = shaderCodes,
|
ShaderCodes = shaderCodes,
|
||||||
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
|
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
|
||||||
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
|
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
|
||||||
};
|
};
|
||||||
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Antlr4.Runtime.Misc;
|
using Antlr4.Runtime.Misc;
|
||||||
using Ghost.DSL.ShaderParser.Model;
|
using Ghost.DSL.ShaderParser.Model;
|
||||||
using TerraFX.Interop.Windows;
|
|
||||||
|
|
||||||
namespace Ghost.DSL.ShaderParser;
|
namespace Ghost.DSL.ShaderParser;
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace Ghost.DSL;
|
|
||||||
|
|
||||||
public struct ShaderPropertyInfo
|
|
||||||
{
|
|
||||||
public string shaderName;
|
|
||||||
public string code;
|
|
||||||
public uint size;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ShaderPropertiesRegistry
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<string, ShaderPropertyInfo> s_nameToCode = new Dictionary<string, ShaderPropertyInfo>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public static void Register(string name, string code, uint size)
|
|
||||||
{
|
|
||||||
s_nameToCode[name] = new ShaderPropertyInfo { shaderName = name, code = code, size = size };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetInfo(string name, out ShaderPropertyInfo info)
|
|
||||||
{
|
|
||||||
return s_nameToCode.TryGetValue(name, out info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,38 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class)]
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
public sealed class CustomAssetHandlerAttribute : Attribute
|
public sealed class CustomAssetHandlerAttribute : Attribute
|
||||||
{
|
{
|
||||||
public CustomAssetHandlerAttribute(string assetTypeID, string[] supportedExtensions, int version = 1)
|
public required string AssetTypeId
|
||||||
{
|
{
|
||||||
|
get; set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public required AssetType RuntimeAssetType
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required string[] Extensions
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Version
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public bool AllowCaching
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAsset : IDisposable
|
public abstract class IAsset : GhostObject
|
||||||
{
|
{
|
||||||
public Guid ID
|
public Guid ID
|
||||||
{
|
{
|
||||||
@@ -27,16 +48,21 @@ public interface IAsset : IDisposable
|
|||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected IAsset(Guid id, Guid typeId, IAssetSettings? settings)
|
||||||
|
:base(id)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
TypeID = typeId;
|
||||||
|
Settings = settings;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAssetExportOptions;
|
public interface IAssetExportOptions;
|
||||||
|
|
||||||
public interface IAssetHandler
|
public interface IAssetHandler
|
||||||
{
|
{
|
||||||
AssetType RuntimeAssetType { get; }
|
IAssetSettings? CreateDefaultSettings(string ext);
|
||||||
Guid EditorAssetTypeID { get; }
|
|
||||||
|
|
||||||
IAssetSettings? CreateDefaultSettings();
|
|
||||||
|
|
||||||
ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
||||||
ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default);
|
ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default);
|
||||||
@@ -44,11 +70,11 @@ public interface IAssetHandler
|
|||||||
|
|
||||||
public interface IImportableAssetHandler : IAssetHandler
|
public interface IImportableAssetHandler : IAssetHandler
|
||||||
{
|
{
|
||||||
bool CanExport { get; }
|
ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
||||||
ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
|
||||||
ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readonly record struct ImportedSubAsset(Guid Guid, string Kind, string DisplayName, string StablePath, string VirtualSourcePath, Guid AssetTypeId);
|
||||||
|
|
||||||
public interface IPackableAssetHandler : IAssetHandler
|
public interface IPackableAssetHandler : IAssetHandler
|
||||||
{
|
{
|
||||||
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);
|
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);
|
||||||
|
|||||||
@@ -1,36 +1,54 @@
|
|||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public readonly struct AssetHandlerInfo
|
||||||
|
{
|
||||||
|
public Type HandlerType { get; init; }
|
||||||
|
public AssetType RuntimeAssetType { get; init; }
|
||||||
|
public Guid EditorAssetTypeID { get; init; }
|
||||||
|
public int Version { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class AssetHandlerRegistry
|
public static class AssetHandlerRegistry
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, IAssetHandler> s_byExtension;
|
private static readonly Dictionary<string, AssetHandlerInfo> s_byExtension;
|
||||||
private static readonly Dictionary<string, AssetType> s_typeByExtension;
|
private static readonly Dictionary<Guid, AssetHandlerInfo> s_byTypeId;
|
||||||
private static readonly Dictionary<Guid, IAssetHandler> s_byTypeId;
|
|
||||||
private static readonly Dictionary<Guid, int> s_versionByTypeId;
|
|
||||||
|
|
||||||
private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes;
|
private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes;
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<Type, IAssetHandler?> s_handlerCache;
|
||||||
|
|
||||||
static AssetHandlerRegistry()
|
static AssetHandlerRegistry()
|
||||||
{
|
{
|
||||||
s_byExtension = new Dictionary<string, IAssetHandler>(StringComparer.OrdinalIgnoreCase);
|
s_byExtension = new Dictionary<string, AssetHandlerInfo>(StringComparer.OrdinalIgnoreCase);
|
||||||
s_typeByExtension = new Dictionary<string, AssetType>(StringComparer.OrdinalIgnoreCase);
|
s_byTypeId = new Dictionary<Guid, AssetHandlerInfo>();
|
||||||
s_byTypeId = new Dictionary<Guid, IAssetHandler>();
|
|
||||||
s_versionByTypeId = new Dictionary<Guid, int>();
|
|
||||||
|
|
||||||
s_iAssetSettingsTypes = new List<(Type Type, string Name)>();
|
s_iAssetSettingsTypes = new List<(Type Type, string Name)>();
|
||||||
|
s_handlerCache = new ConcurrentDictionary<Type, IAssetHandler?>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RegisterHandler(IAssetHandler handler, Guid assetTypeId, ReadOnlySpan<string> extensions, int version)
|
public static void RegisterHandler(Type handlerType, Guid assetTypeId, AssetType runtimeAssetType, int version, bool allowCaching, params ReadOnlySpan<string> extensions)
|
||||||
{
|
{
|
||||||
s_byTypeId[assetTypeId] = handler;
|
var info = new AssetHandlerInfo
|
||||||
s_versionByTypeId[assetTypeId] = version;
|
{
|
||||||
|
HandlerType = handlerType,
|
||||||
|
RuntimeAssetType = runtimeAssetType,
|
||||||
|
EditorAssetTypeID = assetTypeId,
|
||||||
|
Version = version
|
||||||
|
};
|
||||||
|
|
||||||
|
s_byTypeId[assetTypeId] = info;
|
||||||
|
|
||||||
foreach (var ext in extensions)
|
foreach (var ext in extensions)
|
||||||
{
|
{
|
||||||
var normalizedExt = ext.StartsWith('.') ? ext : "." + ext;
|
var normalizedExt = ext.StartsWith('.') ? ext : "." + ext;
|
||||||
s_byExtension[normalizedExt] = handler;
|
s_byExtension[normalizedExt] = info;
|
||||||
s_typeByExtension[normalizedExt] = handler.RuntimeAssetType;
|
}
|
||||||
|
|
||||||
|
if (allowCaching)
|
||||||
|
{
|
||||||
|
s_handlerCache[handlerType] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,36 +65,59 @@ public static class AssetHandlerRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
||||||
s_byExtension.TryGetValue(normalized, out var handler);
|
if (!s_byExtension.TryGetValue(normalized, out var info))
|
||||||
return handler;
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (IAssetHandler?)Activator.CreateInstance(t);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IAssetHandler? GetByAssetTypeId(Guid typeId)
|
public static IAssetHandler? GetByAssetTypeId(Guid typeId)
|
||||||
{
|
{
|
||||||
s_byTypeId.TryGetValue(typeId, out var handler);
|
if (!s_byTypeId.TryGetValue(typeId, out var info))
|
||||||
return handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int GetVersionByAssetTypeId(Guid typeId)
|
|
||||||
{
|
{
|
||||||
s_versionByTypeId.TryGetValue(typeId, out var version);
|
return null;
|
||||||
return version;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<string> GetSupportedExtensions()
|
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
|
||||||
{
|
{
|
||||||
return s_byExtension.Keys;
|
try
|
||||||
|
{
|
||||||
|
return (IAssetHandler?)Activator.CreateInstance(t);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AssetType GetRuntimeAssetTypeByExtension(string extension)
|
public static bool TryGetHandlerInfoByAssetTypeId(Guid typeId, out AssetHandlerInfo info)
|
||||||
|
{
|
||||||
|
return s_byTypeId.TryGetValue(typeId, out info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetHandlerInfoByExtension(string extension, out AssetHandlerInfo info)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(extension))
|
if (string.IsNullOrEmpty(extension))
|
||||||
{
|
{
|
||||||
return AssetType.Unknown;
|
info = default;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
||||||
return s_typeByExtension.GetValueOrDefault(normalized, AssetType.Unknown);
|
return s_byExtension.TryGetValue(normalized, out info);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes()
|
public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes()
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ public sealed class AssetMeta
|
|||||||
public required Guid Guid { get; init; }
|
public required Guid Guid { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Guid that identifies which IAssetHandler processes this asset.
|
/// The Guid that identifies type id of asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? HandlerTypeId { get; set; }
|
public Guid? AssetTypeId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Version of the handler that last imported this asset.
|
/// Version of the handler that last imported this asset.
|
||||||
@@ -69,7 +69,7 @@ internal static class AssetMetaIO
|
|||||||
public const string META_EXTENSION_NAME = "gmeta";
|
public const string META_EXTENSION_NAME = "gmeta";
|
||||||
public const string META_EXTENSION = ".gmeta";
|
public const string META_EXTENSION = ".gmeta";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions s_options = new()
|
internal static readonly JsonSerializerOptions s_options = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
@@ -136,6 +136,7 @@ internal static class AssetMetaIO
|
|||||||
}
|
}
|
||||||
|
|
||||||
File.Move(tempPath, metaPath);
|
File.Move(tempPath, metaPath);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetMetaPath(string sourceFilePath)
|
public static string GetMetaPath(string sourceFilePath)
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Engine;
|
|
||||||
using Ghost.Graphics.RHI;
|
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
|
||||||
using Misaki.HighPerformance.Mathematics;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
|
||||||
|
|
||||||
public class MeshNode : IDisposable
|
|
||||||
{
|
|
||||||
public string Name
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = string.Empty;
|
|
||||||
|
|
||||||
public float4x4 LocalTransform
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public MeshNode? Parent
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyCollection<MeshNode> Children
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = Array.Empty<MeshNode>();
|
|
||||||
|
|
||||||
~MeshNode()
|
|
||||||
{
|
|
||||||
Dispose(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public MeshNode Clone()
|
|
||||||
{
|
|
||||||
return (MeshNode)MemberwiseClone();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var child in Children)
|
|
||||||
{
|
|
||||||
child.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Parent = null;
|
|
||||||
Children = Array.Empty<MeshNode>();
|
|
||||||
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Describes one material partition within a unified vertex/index buffer.
|
|
||||||
/// </summary>
|
|
||||||
public struct MaterialPartInfo
|
|
||||||
{
|
|
||||||
/// <summary> The material slot index (from ufbx face_material). </summary>
|
|
||||||
public int materialIndex;
|
|
||||||
/// <summary> Byte offset into the unified index buffer. </summary>
|
|
||||||
public int indexStart;
|
|
||||||
/// <summary> Number of indices belonging to this part. </summary>
|
|
||||||
public int indexCount;
|
|
||||||
/// <summary> Byte offset into the unified vertex buffer. </summary>
|
|
||||||
public int vertexStart;
|
|
||||||
/// <summary> Number of unique vertices belonging to this part. </summary>
|
|
||||||
public int vertexCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GeometryMeshNode : MeshNode
|
|
||||||
{
|
|
||||||
private UnsafeList<Vertex> _vertices;
|
|
||||||
private UnsafeList<uint> _indices;
|
|
||||||
private UnsafeArray<MaterialPartInfo> _materialParts;
|
|
||||||
|
|
||||||
public UnsafeList<Vertex> Vertices
|
|
||||||
{
|
|
||||||
get => _vertices;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_vertices.Dispose();
|
|
||||||
_vertices = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public UnsafeList<uint> Indices
|
|
||||||
{
|
|
||||||
get => _indices;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_indices.Dispose();
|
|
||||||
_indices = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public UnsafeArray<MaterialPartInfo> MaterialParts
|
|
||||||
{
|
|
||||||
get => _materialParts;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_materialParts.Dispose();
|
|
||||||
_materialParts = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
_vertices.Dispose();
|
|
||||||
_indices.Dispose();
|
|
||||||
_materialParts.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class LightMeshNode : MeshNode
|
|
||||||
{
|
|
||||||
public float3 Color
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public float Intensity
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract class MeshAsset : IAsset
|
|
||||||
{
|
|
||||||
private MeshNode _root;
|
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(MeshAsset).GUID;
|
|
||||||
|
|
||||||
public MeshNode Root
|
|
||||||
{
|
|
||||||
get => _root;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_root?.Dispose();
|
|
||||||
_root = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal MeshAsset(MeshNode root, Guid id, MeshAssetSettings settings)
|
|
||||||
{
|
|
||||||
_root = root;
|
|
||||||
|
|
||||||
ID = id;
|
|
||||||
Settings = settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_root?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Guid(GUID)]
|
|
||||||
public partial class FBXAsset : MeshAsset
|
|
||||||
{
|
|
||||||
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
|
|
||||||
|
|
||||||
internal FBXAsset(MeshNode root, Guid id, FbxAssetSettings settings)
|
|
||||||
: base(root, id, settings)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum CoordinateAxis
|
|
||||||
{
|
|
||||||
PositiveX,
|
|
||||||
PositiveY,
|
|
||||||
PositiveZ,
|
|
||||||
NegativeX,
|
|
||||||
NegativeY,
|
|
||||||
NegativeZ
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum VertexDataSource
|
|
||||||
{
|
|
||||||
Imported,
|
|
||||||
Computed,
|
|
||||||
ComputedIfMissing
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MeshAssetSettings : IAssetSettings
|
|
||||||
{
|
|
||||||
public VertexDataSource NormalDataSource
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = VertexDataSource.ComputedIfMissing;
|
|
||||||
|
|
||||||
public VertexDataSource TangentDataSource
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = VertexDataSource.ComputedIfMissing;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class ObjAssetSettings : MeshAssetSettings
|
|
||||||
{
|
|
||||||
public CoordinateAxis ObjectUpAxis
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = CoordinateAxis.PositiveY;
|
|
||||||
|
|
||||||
public CoordinateAxis ObjectForwardAxis
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = CoordinateAxis.NegativeZ;
|
|
||||||
|
|
||||||
public CoordinateAxis ObjectRightAxis
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = CoordinateAxis.PositiveX;
|
|
||||||
|
|
||||||
public float UnitMeterScale
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = 1.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class FbxAssetSettings : MeshAssetSettings
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class FBXAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|
||||||
{
|
|
||||||
public AssetType RuntimeAssetType => AssetType.Mesh;
|
|
||||||
|
|
||||||
public Guid EditorAssetTypeID => typeof(FBXAsset).GUID;
|
|
||||||
|
|
||||||
public bool CanExport => false;
|
|
||||||
|
|
||||||
public IAssetSettings? CreateDefaultSettings()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
181
src/Editor/Ghost.Editor.Core/Assets/MeshNode.cs
Normal file
181
src/Editor/Ghost.Editor.Core/Assets/MeshNode.cs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public class MeshNode : IDisposable
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public float4x4 LocalTransform
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeshNode? Parent
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<MeshNode> Children
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = Array.Empty<MeshNode>();
|
||||||
|
|
||||||
|
~MeshNode()
|
||||||
|
{
|
||||||
|
Dispose(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeshNode Clone()
|
||||||
|
{
|
||||||
|
return (MeshNode)MemberwiseClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
child.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Parent = null;
|
||||||
|
Children = Array.Empty<MeshNode>();
|
||||||
|
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes one material partition within a unified vertex/index buffer.
|
||||||
|
/// </summary>
|
||||||
|
public struct MaterialPartInfo
|
||||||
|
{
|
||||||
|
/// <summary> The material slot index (from ufbx face_material). </summary>
|
||||||
|
public int materialIndex;
|
||||||
|
/// <summary> Byte offset into the unified index buffer. </summary>
|
||||||
|
public int indexStart;
|
||||||
|
/// <summary> Number of indices belonging to this part. </summary>
|
||||||
|
public int indexCount;
|
||||||
|
/// <summary> Byte offset into the unified vertex buffer. </summary>
|
||||||
|
public int vertexStart;
|
||||||
|
/// <summary> Number of unique vertices belonging to this part. </summary>
|
||||||
|
public int vertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GeometryMeshNode : MeshNode
|
||||||
|
{
|
||||||
|
private UnsafeList<Vertex> _vertices;
|
||||||
|
private UnsafeList<uint> _indices;
|
||||||
|
private UnsafeArray<MaterialPartInfo> _materialParts;
|
||||||
|
|
||||||
|
public UnsafeList<Vertex> Vertices
|
||||||
|
{
|
||||||
|
get => _vertices;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_vertices.Dispose();
|
||||||
|
_vertices = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UnsafeList<uint> Indices
|
||||||
|
{
|
||||||
|
get => _indices;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_indices.Dispose();
|
||||||
|
_indices = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UnsafeArray<MaterialPartInfo> MaterialParts
|
||||||
|
{
|
||||||
|
get => _materialParts;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_materialParts.Dispose();
|
||||||
|
_materialParts = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
_vertices.Dispose();
|
||||||
|
_indices.Dispose();
|
||||||
|
_materialParts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LightMeshNode : MeshNode
|
||||||
|
{
|
||||||
|
public float3 Color
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float Intensity
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModelManifest
|
||||||
|
{
|
||||||
|
public Guid AssetId
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ModelManifestNode Root
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new ModelManifestNode();
|
||||||
|
|
||||||
|
public List<ModelManifestSubAsset> Meshes
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new List<ModelManifestSubAsset>();
|
||||||
|
|
||||||
|
public List<ModelManifestMetadata> Metadata
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new List<ModelManifestMetadata>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModelManifestNode
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string StablePath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public float4x4 LocalTransform
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid MeshGuid
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelManifestNode> Children
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new List<ModelManifestNode>();
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ using Ghost.Graphics.RHI;
|
|||||||
using Ghost.Graphics.Utilities;
|
using Ghost.Graphics.Utilities;
|
||||||
using Ghost.MeshOptimizer;
|
using Ghost.MeshOptimizer;
|
||||||
using Ghost.Ufbx;
|
using Ghost.Ufbx;
|
||||||
using Misaki.HighPerformance.Jobs;
|
|
||||||
using Misaki.HighPerformance.LowLevel;
|
using Misaki.HighPerformance.LowLevel;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
@@ -15,7 +14,7 @@ using System.Text;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
internal readonly unsafe struct MeshParsingJob : IJob
|
internal unsafe class MeshParsingJob
|
||||||
{
|
{
|
||||||
private struct GeometryPart : IDisposable
|
private struct GeometryPart : IDisposable
|
||||||
{
|
{
|
||||||
@@ -36,14 +35,20 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
|
|
||||||
private readonly string _filePath;
|
private readonly string _filePath;
|
||||||
private readonly AllocationHandle _allocationHandle;
|
private readonly AllocationHandle _allocationHandle;
|
||||||
private readonly MeshAssetSettings _settings;
|
private readonly ModelAssetSettings _settings;
|
||||||
|
|
||||||
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, MeshAssetSettings settings)
|
private readonly TaskCompletionSource<Result> _taskCompletionSource;
|
||||||
|
|
||||||
|
public Task<Result> Task => _taskCompletionSource.Task;
|
||||||
|
|
||||||
|
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, ModelAssetSettings settings)
|
||||||
{
|
{
|
||||||
_rootNode = rootNode;
|
_rootNode = rootNode;
|
||||||
_filePath = filePath;
|
_filePath = filePath;
|
||||||
_allocationHandle = allocationHandle;
|
_allocationHandle = allocationHandle;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
|
||||||
|
_taskCompletionSource = new TaskCompletionSource<Result>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
@@ -80,7 +85,7 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ParseHierarchy(ufbx_node* node, MeshNode self)
|
private void ParseHierarchy(ufbx_node* node, MeshNode self, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
var children = new List<MeshNode>();
|
var children = new List<MeshNode>();
|
||||||
|
|
||||||
@@ -90,7 +95,7 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
|
|
||||||
if (node->mesh != null)
|
if (node->mesh != null)
|
||||||
{
|
{
|
||||||
var geoNode = ParseGeometry(node->mesh);
|
var geoNode = ParseGeometry(node->mesh, allocationHandle);
|
||||||
if (geoNode != null)
|
if (geoNode != null)
|
||||||
{
|
{
|
||||||
children.Add(geoNode);
|
children.Add(geoNode);
|
||||||
@@ -102,14 +107,14 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
for (var i = 0u; i < node->children.count; i++)
|
for (var i = 0u; i < node->children.count; i++)
|
||||||
{
|
{
|
||||||
var childNode = new MeshNode();
|
var childNode = new MeshNode();
|
||||||
ParseHierarchy(node->children.data[i], childNode);
|
ParseHierarchy(node->children.data[i], childNode, allocationHandle);
|
||||||
childNode.Parent = self;
|
childNode.Parent = self;
|
||||||
|
|
||||||
children.Add(childNode);
|
children.Add(childNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh)
|
private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
if (pMesh->num_faces == 0)
|
if (pMesh->num_faces == 0)
|
||||||
{
|
{
|
||||||
@@ -120,18 +125,18 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
|
|
||||||
// Bucket faces by material
|
// Bucket faces by material
|
||||||
|
|
||||||
using var materialBuckets = new UnsafeArray<UnsafeList<Vertex>>(numMaterials, AllocationHandle.FreeList);
|
using var materialBuckets = new UnsafeArray<UnsafeList<Vertex>>(numMaterials, allocationHandle);
|
||||||
using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, AllocationHandle.FreeList);
|
using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
|
||||||
using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, AllocationHandle.FreeList);
|
using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
|
||||||
|
|
||||||
for (var i = 0; i < numMaterials; i++)
|
for (var i = 0; i < numMaterials; i++)
|
||||||
{
|
{
|
||||||
materialBuckets[i] = new UnsafeList<Vertex>(1024, AllocationHandle.FreeList);
|
materialBuckets[i] = new UnsafeList<Vertex>(10240, allocationHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
|
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
|
||||||
|
|
||||||
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, AllocationHandle.FreeList);
|
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, allocationHandle);
|
||||||
|
|
||||||
for (var j = 0u; j < pMesh->num_faces; j++)
|
for (var j = 0u; j < pMesh->num_faces; j++)
|
||||||
{
|
{
|
||||||
@@ -192,7 +197,7 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
|
|
||||||
// Per-material weld + optimize, collect intermediate results
|
// Per-material weld + optimize, collect intermediate results
|
||||||
|
|
||||||
using var partResults = new UnsafeList<GeometryPart>(numMaterials, AllocationHandle.FreeList);
|
using var partResults = new UnsafeList<GeometryPart>(numMaterials, allocationHandle);
|
||||||
|
|
||||||
for (var m = 0; m < numMaterials; m++)
|
for (var m = 0; m < numMaterials; m++)
|
||||||
{
|
{
|
||||||
@@ -205,8 +210,8 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
|
|
||||||
var numIndices = (uint)flatVertices.Count;
|
var numIndices = (uint)flatVertices.Count;
|
||||||
|
|
||||||
using var weldedIndices = new UnsafeArray<uint>((int)numIndices, AllocationHandle.FreeList);
|
using var weldedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
|
||||||
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, AllocationHandle.FreeList);
|
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
|
||||||
|
|
||||||
var stream = new ufbx_vertex_stream
|
var stream = new ufbx_vertex_stream
|
||||||
{
|
{
|
||||||
@@ -226,8 +231,8 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
|
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
|
||||||
|
|
||||||
// Allocate temporary per-part buffers (will be merged then disposed)
|
// Allocate temporary per-part buffers (will be merged then disposed)
|
||||||
var partVertices = new UnsafeList<Vertex>((int)numUniqueVertices, AllocationHandle.FreeList);
|
var partVertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocationHandle);
|
||||||
var partIndices = new UnsafeList<uint>((int)numIndices, AllocationHandle.FreeList);
|
var partIndices = new UnsafeList<uint>((int)numIndices, allocationHandle);
|
||||||
|
|
||||||
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(partVertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
|
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(partVertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
|
||||||
|
|
||||||
@@ -319,7 +324,7 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Execute(ref readonly JobExecutionContext context)
|
public Result Execute()
|
||||||
{
|
{
|
||||||
var error = new ufbx_error();
|
var error = new ufbx_error();
|
||||||
var load_Opts = new ufbx_load_opts
|
var load_Opts = new ufbx_load_opts
|
||||||
@@ -345,22 +350,27 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
load_Opts.obj_search_mtl_by_filename = true;
|
load_Opts.obj_search_mtl_by_filename = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(_filePath) + 1, AllocationHandle.FreeList);
|
using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(_filePath) + 1, AllocationHandle.TLSF);
|
||||||
var count = Encoding.UTF8.GetBytes(_filePath, str.AsSpan());
|
var count = Encoding.UTF8.GetBytes(_filePath, str.AsSpan());
|
||||||
str[count] = 0;
|
str[count] = 0;
|
||||||
|
|
||||||
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
|
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
|
||||||
if (scene.Get() == null)
|
if (scene.Get() == null)
|
||||||
{
|
{
|
||||||
Logger.Error(error.description.ToString());
|
return Result.Failure(error.description.ToString());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ParseHierarchy(scene.Get()->root_node, _rootNode);
|
ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class MeshProcessor
|
internal static partial class MeshProcessor
|
||||||
{
|
{
|
||||||
|
public static Task<Result> ParseMeshAsync(MeshNode root, string sourcePath, AllocationHandle allocationHandle, ModelAssetSettings meshSettings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var parseJob = new MeshParsingJob(root, sourcePath, allocationHandle, meshSettings);
|
||||||
|
return Task.Run(parseJob.Execute, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ using Ghost.Core;
|
|||||||
using Ghost.Graphics.Core;
|
using Ghost.Graphics.Core;
|
||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Ghost.MeshOptimizer;
|
using Ghost.MeshOptimizer;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using Misaki.HighPerformance.LowLevel;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Utilities;
|
||||||
using Misaki.HighPerformance.Mathematics;
|
using Misaki.HighPerformance.Mathematics;
|
||||||
using Misaki.HighPerformance.Mathematics.Geometry;
|
using Misaki.HighPerformance.Mathematics.Geometry;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using TerraFX.Interop.Windows;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
@@ -158,13 +163,10 @@ public unsafe struct ClodCluster
|
|||||||
public nuint localIndexCount;
|
public nuint localIndexCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
internal static unsafe partial class MeshProcessor
|
||||||
/// Delegate type for processing generated LOD groups.
|
|
||||||
/// </summary>
|
|
||||||
public unsafe delegate int ClodOutputDelegate(void* context, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters);
|
|
||||||
|
|
||||||
public static unsafe partial class MeshProcessor
|
|
||||||
{
|
{
|
||||||
|
private delegate int ClodOutputDelegate(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters);
|
||||||
|
|
||||||
private static ClodBounds ComputeBounds(ref readonly ClodMesh mesh, UnsafeList<uint> indices, float error)
|
private static ClodBounds ComputeBounds(ref readonly ClodMesh mesh, UnsafeList<uint> indices, float error)
|
||||||
{
|
{
|
||||||
var bounds = MeshOptApi.ComputeClusterBounds((uint*)indices.GetUnsafePtr(), (nuint)indices.Count, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
|
var bounds = MeshOptApi.ComputeClusterBounds((uint*)indices.GetUnsafePtr(), (nuint)indices.Count, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
|
||||||
@@ -176,9 +178,9 @@ public static unsafe partial class MeshProcessor
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ClodBounds MergeBounds(UnsafeList<Cluster> clusters, UnsafeList<int> group)
|
private static ClodBounds MergeBounds(UnsafeList<Cluster> clusters, UnsafeList<int> group, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
using var boundsList = new UnsafeArray<ClodBounds>(group.Count, AllocationHandle.FreeList);
|
using var boundsList = new UnsafeArray<ClodBounds>(group.Count, allocationHandle);
|
||||||
for (var j = 0; j < group.Count; j++)
|
for (var j = 0; j < group.Count; j++)
|
||||||
{
|
{
|
||||||
boundsList[j] = (clusters[group[j]].bounds);
|
boundsList[j] = (clusters[group[j]].bounds);
|
||||||
@@ -206,13 +208,13 @@ public static unsafe partial class MeshProcessor
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UnsafeList<Cluster> Clusterize(ref readonly ClodConfig config, ref readonly ClodMesh mesh, uint* indices, nuint indexCount)
|
private static UnsafeList<Cluster> Clusterize(ref readonly ClodConfig config, ref readonly ClodMesh mesh, uint* indices, nuint indexCount, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
var maxMeshlets = MeshOptApi.BuildMeshletsBound(indexCount, config.maxVertices, config.minTriangles);
|
var maxMeshlets = MeshOptApi.BuildMeshletsBound(indexCount, config.maxVertices, config.minTriangles);
|
||||||
|
|
||||||
using var meshlets = new UnsafeArray<meshopt_Meshlet>((int)maxMeshlets, AllocationHandle.FreeList);
|
using var meshlets = new UnsafeArray<meshopt_Meshlet>((int)maxMeshlets, allocationHandle);
|
||||||
using var meshletVertices = new UnsafeArray<uint>((int)indexCount, AllocationHandle.FreeList);
|
using var meshletVertices = new UnsafeArray<uint>((int)indexCount, allocationHandle);
|
||||||
using var meshletTriangles = new UnsafeArray<byte>((int)indexCount, AllocationHandle.FreeList);
|
using var meshletTriangles = new UnsafeArray<byte>((int)indexCount, allocationHandle);
|
||||||
|
|
||||||
var pMeshlets = (meshopt_Meshlet*)meshlets.GetUnsafePtr();
|
var pMeshlets = (meshopt_Meshlet*)meshlets.GetUnsafePtr();
|
||||||
var pMeshletVertices = (uint*)meshletVertices.GetUnsafePtr();
|
var pMeshletVertices = (uint*)meshletVertices.GetUnsafePtr();
|
||||||
@@ -240,7 +242,7 @@ public static unsafe partial class MeshProcessor
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var clusters = new UnsafeList<Cluster>((int)meshletCount, AllocationHandle.FreeList);
|
var clusters = new UnsafeList<Cluster>((int)meshletCount, allocationHandle);
|
||||||
|
|
||||||
for (nuint i = 0; i < meshletCount; i++)
|
for (nuint i = 0; i < meshletCount; i++)
|
||||||
{
|
{
|
||||||
@@ -259,9 +261,9 @@ public static unsafe partial class MeshProcessor
|
|||||||
var cluster = new Cluster
|
var cluster = new Cluster
|
||||||
{
|
{
|
||||||
vertices = meshlet.vertex_count,
|
vertices = meshlet.vertex_count,
|
||||||
indices = new UnsafeList<uint>((int)(meshlet.triangle_count * 3), AllocationHandle.FreeList),
|
indices = new UnsafeList<uint>((int)(meshlet.triangle_count * 3), allocationHandle),
|
||||||
uniqueVertices = new UnsafeList<uint>((int)meshlet.vertex_count, AllocationHandle.FreeList),
|
uniqueVertices = new UnsafeList<uint>((int)meshlet.vertex_count, allocationHandle),
|
||||||
localIndices = new UnsafeList<byte>((int)(meshlet.triangle_count * 3), AllocationHandle.FreeList),
|
localIndices = new UnsafeList<byte>((int)(meshlet.triangle_count * 3), allocationHandle),
|
||||||
group = -1,
|
group = -1,
|
||||||
refined = -1
|
refined = -1
|
||||||
};
|
};
|
||||||
@@ -328,12 +330,12 @@ public static unsafe partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UnsafeList<UnsafeList<int>> Partition(ref readonly ClodConfig config, ref readonly ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> pending, UnsafeArray<uint> remap)
|
private static UnsafeList<UnsafeList<int>> Partition(ref readonly ClodConfig config, ref readonly ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> pending, UnsafeArray<uint> remap, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
if (pending.Count <= (int)config.partitionSize)
|
if (pending.Count <= (int)config.partitionSize)
|
||||||
{
|
{
|
||||||
var single = new UnsafeList<UnsafeList<int>>(1, AllocationHandle.FreeList);
|
var single = new UnsafeList<UnsafeList<int>>(1, allocationHandle);
|
||||||
var pendingcpy = new UnsafeList<int>(pending.Count, AllocationHandle.FreeList);
|
var pendingcpy = new UnsafeList<int>(pending.Count, allocationHandle);
|
||||||
|
|
||||||
pendingcpy.AddRange(pending.AsSpan());
|
pendingcpy.AddRange(pending.AsSpan());
|
||||||
single.Add(pendingcpy);
|
single.Add(pendingcpy);
|
||||||
@@ -347,8 +349,8 @@ public static unsafe partial class MeshProcessor
|
|||||||
totalIndexCount += (nuint)clusters[pending[i]].indices.Count;
|
totalIndexCount += (nuint)clusters[pending[i]].indices.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var clusterIndices = new UnsafeList<uint>((int)totalIndexCount, AllocationHandle.FreeList);
|
using var clusterIndices = new UnsafeList<uint>((int)totalIndexCount, allocationHandle);
|
||||||
using var clusterCounts = new UnsafeList<uint>(pending.Count, AllocationHandle.FreeList);
|
using var clusterCounts = new UnsafeList<uint>(pending.Count, allocationHandle);
|
||||||
|
|
||||||
nuint offset = 0;
|
nuint offset = 0;
|
||||||
for (var i = 0; i < pending.Count; i++)
|
for (var i = 0; i < pending.Count; i++)
|
||||||
@@ -363,7 +365,7 @@ public static unsafe partial class MeshProcessor
|
|||||||
offset += (nuint)cluster.indices.Count;
|
offset += (nuint)cluster.indices.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var clusterPart = new UnsafeArray<uint>(pending.Count, AllocationHandle.FreeList);
|
using var clusterPart = new UnsafeArray<uint>(pending.Count, allocationHandle);
|
||||||
|
|
||||||
var partitionCount = MeshOptApi.PartitionClusters(
|
var partitionCount = MeshOptApi.PartitionClusters(
|
||||||
(uint*)clusterPart.GetUnsafePtr(),
|
(uint*)clusterPart.GetUnsafePtr(),
|
||||||
@@ -377,10 +379,10 @@ public static unsafe partial class MeshProcessor
|
|||||||
config.partitionSize
|
config.partitionSize
|
||||||
);
|
);
|
||||||
|
|
||||||
var partitions = new UnsafeList<UnsafeList<int>>((int)partitionCount, AllocationHandle.FreeList);
|
var partitions = new UnsafeList<UnsafeList<int>>((int)partitionCount, allocationHandle);
|
||||||
for (nuint i = 0; i < partitionCount; i++)
|
for (nuint i = 0; i < partitionCount; i++)
|
||||||
{
|
{
|
||||||
partitions.Add(new UnsafeList<int>((int)(config.partitionSize + config.partitionSize / 3), AllocationHandle.FreeList));
|
partitions.Add(new UnsafeList<int>((int)(config.partitionSize + config.partitionSize / 3), allocationHandle));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 0; i < pending.Count; i++)
|
for (var i = 0; i < pending.Count; i++)
|
||||||
@@ -391,9 +393,12 @@ public static unsafe partial class MeshProcessor
|
|||||||
return partitions;
|
return partitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int OutputGroup(ref readonly ClodConfig config, ref readonly ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> group, ClodBounds simplified, int depth, void* outputContext, ClodOutputDelegate? outputCallback)
|
private static int OutputGroup(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
|
||||||
|
UnsafeList<Cluster> clusters, UnsafeList<int> group, ClodBounds simplified, int depth,
|
||||||
|
MeshletContext outputContext, ClodOutputDelegate? outputCallback,
|
||||||
|
AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
using var groupClusters = new UnsafeList<ClodCluster>(group.Count, AllocationHandle.FreeList);
|
using var groupClusters = new UnsafeList<ClodCluster>(group.Count, allocationHandle);
|
||||||
|
|
||||||
for (var i = 0; i < group.Count; i++)
|
for (var i = 0; i < group.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -427,10 +432,10 @@ public static unsafe partial class MeshProcessor
|
|||||||
public uint id;
|
public uint id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SimplifyFallback(ref UnsafeArray<uint> lod, ref readonly ClodMesh mesh, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint target_count, float* error)
|
private static void SimplifyFallback(ref UnsafeArray<uint> lod, ref readonly ClodMesh mesh, ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint target_count, float* error, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
using var subset = new UnsafeArray<SloppyVertex>(indices.Count, AllocationHandle.FreeList);
|
using var subset = new UnsafeArray<SloppyVertex>(indices.Count, allocationHandle);
|
||||||
using var subset_locks = new UnsafeArray<byte>(indices.Count, AllocationHandle.FreeList);
|
using var subset_locks = new UnsafeArray<byte>(indices.Count, allocationHandle);
|
||||||
|
|
||||||
lod.Resize(indices.Count);
|
lod.Resize(indices.Count);
|
||||||
|
|
||||||
@@ -464,9 +469,11 @@ public static unsafe partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint targetCount, float* error)
|
private static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
|
||||||
|
ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint targetCount, float* error,
|
||||||
|
AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
var lod = new UnsafeArray<uint>(indices.Count, AllocationHandle.FreeList);
|
var lod = new UnsafeArray<uint>(indices.Count, allocationHandle);
|
||||||
|
|
||||||
if (targetCount >= (nuint)indices.Count)
|
if (targetCount >= (nuint)indices.Count)
|
||||||
{
|
{
|
||||||
@@ -531,7 +538,7 @@ public static unsafe partial class MeshProcessor
|
|||||||
|
|
||||||
if ((nuint)lod.Length > targetCount && config.simplifyFallbackSloppy)
|
if ((nuint)lod.Length > targetCount && config.simplifyFallbackSloppy)
|
||||||
{
|
{
|
||||||
SimplifyFallback(ref lod, in mesh, indices, locks, targetCount, error);
|
SimplifyFallback(ref lod, in mesh, indices, locks, targetCount, error, allocationHandle);
|
||||||
*error *= config.simplifyErrorFactorSloppy;
|
*error *= config.simplifyErrorFactorSloppy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,12 +582,12 @@ public static unsafe partial class MeshProcessor
|
|||||||
/// <param name="outputContext">Optional context pointer passed to the output callback.</param>
|
/// <param name="outputContext">Optional context pointer passed to the output callback.</param>
|
||||||
/// <param name="outputCallback">Delegate invoked for each generated LOD group.</param>
|
/// <param name="outputCallback">Delegate invoked for each generated LOD group.</param>
|
||||||
/// <returns>The total count of generated clusters.</returns>
|
/// <returns>The total count of generated clusters.</returns>
|
||||||
public static nuint Build(ref readonly ClodConfig config, ref readonly ClodMesh mesh, void* outputContext, ClodOutputDelegate? outputCallback)
|
private static nuint Build(ref readonly ClodConfig config, ref readonly ClodMesh mesh, MeshletContext outputContext, ClodOutputDelegate? outputCallback)
|
||||||
{
|
{
|
||||||
Logger.DebugAssert(mesh.vertexAttributesStride % sizeof(float) == 0, "vertexAttributesStride must be a multiple of sizeof(float)");
|
Logger.DebugAssert(mesh.vertexAttributesStride % sizeof(float) == 0, "vertexAttributesStride must be a multiple of sizeof(float)");
|
||||||
|
|
||||||
using var locks = new UnsafeArray<byte>((int)mesh.vertexCount, AllocationHandle.FreeList, AllocationOption.Clear); ;
|
using var locks = new UnsafeArray<byte>((int)mesh.vertexCount, AllocationHandle.TLSF, AllocationOption.Clear); ;
|
||||||
using var remap = new UnsafeArray<uint>((int)mesh.vertexCount, AllocationHandle.FreeList);
|
using var remap = new UnsafeArray<uint>((int)mesh.vertexCount, AllocationHandle.TLSF);
|
||||||
|
|
||||||
MeshOptApi.GeneratePositionRemap((uint*)remap.GetUnsafePtr(), mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
|
MeshOptApi.GeneratePositionRemap((uint*)remap.GetUnsafePtr(), mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
|
||||||
|
|
||||||
@@ -603,14 +610,14 @@ public static unsafe partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using var clusters = Clusterize(in config, in mesh, mesh.indices, mesh.indexCount);
|
using var clusters = Clusterize(in config, in mesh, mesh.indices, mesh.indexCount, AllocationHandle.TLSF);
|
||||||
|
|
||||||
for (var i = 0; i < clusters.Count; i++)
|
for (var i = 0; i < clusters.Count; i++)
|
||||||
{
|
{
|
||||||
clusters[i].bounds = ComputeBounds(in mesh, clusters[i].indices, 0.0f);
|
clusters[i].bounds = ComputeBounds(in mesh, clusters[i].indices, 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var pending = new UnsafeList<int>(clusters.Count, AllocationHandle.FreeList);
|
using var pending = new UnsafeList<int>(clusters.Count, AllocationHandle.TLSF);
|
||||||
for (var i = 0; i < clusters.Count; i++)
|
for (var i = 0; i < clusters.Count; i++)
|
||||||
{
|
{
|
||||||
pending.Add(i);
|
pending.Add(i);
|
||||||
@@ -620,14 +627,14 @@ public static unsafe partial class MeshProcessor
|
|||||||
|
|
||||||
while (pending.Count > 1)
|
while (pending.Count > 1)
|
||||||
{
|
{
|
||||||
using var groups = Partition(in config, in mesh, clusters, pending, remap);
|
using var groups = Partition(in config, in mesh, clusters, pending, remap, AllocationHandle.TLSF);
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
|
|
||||||
LockBoundary(locks, groups, clusters, remap, mesh.vertexLock);
|
LockBoundary(locks, groups, clusters, remap, mesh.vertexLock);
|
||||||
|
|
||||||
for (var i = 0; i < groups.Count; i++)
|
for (var i = 0; i < groups.Count; i++)
|
||||||
{
|
{
|
||||||
using var merged = new UnsafeList<uint>(groups[i].Count * (int)config.maxTriangles * 3, AllocationHandle.FreeList);
|
using var merged = new UnsafeList<uint>(groups[i].Count * (int)config.maxTriangles * 3, AllocationHandle.TLSF);
|
||||||
for (var j = 0; j < groups[i].Count; j++)
|
for (var j = 0; j < groups[i].Count; j++)
|
||||||
{
|
{
|
||||||
var clusterIndices = clusters[groups[i][j]].indices;
|
var clusterIndices = clusters[groups[i][j]].indices;
|
||||||
@@ -635,28 +642,28 @@ public static unsafe partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
var targetSize = (nuint)(merged.Count / 3 * config.simplifyRatio * 3.0f);
|
var targetSize = (nuint)(merged.Count / 3 * config.simplifyRatio * 3.0f);
|
||||||
var bounds = MergeBounds(clusters, groups[i]);
|
var bounds = MergeBounds(clusters, groups[i], AllocationHandle.TLSF);
|
||||||
|
|
||||||
var error = 0.0f;
|
var error = 0.0f;
|
||||||
using var simplified = Simplify(in config, in mesh, merged.AsReadOnly(), locks.AsReadOnly(), targetSize, &error);
|
using var simplified = Simplify(in config, in mesh, merged.AsReadOnly(), locks.AsReadOnly(), targetSize, &error, AllocationHandle.TLSF);
|
||||||
|
|
||||||
if ((nuint)simplified.Length > (nuint)(merged.Count * config.simplifyThreshold))
|
if ((nuint)simplified.Length > (nuint)(merged.Count * config.simplifyThreshold))
|
||||||
{
|
{
|
||||||
bounds.error = float.MaxValue;
|
bounds.error = float.MaxValue;
|
||||||
OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback);
|
OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback, AllocationHandle.TLSF);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
bounds.error = Math.Max(bounds.error * config.simplifyErrorMergePrevious, error) + error * config.simplifyErrorMergeAdditive;
|
bounds.error = Math.Max(bounds.error * config.simplifyErrorMergePrevious, error) + error * config.simplifyErrorMergeAdditive;
|
||||||
|
|
||||||
var refined = OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback);
|
var refined = OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback, AllocationHandle.TLSF);
|
||||||
|
|
||||||
for (var j = 0; j < groups[i].Count; j++)
|
for (var j = 0; j < groups[i].Count; j++)
|
||||||
{
|
{
|
||||||
clusters[groups[i][j]].Dispose();
|
clusters[groups[i][j]].Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
using var split = Clusterize(in config, in mesh, (uint*)simplified.GetUnsafePtr(), (nuint)simplified.Length);
|
using var split = Clusterize(in config, in mesh, (uint*)simplified.GetUnsafePtr(), (nuint)simplified.Length, AllocationHandle.TLSF);
|
||||||
for (var j = 0; j < split.Count; j++)
|
for (var j = 0; j < split.Count; j++)
|
||||||
{
|
{
|
||||||
split[j].refined = refined;
|
split[j].refined = refined;
|
||||||
@@ -678,7 +685,7 @@ public static unsafe partial class MeshProcessor
|
|||||||
{
|
{
|
||||||
var bounds = clusters[pending[0]].bounds;
|
var bounds = clusters[pending[0]].bounds;
|
||||||
bounds.error = float.MaxValue;
|
bounds.error = float.MaxValue;
|
||||||
OutputGroup(in config, in mesh, clusters, pending, bounds, depth, outputContext, outputCallback);
|
OutputGroup(in config, in mesh, clusters, pending, bounds, depth, outputContext, outputCallback, AllocationHandle.TLSF);
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalClusterCount = (nuint)clusters.Count;
|
var finalClusterCount = (nuint)clusters.Count;
|
||||||
@@ -697,28 +704,42 @@ public static unsafe partial class MeshProcessor
|
|||||||
public int materialIndex;
|
public int materialIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int MeshletOutputCallback(void* contextPtr, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters)
|
private static int MeshletOutputCallback(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters)
|
||||||
{
|
{
|
||||||
var context = (MeshletContext*)contextPtr;
|
var meshletData = context.data;
|
||||||
var pMeshletData = context->data;
|
var materialIndex = context.materialIndex;
|
||||||
var materialIndex = context->materialIndex;
|
|
||||||
|
|
||||||
// Ensure lists are initialized
|
// Ensure lists are initialized
|
||||||
if (!pMeshletData->groups.IsCreated) pMeshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.Persistent);
|
if (!meshletData->groups.IsCreated)
|
||||||
if (!pMeshletData->meshlets.IsCreated) pMeshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.Persistent);
|
{
|
||||||
if (!pMeshletData->meshletVertices.IsCreated) pMeshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.Persistent);
|
meshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.TLSF);
|
||||||
if (!pMeshletData->meshletTriangles.IsCreated) pMeshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.Persistent);
|
}
|
||||||
|
|
||||||
|
if (!meshletData->meshlets.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meshletData->meshletVertices.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meshletData->meshletTriangles.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
var meshletGroup = new MeshletGroup
|
var meshletGroup = new MeshletGroup
|
||||||
{
|
{
|
||||||
boundingSphere = new SphereBounds(group.simplified.center, group.simplified.radius),
|
boundingSphere = new SphereBounds(group.simplified.center, group.simplified.radius),
|
||||||
boundingBox = new AABB(group.simplified.center - group.simplified.radius, group.simplified.center + group.simplified.radius),
|
boundingBox = new AABB(group.simplified.center - group.simplified.radius, group.simplified.center + group.simplified.radius),
|
||||||
parentError = group.simplified.error,
|
parentError = group.simplified.error,
|
||||||
meshletStartIndex = (uint)pMeshletData->meshlets.Count,
|
meshletStartIndex = (uint)meshletData->meshlets.Count,
|
||||||
meshletCount = (uint)clusters.Count,
|
meshletCount = (uint)clusters.Count,
|
||||||
lodLevel = (uint)group.depth
|
lodLevel = (uint)group.depth
|
||||||
};
|
};
|
||||||
pMeshletData->groups.Add(meshletGroup);
|
meshletData->groups.Add(meshletGroup);
|
||||||
|
|
||||||
for (var i = 0; i < clusters.Count; i++)
|
for (var i = 0; i < clusters.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -731,20 +752,20 @@ public static unsafe partial class MeshProcessor
|
|||||||
boundingBox = new AABB(cluster.bounds.center - cluster.bounds.radius, cluster.bounds.center + cluster.bounds.radius),
|
boundingBox = new AABB(cluster.bounds.center - cluster.bounds.radius, cluster.bounds.center + cluster.bounds.radius),
|
||||||
vertexCount = (byte)cluster.vertexCount,
|
vertexCount = (byte)cluster.vertexCount,
|
||||||
triangleCount = (byte)(cluster.localIndexCount / 3),
|
triangleCount = (byte)(cluster.localIndexCount / 3),
|
||||||
vertexOffset = (uint)pMeshletData->meshletVertices.Count,
|
vertexOffset = (uint)meshletData->meshletVertices.Count,
|
||||||
triangleOffset = (uint)pMeshletData->meshletTriangles.Count,
|
triangleOffset = (uint)meshletData->meshletTriangles.Count,
|
||||||
groupIndex = (uint)pMeshletData->groups.Count - 1,
|
groupIndex = (uint)meshletData->groups.Count - 1,
|
||||||
clusterError = cluster.bounds.error,
|
clusterError = cluster.bounds.error,
|
||||||
parentError = group.simplified.error,
|
parentError = group.simplified.error,
|
||||||
localMaterialIndex = (byte)materialIndex,
|
localMaterialIndex = (byte)materialIndex,
|
||||||
lodLevel = (byte)group.depth,
|
lodLevel = (byte)group.depth,
|
||||||
};
|
};
|
||||||
pMeshletData->meshlets.Add(meshlet);
|
meshletData->meshlets.Add(meshlet);
|
||||||
|
|
||||||
// Add unique vertices
|
// Add unique vertices
|
||||||
for (nuint j = 0; j < cluster.vertexCount; j++)
|
for (nuint j = 0; j < cluster.vertexCount; j++)
|
||||||
{
|
{
|
||||||
pMeshletData->meshletVertices.Add(cluster.uniqueVertices[j]);
|
meshletData->meshletVertices.Add(cluster.uniqueVertices[j]);
|
||||||
}
|
}
|
||||||
// Add local triangles (packed into uints)
|
// Add local triangles (packed into uints)
|
||||||
var triangleCount = cluster.localIndexCount / 3;
|
var triangleCount = cluster.localIndexCount / 3;
|
||||||
@@ -754,11 +775,28 @@ public static unsafe partial class MeshProcessor
|
|||||||
uint i1 = cluster.localIndices[j * 3 + 1];
|
uint i1 = cluster.localIndices[j * 3 + 1];
|
||||||
uint i2 = cluster.localIndices[j * 3 + 2];
|
uint i2 = cluster.localIndices[j * 3 + 2];
|
||||||
var packedTriangle = i0 | (i1 << 8) | (i2 << 16);
|
var packedTriangle = i0 | (i1 << 8) | (i2 << 16);
|
||||||
pMeshletData->meshletTriangles.Add(packedTriangle);
|
meshletData->meshletTriangles.Add(packedTriangle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return meshletData->groups.Count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
internal static partial class MeshProcessor
|
||||||
|
{
|
||||||
|
private class MeshletBuildJob
|
||||||
|
{
|
||||||
|
public ClodConfig clodConfig;
|
||||||
|
public ClodMesh clodMesh;
|
||||||
|
|
||||||
|
public MeshletContext context;
|
||||||
|
|
||||||
|
public void Execute()
|
||||||
|
{
|
||||||
|
Build(in clodConfig, in clodMesh, context, MeshletOutputCallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -766,9 +804,8 @@ public static unsafe partial class MeshProcessor
|
|||||||
/// Each <see cref="MaterialPartInfo"/> describes a material partition's index range within the unified buffer.
|
/// Each <see cref="MaterialPartInfo"/> describes a material partition's index range within the unified buffer.
|
||||||
/// Meshlets are built per-part and tagged with the corresponding <c>localMaterialIndex</c>.
|
/// Meshlets are built per-part and tagged with the corresponding <c>localMaterialIndex</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void BuildMeshlets(MeshletMeshData* pMeshletData, ReadOnlyUnsafeCollection<Vertex> vertices, ReadOnlyUnsafeCollection<uint> indices, ReadOnlySpan<MaterialPartInfo> parts)
|
public static async Task<DisposablePtr<MeshletMeshData>> BuildMeshletsAsync(ReadOnlyView<Vertex> vertices, ReadOnlyView<uint> indices, ReadOnlyView<MaterialPartInfo> parts, CancellationToken token)
|
||||||
{
|
{
|
||||||
Logger.DebugAssert(pMeshletData->meshletCount == 0, "Meshlet data is not empty.");
|
|
||||||
Logger.DebugAssert(vertices.Count > 0, "Mesh must have vertices to build meshlets.");
|
Logger.DebugAssert(vertices.Count > 0, "Mesh must have vertices to build meshlets.");
|
||||||
Logger.DebugAssert(indices.Count > 0, "Mesh must have indices to build meshlets.");
|
Logger.DebugAssert(indices.Count > 0, "Mesh must have indices to build meshlets.");
|
||||||
Logger.DebugAssert(parts.Length > 0, "Must have at least one material part.");
|
Logger.DebugAssert(parts.Length > 0, "Must have at least one material part.");
|
||||||
@@ -797,10 +834,23 @@ public static unsafe partial class MeshProcessor
|
|||||||
simplifyFallbackSloppy = true,
|
simplifyFallbackSloppy = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
IntPtr meshletData;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
// NOTE: We use NativeMemory here instead of MemoryUtility (use mimalloc internally) because this is a async method and may run a random thread pool thread which never dies.
|
||||||
|
// This will case mimalloc to allocate new heaps that hardly ever get freed, leading to memory bloat. Using NativeMemory ensures that we use the shared heap which doesn't have this issue.
|
||||||
|
meshletData = (IntPtr)NativeMemory.AllocZeroed(MemoryUtility.SizeOf<MeshletMeshData>());
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
for (var i = 0; i < parts.Length; i++)
|
for (var i = 0; i < parts.Length; i++)
|
||||||
{
|
{
|
||||||
ref readonly var part = ref parts[i];
|
ref readonly var part = ref parts[i];
|
||||||
|
MeshletBuildJob job;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
// Each part references a slice of the global index buffer,
|
// Each part references a slice of the global index buffer,
|
||||||
// but vertex positions are the full unified buffer so global indices remain valid.
|
// but vertex positions are the full unified buffer so global indices remain valid.
|
||||||
var clodMesh = new ClodMesh
|
var clodMesh = new ClodMesh
|
||||||
@@ -817,13 +867,24 @@ public static unsafe partial class MeshProcessor
|
|||||||
|
|
||||||
var context = new MeshletContext
|
var context = new MeshletContext
|
||||||
{
|
{
|
||||||
data = pMeshletData,
|
data = (MeshletMeshData*)meshletData,
|
||||||
materialIndex = part.materialIndex
|
materialIndex = part.materialIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
Build(in config, in clodMesh, &context, MeshletOutputCallback);
|
job = new MeshletBuildJob
|
||||||
|
{
|
||||||
|
clodConfig = config,
|
||||||
|
clodMesh = clodMesh,
|
||||||
|
context = context
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.Run(job.Execute, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pMeshletData = (MeshletMeshData*)meshletData;
|
||||||
pMeshletData->meshletCount = pMeshletData->meshlets.IsCreated ? pMeshletData->meshlets.Count : 0;
|
pMeshletData->meshletCount = pMeshletData->meshlets.IsCreated ? pMeshletData->meshlets.Count : 0;
|
||||||
|
|
||||||
if (pMeshletData->groups.IsCreated && pMeshletData->groups.Count > 0)
|
if (pMeshletData->groups.IsCreated && pMeshletData->groups.Count > 0)
|
||||||
@@ -844,10 +905,346 @@ public static unsafe partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
pMeshletData->materialSlotCount = maxMaterialSlot + 1;
|
pMeshletData->materialSlotCount = maxMaterialSlot + 1;
|
||||||
|
|
||||||
|
return new DisposablePtr<MeshletMeshData>(pMeshletData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
NativeMemory.Free((void*)meshletData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void BuildClusterLodHierarchy()
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TempBinaryNode
|
||||||
{
|
{
|
||||||
// TODO: Implement a function that builds a cluster LOD hierarchy for a mesh, which can be used for efficient rendering of large meshes with varying levels of detail.
|
public AABB bounds;
|
||||||
|
public float maxParentError;
|
||||||
|
public int leftChild;
|
||||||
|
public int rightChild;
|
||||||
|
public int meshletIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int BuildBinaryTree(ref UnsafeList<TempBinaryNode> nodes, UnsafeArray<int> meshletIndices, int start, int end, ReadOnlySpan<Meshlet> meshlets)
|
||||||
|
{
|
||||||
|
if (start == end - 1)
|
||||||
|
{
|
||||||
|
var meshletIndex = meshletIndices[start];
|
||||||
|
ref readonly var m = ref meshlets[meshletIndex];
|
||||||
|
|
||||||
|
var node = new TempBinaryNode
|
||||||
|
{
|
||||||
|
bounds = m.boundingBox,
|
||||||
|
maxParentError = m.parentError,
|
||||||
|
leftChild = -1,
|
||||||
|
rightChild = -1,
|
||||||
|
meshletIndex = meshletIndex
|
||||||
|
};
|
||||||
|
var nodeIndex = nodes.Count;
|
||||||
|
nodes.Add(node);
|
||||||
|
return nodeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute centroid bounds
|
||||||
|
var centroidMin = new float3(float.MaxValue);
|
||||||
|
var centroidMax = new float3(float.MinValue);
|
||||||
|
for (var i = start; i < end; i++)
|
||||||
|
{
|
||||||
|
var m = meshlets[meshletIndices[i]];
|
||||||
|
var center = m.boundingBox.Center;
|
||||||
|
centroidMin = math.min(centroidMin, center);
|
||||||
|
centroidMax = math.max(centroidMax, center);
|
||||||
|
}
|
||||||
|
|
||||||
|
var extents = centroidMax - centroidMin;
|
||||||
|
var splitAxis = 0;
|
||||||
|
if (extents.y > extents.x && extents.y > extents.z)
|
||||||
|
{
|
||||||
|
splitAxis = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extents.z > extents.x && extents.z > extents.y)
|
||||||
|
{
|
||||||
|
splitAxis = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f;
|
||||||
|
|
||||||
|
// Partition
|
||||||
|
var mid = start;
|
||||||
|
for (var i = start; i < end; i++)
|
||||||
|
{
|
||||||
|
var center = meshlets[meshletIndices[i]].boundingBox.Center;
|
||||||
|
if (center[splitAxis] < splitPoint)
|
||||||
|
{
|
||||||
|
var temp = meshletIndices[mid];
|
||||||
|
meshletIndices[mid] = meshletIndices[i];
|
||||||
|
meshletIndices[i] = temp;
|
||||||
|
mid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mid == start || mid == end)
|
||||||
|
{
|
||||||
|
mid = start + (end - start) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = BuildBinaryTree(ref nodes, meshletIndices, start, mid, meshlets);
|
||||||
|
var right = BuildBinaryTree(ref nodes, meshletIndices, mid, end, meshlets);
|
||||||
|
|
||||||
|
var leftNode = nodes[left];
|
||||||
|
var rightNode = nodes[right];
|
||||||
|
|
||||||
|
var mergedBounds = new AABB(
|
||||||
|
math.min(leftNode.bounds.Min, rightNode.bounds.Min),
|
||||||
|
math.max(leftNode.bounds.Max, rightNode.bounds.Max)
|
||||||
|
);
|
||||||
|
|
||||||
|
var internalNodeIndex = nodes.Count;
|
||||||
|
nodes.Add(new TempBinaryNode
|
||||||
|
{
|
||||||
|
bounds = mergedBounds,
|
||||||
|
maxParentError = Math.Max(leftNode.maxParentError, rightNode.maxParentError),
|
||||||
|
leftChild = left,
|
||||||
|
rightChild = right,
|
||||||
|
meshletIndex = -1
|
||||||
|
});
|
||||||
|
|
||||||
|
return internalNodeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GatherChildren(UnsafeList<TempBinaryNode> binaryNodes, int nodeIndex, ref UnsafeList<int> gathered)
|
||||||
|
{
|
||||||
|
gathered.Clear();
|
||||||
|
var node = binaryNodes[nodeIndex];
|
||||||
|
if (node.leftChild != -1)
|
||||||
|
{
|
||||||
|
gathered.Add(node.leftChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.rightChild != -1)
|
||||||
|
{
|
||||||
|
gathered.Add(node.rightChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (gathered.Count < 4)
|
||||||
|
{
|
||||||
|
var largestInternalIndex = -1;
|
||||||
|
var maxSurfaceArea = -1.0f;
|
||||||
|
var listIndexToRemove = -1;
|
||||||
|
|
||||||
|
for (var i = 0; i < gathered.Count; i++)
|
||||||
|
{
|
||||||
|
var childIdx = gathered[i];
|
||||||
|
var childNode = binaryNodes[childIdx];
|
||||||
|
if (childNode.leftChild != -1) // is internal
|
||||||
|
{
|
||||||
|
var extents = childNode.bounds.Extents;
|
||||||
|
var sa = extents.x * extents.y + extents.y * extents.z + extents.z * extents.x;
|
||||||
|
if (sa > maxSurfaceArea)
|
||||||
|
{
|
||||||
|
maxSurfaceArea = sa;
|
||||||
|
largestInternalIndex = childIdx;
|
||||||
|
listIndexToRemove = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largestInternalIndex == -1)
|
||||||
|
{
|
||||||
|
break; // all gathered are leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
gathered.RemoveAt(listIndexToRemove);
|
||||||
|
var largestNode = binaryNodes[largestInternalIndex];
|
||||||
|
if (largestNode.leftChild != -1)
|
||||||
|
{
|
||||||
|
gathered.Add(largestNode.leftChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largestNode.rightChild != -1)
|
||||||
|
{
|
||||||
|
gathered.Add(largestNode.rightChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CollapseTo4Ary(UnsafeList<TempBinaryNode> binaryNodes, int binaryNodeIndex, UnsafeList<MeshletHierarchyNode> hierarchyNodes)
|
||||||
|
{
|
||||||
|
var node = binaryNodes[binaryNodeIndex];
|
||||||
|
if (node.leftChild == -1)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = AllocationManager.CreateStackScope();
|
||||||
|
var gathered = new UnsafeList<int>(4, scope.AllocationHandle);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GatherChildren(binaryNodes, binaryNodeIndex, ref gathered);
|
||||||
|
|
||||||
|
var bvhNode = new MeshletHierarchyNode();
|
||||||
|
|
||||||
|
var minX = new float4(float.PositiveInfinity);
|
||||||
|
var minY = new float4(float.PositiveInfinity);
|
||||||
|
var minZ = new float4(float.PositiveInfinity);
|
||||||
|
var maxX = new float4(float.NegativeInfinity);
|
||||||
|
var maxY = new float4(float.NegativeInfinity);
|
||||||
|
var maxZ = new float4(float.NegativeInfinity);
|
||||||
|
var maxParentError = new float4(0);
|
||||||
|
var nodeData = new uint4(0xFFFFFFFF);
|
||||||
|
|
||||||
|
var outNodeIndex = hierarchyNodes.Count;
|
||||||
|
hierarchyNodes.Add(bvhNode); // Reserve slot
|
||||||
|
|
||||||
|
for (var i = 0; i < gathered.Count; i++)
|
||||||
|
{
|
||||||
|
var childIdx = gathered[i];
|
||||||
|
var childNode = binaryNodes[childIdx];
|
||||||
|
|
||||||
|
uint data = 0;
|
||||||
|
if (childNode.leftChild == -1)
|
||||||
|
{
|
||||||
|
data = (uint)childNode.meshletIndex;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var child4AryIndex = CollapseTo4Ary(binaryNodes, childIdx, hierarchyNodes);
|
||||||
|
data = (1u << 31) | (uint)child4AryIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
minX.x = childNode.bounds.Min.x; minY.x = childNode.bounds.Min.y; minZ.x = childNode.bounds.Min.z;
|
||||||
|
maxX.x = childNode.bounds.Max.x; maxY.x = childNode.bounds.Max.y; maxZ.x = childNode.bounds.Max.z;
|
||||||
|
maxParentError.x = childNode.maxParentError;
|
||||||
|
nodeData.x = data;
|
||||||
|
}
|
||||||
|
else if (i == 1)
|
||||||
|
{
|
||||||
|
minX.y = childNode.bounds.Min.x; minY.y = childNode.bounds.Min.y; minZ.y = childNode.bounds.Min.z;
|
||||||
|
maxX.y = childNode.bounds.Max.x; maxY.y = childNode.bounds.Max.y; maxZ.y = childNode.bounds.Max.z;
|
||||||
|
maxParentError.y = childNode.maxParentError;
|
||||||
|
nodeData.y = data;
|
||||||
|
}
|
||||||
|
else if (i == 2)
|
||||||
|
{
|
||||||
|
minX.z = childNode.bounds.Min.x; minY.z = childNode.bounds.Min.y; minZ.z = childNode.bounds.Min.z;
|
||||||
|
maxX.z = childNode.bounds.Max.x; maxY.z = childNode.bounds.Max.y; maxZ.z = childNode.bounds.Max.z;
|
||||||
|
maxParentError.z = childNode.maxParentError;
|
||||||
|
nodeData.z = data;
|
||||||
|
}
|
||||||
|
else if (i == 3)
|
||||||
|
{
|
||||||
|
minX.w = childNode.bounds.Min.x; minY.w = childNode.bounds.Min.y; minZ.w = childNode.bounds.Min.z;
|
||||||
|
maxX.w = childNode.bounds.Max.x; maxY.w = childNode.bounds.Max.y; maxZ.w = childNode.bounds.Max.z;
|
||||||
|
maxParentError.w = childNode.maxParentError;
|
||||||
|
nodeData.w = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bvhNode.minX = minX;
|
||||||
|
bvhNode.minY = minY;
|
||||||
|
bvhNode.minZ = minZ;
|
||||||
|
bvhNode.maxX = maxX;
|
||||||
|
bvhNode.maxY = maxY;
|
||||||
|
bvhNode.maxZ = maxZ;
|
||||||
|
bvhNode.maxParentError = maxParentError;
|
||||||
|
bvhNode.nodeData = nodeData;
|
||||||
|
|
||||||
|
hierarchyNodes[outNodeIndex] = bvhNode;
|
||||||
|
return outNodeIndex;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gathered.Dispose();
|
||||||
|
scope.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe class BuildClusterLodHierarchyJob
|
||||||
|
{
|
||||||
|
public MeshletMeshData* meshletData;
|
||||||
|
|
||||||
|
public void Execute()
|
||||||
|
{
|
||||||
|
using var meshletIndices = new UnsafeArray<int>(meshletData->meshletCount, AllocationHandle.TLSF);
|
||||||
|
for (var i = 0; i < meshletData->meshletCount; i++)
|
||||||
|
{
|
||||||
|
meshletIndices[i] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
var binaryNodes = new UnsafeList<TempBinaryNode>(meshletData->meshletCount * 2, AllocationHandle.TLSF);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rootIndex = BuildBinaryTree(ref binaryNodes, meshletIndices, 0, meshletIndices.Length, meshletData->meshlets);
|
||||||
|
|
||||||
|
if (!meshletData->hierarchyNodes.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->hierarchyNodes = new UnsafeList<MeshletHierarchyNode>(meshletData->meshletCount, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binaryNodes[rootIndex].leftChild == -1)
|
||||||
|
{
|
||||||
|
var bvhNode = new MeshletHierarchyNode();
|
||||||
|
bvhNode.minX = new float4(float.PositiveInfinity);
|
||||||
|
bvhNode.minY = new float4(float.PositiveInfinity);
|
||||||
|
bvhNode.minZ = new float4(float.PositiveInfinity);
|
||||||
|
bvhNode.maxX = new float4(float.NegativeInfinity);
|
||||||
|
bvhNode.maxY = new float4(float.NegativeInfinity);
|
||||||
|
bvhNode.maxZ = new float4(float.NegativeInfinity);
|
||||||
|
bvhNode.maxParentError = new float4(0);
|
||||||
|
bvhNode.nodeData = new uint4(0xFFFFFFFF);
|
||||||
|
|
||||||
|
var childNode = binaryNodes[rootIndex];
|
||||||
|
bvhNode.minX.x = childNode.bounds.Min.x;
|
||||||
|
bvhNode.minY.x = childNode.bounds.Min.y;
|
||||||
|
bvhNode.minZ.x = childNode.bounds.Min.z;
|
||||||
|
bvhNode.maxX.x = childNode.bounds.Max.x;
|
||||||
|
bvhNode.maxY.x = childNode.bounds.Max.y;
|
||||||
|
bvhNode.maxZ.x = childNode.bounds.Max.z;
|
||||||
|
bvhNode.maxParentError.x = childNode.maxParentError;
|
||||||
|
bvhNode.nodeData.x = (uint)childNode.meshletIndex;
|
||||||
|
|
||||||
|
meshletData->hierarchyNodes.Add(bvhNode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CollapseTo4Ary(binaryNodes, rootIndex, meshletData->hierarchyNodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
binaryNodes.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a cluster LOD hierarchy from the input meshlet data.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meshletData">The meshlet data.</param>
|
||||||
|
public static Task BuildClusterLodHierarchyAsync(SharedPtr<MeshletMeshData> meshletData, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (meshletData.GetRef().meshletCount == 0)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var job = new BuildClusterLodHierarchyJob
|
||||||
|
{
|
||||||
|
meshletData = meshletData.Get()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.Run(job.Execute, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
475
src/Editor/Ghost.Editor.Core/Assets/ModelAssetHandler.cs
Normal file
475
src/Editor/Ghost.Editor.Core/Assets/ModelAssetHandler.cs
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Utilities;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Graphics.Core;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using Misaki.HighPerformance.Mathematics.Geometry;
|
||||||
|
using System.IO.Hashing;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public sealed class ModelManifestSubAsset
|
||||||
|
{
|
||||||
|
public Guid Guid
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string StablePath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public int MaterialSlotCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int VertexCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IndexCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModelManifestMetadata
|
||||||
|
{
|
||||||
|
public string Kind
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string StablePath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ImportedModelAsset : IAsset
|
||||||
|
{
|
||||||
|
public ModelManifest Manifest
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
|
||||||
|
: base(id, typeof(ModelAsset).GUID, settings)
|
||||||
|
{
|
||||||
|
Manifest = manifest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public abstract class ModelAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
|
||||||
|
|
||||||
|
private MeshNode _root;
|
||||||
|
|
||||||
|
public MeshNode Root
|
||||||
|
{
|
||||||
|
get => _root;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_root?.Dispose();
|
||||||
|
_root = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ModelAsset(MeshNode root, Guid id, ModelAssetSettings settings)
|
||||||
|
: base(id, typeof(ModelAsset).GUID, settings)
|
||||||
|
{
|
||||||
|
_root = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_root?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CoordinateAxis
|
||||||
|
{
|
||||||
|
PositiveX,
|
||||||
|
PositiveY,
|
||||||
|
PositiveZ,
|
||||||
|
NegativeX,
|
||||||
|
NegativeY,
|
||||||
|
NegativeZ
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VertexDataSource
|
||||||
|
{
|
||||||
|
Imported,
|
||||||
|
Computed,
|
||||||
|
ComputedIfMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModelAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
public VertexDataSource NormalDataSource
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VertexDataSource.ComputedIfMissing;
|
||||||
|
|
||||||
|
public VertexDataSource TangentDataSource
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VertexDataSource.ComputedIfMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ObjAssetSettings : ModelAssetSettings
|
||||||
|
{
|
||||||
|
public CoordinateAxis ObjectUpAxis
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = CoordinateAxis.PositiveY;
|
||||||
|
|
||||||
|
public CoordinateAxis ObjectForwardAxis
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = CoordinateAxis.NegativeZ;
|
||||||
|
|
||||||
|
public CoordinateAxis ObjectRightAxis
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = CoordinateAxis.PositiveX;
|
||||||
|
|
||||||
|
public float UnitMeterScale
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FbxAssetSettings : ModelAssetSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = ModelAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
|
||||||
|
internal class ModelAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
};
|
||||||
|
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ObjAssetSettings();
|
||||||
|
}
|
||||||
|
else if (string.Equals(ext, ".fbx", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new FbxAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var importedPath = ImportCoordinator.GetImportedAssetPath(id);
|
||||||
|
if (!File.Exists(importedPath))
|
||||||
|
{
|
||||||
|
return Result.Failure<IAsset>("Imported model manifest does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = new FileStream(importedPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var manifest = await JsonSerializer.DeserializeAsync<ModelManifest>(stream, s_jsonOptions, token).ConfigureAwait(false);
|
||||||
|
return manifest != null
|
||||||
|
? Result.Success<IAsset>(new ImportedModelAsset(id, settings, manifest))
|
||||||
|
: Result.Failure<IAsset>("Failed to deserialize model manifest.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure<IAsset>(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result.Failure("Saving model assets is not supported yet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return Result.Failure<ImportedSubAsset[]>("Source file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meshSettings = ResolveSettings(sourcePath, settings);
|
||||||
|
|
||||||
|
using var root = new MeshNode();
|
||||||
|
var result = await MeshProcessor.ParseMeshAsync(root, sourcePath, AllocationHandle.TLSF, meshSettings, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = new ModelManifest
|
||||||
|
{
|
||||||
|
AssetId = id,
|
||||||
|
};
|
||||||
|
|
||||||
|
var importedSubAssets = new List<ImportedSubAsset>();
|
||||||
|
manifest.Root = await WriteNodeAsync(id, sourcePath, root, string.Empty, manifest, importedSubAssets, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||||
|
|
||||||
|
await using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, manifest, s_jsonOptions, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return importedSubAssets.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure<ImportedSubAsset[]>($"Failed to import mesh asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModelAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
|
||||||
|
{
|
||||||
|
if (settings is ModelAssetSettings meshSettings)
|
||||||
|
{
|
||||||
|
return meshSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetExtension(sourcePath).Equals(".obj", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? new ObjAssetSettings()
|
||||||
|
: new FbxAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<ModelManifestNode> WriteNodeAsync(
|
||||||
|
Guid parentGuid,
|
||||||
|
string sourcePath,
|
||||||
|
MeshNode node,
|
||||||
|
string parentPath,
|
||||||
|
ModelManifest manifest,
|
||||||
|
List<ImportedSubAsset> importedSubAssets,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var stablePath = string.IsNullOrEmpty(parentPath)
|
||||||
|
? SanitizePathSegment(node.Name)
|
||||||
|
: $"{parentPath}/{SanitizePathSegment(node.Name)}";
|
||||||
|
|
||||||
|
var manifestNode = new ModelManifestNode
|
||||||
|
{
|
||||||
|
Name = node.Name,
|
||||||
|
StablePath = stablePath,
|
||||||
|
LocalTransform = node.LocalTransform,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node is GeometryMeshNode geometry)
|
||||||
|
{
|
||||||
|
var meshGuid = CreateDeterministicSubAssetGuid(parentGuid, "Mesh", stablePath);
|
||||||
|
var meshPath = ImportCoordinator.GetImportedAssetPath(meshGuid);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(meshPath)!);
|
||||||
|
|
||||||
|
var (materialSlotCount, lodLevelCount) = await WriteMeshContentAsync(meshPath, geometry, token).ConfigureAwait(false);
|
||||||
|
manifestNode.MeshGuid = meshGuid;
|
||||||
|
|
||||||
|
manifest.Meshes.Add(new ModelManifestSubAsset
|
||||||
|
{
|
||||||
|
Guid = meshGuid,
|
||||||
|
Name = node.Name,
|
||||||
|
StablePath = stablePath,
|
||||||
|
MaterialSlotCount = materialSlotCount,
|
||||||
|
VertexCount = geometry.Vertices.Count,
|
||||||
|
IndexCount = geometry.Indices.Count,
|
||||||
|
});
|
||||||
|
|
||||||
|
importedSubAssets.Add(new ImportedSubAsset(
|
||||||
|
meshGuid,
|
||||||
|
"Mesh",
|
||||||
|
node.Name,
|
||||||
|
stablePath,
|
||||||
|
$"{sourcePath}#Mesh/{stablePath}",
|
||||||
|
typeof(ModelAsset).GUID));
|
||||||
|
}
|
||||||
|
else if (node is LightMeshNode)
|
||||||
|
{
|
||||||
|
manifest.Metadata.Add(new ModelManifestMetadata
|
||||||
|
{
|
||||||
|
Kind = "Light",
|
||||||
|
Name = node.Name,
|
||||||
|
StablePath = stablePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
manifestNode.Children.Add(await WriteNodeAsync(parentGuid, sourcePath, child, stablePath, manifest, importedSubAssets, token).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifestNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token)
|
||||||
|
{
|
||||||
|
using var meshletData = await MeshProcessor.BuildMeshletsAsync(geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
|
||||||
|
await MeshProcessor.BuildClusterLodHierarchyAsync(meshletData.Share(), token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var bounds = ComputeBounds(geometry.Vertices);
|
||||||
|
var header = new MeshContentHeader
|
||||||
|
{
|
||||||
|
magic = MeshContentHeader.MAGIC,
|
||||||
|
version = MeshContentHeader.VERSION,
|
||||||
|
vertexCount = geometry.Vertices.Count,
|
||||||
|
indexCount = geometry.Indices.Count,
|
||||||
|
materialPartCount = geometry.MaterialParts.Length,
|
||||||
|
meshletCount = meshletData.GetRef().meshlets.Count,
|
||||||
|
meshletGroupCount = meshletData.GetRef().groups.Count,
|
||||||
|
meshletHierarchyNodeCount = meshletData.GetRef().hierarchyNodes.Count,
|
||||||
|
meshletVertexCount = meshletData.GetRef().meshletVertices.Count,
|
||||||
|
meshletTriangleCount = meshletData.GetRef().meshletTriangles.Count,
|
||||||
|
materialSlotCount = meshletData.GetRef().materialSlotCount,
|
||||||
|
lodLevelCount = meshletData.GetRef().lodLevelCount,
|
||||||
|
boundsMin = bounds.Min,
|
||||||
|
boundsMax = bounds.Max,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
stream.Write(header);
|
||||||
|
|
||||||
|
header.vertexOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
|
||||||
|
|
||||||
|
header.indexOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
|
||||||
|
|
||||||
|
header.materialPartOffset = stream.Position;
|
||||||
|
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
|
||||||
|
|
||||||
|
header.meshletOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
|
||||||
|
|
||||||
|
header.meshletGroupOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
|
||||||
|
|
||||||
|
header.meshletHierarchyNodeOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
|
||||||
|
|
||||||
|
header.meshletVertexOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
|
||||||
|
|
||||||
|
header.meshletTriangleOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
stream.Write(header);
|
||||||
|
stream.Flush();
|
||||||
|
|
||||||
|
return (meshletData.GetRef().materialSlotCount, meshletData.GetRef().lodLevelCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AABB ComputeBounds(UnsafeList<Vertex> vertices)
|
||||||
|
{
|
||||||
|
var min = new float3(float.MaxValue);
|
||||||
|
var max = new float3(float.MinValue);
|
||||||
|
for (var i = 0; i < vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var p = vertices[i].position;
|
||||||
|
min = math.min(min, p);
|
||||||
|
max = math.max(max, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AABB(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid CreateDeterministicSubAssetGuid(Guid parentGuid, string kind, string stablePath)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes($"{parentGuid:N}:{kind}:{stablePath}");
|
||||||
|
Span<byte> hash = stackalloc byte[16];
|
||||||
|
var hashValue = XxHash128.HashToUInt128(bytes);
|
||||||
|
Unsafe.WriteUnaligned(ref hash[0], hashValue);
|
||||||
|
|
||||||
|
hash[6] = (byte)((hash[6] & 0x0F) | 0x50);
|
||||||
|
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
|
||||||
|
return new Guid(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizePathSegment(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return "Node";
|
||||||
|
}
|
||||||
|
|
||||||
|
var chars = value.ToCharArray();
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
if (chars[i] == '/' || chars[i] == '\\' || chars[i] == '#')
|
||||||
|
{
|
||||||
|
chars[i] = '_';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMaterialParts(Stream stream, ReadOnlySpan<MaterialPartInfo> parts)
|
||||||
|
{
|
||||||
|
if (parts.IsEmpty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = parts.Length <= 64
|
||||||
|
? stackalloc MeshContentMaterialPart[parts.Length]
|
||||||
|
: new MeshContentMaterialPart[parts.Length];
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
buffer[i] = new MeshContentMaterialPart
|
||||||
|
{
|
||||||
|
materialIndex = parts[i].materialIndex,
|
||||||
|
indexStart = parts[i].indexStart,
|
||||||
|
indexCount = parts[i].indexCount,
|
||||||
|
vertexStart = parts[i].vertexStart,
|
||||||
|
vertexCount = parts[i].vertexCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.Write(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
35
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public sealed class SceneAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "1B5E3F2A-8D91-4C67-BE32-A0F9C6D4E781";
|
||||||
|
|
||||||
|
public ushort RuntimeSceneID
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SceneName
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int EntityCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneAsset(Guid id, IAssetSettings? settings)
|
||||||
|
: base(id, typeof(SceneAsset).GUID, settings)
|
||||||
|
{
|
||||||
|
SceneName = string.Empty;
|
||||||
|
EntityCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SceneAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
161
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
161
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = SceneAsset.GUID, RuntimeAssetType = AssetType.Scene, Extensions = new[] { ".gscene" })]
|
||||||
|
internal class SceneAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
|
{
|
||||||
|
[AssetOpenHandler(".gscene")]
|
||||||
|
private static async Task<Result> OpenAsync(string path)
|
||||||
|
{
|
||||||
|
// Actually double clicking the asset in content browser will just open it.
|
||||||
|
// We probably shouldn't do the actual loading in OpenAsync, but let's keep it simple for now.
|
||||||
|
// OpenAsync usually returns immediately if there's no UI, or we should use AssetRegistry.LoadAssetAsync
|
||||||
|
var assetRegistry = EditorApplication.GetService<IAssetRegistry>();
|
||||||
|
var id = Guid.NewGuid(); // Wait, how do we know the ID?
|
||||||
|
// AssetMeta handles this. This method is just a quick hack for double clicking.
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(path);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to load scene.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
service.LoadSceneIntoEditorWorld(data, SceneLoadingType.Single, null);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
return new SceneAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
|
||||||
|
var asset = new SceneAsset(id, settings)
|
||||||
|
{
|
||||||
|
SceneName = Path.GetFileNameWithoutExtension(assetPath),
|
||||||
|
EntityCount = data?.Entities?.Count ?? 0,
|
||||||
|
RuntimeSceneID = Ghost.Engine.Core.Scene.INVALID_ID // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data != null)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<IAsset>();
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
service.LoadSceneIntoEditorWorld(data, SceneLoadingType.Single, (scene) =>
|
||||||
|
{
|
||||||
|
asset.RuntimeSceneID = scene.ID;
|
||||||
|
EditorApplication.GetService<IEditorWorldService>().RegisterSceneAsset(scene.ID, asset);
|
||||||
|
tcs.TrySetResult(asset);
|
||||||
|
});
|
||||||
|
return Result.Success(await tcs.Task);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success<IAsset>(asset);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (asset is not SceneAsset sceneAsset)
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset type is not SceneAsset");
|
||||||
|
}
|
||||||
|
|
||||||
|
var worldService = EditorApplication.GetService<IEditorWorldService>();
|
||||||
|
var tcs = new TaskCompletionSource<byte[]>();
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scene = Ghost.Engine.Core.Scene.FromID(sceneAsset.RuntimeSceneID);
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
var bytes = service.SerializeSceneToMemory(scene);
|
||||||
|
tcs.TrySetResult(bytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await tcs.Task;
|
||||||
|
await File.WriteAllBytesAsync(targetPath, bytes, token);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to save scene: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Source scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(sourcePath, token);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
SceneSerializationService.SerializeToBinary(data, stream);
|
||||||
|
|
||||||
|
return Result.Success(Array.Empty<ImportedSubAsset>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to import scene asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneSerializationService.SerializeToBinary(data, targetStream);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to pack scene asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Core.Graphics;
|
using Ghost.Core.Graphics;
|
||||||
using Ghost.DSL.ShaderCompiler;
|
using Ghost.DSL.ShaderCompiler;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
@@ -11,32 +11,16 @@ public sealed partial class GraphicsShaderAsset : IAsset
|
|||||||
{
|
{
|
||||||
public const string GUID = "7BD4591C-B017-4814-AA0B-3F30EB3E727E";
|
public const string GUID = "7BD4591C-B017-4814-AA0B-3F30EB3E727E";
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings? Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(GraphicsShaderAsset).GUID;
|
|
||||||
|
|
||||||
public GraphicsShaderDescriptor Descriptor
|
public GraphicsShaderDescriptor Descriptor
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal GraphicsShaderAsset(GraphicsShaderDescriptor descriptor, Guid id)
|
internal GraphicsShaderAsset(GraphicsShaderDescriptor descriptor, Guid id)
|
||||||
|
: base(id, typeof(GraphicsShaderAsset).GUID, null)
|
||||||
{
|
{
|
||||||
ID = id;
|
|
||||||
Descriptor = descriptor;
|
Descriptor = descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Guid(GUID)]
|
[Guid(GUID)]
|
||||||
@@ -44,42 +28,23 @@ public sealed partial class ComputeShaderAsset : IAsset
|
|||||||
{
|
{
|
||||||
public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A";
|
public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A";
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings? Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(ComputeShaderAsset).GUID;
|
|
||||||
|
|
||||||
public ComputeShaderDescriptor Descriptor
|
public ComputeShaderDescriptor Descriptor
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
|
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
|
||||||
|
: base(id, typeof(ComputeShaderAsset).GUID, null)
|
||||||
{
|
{
|
||||||
ID = id;
|
|
||||||
Descriptor = descriptor;
|
Descriptor = descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shader does not handle import/export via asset registry, it will handled by the hot reload system.
|
// Shader does not handle import/export via asset registry, it will handled by the hot reload system.
|
||||||
[CustomAssetHandler(GraphicsShaderAsset.GUID, [".gshdr"], 1)]
|
[CustomAssetHandler(AssetTypeId = GraphicsShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gshdr" })]
|
||||||
internal class GraphicsShaderAssetHandler : IPackableAssetHandler
|
internal class GraphicsShaderAssetHandler : IPackableAssetHandler
|
||||||
{
|
{
|
||||||
public AssetType RuntimeAssetType => AssetType.Shader;
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
public Guid EditorAssetTypeID => typeof(GraphicsShaderAsset).GUID;
|
|
||||||
|
|
||||||
public IAssetSettings? CreateDefaultSettings()
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -109,17 +74,14 @@ internal class GraphicsShaderAssetHandler : IPackableAssetHandler
|
|||||||
|
|
||||||
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CustomAssetHandler(ComputeShaderAsset.GUID, [".gcomp"], 1)]
|
[CustomAssetHandler(AssetTypeId = ComputeShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gcomp" })]
|
||||||
internal class ComputeShaderAssetHandler : IPackableAssetHandler
|
internal class ComputeShaderAssetHandler : IPackableAssetHandler
|
||||||
{
|
{
|
||||||
public AssetType RuntimeAssetType => AssetType.Shader;
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
public Guid EditorAssetTypeID => typeof(ComputeShaderAsset).GUID;
|
|
||||||
|
|
||||||
public IAssetSettings? CreateDefaultSettings()
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -149,6 +111,6 @@ internal class ComputeShaderAssetHandler : IPackableAssetHandler
|
|||||||
|
|
||||||
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Ghost.StbI;
|
using Ghost.StbI;
|
||||||
using Misaki.HighPerformance.LowLevel;
|
using Misaki.HighPerformance.LowLevel;
|
||||||
@@ -54,11 +54,6 @@ public unsafe class TextureAsset : IAsset
|
|||||||
{
|
{
|
||||||
public const string GUID = "27965FFF-860C-40EF-9123-1874D7DE9CDC";
|
public const string GUID = "27965FFF-860C-40EF-9123-1874D7DE9CDC";
|
||||||
|
|
||||||
private static readonly Guid s_typeID = Guid.Parse(GUID);
|
|
||||||
|
|
||||||
private readonly Guid _id;
|
|
||||||
private readonly IAssetSettings _settings;
|
|
||||||
|
|
||||||
private readonly IntPtr _textureData;
|
private readonly IntPtr _textureData;
|
||||||
private readonly uint _width;
|
private readonly uint _width;
|
||||||
private readonly uint _height;
|
private readonly uint _height;
|
||||||
@@ -66,10 +61,6 @@ public unsafe class TextureAsset : IAsset
|
|||||||
private readonly uint _colorComponents;
|
private readonly uint _colorComponents;
|
||||||
private readonly uint _dimension;
|
private readonly uint _dimension;
|
||||||
|
|
||||||
public Guid ID => _id;
|
|
||||||
public Guid TypeID => typeof(TextureAsset).GUID;
|
|
||||||
public IAssetSettings Settings => _settings;
|
|
||||||
|
|
||||||
public IntPtr TextureData => _textureData;
|
public IntPtr TextureData => _textureData;
|
||||||
public uint Width => _width;
|
public uint Width => _width;
|
||||||
public uint Height => _height;
|
public uint Height => _height;
|
||||||
@@ -78,10 +69,8 @@ public unsafe class TextureAsset : IAsset
|
|||||||
public uint ColorComponents => _colorComponents;
|
public uint ColorComponents => _colorComponents;
|
||||||
|
|
||||||
internal TextureAsset([OwnershipTransfer] IntPtr data, TextureContentHeader header, Guid id, IAssetSettings settings)
|
internal TextureAsset([OwnershipTransfer] IntPtr data, TextureContentHeader header, Guid id, IAssetSettings settings)
|
||||||
|
: base(id, typeof(TextureAsset).GUID, settings)
|
||||||
{
|
{
|
||||||
_id = id;
|
|
||||||
_settings = settings;
|
|
||||||
|
|
||||||
_textureData = data;
|
_textureData = data;
|
||||||
_width = header.width;
|
_width = header.width;
|
||||||
_height = header.height;
|
_height = header.height;
|
||||||
@@ -90,15 +79,9 @@ public unsafe class TextureAsset : IAsset
|
|||||||
_colorComponents = header.colorComponents;
|
_colorComponents = header.colorComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
~TextureAsset()
|
protected override void Dispose(bool disposing)
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
{
|
||||||
StbIApi.ImageFree((void*)_textureData);
|
StbIApi.ImageFree((void*)_textureData);
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +232,7 @@ public class TextureAssetSettings : IAssetSettings
|
|||||||
} = new SamplerSettings();
|
} = new SamplerSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
[CustomAssetHandler(TextureAsset.GUID, [".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr"], 1)]
|
[CustomAssetHandler(AssetTypeId = TextureAsset.GUID, RuntimeAssetType = AssetType.Texture, Extensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
|
||||||
internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
{
|
{
|
||||||
internal struct TextureInfo
|
internal struct TextureInfo
|
||||||
@@ -263,11 +246,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
|
|||||||
public bool isHDR;
|
public bool isHDR;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanExport => false;
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
public AssetType RuntimeAssetType => AssetType.Texture;
|
|
||||||
public Guid EditorAssetTypeID => typeof(TextureAsset).GUID;
|
|
||||||
|
|
||||||
public IAssetSettings? CreateDefaultSettings()
|
|
||||||
{
|
{
|
||||||
return new TextureAssetSettings();
|
return new TextureAssetSettings();
|
||||||
}
|
}
|
||||||
@@ -440,7 +419,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
|
|||||||
}, token).ConfigureAwait(false);
|
}, token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
if (!File.Exists(sourcePath))
|
if (!File.Exists(sourcePath))
|
||||||
{
|
{
|
||||||
@@ -464,7 +443,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
|
|||||||
|
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return result;
|
return Result.Failure(result.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (cachePath, mip) = result.Value;
|
var (cachePath, mip) = result.Value;
|
||||||
@@ -486,7 +465,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
|
|||||||
await ddsStream.CopyToAsync(targetStream, token).ConfigureAwait(false);
|
await ddsStream.CopyToAsync(targetStream, token).ConfigureAwait(false);
|
||||||
await targetStream.FlushAsync(token).ConfigureAwait(false);
|
await targetStream.FlushAsync(token).ConfigureAwait(false);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success(Array.Empty<ImportedSubAsset>());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -494,13 +473,8 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return ValueTask.FromResult(Result.Failure("Exporting texture assets is not supported yet."));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
return ValueTask.FromResult(Result.Failure("Packing texture assets is not supported yet."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ internal static partial class TextureProcessor
|
|||||||
public int numMipLevels;
|
public int numMipLevels;
|
||||||
public int channelCount;
|
public int channelCount;
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut)
|
private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut)
|
||||||
{
|
{
|
||||||
var x = i / N;
|
var x = i / N;
|
||||||
@@ -43,23 +43,18 @@ internal static partial class TextureProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GGX Importance Sampling
|
// GGX Importance Sampling
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness)
|
private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness)
|
||||||
{
|
{
|
||||||
var a = roughness * roughness; // Disney remap roughness for better visual linearity
|
var a = roughness * roughness; // Disney remap roughness for better visual linearity
|
||||||
|
|
||||||
var phi = 2.0f * PI * Xi.x;
|
var phi = 2.0f * PI * Xi.x;
|
||||||
|
|
||||||
// Clamp the inside of the cosTheta Sqrt to prevent NaN on division precision edges
|
var cosTheta = TFloat.Sqrt((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y));
|
||||||
var cosThetaInner = TFloat.Max((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y), TFloat.Zero);
|
var sinTheta = TFloat.Sqrt(1.0f - cosTheta * cosTheta);
|
||||||
var cosTheta = TFloat.Sqrt(cosThetaInner);
|
|
||||||
|
|
||||||
// Clamp the inside of sinTheta to prevent sqrt of negative floating-point errors
|
|
||||||
var sinThetaInner = TFloat.Max(1.0f - cosTheta * cosTheta, TFloat.Zero);
|
|
||||||
var sinTheta = TFloat.Sqrt(sinThetaInner);
|
|
||||||
|
|
||||||
// Spherical to Cartesian coordinates (Halfway vector)
|
// Spherical to Cartesian coordinates (Halfway vector)
|
||||||
var (sinPhi, cosPhi) = TFloat.SinCos(phi);
|
TFloat.SinCos(phi, out var sinPhi, out var cosPhi);
|
||||||
var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta);
|
var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta);
|
||||||
|
|
||||||
// Tangent space to World space
|
// Tangent space to World space
|
||||||
@@ -73,13 +68,13 @@ internal static partial class TextureProcessor
|
|||||||
return MathV.Normalize(sampleVec);
|
return MathV.Normalize(sampleVec);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
private static float3 CubemapUVToDir(int face, float u, float v)
|
private static float3 CubemapUVToDir(int face, float u, float v)
|
||||||
{
|
{
|
||||||
var sc = 2.0f * u - 1.0f;
|
var sc = 2.0f * u - 1.0f;
|
||||||
var tc = 1.0f - 2.0f * v;
|
var tc = 1.0f - 2.0f * v;
|
||||||
|
|
||||||
float x = 0, y = 0, z = 0;
|
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||||
switch (face)
|
switch (face)
|
||||||
{
|
{
|
||||||
case 0: x = 1.0f; y = tc; z = -sc; break;
|
case 0: x = 1.0f; y = tc; z = -sc; break;
|
||||||
@@ -93,7 +88,7 @@ internal static partial class TextureProcessor
|
|||||||
return normalize(float3(x, y, z));
|
return normalize(float3(x, y, z));
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir)
|
private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir)
|
||||||
{
|
{
|
||||||
var absX = TFloat.Abs(dir.x);
|
var absX = TFloat.Abs(dir.x);
|
||||||
@@ -140,6 +135,7 @@ internal static partial class TextureProcessor
|
|||||||
return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1);
|
return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
|
||||||
public void Execute(int loopIndex, ref readonly JobExecutionContext ctx)
|
public void Execute(int loopIndex, ref readonly JobExecutionContext ctx)
|
||||||
{
|
{
|
||||||
var m = 0;
|
var m = 0;
|
||||||
@@ -226,7 +222,7 @@ internal static partial class TextureProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
var totalWeight = 0.0f;
|
var totalWeight = 0.0f;
|
||||||
var prefilteredColor = float3(0, 0, 0);
|
var prefilteredColor = float3(0.0f, 0.0f, 0.0f);
|
||||||
|
|
||||||
for (var i = 0; i < TFloat.LaneWidth; i++)
|
for (var i = 0; i < TFloat.LaneWidth; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Windows.System;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core;
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -5,35 +7,6 @@ namespace Ghost.Editor.Core;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class DiscoverableAttributeBase : Attribute;
|
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)]
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
public class CustomEditorAttribute : DiscoverableAttributeBase
|
public class CustomEditorAttribute : DiscoverableAttributeBase
|
||||||
{
|
{
|
||||||
@@ -48,29 +21,16 @@ public class CustomEditorAttribute : DiscoverableAttributeBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)]
|
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
|
||||||
public class EditorInjectionAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
{
|
||||||
public enum ServiceLifetime
|
internal string[] Extensions
|
||||||
{
|
|
||||||
Singleton,
|
|
||||||
Transient,
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceLifetime Lifetime
|
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Type ImplementationType
|
public AssetOpenHandlerAttribute(params string[] extensions)
|
||||||
{
|
{
|
||||||
get;
|
Extensions = extensions;
|
||||||
}
|
|
||||||
|
|
||||||
public EditorInjectionAttribute(ServiceLifetime lifetime, Type implementationType)
|
|
||||||
{
|
|
||||||
Lifetime = lifetime;
|
|
||||||
ImplementationType = implementationType;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +52,35 @@ public sealed class ContextMenuItemAttribute : DiscoverableAttributeBase
|
|||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ContextMenuItemAttribute(string tag, string name, int group = 0)
|
public int Priority
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContextMenuItemAttribute(string tag, string name, int group = 0, int priority = 0)
|
||||||
{
|
{
|
||||||
Tag = tag;
|
Tag = tag;
|
||||||
Name = name;
|
Name = name;
|
||||||
Group = group;
|
Group = group;
|
||||||
|
Priority = priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ShortcutAttribute : DiscoverableAttributeBase
|
||||||
|
{
|
||||||
|
public VirtualKey Key
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VirtualKeyModifiers Modifiers
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShortcutAttribute(VirtualKey key, VirtualKeyModifiers modifiers = VirtualKeyModifiers.None)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Modifiers = modifiers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Services;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Engine.AssetLoader;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Contracts;
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
@@ -59,4 +58,7 @@ public interface IAssetRegistry : IDisposable
|
|||||||
ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default);
|
ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default);
|
||||||
ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default);
|
ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default);
|
||||||
ValueTask<Result[]> SaveDirtyAssetsAsync();
|
ValueTask<Result[]> SaveDirtyAssetsAsync();
|
||||||
|
|
||||||
|
Task<Result> OpenAssetAsync(Guid id);
|
||||||
|
Task<Result> OpenAssetAsync(string assetPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IDirtyTrackerService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the specified object as dirty.
|
||||||
|
/// </summary>
|
||||||
|
void MarkDirty(GhostObject obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the specified object is dirty compared to its clean state.
|
||||||
|
/// </summary>
|
||||||
|
bool IsDirty(GhostObject obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the specified object as clean (e.g., after a successful save).
|
||||||
|
/// </summary>
|
||||||
|
void MarkClean(GhostObject obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a list of all currently dirty objects.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<GhostObject> GetDirtyObjects();
|
||||||
|
}
|
||||||
@@ -9,5 +9,8 @@ public interface IInspectable
|
|||||||
|
|
||||||
UIElement? CreateHeader();
|
UIElement? CreateHeader();
|
||||||
|
|
||||||
UIElement? CreateInspector();
|
IInspectorModel CreateInspectorModel();
|
||||||
|
|
||||||
|
// void OnSelected();
|
||||||
|
// void OnDeselected();
|
||||||
}
|
}
|
||||||
26
src/Editor/Ghost.Editor.Core/Contracts/IInspectorModel.cs
Normal file
26
src/Editor/Ghost.Editor.Core/Contracts/IInspectorModel.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an active model for an object being inspected.
|
||||||
|
/// Responsible for generating its own UI.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInspectorModel : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generate the UI element that represents the body of the inspector for this model.
|
||||||
|
/// </summary>
|
||||||
|
UIElement BuildUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An inspector model that requires continuous synchronization (e.g. per-frame updates).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISyncableInspectorModel : IInspectorModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Called per-frame to sync data (e.g. from ECS to UI and back).
|
||||||
|
/// </summary>
|
||||||
|
void Sync();
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ public enum ShaderStage
|
|||||||
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
|
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IShaderCompiler : IDisposable
|
internal interface IShaderCompiler : IDisposable
|
||||||
{
|
{
|
||||||
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,9 @@ public sealed partial class Float3Field : ValueControl<float3>
|
|||||||
_yComponent = GetTemplateChild("YComponent") as NumberBox;
|
_yComponent = GetTemplateChild("YComponent") as NumberBox;
|
||||||
_zComponent = GetTemplateChild("ZComponent") as NumberBox;
|
_zComponent = GetTemplateChild("ZComponent") as NumberBox;
|
||||||
|
|
||||||
|
SuppressChangedEvent = true;
|
||||||
SyncFromValue();
|
SyncFromValue();
|
||||||
|
SuppressChangedEvent = false;
|
||||||
|
|
||||||
_xComponent?.ValueChanged += OnComponentChanged;
|
_xComponent?.ValueChanged += OnComponentChanged;
|
||||||
_yComponent?.ValueChanged += OnComponentChanged;
|
_yComponent?.ValueChanged += OnComponentChanged;
|
||||||
@@ -44,11 +46,9 @@ public sealed partial class Float3Field : ValueControl<float3>
|
|||||||
|
|
||||||
private void SyncFromValue()
|
private void SyncFromValue()
|
||||||
{
|
{
|
||||||
SuppressChangedEvent = true;
|
|
||||||
_xComponent?.Value = Value.x;
|
_xComponent?.Value = Value.x;
|
||||||
_yComponent?.Value = Value.y;
|
_yComponent?.Value = Value.y;
|
||||||
_zComponent?.Value = Value.z;
|
_zComponent?.Value = Value.z;
|
||||||
SuppressChangedEvent = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnComponentChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
|
private void OnComponentChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
|
||||||
@@ -63,7 +63,6 @@ public sealed partial class Float3Field : ValueControl<float3>
|
|||||||
(float)(_yComponent?.Value ?? 0),
|
(float)(_yComponent?.Value ?? 0),
|
||||||
(float)(_zComponent?.Value ?? 0));
|
(float)(_zComponent?.Value ?? 0));
|
||||||
|
|
||||||
RiseChangedEvent(Value, newValue);
|
|
||||||
Value = newValue;
|
Value = newValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public sealed partial class PropertyField : ContentControl
|
public sealed partial class PropertyField : ContentControl
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<Type, DependencyProperty> _valueProperties = new()
|
private static readonly Dictionary<Type, DependencyProperty> s_valueProperties = new()
|
||||||
{
|
{
|
||||||
{ typeof(TextBox), TextBox.TextProperty },
|
{ typeof(TextBox), TextBox.TextProperty },
|
||||||
{ typeof(NumberBox), NumberBox.ValueProperty },
|
{ typeof(NumberBox), NumberBox.ValueProperty },
|
||||||
@@ -39,6 +39,18 @@ public sealed partial class PropertyField : ContentControl
|
|||||||
typeof(PropertyField),
|
typeof(PropertyField),
|
||||||
new PropertyMetadata(default(string)));
|
new PropertyMetadata(default(string)));
|
||||||
|
|
||||||
|
public bool IsEditable
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsEditableProperty);
|
||||||
|
set => SetValue(IsEditableProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsEditableProperty = DependencyProperty.Register(
|
||||||
|
nameof(IsEditable),
|
||||||
|
typeof(bool),
|
||||||
|
typeof(PropertyField),
|
||||||
|
new PropertyMetadata(true));
|
||||||
|
|
||||||
public PropertyField()
|
public PropertyField()
|
||||||
{
|
{
|
||||||
DefaultStyleKey = typeof(PropertyField);
|
DefaultStyleKey = typeof(PropertyField);
|
||||||
@@ -48,7 +60,7 @@ public sealed partial class PropertyField : ContentControl
|
|||||||
{
|
{
|
||||||
while (fieldType != null)
|
while (fieldType != null)
|
||||||
{
|
{
|
||||||
if (_valueProperties.TryGetValue(fieldType, out var dp))
|
if (s_valueProperties.TryGetValue(fieldType, out var dp))
|
||||||
{
|
{
|
||||||
return dp;
|
return dp;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,22 @@
|
|||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="local:PropertyField">
|
<ControlTemplate TargetType="local:PropertyField">
|
||||||
<Grid Height="32" Margin="2,4">
|
<StackPanel Margin="2,4" Spacing="4">
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="125" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="0,0,0,4"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Style="{StaticResource BodyTextBlockStyle}"
|
Style="{StaticResource BodyTextBlockStyle}"
|
||||||
Text="{TemplateBinding Label}"
|
Text="{TemplateBinding Label}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<ContentPresenter
|
<ContentControl
|
||||||
Grid.Column="1"
|
Margin="2,0,0,0"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
Content="{TemplateBinding Content}"
|
Content="{TemplateBinding Content}"
|
||||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
</Grid>
|
IsEnabled="{TemplateBinding IsEditable}" />
|
||||||
|
</StackPanel>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public partial class ControlsDictionary : ResourceDictionary
|
public partial class ControlsDictionary : ResourceDictionary
|
||||||
{
|
{
|
||||||
private const string _DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
private const string DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
||||||
|
|
||||||
public ControlsDictionary()
|
public ControlsDictionary()
|
||||||
{
|
{
|
||||||
Source = new Uri(_DICTIONARY_PATH, UriKind.Absolute);
|
Source = new Uri(DICTIONARY_PATH, UriKind.Absolute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,5 @@
|
|||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
|
<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/BasicInput/Float3Field.xaml" />
|
||||||
|
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/Internal/ComponentView.xaml" />
|
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,44 +1,12 @@
|
|||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls;
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
public sealed partial class ContextFlyout : MenuFlyout
|
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;
|
private bool _isPopulated;
|
||||||
|
|
||||||
public string Tag
|
public string ContextMenuTag
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
} = string.Empty;
|
} = string.Empty;
|
||||||
@@ -48,160 +16,13 @@ public sealed partial class ContextFlyout : MenuFlyout
|
|||||||
Opening += ContextFlyout_Opening;
|
Opening += ContextFlyout_Opening;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively sorts nodes and calculates folder pGroups
|
|
||||||
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()
|
private void PopulateContextMenu()
|
||||||
{
|
{
|
||||||
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
var rootNodes = MenuUtility.BuildTree(ContextMenuTag);
|
||||||
if (methods == null)
|
MenuUtility.BuildNodes(rootNodes, Items);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Build the Tree
|
private void ContextFlyout_Opening(object? sender, object e)
|
||||||
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)
|
if (_isPopulated)
|
||||||
{
|
{
|
||||||
|
|||||||
53
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuContextBar.cs
Normal file
53
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuContextBar.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public sealed partial class MenuContextBar : MenuBar
|
||||||
|
{
|
||||||
|
private bool _isPopulated;
|
||||||
|
|
||||||
|
public string ContextMenuTag
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public MenuContextBar()
|
||||||
|
{
|
||||||
|
Loaded += MenuContextBar_Loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MenuContextBar_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isPopulated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PopulateMenu();
|
||||||
|
_isPopulated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateMenu()
|
||||||
|
{
|
||||||
|
var rootNodes = MenuUtility.BuildTree(ContextMenuTag);
|
||||||
|
|
||||||
|
foreach (var node in rootNodes)
|
||||||
|
{
|
||||||
|
if (node.Children.Count == 0)
|
||||||
|
{
|
||||||
|
Logger.Warning($"Menu item '{node.Name}' cannot be placed at the root of a MenuContextBar because it lacks a parent group.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var menuBarItem = new MenuBarItem
|
||||||
|
{
|
||||||
|
Title = node.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
MenuUtility.BuildNodes(node.Children, menuBarItem.Items);
|
||||||
|
Items.Add(menuBarItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuUtility.cs
Normal file
236
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuUtility.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Reflection;
|
||||||
|
using Windows.System;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
internal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int RawPriority
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 0;
|
||||||
|
|
||||||
|
public int EffectivePriority
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VirtualKey ShortCut
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VirtualKey.None;
|
||||||
|
|
||||||
|
public VirtualKeyModifiers ShortCutModifiers
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VirtualKeyModifiers.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class MenuUtility
|
||||||
|
{
|
||||||
|
// Recursively sorts nodes and calculates folder pGroups
|
||||||
|
public 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);
|
||||||
|
node.EffectivePriority = node.Children.Max(c => c.EffectivePriority);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
node.EffectiveGroup = node.RawGroup;
|
||||||
|
node.EffectivePriority = node.RawPriority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by Group, then by Priority (higher first), then by Name
|
||||||
|
nodes.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var groupCompare = a.EffectiveGroup.CompareTo(b.EffectiveGroup);
|
||||||
|
if (groupCompare != 0)
|
||||||
|
{
|
||||||
|
return groupCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
var priorityCompare = b.EffectivePriority.CompareTo(a.EffectivePriority);
|
||||||
|
return priorityCompare != 0
|
||||||
|
? priorityCompare
|
||||||
|
: string.CompareOrdinal(a.Name, b.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively builds the UI elements
|
||||||
|
public 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.ShortCut != VirtualKey.None)
|
||||||
|
{
|
||||||
|
menuItem.KeyboardAccelerators.Add(new Microsoft.UI.Xaml.Input.KeyboardAccelerator
|
||||||
|
{
|
||||||
|
Key = node.ShortCut,
|
||||||
|
Modifiers = node.ShortCutModifiers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
targetCollection.Add(menuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<MenuNode> BuildTree(string tag)
|
||||||
|
{
|
||||||
|
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
||||||
|
if (methods == null)
|
||||||
|
{
|
||||||
|
return new List<MenuNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
currentNode.RawPriority = attr.Priority;
|
||||||
|
|
||||||
|
var shortCutAttr = method.GetCustomAttribute<ShortcutAttribute>();
|
||||||
|
if (shortCutAttr != null)
|
||||||
|
{
|
||||||
|
currentNode.ShortCut = shortCutAttr.Key;
|
||||||
|
currentNode.ShortCutModifiers = shortCutAttr.Modifiers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel = currentNode.Children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PrepareNodes(rootNodes);
|
||||||
|
return rootNodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml
Normal file
32
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="Ghost.Editor.Core.Controls.ReferenceField"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
AllowDrop="{x:Bind AllowDrop, Mode=OneWay}"
|
||||||
|
DragOver="OnDragOver"
|
||||||
|
Drop="OnDrop">
|
||||||
|
|
||||||
|
<Grid CornerRadius="4" BorderThickness="1" x:Name="RootBorder" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<FontIcon Grid.Column="0" Margin="8,0,0,0" Glyph="{x:Bind IconGlyph, Mode=OneWay}" FontSize="12" Foreground="{ThemeResource TextFillColorSecondaryBrush}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Margin="8,4,8,4" Text="{x:Bind DisplayText, Mode=OneWay}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" FontSize="12" Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||||
|
|
||||||
|
<Button Grid.Column="2" x:Name="GotoButton" Margin="0,0,4,0" Padding="4" Background="Transparent" BorderThickness="0" Click="OnGotoButtonClicked" Visibility="Collapsed" VerticalAlignment="Center">
|
||||||
|
<FontIcon Glyph="" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" x:Name="ClearButton" Margin="0,0,4,0" Padding="4" Background="Transparent" BorderThickness="0" Click="OnClearButtonClicked" Visibility="Collapsed" VerticalAlignment="Center">
|
||||||
|
<FontIcon Glyph="" FontSize="10" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
158
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml.cs
Normal file
158
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public sealed partial class ReferenceField : UserControl
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty DisplayTextProperty =
|
||||||
|
DependencyProperty.Register(nameof(DisplayText), typeof(string), typeof(ReferenceField), new PropertyMetadata(string.Empty, OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty TypeLabelProperty =
|
||||||
|
DependencyProperty.Register(nameof(TypeLabel), typeof(string), typeof(ReferenceField), new PropertyMetadata("Object", OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IconGlyphProperty =
|
||||||
|
DependencyProperty.Register(nameof(IconGlyph), typeof(string), typeof(ReferenceField), new PropertyMetadata("\uEA86"));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty HasValueProperty =
|
||||||
|
DependencyProperty.Register(nameof(HasValue), typeof(bool), typeof(ReferenceField), new PropertyMetadata(false, OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsReadOnlyProperty =
|
||||||
|
DependencyProperty.Register(nameof(IsReadOnly), typeof(bool), typeof(ReferenceField), new PropertyMetadata(false, OnStateChanged));
|
||||||
|
|
||||||
|
public string DisplayText
|
||||||
|
{
|
||||||
|
get => (string)GetValue(DisplayTextProperty);
|
||||||
|
set => SetValue(DisplayTextProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TypeLabel
|
||||||
|
{
|
||||||
|
get => (string)GetValue(TypeLabelProperty);
|
||||||
|
set => SetValue(TypeLabelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string IconGlyph
|
||||||
|
{
|
||||||
|
get => (string)GetValue(IconGlyphProperty);
|
||||||
|
set => SetValue(IconGlyphProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasValue
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(HasValueProperty);
|
||||||
|
set => SetValue(HasValueProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadOnly
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsReadOnlyProperty);
|
||||||
|
set => SetValue(IsReadOnlyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Func<DragEventArgs, bool>? ValidateDrop;
|
||||||
|
public Action<DragEventArgs>? OnDropAccepted;
|
||||||
|
public Action? OnClearClicked;
|
||||||
|
public Action? OnGotoClicked;
|
||||||
|
|
||||||
|
private readonly SolidColorBrush _accentBrush;
|
||||||
|
private readonly SolidColorBrush _errorBrush;
|
||||||
|
private readonly SolidColorBrush _defaultBorderBrush;
|
||||||
|
|
||||||
|
public ReferenceField()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_accentBrush = (SolidColorBrush)Application.Current.Resources["SystemControlHighlightAccentBrush"];
|
||||||
|
_errorBrush = new SolidColorBrush(Microsoft.UI.Colors.Red);
|
||||||
|
_defaultBorderBrush = (SolidColorBrush)Application.Current.Resources["CardStrokeColorDefaultBrush"];
|
||||||
|
|
||||||
|
UpdateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
((ReferenceField)d).UpdateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateState()
|
||||||
|
{
|
||||||
|
if (HasValue)
|
||||||
|
{
|
||||||
|
ClearButton.Visibility = IsReadOnly ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
GotoButton.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ClearButton.Visibility = Visibility.Collapsed;
|
||||||
|
GotoButton.Visibility = Visibility.Collapsed;
|
||||||
|
if (string.IsNullOrEmpty(DisplayText))
|
||||||
|
{
|
||||||
|
// We shouldn't change DependencyProperty value here to avoid loops,
|
||||||
|
// but we can bind a different text if needed. For now, rely on caller to set DisplayText to "None (Type)".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AllowDrop = !IsReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid = ValidateDrop?.Invoke(e) ?? false;
|
||||||
|
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.Link;
|
||||||
|
RootBorder.BorderBrush = _accentBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.None;
|
||||||
|
// Optionally set error brush
|
||||||
|
RootBorder.BorderBrush = _errorBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDragLeave(DragEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDragLeave(e);
|
||||||
|
RootBorder.BorderBrush = _defaultBorderBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
RootBorder.BorderBrush = _defaultBorderBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
|
||||||
|
if (IsReadOnly) return;
|
||||||
|
|
||||||
|
var isValid = ValidateDrop?.Invoke(e) ?? false;
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
OnDropAccepted?.Invoke(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClearButtonClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OnClearClicked?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGotoButtonClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OnGotoClicked?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
using Ghost.Editor.Core.Event;
|
using Ghost.Editor.Core.Event;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls;
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
public partial class ValueControl<T> : Control
|
public interface INotifyValueChanged<T>
|
||||||
|
{
|
||||||
|
T Value { get; set; }
|
||||||
|
|
||||||
|
event ValueChangedEventHandler<T>? OnValueChanged;
|
||||||
|
|
||||||
|
void SetValueWithoutNotify(T value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class ValueControl<T> : Control, INotifyValueChanged<T>
|
||||||
{
|
{
|
||||||
private bool _suppressChangedEvent;
|
private bool _suppressChangedEvent;
|
||||||
|
|
||||||
@@ -39,7 +49,7 @@ public partial class ValueControl<T> : Control
|
|||||||
{
|
{
|
||||||
valueControl.ValueChanged((T)e.OldValue, (T)e.NewValue);
|
valueControl.ValueChanged((T)e.OldValue, (T)e.NewValue);
|
||||||
|
|
||||||
if (!valueControl._suppressChangedEvent)
|
if (!valueControl.SuppressChangedEvent)
|
||||||
{
|
{
|
||||||
valueControl.OnValueChanged?.Invoke(valueControl, new((T)e.OldValue, (T)e.NewValue));
|
valueControl.OnValueChanged?.Invoke(valueControl, new((T)e.OldValue, (T)e.NewValue));
|
||||||
}
|
}
|
||||||
@@ -55,16 +65,26 @@ public partial class ValueControl<T> : Control
|
|||||||
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the value of the control.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new value to set.</param>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void SetValue(T value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the _value without notifying the change event.
|
/// Sets the _value without notifying the change event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The new _value to set.</param>
|
/// <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.
|
/// <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>
|
/// Useful when you need to change the _value programmatically without triggering the change event.</remarks>
|
||||||
public void SetValueWithoutNotifying(T value)
|
public void SetValueWithoutNotify(T value)
|
||||||
{
|
{
|
||||||
_suppressChangedEvent = true;
|
SuppressChangedEvent = true;
|
||||||
SetValue(ValueProperty, value);
|
SetValue(value);
|
||||||
_suppressChangedEvent = false;
|
SuppressChangedEvent = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core;
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
|
public enum EditorState
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
Compiling,
|
||||||
|
}
|
||||||
|
|
||||||
public static class EditorApplication
|
public static class EditorApplication
|
||||||
{
|
{
|
||||||
public const string ASSETS_FOLDER_NAME = "Assets";
|
public const string ASSETS_FOLDER_NAME = "Assets";
|
||||||
@@ -53,8 +63,15 @@ public static class EditorApplication
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static EditorState State
|
||||||
|
{
|
||||||
|
get; internal set;
|
||||||
|
} = EditorState.Idle;
|
||||||
|
|
||||||
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
||||||
{
|
{
|
||||||
|
projectPath = PathUtility.Normalize(projectPath);
|
||||||
|
|
||||||
Environment.CurrentDirectory = projectPath;
|
Environment.CurrentDirectory = projectPath;
|
||||||
|
|
||||||
s_serviceProvider = serviceProvider;
|
s_serviceProvider = serviceProvider;
|
||||||
@@ -86,12 +103,25 @@ public static class EditorApplication
|
|||||||
public static T GetService<T>()
|
public static T GetService<T>()
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
if (s_serviceProvider?.GetService(typeof(T)) is not T service)
|
if (TryGetService<T>(out var service))
|
||||||
{
|
{
|
||||||
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices.");
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
return service;
|
throw new ArgumentException("Requested service of type " + typeof(T).FullName + " is not registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetService<T>([NotNullWhen(true)] out T? service)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (s_serviceProvider?.GetService(typeof(T)) is T resolvedService)
|
||||||
|
{
|
||||||
|
service = resolvedService;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
service = null;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void Shutdown()
|
internal static void Shutdown()
|
||||||
|
|||||||
@@ -10,14 +10,41 @@
|
|||||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
<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 -->
|
<NoWarn>$(NoWarn);MVVMTK0050</NoWarn>
|
||||||
<langversion>preview</langversion>
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentIcons.WinUI" Version="2.1.324" />
|
<Content Remove="Assets\MeshNode.cs" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" />
|
</ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
<PackageReference Include="FluentIcons.WinUI" Version="2.1.328" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -30,6 +57,7 @@
|
|||||||
<ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.csproj" />
|
<ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.csproj" />
|
||||||
<ProjectReference Include="..\..\ThridParty\Ghost.Ufbx\Ghost.Ufbx.csproj" />
|
<ProjectReference Include="..\..\ThridParty\Ghost.Ufbx\Ghost.Ufbx.csproj" />
|
||||||
<ProjectReference Include="..\..\ThridParty\Ghost.StbI\Ghost.StbI.csproj" />
|
<ProjectReference Include="..\..\ThridParty\Ghost.StbI\Ghost.StbI.csproj" />
|
||||||
|
<ProjectReference Include="..\Ghost.DSL\Ghost.DSL.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -39,8 +67,5 @@
|
|||||||
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
</Page>
|
</Page>
|
||||||
<Page Update="Controls\Internal\ComponentView.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</Page>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
100
src/Editor/Ghost.Editor.Core/GhostObject.cs
Normal file
100
src/Editor/Ghost.Editor.Core/GhostObject.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The base class for all objects that can be tracked and recorded by the Undo system.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class GhostObject : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A persistent unique identifier used to track this object across Undo/Redo operations,
|
||||||
|
/// even if the underlying object is destroyed and resurrected.
|
||||||
|
/// </summary>
|
||||||
|
public Guid InstanceID { get; protected set; }
|
||||||
|
|
||||||
|
// Use WeakReference so we don't prevent Garbage Collection of dead objects
|
||||||
|
private static readonly Dictionary<Guid, WeakReference<GhostObject>> s_objectRegistry = new();
|
||||||
|
|
||||||
|
public static event Action<GhostObject>? OnObjectModified;
|
||||||
|
|
||||||
|
protected GhostObject()
|
||||||
|
{
|
||||||
|
InstanceID = Guid.NewGuid();
|
||||||
|
s_objectRegistry[InstanceID] = new WeakReference<GhostObject>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected GhostObject(Guid instanceID)
|
||||||
|
{
|
||||||
|
InstanceID = instanceID;
|
||||||
|
s_objectRegistry[InstanceID] = new WeakReference<GhostObject>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a GhostObject by its InstanceID in O(1) time.
|
||||||
|
/// </summary>
|
||||||
|
public static GhostObject? Find(Guid id)
|
||||||
|
{
|
||||||
|
if (s_objectRegistry.TryGetValue(id, out var weakRef))
|
||||||
|
{
|
||||||
|
if (weakRef.TryGetTarget(out var obj))
|
||||||
|
{
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Dead object, GC has collected it
|
||||||
|
s_objectRegistry.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called before mutating state.
|
||||||
|
/// Hooks into the Undo and Dirty Tracking systems.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void Modify()
|
||||||
|
{
|
||||||
|
OnObjectModified?.Invoke(this);
|
||||||
|
|
||||||
|
// TODO: Unify RecordObject in future sessions. For now, we skip IUndoService.RecordObject here
|
||||||
|
// since specialized methods are still required in UndoService.
|
||||||
|
|
||||||
|
// Mark dirty for persistence directly
|
||||||
|
EditorApplication.GetService<IDirtyTrackerService>().MarkDirty(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the state of this object into a binary format.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes the state of this object from a binary format.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
s_objectRegistry.Remove(InstanceID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~GhostObject()
|
||||||
|
{
|
||||||
|
Dispose(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata for an entire ECS component type, including all its editable fields.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ComponentDescriptor
|
||||||
|
{
|
||||||
|
public Type ComponentType { get; }
|
||||||
|
public Identifier<IComponent> ComponentId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public int Size { get; }
|
||||||
|
public bool IsShared { get; }
|
||||||
|
public PropertyDescriptor[] Properties { get; }
|
||||||
|
|
||||||
|
private ComponentDescriptor(Type componentType, Identifier<IComponent> componentId, string displayName, int size, bool isShared, PropertyDescriptor[] properties)
|
||||||
|
{
|
||||||
|
ComponentType = componentType;
|
||||||
|
ComponentId = componentId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
Size = size;
|
||||||
|
IsShared = isShared;
|
||||||
|
Properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentDescriptor Create(Type componentType)
|
||||||
|
{
|
||||||
|
var componentId = ComponentRegistry.GetComponentID(componentType);
|
||||||
|
var info = ComponentRegistry.GetComponentInfo(componentId);
|
||||||
|
|
||||||
|
var nameAttr = componentType.GetCustomAttribute<InspectorNameAttribute>();
|
||||||
|
var displayName = nameAttr?.Name ?? componentType.Name;
|
||||||
|
|
||||||
|
var properties = new List<PropertyDescriptor>();
|
||||||
|
var fields = componentType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
if (field.GetCustomAttribute<HideInInspectorAttribute>() != null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Exclude internal/private fields unless they have a specific attribute, but for now we just show public or specifically included.
|
||||||
|
if (!field.IsPublic && field.GetCustomAttribute<InspectorNameAttribute>() == null)
|
||||||
|
{
|
||||||
|
// In GhostEngine we often use public fields for component data, or private fields with [InspectorName].
|
||||||
|
// We'll just include public fields by default, and any non-public with specific attributes.
|
||||||
|
if (field.GetCustomAttribute<ReadOnlyInInspectorAttribute>() == null &&
|
||||||
|
field.GetCustomAttribute<InspectorGroupAttribute>() == null)
|
||||||
|
{
|
||||||
|
continue; // Skip normal private fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
properties.Add(new PropertyDescriptor(field, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ComponentDescriptor(componentType, componentId, displayName, info.size, info.isShared, properties.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
// TODO: We can use source generator to directly generate ComponentDescriptor on each component type and avoid reflection and caching altogether. This is just a quick solution for now.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe cache of ComponentDescriptor per component type.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentDescriptorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<nint, ComponentDescriptor> s_cache = new();
|
||||||
|
private static readonly Lock s_lock = new();
|
||||||
|
|
||||||
|
public static ComponentDescriptor GetOrCreate(Type componentType)
|
||||||
|
{
|
||||||
|
var handle = componentType.TypeHandle.Value;
|
||||||
|
|
||||||
|
lock (s_lock)
|
||||||
|
{
|
||||||
|
if (s_cache.TryGetValue(handle, out var descriptor))
|
||||||
|
{
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptor = ComponentDescriptor.Create(componentType);
|
||||||
|
s_cache[handle] = descriptor;
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentDescriptor GetOrCreate(Identifier<IComponent> componentId)
|
||||||
|
{
|
||||||
|
return GetOrCreate(ComponentRegistry.s_runtimeIDToType[componentId.Value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,16 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Inspector;
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
public abstract class ComponentEditor
|
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>
|
/// <summary>
|
||||||
/// Called when the component editor is created.
|
/// Called when the component editor is created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="container">The container to add the editor controls to.</param>
|
/// <param name="root">The root panel to which the editor should add its UI elements.</param>
|
||||||
public virtual void Create(StackPanel container)
|
/// <param name="componentNode">The component node being edited.</param>
|
||||||
{
|
public abstract void Create(Panel root, ComponentNode componentNode);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public virtual void Destroy() { }
|
||||||
/// 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()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry mapping ECS component types to their custom UI editor types.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentEditorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, Type> s_editors = new();
|
||||||
|
|
||||||
|
static ComponentEditorRegistry()
|
||||||
|
{
|
||||||
|
var editorTypes = TypeCache.GetTypesWithAttribute<CustomEditorAttribute>();
|
||||||
|
if (editorTypes == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var editorType in editorTypes)
|
||||||
|
{
|
||||||
|
var attr = editorType.GetCustomAttribute<CustomEditorAttribute>();
|
||||||
|
if (attr != null && attr.TargetType != null)
|
||||||
|
{
|
||||||
|
if (typeof(ComponentEditor).IsAssignableFrom(editorType))
|
||||||
|
{
|
||||||
|
s_editors[attr.TargetType] = editorType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a custom editor exists for the given component type.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasCustomEditor(Type componentType)
|
||||||
|
{
|
||||||
|
return s_editors.ContainsKey(componentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates the custom editor for the given component type, or null if none exists.
|
||||||
|
/// </summary>
|
||||||
|
public static ComponentEditor? CreateCustomEditor(Type componentType)
|
||||||
|
{
|
||||||
|
if (s_editors.TryGetValue(componentType, out var editorType))
|
||||||
|
{
|
||||||
|
return (ComponentEditor?)Activator.CreateInstance(editorType);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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,15 @@
|
|||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a class as a custom property drawer for a specific type.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public sealed class CustomPropertyDrawerAttribute : DiscoverableAttributeBase
|
||||||
|
{
|
||||||
|
public Type TargetFieldType { get; }
|
||||||
|
|
||||||
|
public CustomPropertyDrawerAttribute(Type targetFieldType)
|
||||||
|
{
|
||||||
|
TargetFieldType = targetFieldType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class EmptyDrawer<T> : PropertyDrawer<T> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
// For a nested struct, the PropertyField will draw the Label,
|
||||||
|
// and this empty border will be the Content (taking no space).
|
||||||
|
// The children properties will be drawn underneath.
|
||||||
|
return new Border();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
internal class EntityDrawer : PropertyDrawer<Entity>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<Entity> model)
|
||||||
|
{
|
||||||
|
static void UpdateUI(Entity val, ReferenceField field)
|
||||||
|
{
|
||||||
|
if (val.IsValid)
|
||||||
|
{
|
||||||
|
field.HasValue = true;
|
||||||
|
|
||||||
|
// TODO: For now, just display the Entity ID. We could resolve its SceneGraph Node name in the future.
|
||||||
|
field.DisplayText = $"Entity {val.ID}:{val.Generation}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.HasValue = false;
|
||||||
|
field.DisplayText = "None (Entity)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = new ReferenceField
|
||||||
|
{
|
||||||
|
TypeLabel = "Entity",
|
||||||
|
IconGlyph = "\uF158",
|
||||||
|
Margin = new Thickness(0, 2, 0, 2),
|
||||||
|
ValidateDrop = (args) =>
|
||||||
|
{
|
||||||
|
// TODO: Implement drag and drop for entities from the hierarchy
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnClearClicked = () =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(Entity.Invalid);
|
||||||
|
UpdateUI(Entity.Invalid, field);
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateUI(model.Value, field);
|
||||||
|
|
||||||
|
model.OnValueChanged += (val) =>
|
||||||
|
{
|
||||||
|
field.DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
UpdateUI(val, field);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Editor/Ghost.Editor.Core/Inspector/Drawers/EnumDrawer.cs
Normal file
38
src/Editor/Ghost.Editor.Core/Inspector/Drawers/EnumDrawer.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class EnumDrawer<T> : PropertyDrawer<T>
|
||||||
|
where T : unmanaged, Enum
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var comboBox = new ComboBox
|
||||||
|
{
|
||||||
|
ItemsSource = Enum.GetNames(typeof(T)),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsEnabled = !model.Descriptor.IsReadOnly,
|
||||||
|
SelectedItem = model.Value.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
comboBox.SelectionChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (comboBox.SelectedItem is string str)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<T>(str, out var parsed))
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
comboBox.SelectedItem = newVal.ToString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return comboBox;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class Float3Drawer : PropertyDrawer<float3>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(SceneGraph.PropertyNode<float3> node)
|
||||||
|
{
|
||||||
|
var field = new Float3Field
|
||||||
|
{
|
||||||
|
IsEnabled = !node.Descriptor.IsReadOnly,
|
||||||
|
Value = node.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
field.BindTwoWay(node);
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<Handle<T>> model)
|
||||||
|
{
|
||||||
|
static void UpdateUI(HandlePropertyNode<T> handleNode, ReferenceField field)
|
||||||
|
{
|
||||||
|
var guid = handleNode?.AssetGuid ?? Guid.Empty;
|
||||||
|
field.HasValue = guid != Guid.Empty;
|
||||||
|
field.DisplayText = guid != Guid.Empty ? $"{typeof(T).Name} ({guid.ToString().Substring(0, 8)})" : $"None ({typeof(T).Name})";
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = new ReferenceField
|
||||||
|
{
|
||||||
|
TypeLabel = typeof(T).Name,
|
||||||
|
Margin = new Thickness(0, 2, 0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var handleNode = model as HandlePropertyNode<T>;
|
||||||
|
Logger.DebugAssert(handleNode != null);
|
||||||
|
|
||||||
|
field.ValidateDrop = (args) =>
|
||||||
|
{
|
||||||
|
// For now, assume payload has standard string Guid or we implement format
|
||||||
|
return args.DataView.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text);
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnDropAccepted = async (args) =>
|
||||||
|
{
|
||||||
|
if (handleNode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = await args.DataView.GetTextAsync();
|
||||||
|
if (Guid.TryParse(text, out var guid))
|
||||||
|
{
|
||||||
|
handleNode.SetHandleFromAsset(guid);
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnClearClicked = () =>
|
||||||
|
{
|
||||||
|
if (handleNode != null)
|
||||||
|
{
|
||||||
|
handleNode.ClearHandle();
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
|
||||||
|
// When ECS value changes outside of UI
|
||||||
|
model.OnValueChanged += (val) =>
|
||||||
|
{
|
||||||
|
// UI Thread check usually required here, but property model events should be on UI thread or marshaled
|
||||||
|
field.DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class NumberBoxDrawer<T> : PropertyDrawer<T>
|
||||||
|
where T : unmanaged, INumber<T>, IMinMaxValue<T>
|
||||||
|
{
|
||||||
|
private readonly int _fractionDigits;
|
||||||
|
private readonly double _min;
|
||||||
|
private readonly double _max;
|
||||||
|
|
||||||
|
public NumberBoxDrawer(int fractionDigits, double min, double max)
|
||||||
|
{
|
||||||
|
_fractionDigits = fractionDigits;
|
||||||
|
_min = min;
|
||||||
|
_max = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe NumberBoxDrawer<T> CreateFloatingPoint()
|
||||||
|
{
|
||||||
|
var digits = sizeof(T) > 4 ? 6 : 3;
|
||||||
|
return new NumberBoxDrawer<T>(digits, double.CreateTruncating(T.MinValue), double.CreateTruncating(T.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NumberBoxDrawer<T> CreateInteger()
|
||||||
|
{
|
||||||
|
return new NumberBoxDrawer<T>(0, double.CreateTruncating(T.MinValue), double.CreateTruncating(T.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override FrameworkElement CreateControlT(SceneGraph.PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var box = new NumberBox
|
||||||
|
{
|
||||||
|
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
MaxWidth = double.PositiveInfinity, // To fill PropertyField
|
||||||
|
Maximum = _max,
|
||||||
|
Minimum = _min,
|
||||||
|
Value = double.CreateTruncating(model.Value)
|
||||||
|
};
|
||||||
|
|
||||||
|
var formatter = new Windows.Globalization.NumberFormatting.DecimalFormatter
|
||||||
|
{
|
||||||
|
FractionDigits = _fractionDigits
|
||||||
|
};
|
||||||
|
box.NumberFormatter = formatter;
|
||||||
|
|
||||||
|
box.ValueChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (double.IsNaN(e.NewValue)) return;
|
||||||
|
model.SetValueFromUI(T.CreateTruncating(e.NewValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
box.Value = double.CreateTruncating(newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class ReadOnlyDrawer<T> : PropertyDrawer<T> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var box = new TextBox
|
||||||
|
{
|
||||||
|
Text = model.Value.ToString(),
|
||||||
|
IsReadOnly = true,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
box.Text = newVal.ToString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class ToggleSwitchDrawer : PropertyDrawer<bool>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(SceneGraph.PropertyNode<bool> model)
|
||||||
|
{
|
||||||
|
var toggle = new ToggleSwitch
|
||||||
|
{
|
||||||
|
OnContent = "",
|
||||||
|
OffContent = "",
|
||||||
|
IsOn = model.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.Toggled += (s, e) =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(toggle.IsOn);
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
toggle.IsOn = newVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
return toggle;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/Editor/Ghost.Editor.Core/Inspector/EntityInspectorModel.cs
Normal file
191
src/Editor/Ghost.Editor.Core/Inspector/EntityInspectorModel.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Model for an entire entity being inspected.
|
||||||
|
/// Discovers components from archetype, builds ComponentModels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EntityInspectorModel : ISyncableInspectorModel
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
private readonly Entity _entity;
|
||||||
|
private EntityNode? _entityNode;
|
||||||
|
private readonly List<ComponentNode> _components = new();
|
||||||
|
private readonly List<ComponentEditor> _activeCustomEditors = new();
|
||||||
|
private int _lastArchetypeId = -1;
|
||||||
|
|
||||||
|
public World World => _world;
|
||||||
|
public Entity Entity => _entity;
|
||||||
|
public IReadOnlyList<ComponentNode> Components => _components;
|
||||||
|
|
||||||
|
public EntityInspectorModel(World world, Entity entity)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildComponentList()
|
||||||
|
{
|
||||||
|
_components.Clear();
|
||||||
|
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entityNode == null)
|
||||||
|
{
|
||||||
|
var syncService = EditorApplication.GetService<Services.SceneGraphSyncService>();
|
||||||
|
if (syncService != null && syncService.TryGetNode(_entity, out var node))
|
||||||
|
{
|
||||||
|
_entityNode = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entityNode != null)
|
||||||
|
{
|
||||||
|
// Update components list in EntityNode first
|
||||||
|
_entityNode.BuildComponents();
|
||||||
|
|
||||||
|
foreach (var compNode in _entityNode.Components)
|
||||||
|
{
|
||||||
|
_components.Add(compNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when entity archetype may have changed.
|
||||||
|
/// Returns true if structure was rebuilt (components added/removed).
|
||||||
|
/// </summary>
|
||||||
|
public bool RefreshStructure()
|
||||||
|
{
|
||||||
|
var locationResult = _world.EntityManager.GetEntityLocation(_entity);
|
||||||
|
if (locationResult.IsFailure)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
if (location.archetypeID == _lastArchetypeId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastArchetypeId = location.archetypeID;
|
||||||
|
RebuildComponentList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildPropertyUI(PropertyNode propNode, Panel container)
|
||||||
|
{
|
||||||
|
var drawer = PropertyDrawerRegistry.GetDrawer(propNode.Descriptor.ValueType);
|
||||||
|
var control = drawer.CreateControl(propNode);
|
||||||
|
|
||||||
|
var propertyField = new Controls.PropertyField
|
||||||
|
{
|
||||||
|
Label = propNode.Descriptor.DisplayName,
|
||||||
|
Content = control,
|
||||||
|
IsEditable = !propNode.Descriptor.IsReadOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
container.Children.Add(propertyField);
|
||||||
|
|
||||||
|
if (propNode.Children != null && propNode.Children.Length > 0)
|
||||||
|
{
|
||||||
|
var childrenPanel = new StackPanel { Spacing = 4, Margin = new Thickness(12, 4, 0, 0) };
|
||||||
|
foreach (var child in propNode.Children)
|
||||||
|
{
|
||||||
|
BuildPropertyUI(child, childrenPanel);
|
||||||
|
}
|
||||||
|
container.Children.Add(childrenPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all component values from ECS -> model.
|
||||||
|
/// </summary>
|
||||||
|
public void SyncFromECS()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var comp in _components)
|
||||||
|
{
|
||||||
|
foreach (var prop in comp.Properties)
|
||||||
|
{
|
||||||
|
prop.Sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Sync()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshStructure();
|
||||||
|
SyncFromECS();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Deselect is not supported yet.
|
||||||
|
|
||||||
|
public UIElement BuildUI()
|
||||||
|
{
|
||||||
|
RefreshStructure();
|
||||||
|
|
||||||
|
var container = new StackPanel { Spacing = 4 };
|
||||||
|
|
||||||
|
foreach (var compNode in _components)
|
||||||
|
{
|
||||||
|
// TODO: Use a more compact UI for components
|
||||||
|
var expander = new Expander
|
||||||
|
{
|
||||||
|
Header = compNode.Descriptor.DisplayName,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsExpanded = true,
|
||||||
|
Margin = new Thickness(4, 2, 4, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var propertiesPanel = new StackPanel { Spacing = 8 };
|
||||||
|
|
||||||
|
if (ComponentEditorRegistry.HasCustomEditor(compNode.ComponentType))
|
||||||
|
{
|
||||||
|
var editor = ComponentEditorRegistry.CreateCustomEditor(compNode.ComponentType);
|
||||||
|
if (editor != null)
|
||||||
|
{
|
||||||
|
editor.Create(propertiesPanel, compNode);
|
||||||
|
_activeCustomEditors.Add(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var propNode in compNode.Properties)
|
||||||
|
{
|
||||||
|
BuildPropertyUI(propNode, propertiesPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expander.Content = propertiesPanel;
|
||||||
|
container.Children.Add(expander);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_components.Clear();
|
||||||
|
_activeCustomEditors.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Editor/Ghost.Editor.Core/Inspector/PropertyDescriptor.cs
Normal file
120
src/Editor/Ghost.Editor.Core/Inspector/PropertyDescriptor.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a single editable field within an ECS component.
|
||||||
|
/// Knows how to read/write a specific field directly from/to unmanaged memory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PropertyDescriptor
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public Type ValueType { get; }
|
||||||
|
public int OffsetInComponent { get; }
|
||||||
|
public bool IsReadOnly { get; }
|
||||||
|
|
||||||
|
// For nested structs (e.g. float4x4 -> float4 -> float)
|
||||||
|
public PropertyDescriptor[]? Children { get; }
|
||||||
|
|
||||||
|
// TODO: Use source generators to build these at compile time and avoid all reflection/attributes at runtime.
|
||||||
|
internal PropertyDescriptor(FieldInfo fieldInfo, int parentOffset)
|
||||||
|
{
|
||||||
|
Name = fieldInfo.Name;
|
||||||
|
ValueType = fieldInfo.FieldType;
|
||||||
|
OffsetInComponent = parentOffset + (int)Marshal.OffsetOf(fieldInfo.DeclaringType!, fieldInfo.Name);
|
||||||
|
|
||||||
|
IsReadOnly = fieldInfo.GetCustomAttribute<ReadOnlyInInspectorAttribute>() != null;
|
||||||
|
|
||||||
|
var nameAttr = fieldInfo.GetCustomAttribute<InspectorNameAttribute>();
|
||||||
|
DisplayName = nameAttr?.Name ?? FormatName(Name);
|
||||||
|
|
||||||
|
// Handle nested structs if this is an unmanaged struct that is not a primitive or common vector type we have custom drawers for.
|
||||||
|
if (ValueType.IsValueType && !ValueType.IsPrimitive && !ValueType.IsEnum)
|
||||||
|
{
|
||||||
|
if (!PropertyDrawerRegistry.HasCustomDrawer(ValueType))
|
||||||
|
{
|
||||||
|
var children = new List<PropertyDescriptor>();
|
||||||
|
var fields = ValueType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
foreach (var nestedField in fields)
|
||||||
|
{
|
||||||
|
if (!nestedField.IsPublic &&
|
||||||
|
nestedField.GetCustomAttribute<InspectorGroupAttribute>() == null &&
|
||||||
|
nestedField.GetCustomAttribute<ReadOnlyInInspectorAttribute>() == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
children.Add(new PropertyDescriptor(nestedField, OffsetInComponent));
|
||||||
|
}
|
||||||
|
if (children.Count > 0)
|
||||||
|
{
|
||||||
|
Children = children.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal PropertyDescriptor(string name, Type type, int offset, bool isReadOnly, PropertyDescriptor[]? children = null)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
DisplayName = FormatName(name);
|
||||||
|
ValueType = type;
|
||||||
|
OffsetInComponent = offset;
|
||||||
|
IsReadOnly = isReadOnly;
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.StartsWith('_'))
|
||||||
|
{
|
||||||
|
name = name.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Length == 0)
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return char.ToUpperInvariant(name[0]) + name.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe object ReadBoxed(void* pComponent)
|
||||||
|
{
|
||||||
|
var src = (byte*)pComponent + OffsetInComponent;
|
||||||
|
return Marshal.PtrToStructure((nint)src, ValueType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void WriteBoxed(void* pComponent, object value)
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dst = (byte*)pComponent + OffsetInComponent;
|
||||||
|
Marshal.StructureToPtr(value, (nint)dst, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe ref T Read<T>(void* pComponent) where T : unmanaged
|
||||||
|
{
|
||||||
|
return ref *(T*)((byte*)pComponent + OffsetInComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void Write<T>(void* pComponent, in T value) where T : unmanaged
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*(T*)((byte*)pComponent + OffsetInComponent) = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawer.cs
Normal file
25
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for type-specific property UI factories.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PropertyDrawer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create the UI control bound to the given property node.
|
||||||
|
/// </summary>
|
||||||
|
public abstract FrameworkElement CreateControl(PropertyNode model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class PropertyDrawer<T> : PropertyDrawer where T : unmanaged
|
||||||
|
{
|
||||||
|
public sealed override FrameworkElement CreateControl(PropertyNode model)
|
||||||
|
{
|
||||||
|
return CreateControlT((PropertyNode<T>)model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract FrameworkElement CreateControlT(PropertyNode<T> model);
|
||||||
|
}
|
||||||
122
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawerRegistry.cs
Normal file
122
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawerRegistry.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers PropertyDrawer subclasses and maps field types to drawers.
|
||||||
|
/// </summary>
|
||||||
|
public static class PropertyDrawerRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, PropertyDrawer> s_drawers = new();
|
||||||
|
private static bool s_initialized;
|
||||||
|
private static readonly Lock s_lock = new();
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
lock (s_lock)
|
||||||
|
{
|
||||||
|
if (s_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register built-in drawers
|
||||||
|
s_drawers[typeof(float)] = NumberBoxDrawer<float>.CreateFloatingPoint();
|
||||||
|
s_drawers[typeof(double)] = NumberBoxDrawer<double>.CreateFloatingPoint();
|
||||||
|
s_drawers[typeof(int)] = NumberBoxDrawer<int>.CreateInteger();
|
||||||
|
s_drawers[typeof(uint)] = NumberBoxDrawer<uint>.CreateInteger();
|
||||||
|
s_drawers[typeof(short)] = NumberBoxDrawer<short>.CreateInteger();
|
||||||
|
s_drawers[typeof(ushort)] = NumberBoxDrawer<ushort>.CreateInteger();
|
||||||
|
s_drawers[typeof(long)] = NumberBoxDrawer<long>.CreateInteger();
|
||||||
|
s_drawers[typeof(ulong)] = NumberBoxDrawer<ulong>.CreateInteger();
|
||||||
|
s_drawers[typeof(sbyte)] = NumberBoxDrawer<sbyte>.CreateInteger();
|
||||||
|
s_drawers[typeof(byte)] = NumberBoxDrawer<byte>.CreateInteger();
|
||||||
|
s_drawers[typeof(bool)] = new ToggleSwitchDrawer();
|
||||||
|
|
||||||
|
s_drawers[typeof(float3)] = new Float3Drawer();
|
||||||
|
|
||||||
|
s_drawers[typeof(Entity)] = new EntityDrawer();
|
||||||
|
|
||||||
|
// Discover user-defined drawers via TypeCache
|
||||||
|
var customDrawers = TypeCache.GetTypesWithAttribute<CustomPropertyDrawerAttribute>();
|
||||||
|
if (customDrawers != null)
|
||||||
|
{
|
||||||
|
foreach (var typeInfo in customDrawers)
|
||||||
|
{
|
||||||
|
var type = typeInfo.AsType();
|
||||||
|
var attr = type.GetCustomAttribute<CustomPropertyDrawerAttribute>();
|
||||||
|
if (attr != null && typeof(PropertyDrawer).IsAssignableFrom(type))
|
||||||
|
{
|
||||||
|
if (Activator.CreateInstance(typeInfo) is PropertyDrawer drawer)
|
||||||
|
{
|
||||||
|
s_drawers[attr.TargetFieldType] = drawer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasCustomDrawer(Type fieldType)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
return s_drawers.ContainsKey(fieldType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyDrawer GetDrawer(Type fieldType)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_drawers.TryGetValue(fieldType, out var drawer))
|
||||||
|
{
|
||||||
|
return drawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType.IsEnum)
|
||||||
|
{
|
||||||
|
var enumDrawerType = typeof(EnumDrawer<>).MakeGenericType(fieldType);
|
||||||
|
var enumDrawer = (PropertyDrawer)Activator.CreateInstance(enumDrawerType)!;
|
||||||
|
s_drawers[fieldType] = enumDrawer;
|
||||||
|
return enumDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Handle<>))
|
||||||
|
{
|
||||||
|
var argType = fieldType.GetGenericArguments()[0];
|
||||||
|
var handleDrawerType = typeof(HandleDrawer<>).MakeGenericType(argType);
|
||||||
|
var handleDrawer = (PropertyDrawer)Activator.CreateInstance(handleDrawerType)!;
|
||||||
|
s_drawers[fieldType] = handleDrawer;
|
||||||
|
return handleDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown types. If it's an unmanaged struct with fields, we use EmptyDrawer
|
||||||
|
// to let the children render. If it's a primitive or something else, use ReadOnlyDrawer.
|
||||||
|
Type genericDrawerType;
|
||||||
|
if (fieldType.IsValueType && !fieldType.IsPrimitive && !fieldType.IsEnum)
|
||||||
|
{
|
||||||
|
genericDrawerType = typeof(EmptyDrawer<>);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
genericDrawerType = typeof(ReadOnlyDrawer<>);
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawerType = genericDrawerType.MakeGenericType(fieldType);
|
||||||
|
var drawerInstance = (PropertyDrawer)Activator.CreateInstance(drawerType)!;
|
||||||
|
s_drawers[fieldType] = drawerInstance;
|
||||||
|
return drawerInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Ghost.Editor.Core": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"debugEngines": "managed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
227
src/Editor/Ghost.Editor.Core/SceneGraph/ComponentNode.cs
Normal file
227
src/Editor/Ghost.Editor.Core/SceneGraph/ComponentNode.cs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single component on an entity within the Editor's scene graph.
|
||||||
|
/// Acts as the middleware between the Inspector's PropertyModels and the actual ECS memory.
|
||||||
|
/// </summary>
|
||||||
|
public unsafe class ComponentNode
|
||||||
|
{
|
||||||
|
private readonly IUndoService _undoService;
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, int> _propertyIndices;
|
||||||
|
protected readonly World _world;
|
||||||
|
|
||||||
|
public EntityNode EntityNode { get; }
|
||||||
|
|
||||||
|
public Type ComponentType { get; }
|
||||||
|
public ComponentDescriptor Descriptor { get; }
|
||||||
|
public PropertyNode[] Properties { get; }
|
||||||
|
public string Name => Descriptor.DisplayName;
|
||||||
|
|
||||||
|
internal ComponentNode(World world, EntityNode entityNode, Type componentType, ComponentDescriptor descriptor)
|
||||||
|
{
|
||||||
|
_undoService = EditorApplication.GetService<IUndoService>();
|
||||||
|
_worldService = EditorApplication.GetService<IEditorWorldService>();
|
||||||
|
|
||||||
|
_propertyIndices = new Dictionary<string, int>(descriptor.Properties.Length);
|
||||||
|
_world = world;
|
||||||
|
|
||||||
|
EntityNode = entityNode;
|
||||||
|
|
||||||
|
ComponentType = componentType;
|
||||||
|
Descriptor = descriptor;
|
||||||
|
|
||||||
|
Properties = new PropertyNode[descriptor.Properties.Length];
|
||||||
|
for (var i = 0; i < descriptor.Properties.Length; i++)
|
||||||
|
{
|
||||||
|
_propertyIndices[descriptor.Properties[i].Name] = i;
|
||||||
|
|
||||||
|
// TODO: We should use a registry/factory for different PropertyNode types instead of hardcoding HandlePropertyNode here. This is just a quick solution for handles for now.
|
||||||
|
var prop = descriptor.Properties[i];
|
||||||
|
if (prop.ValueType.IsGenericType && prop.ValueType.GetGenericTypeDefinition() == typeof(Ghost.Core.Handle<>))
|
||||||
|
{
|
||||||
|
var nodeType = typeof(HandlePropertyNode<>).MakeGenericType(prop.ValueType.GetGenericArguments()[0]);
|
||||||
|
Properties[i] = (PropertyNode)Activator.CreateInstance(nodeType, prop, this)!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create a standard PropertyNode<T> for non-handle types
|
||||||
|
// We use MakeGenericType to create the correct PropertyNode<T> based on FieldType
|
||||||
|
var nodeType = typeof(PropertyNode<>).MakeGenericType(prop.ValueType);
|
||||||
|
Properties[i] = (PropertyNode)Activator.CreateInstance(nodeType, prop, this, null)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPropertyValue<T>(PropertyDescriptor property, T value)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
if (property.ValueType != typeof(T))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Property type does not match value type");
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoService.RecordEntityComponent(this, $"Edit property {property.DisplayName} on {Descriptor.DisplayName}");
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
var ptr = _world.EntityManager.GetSharedComponent(EntityNode.Entity, Descriptor.ComponentId);
|
||||||
|
if (ptr != null)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
System.Runtime.CompilerServices.Unsafe.CopyBlock(buffer.GetUnsafePtr(), ptr, (uint)Descriptor.Size);
|
||||||
|
property.Write(buffer.GetUnsafePtr(), value);
|
||||||
|
_world.EntityManager.SetSharedComponent(EntityNode.Entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
property.Write(pComponent, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponent<T>(T value)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
if (typeof(T) != ComponentType)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Value type does not match component type");
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoService.RecordEntityComponent(this, $"Edit component {Descriptor.DisplayName}");
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
buffer.GetElementAt<T>(0) = value;
|
||||||
|
_world.EntityManager.SetSharedComponent(EntityNode.Entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
*(T*)pComponent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public PropertyNode GetProperty(string propertyName)
|
||||||
|
{
|
||||||
|
if (_propertyIndices.TryGetValue(propertyName, out var index))
|
||||||
|
{
|
||||||
|
return Properties[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Property '{propertyName}' not found in component '{Name}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PropertyNode<T> GetProperty<T>(string propertyName)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
var prop = GetProperty(propertyName);
|
||||||
|
if (prop is PropertyNode<T> typedProp)
|
||||||
|
{
|
||||||
|
return typedProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Property '{propertyName}' is not of type {typeof(T).Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void* GetComponentPointer()
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
return _world.EntityManager.GetSharedComponent(EntityNode.Entity, Descriptor.ComponentId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return _world.EntityManager.GetComponent(EntityNode.Entity, Descriptor.ComponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public T GetComponent<T>()
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
if (typeof(T) != ComponentType)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Field type does not match component type");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
return *(T*)pComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T GetPropertyValue<T>(PropertyDescriptor field)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
return field.Read<T>(pComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialize this component to JSON. Base reads from ECS directly.</summary>
|
||||||
|
public virtual void Serialize(Utf8JsonWriter writer, JsonSerializerOptions options, Action<object>? preSerialize = null)
|
||||||
|
{
|
||||||
|
var boxed = System.Runtime.InteropServices.Marshal.PtrToStructure((nint)GetComponentPointer(), ComponentType);
|
||||||
|
if (boxed != null)
|
||||||
|
{
|
||||||
|
preSerialize?.Invoke(boxed);
|
||||||
|
|
||||||
|
var jsonString = JsonSerializer.Serialize(boxed, ComponentType, options);
|
||||||
|
using var doc = JsonDocument.Parse(jsonString);
|
||||||
|
var root = System.Text.Json.Nodes.JsonObject.Create(doc.RootElement);
|
||||||
|
if (root != null)
|
||||||
|
{
|
||||||
|
foreach (var prop in Properties)
|
||||||
|
{
|
||||||
|
prop.SerializeOverride(root, boxed);
|
||||||
|
}
|
||||||
|
root.WriteTo(writer, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonSerializer.Serialize(writer, boxed, ComponentType, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deserialize from JSON and apply to ECS. Base writes to ECS directly.</summary>
|
||||||
|
public virtual void Deserialize(JsonElement element, JsonSerializerOptions options, Action<object>? postDeserialize = null)
|
||||||
|
{
|
||||||
|
var boxed = element.Deserialize(ComponentType, options);
|
||||||
|
if (boxed != null)
|
||||||
|
{
|
||||||
|
postDeserialize?.Invoke(boxed);
|
||||||
|
|
||||||
|
foreach (var prop in Properties)
|
||||||
|
{
|
||||||
|
prop.DeserializeOverride(element, boxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
System.Runtime.InteropServices.Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
_world.EntityManager.SetSharedComponent(EntityNode.Entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
System.Runtime.InteropServices.Marshal.StructureToPtr(boxed, (nint)GetComponentPointer(), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
@@ -6,9 +7,51 @@ namespace Ghost.Editor.Core.SceneGraph;
|
|||||||
|
|
||||||
public sealed partial class EntityNode : SceneGraphNode
|
public sealed partial class EntityNode : SceneGraphNode
|
||||||
{
|
{
|
||||||
private readonly Entity _entity;
|
public Entity Entity
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
public List<ComponentNode> Components { get; } = new();
|
||||||
|
|
||||||
public Entity Entity => _entity;
|
public SceneNode? SceneNode { get; }
|
||||||
|
|
||||||
|
internal EntityNode(World world, Entity entity, string name, SceneNode? sceneNode)
|
||||||
|
: base(world, name)
|
||||||
|
{
|
||||||
|
Entity = entity;
|
||||||
|
SceneNode = sceneNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SceneNode? GetOwningSceneNode() => SceneNode;
|
||||||
|
|
||||||
|
public void BuildComponents()
|
||||||
|
{
|
||||||
|
Components.Clear();
|
||||||
|
var locationResult = World.EntityManager.GetEntityLocation(Entity);
|
||||||
|
if (!locationResult.IsSuccess)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref World.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
var it = archetype._signature.GetIterator();
|
||||||
|
while (it.Next(out var componentID))
|
||||||
|
{
|
||||||
|
if (ComponentRegistry.s_runtimeIDToType.TryGetValue(componentID, out var type))
|
||||||
|
{
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(new Ghost.Core.Identifier<IComponent>(componentID));
|
||||||
|
if (compInfo.isCleanup)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compDescriptor = Inspector.ComponentDescriptor.Create(type);
|
||||||
|
Components.Add(new ComponentNode(World, this, type, compDescriptor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override IconSource? CreateIcon()
|
public override IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
@@ -20,26 +63,54 @@ public sealed partial class EntityNode : SceneGraphNode
|
|||||||
|
|
||||||
public override UIElement? CreateHeader()
|
public override UIElement? CreateHeader()
|
||||||
{
|
{
|
||||||
return null;
|
var root = new Grid
|
||||||
|
{
|
||||||
|
ColumnSpacing = 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition
|
||||||
|
{
|
||||||
|
Width = new GridLength(1, GridUnitType.Star)
|
||||||
|
});
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition
|
||||||
|
{
|
||||||
|
Width = GridLength.Auto,
|
||||||
|
MinWidth = 20
|
||||||
|
});
|
||||||
|
|
||||||
|
var nameBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = Name,
|
||||||
|
FontSize = 14,
|
||||||
|
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
|
||||||
|
};
|
||||||
|
|
||||||
|
nameBox.SetBinding(TextBox.TextProperty, new Microsoft.UI.Xaml.Data.Binding
|
||||||
|
{
|
||||||
|
Source = this,
|
||||||
|
Path = new PropertyPath(nameof(Name)),
|
||||||
|
Mode = Microsoft.UI.Xaml.Data.BindingMode.TwoWay,
|
||||||
|
UpdateSourceTrigger = Microsoft.UI.Xaml.Data.UpdateSourceTrigger.PropertyChanged
|
||||||
|
});
|
||||||
|
|
||||||
|
var entityBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{Entity.ID}:{Entity.Generation}",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(nameBox, 0);
|
||||||
|
Grid.SetColumn(entityBlock, 1);
|
||||||
|
|
||||||
|
root.Children.Add(nameBox);
|
||||||
|
root.Children.Add(entityBlock);
|
||||||
|
|
||||||
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override UIElement? CreateInspector()
|
public override IInspectorModel CreateInspectorModel()
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
return new Inspector.EntityInspectorModel(World, Entity);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/Editor/Ghost.Editor.Core/SceneGraph/HandlePropertyNode.cs
Normal file
134
src/Editor/Ghost.Editor.Core/SceneGraph/HandlePropertyNode.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public class HandlePropertyNode<T> : PropertyNode<Handle<T>> where T : unmanaged
|
||||||
|
{
|
||||||
|
public Guid AssetGuid { get; private set; } = Guid.Empty;
|
||||||
|
public long ExpectedHandleValue { get; private set; }
|
||||||
|
|
||||||
|
public HandlePropertyNode(PropertyDescriptor descriptor, ComponentNode parent)
|
||||||
|
: base(descriptor, parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetHandleFromAsset(Guid assetGuid)
|
||||||
|
{
|
||||||
|
var assetManager = EditorApplication.GetService<AssetManager>();
|
||||||
|
|
||||||
|
MethodInfo? resolveMethod = null;
|
||||||
|
if (typeof(T).Name == "GPUTexture")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveTexture", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
else if (typeof(T).Name == "Mesh")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveMesh", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
Handle<T> handle = default;
|
||||||
|
if (resolveMethod != null && assetManager != null)
|
||||||
|
{
|
||||||
|
var res = resolveMethod.Invoke(assetManager, new object[] { assetGuid });
|
||||||
|
if (res != null)
|
||||||
|
{
|
||||||
|
handle = (Handle<T>)res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.Error($"No resolve method found for type {typeof(T).Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetGuid = assetGuid;
|
||||||
|
ExpectedHandleValue = UnsafeGetHandleValue(handle);
|
||||||
|
SetValueFromUI(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearHandle()
|
||||||
|
{
|
||||||
|
AssetGuid = Guid.Empty;
|
||||||
|
ExpectedHandleValue = 0;
|
||||||
|
SetValueFromUI(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long UnsafeGetHandleValue(Handle<T> handle)
|
||||||
|
{
|
||||||
|
return System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SerializeOverride(JsonObject jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
if (AssetGuid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var camelCaseName = char.ToLowerInvariant(Descriptor.Name[0]) + Descriptor.Name.Substring(1);
|
||||||
|
if (jsonRoot.ContainsKey(camelCaseName))
|
||||||
|
jsonRoot[camelCaseName] = AssetGuid.ToString();
|
||||||
|
else
|
||||||
|
jsonRoot[Descriptor.Name] = AssetGuid.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeOverride(JsonElement jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
var camelCaseName = char.ToLowerInvariant(Descriptor.Name[0]) + Descriptor.Name.Substring(1);
|
||||||
|
|
||||||
|
if (jsonRoot.TryGetProperty(camelCaseName, out var propElement) || jsonRoot.TryGetProperty(Descriptor.Name, out propElement))
|
||||||
|
{
|
||||||
|
if (propElement.ValueKind == JsonValueKind.String && Guid.TryParse(propElement.GetString(), out var guid) && guid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var assetManager = EditorApplication.GetService<AssetManager>();
|
||||||
|
|
||||||
|
MethodInfo? resolveMethod = null;
|
||||||
|
if (typeof(T).Name == "GPUTexture")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveTexture", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
else if (typeof(T).Name == "Mesh")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveMesh", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (resolveMethod != null && assetManager != null)
|
||||||
|
{
|
||||||
|
var handleObj = resolveMethod.Invoke(assetManager, new object[] { guid });
|
||||||
|
if (handleObj != null)
|
||||||
|
{
|
||||||
|
var fieldInfo = boxedComponent.GetType().GetField(Descriptor.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
fieldInfo.SetValue(boxedComponent, handleObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = (Handle<T>)handleObj;
|
||||||
|
var handleValue = System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
|
||||||
|
AssetGuid = guid;
|
||||||
|
ExpectedHandleValue = handleValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Validate(object boxedComponent)
|
||||||
|
{
|
||||||
|
if (AssetGuid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var fieldInfo = boxedComponent.GetType().GetField(Descriptor.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
var val = fieldInfo.GetValue(boxedComponent);
|
||||||
|
if (val != null)
|
||||||
|
{
|
||||||
|
var handle = (Handle<T>)val;
|
||||||
|
var currentVal = System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
|
||||||
|
if (currentVal != ExpectedHandleValue)
|
||||||
|
{
|
||||||
|
Logger.Error($"Handle field '{Descriptor.Name}' was modified externally. Guid tracking cleared.");
|
||||||
|
AssetGuid = Guid.Empty;
|
||||||
|
ExpectedHandleValue = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNode.cs
Normal file
86
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNode.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single property/field within a ComponentNode.
|
||||||
|
/// Handles ECS reading/writing as well as serialization overrides (like Guid metadata).
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PropertyNode
|
||||||
|
{
|
||||||
|
public PropertyDescriptor Descriptor { get; }
|
||||||
|
public ComponentNode ComponentNode { get; }
|
||||||
|
public PropertyNode[]? Children { get; protected set; }
|
||||||
|
|
||||||
|
protected PropertyNode(PropertyDescriptor descriptor, ComponentNode parent)
|
||||||
|
{
|
||||||
|
Descriptor = descriptor;
|
||||||
|
ComponentNode = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronize the cached value from the ECS backend.
|
||||||
|
/// </summary>
|
||||||
|
public abstract void Sync();
|
||||||
|
|
||||||
|
public virtual void SerializeOverride(JsonObject jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void DeserializeOverride(JsonElement jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Validate(object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PropertyNode<T> : PropertyNode
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
private T _value;
|
||||||
|
public T Value => _value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event fired when the value is updated from ECS. UI controls bind to this.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<T>? OnValueChanged;
|
||||||
|
|
||||||
|
public PropertyNode(PropertyDescriptor descriptor, ComponentNode parent, PropertyNode[]? children = null)
|
||||||
|
: base(descriptor, parent)
|
||||||
|
{
|
||||||
|
_value = parent.GetPropertyValue<T>(descriptor);
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Sync()
|
||||||
|
{
|
||||||
|
var newValue = ComponentNode.GetPropertyValue<T>(Descriptor);
|
||||||
|
|
||||||
|
if (!EqualityComparer<T>.Default.Equals(_value, newValue))
|
||||||
|
{
|
||||||
|
_value = newValue;
|
||||||
|
OnValueChanged?.Invoke(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Children != null)
|
||||||
|
{
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
child.Sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the UI when the user edits the value.
|
||||||
|
/// </summary>
|
||||||
|
public void SetValueFromUI(T newValue)
|
||||||
|
{
|
||||||
|
_value = newValue;
|
||||||
|
ComponentNode.SetPropertyValue(Descriptor, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 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
|
|
||||||
162
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
162
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public static class SceneGraphBuilder
|
||||||
|
{
|
||||||
|
public static List<SceneNode> Build(World world, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
var sceneNodes = new List<SceneNode>();
|
||||||
|
var sceneEntities = GroupEntitiesByScene(world);
|
||||||
|
|
||||||
|
foreach (var (scene, entities) in sceneEntities)
|
||||||
|
{
|
||||||
|
var sceneName = GetDefaultSceneName(scene);
|
||||||
|
var sceneNode = new SceneNode(world, new Scene(scene), sceneName);
|
||||||
|
BuildEntityTree(entities, sceneNode, initialNames);
|
||||||
|
sceneNodes.Add(sceneNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sceneNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<ushort, List<Entity>> GroupEntitiesByScene(World world)
|
||||||
|
{
|
||||||
|
var sceneMap = new Dictionary<ushort, List<Entity>>();
|
||||||
|
var queryID = new QueryBuilder().WithAll<SceneID>().Build(world);
|
||||||
|
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
|
||||||
|
|
||||||
|
foreach (var chunk in query.GetChunkIterator())
|
||||||
|
{
|
||||||
|
var entities = chunk.GetEntities();
|
||||||
|
var scene = chunk.GetSharedComponent<SceneID>();
|
||||||
|
|
||||||
|
if (scene.value == Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < chunk.EntityCount; i++)
|
||||||
|
{
|
||||||
|
if (!sceneMap.TryGetValue(scene.value, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
sceneMap[scene.value] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entities[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sceneMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildEntityTree(List<Entity> entities, SceneGraphNode parentNode, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
var entitySet = new HashSet<Entity>(entities);
|
||||||
|
var childrenByParent = new Dictionary<Entity, List<Entity>>();
|
||||||
|
var roots = new List<Entity>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
Hierarchy hierarchy = default;
|
||||||
|
var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy);
|
||||||
|
|
||||||
|
if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(hierarchy.parent, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
childrenByParent[hierarchy.parent] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rootEntity in roots)
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(rootEntity, out var n) ? n : "Entity";
|
||||||
|
var entityNode = new EntityNode(parentNode.World, rootEntity, name, parentNode.GetOwningSceneNode());
|
||||||
|
parentNode.Children.Add(entityNode);
|
||||||
|
BuildSubtree(entityNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildSubtree(EntityNode parentNode, Dictionary<Entity, List<Entity>> childrenByParent, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(parentNode.Entity, out var childList))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Hierarchy parentHierarchy = default;
|
||||||
|
if (!TryGetHierarchyComponent(parentNode.World, parentNode.Entity, ref parentHierarchy))
|
||||||
|
{
|
||||||
|
foreach (var childEntity in childList)
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(childEntity, out var n) ? n : "Entity";
|
||||||
|
var childNode = new EntityNode(parentNode.World, childEntity, name, parentNode.SceneNode);
|
||||||
|
parentNode.Children.Add(childNode);
|
||||||
|
BuildSubtree(childNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sibling = parentHierarchy.firstChild;
|
||||||
|
while (sibling.IsValid)
|
||||||
|
{
|
||||||
|
if (childList.Contains(sibling))
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(sibling, out var n) ? n : "Entity";
|
||||||
|
var childNode = new EntityNode(parentNode.World, sibling, name, parentNode.SceneNode);
|
||||||
|
parentNode.Children.Add(childNode);
|
||||||
|
BuildSubtree(childNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
Hierarchy siblingHierarchy = default;
|
||||||
|
if (!TryGetHierarchyComponent(parentNode.World, sibling, ref siblingHierarchy))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sibling = siblingHierarchy.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy)
|
||||||
|
{
|
||||||
|
var location = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (!location.IsSuccess)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID);
|
||||||
|
var hierarchyID = ComponentTypeID<Hierarchy>.Value;
|
||||||
|
if (!archetype.HasComponent(hierarchyID))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID);
|
||||||
|
if (pData == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hierarchy = *(Hierarchy*)pData;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultSceneName(ushort sceneID)
|
||||||
|
{
|
||||||
|
return $"NewScene ({sceneID})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.SceneGraph;
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
[ObservableObject]
|
||||||
|
public abstract partial class SceneGraphNode : GhostObject, IInspectable
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Name
|
public partial string Name
|
||||||
@@ -14,14 +17,89 @@ public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
|||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public World World
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneGraphNode? Parent
|
||||||
|
{
|
||||||
|
get; internal set;
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<SceneGraphNode> Children
|
public ObservableCollection<SceneGraphNode> Children
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
} = new();
|
} = new();
|
||||||
|
|
||||||
public abstract IconSource? CreateIcon();
|
protected SceneGraphNode(World world, string name)
|
||||||
public abstract UIElement? CreateHeader();
|
{
|
||||||
public abstract UIElement? CreateInspector();
|
World = world;
|
||||||
|
Name = name;
|
||||||
|
Children.CollectionChanged += OnChildrenChanged;
|
||||||
|
}
|
||||||
|
|
||||||
public abstract DataTemplate GetSceneHierarchyTemplate();
|
private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OldItems != null)
|
||||||
|
{
|
||||||
|
foreach (SceneGraphNode oldItem in e.OldItems)
|
||||||
|
{
|
||||||
|
if (oldItem.Parent == this)
|
||||||
|
{
|
||||||
|
oldItem.Parent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.NewItems != null)
|
||||||
|
{
|
||||||
|
foreach (SceneGraphNode newItem in e.NewItems)
|
||||||
|
{
|
||||||
|
newItem.Parent = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual SceneNode? GetOwningSceneNode()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Modify()
|
||||||
|
{
|
||||||
|
base.Modify(); // Marks this node dirty via base GhostObject logic
|
||||||
|
|
||||||
|
var sceneNode = GetOwningSceneNode();
|
||||||
|
if (sceneNode != null)
|
||||||
|
{
|
||||||
|
var worldService = EditorApplication.GetService<IEditorWorldService>();
|
||||||
|
var sceneAsset = worldService.GetAssetForScene(sceneNode.Scene.ID);
|
||||||
|
if (sceneAsset != null)
|
||||||
|
{
|
||||||
|
EditorApplication.GetService<IDirtyTrackerService>().MarkDirty(sceneAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
Name = reader.ReadString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual IconSource? CreateIcon()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual UIElement? CreateHeader()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract IInspectorModel CreateInspectorModel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
@@ -5,6 +8,19 @@ namespace Ghost.Editor.Core.SceneGraph;
|
|||||||
|
|
||||||
public sealed partial class SceneNode : SceneGraphNode
|
public sealed partial class SceneNode : SceneGraphNode
|
||||||
{
|
{
|
||||||
|
public Scene Scene
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal SceneNode(World world, Scene scene, string name)
|
||||||
|
: base(world, name)
|
||||||
|
{
|
||||||
|
Scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SceneNode? GetOwningSceneNode() => this;
|
||||||
|
|
||||||
public override IconSource? CreateIcon()
|
public override IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
return new FontIconSource
|
return new FontIconSource
|
||||||
@@ -13,33 +29,13 @@ public sealed partial class SceneNode : SceneGraphNode
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement custom header and inspector UI for the SceneNode
|
|
||||||
public override UIElement? CreateHeader()
|
public override UIElement? CreateHeader()
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override UIElement? CreateInspector()
|
public override IInspectorModel CreateInspectorModel()
|
||||||
{
|
{
|
||||||
return null;
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,94 +5,91 @@ namespace Ghost.Editor.Core.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thread-safe SQLite-backed asset catalog.
|
/// Thread-safe SQLite-backed asset catalog.
|
||||||
/// Replaces the in-memory dictionary approach with persistent storage.
|
/// Uses connection pooling and local command creation for safe multi-threaded access.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class AssetCatalog : IDisposable
|
public sealed partial class AssetCatalog
|
||||||
{
|
{
|
||||||
private readonly SqliteConnection _connection;
|
public readonly record struct SubAssetInfo(Guid Guid, Guid ParentGuid, string Kind, string DisplayName, string StablePath, string SourcePath, Guid AssetTypeId);
|
||||||
private readonly Lock _writeLock = new();
|
|
||||||
|
|
||||||
// Prepared statements
|
private readonly string _connectionString;
|
||||||
private readonly SqliteCommand _cmdGetGuid;
|
|
||||||
private readonly SqliteCommand _cmdGetPath;
|
|
||||||
private readonly SqliteCommand _cmdUpsert;
|
|
||||||
private readonly SqliteCommand _cmdDelete;
|
|
||||||
|
|
||||||
private readonly SqliteCommand _cmdGetHandlerTypeId;
|
private const string SqlGetGuid = "SELECT guid FROM assets WHERE source_path = @path";
|
||||||
private readonly SqliteCommand _cmdGetReferencers;
|
private const string SqlGetPath = "SELECT source_path FROM assets WHERE guid = @guid";
|
||||||
private readonly SqliteCommand _cmdGetDependencies;
|
private const string SqlGetAssetTypeId = "SELECT asset_type_id FROM assets WHERE guid = @guid";
|
||||||
private readonly SqliteCommand _cmdGetImportedAt;
|
private const string SqlGetImportedAt = "SELECT imported_at_ms FROM assets WHERE guid = @guid";
|
||||||
|
private const string SqlUpsert = @"
|
||||||
private readonly SqliteCommand _cmdInsertDep;
|
INSERT INTO assets (guid, source_path, asset_type_id, handler_version, content_hash, settings_hash, imported_at_ms, parent_guid, subasset_kind, display_name, stable_path)
|
||||||
private readonly SqliteCommand _cmdClearDeps;
|
VALUES (@guid, @path, @asset_type_id, @version, @content_hash, @settings_hash, @imported_at_ms, @parent_guid, @subasset_kind, @display_name, @stable_path)
|
||||||
private readonly SqliteCommand _cmdEnumerate;
|
ON CONFLICT(guid) DO UPDATE SET
|
||||||
|
source_path = excluded.source_path,
|
||||||
|
asset_type_id = excluded.asset_type_id,
|
||||||
|
handler_version = excluded.handler_version,
|
||||||
|
content_hash = excluded.content_hash,
|
||||||
|
settings_hash = excluded.settings_hash,
|
||||||
|
imported_at_ms = excluded.imported_at_ms,
|
||||||
|
parent_guid = excluded.parent_guid,
|
||||||
|
subasset_kind = excluded.subasset_kind,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
stable_path = excluded.stable_path";
|
||||||
|
private const string SqlDelete = "DELETE FROM assets WHERE guid = @guid";
|
||||||
|
private const string SqlGetReferencers = "SELECT from_guid FROM dependencies WHERE to_guid = @guid";
|
||||||
|
private const string SqlGetDependencies = "SELECT to_guid FROM dependencies WHERE from_guid = @guid";
|
||||||
|
private const string SqlInsertDep = "INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)";
|
||||||
|
private const string SqlClearDeps = "DELETE FROM dependencies WHERE from_guid = @guid";
|
||||||
|
private const string SqlEnumerate = "SELECT guid, source_path FROM assets";
|
||||||
|
private const string SqlEnumerateSubAssets = "SELECT guid, parent_guid, subasset_kind, display_name, stable_path, source_path, asset_type_id FROM assets WHERE parent_guid = @parent_guid ORDER BY stable_path";
|
||||||
|
private const string SqlDeleteSubAssetsForParent = "DELETE FROM assets WHERE parent_guid = @parent_guid";
|
||||||
|
|
||||||
public AssetCatalog(string dbPath)
|
public AssetCatalog(string dbPath)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||||
|
|
||||||
var connString = new SqliteConnectionStringBuilder
|
var builder = new SqliteConnectionStringBuilder
|
||||||
{
|
{
|
||||||
DataSource = dbPath,
|
DataSource = dbPath,
|
||||||
Cache = SqliteCacheMode.Shared,
|
ForeignKeys = true,
|
||||||
}.ToString();
|
Pooling = true,
|
||||||
|
};
|
||||||
|
_connectionString = builder.ToString();
|
||||||
|
|
||||||
_connection = new SqliteConnection(connString);
|
// Initial setup
|
||||||
_connection.Open();
|
using var connection = OpenConnection();
|
||||||
|
using (var cmd = connection.CreateCommand())
|
||||||
using (var pragma = _connection.CreateCommand())
|
|
||||||
{
|
{
|
||||||
pragma.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;";
|
cmd.CommandText = "PRAGMA journal_mode = WAL;";
|
||||||
pragma.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateSchema();
|
CreateSchemaInternal(connection);
|
||||||
|
|
||||||
_cmdGetGuid = CreateCommand("SELECT guid FROM assets WHERE source_path = @path");
|
|
||||||
_cmdGetPath = CreateCommand("SELECT source_path FROM assets WHERE guid = @guid");
|
|
||||||
_cmdGetHandlerTypeId = CreateCommand("SELECT handler_type_id FROM assets WHERE guid = @guid");
|
|
||||||
_cmdGetImportedAt = CreateCommand("SELECT imported_at_ms FROM assets WHERE guid = @guid");
|
|
||||||
|
|
||||||
_cmdUpsert = CreateCommand(@"
|
|
||||||
INSERT INTO assets (guid, source_path, handler_type_id, handler_version, content_hash, settings_hash, imported_at_ms)
|
|
||||||
VALUES (@guid, @path, @handler_id, @version, @content_hash, @settings_hash, @imported_at_ms)
|
|
||||||
ON CONFLICT(guid) DO UPDATE SET
|
|
||||||
source_path = excluded.source_path,
|
|
||||||
handler_type_id = excluded.handler_type_id,
|
|
||||||
handler_version = excluded.handler_version,
|
|
||||||
content_hash = excluded.content_hash,
|
|
||||||
settings_hash = excluded.settings_hash,
|
|
||||||
imported_at_ms = excluded.imported_at_ms");
|
|
||||||
_cmdDelete = CreateCommand("DELETE FROM assets WHERE guid = @guid");
|
|
||||||
_cmdGetReferencers = CreateCommand("SELECT from_guid FROM dependencies WHERE to_guid = @guid");
|
|
||||||
_cmdGetDependencies = CreateCommand("SELECT to_guid FROM dependencies WHERE from_guid = @guid");
|
|
||||||
|
|
||||||
_cmdInsertDep = CreateCommand("INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)");
|
|
||||||
_cmdClearDeps = CreateCommand("DELETE FROM dependencies WHERE from_guid = @guid");
|
|
||||||
_cmdEnumerate = CreateCommand("SELECT guid, source_path FROM assets");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private SqliteCommand CreateCommand(string sql)
|
private SqliteConnection OpenConnection()
|
||||||
{
|
{
|
||||||
var cmd = _connection.CreateCommand();
|
var connection = new SqliteConnection(_connectionString);
|
||||||
cmd.CommandText = sql;
|
connection.Open();
|
||||||
return cmd;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateSchema()
|
private static void CreateSchemaInternal(SqliteConnection connection)
|
||||||
{
|
{
|
||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = connection.CreateCommand();
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
guid BLOB(16) PRIMARY KEY NOT NULL,
|
guid BLOB (16) PRIMARY KEY NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
handler_type_id BLOB(16),
|
asset_type_id BLOB (16),
|
||||||
handler_version INTEGER NOT NULL DEFAULT 0,
|
handler_version INTEGER NOT NULL DEFAULT 0,
|
||||||
content_hash TEXT,
|
content_hash TEXT,
|
||||||
settings_hash TEXT,
|
settings_hash TEXT,
|
||||||
imported_at_ms INTEGER
|
imported_at_ms INTEGER,
|
||||||
|
parent_guid BLOB (16),
|
||||||
|
subasset_kind TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
stable_path TEXT
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_type_id ON assets(asset_type_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dependencies (
|
CREATE TABLE IF NOT EXISTS dependencies (
|
||||||
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
||||||
@@ -122,96 +119,129 @@ public sealed partial class AssetCatalog : IDisposable
|
|||||||
|
|
||||||
public Guid GetGuid(string sourcePath)
|
public Guid GetGuid(string sourcePath)
|
||||||
{
|
{
|
||||||
_cmdGetGuid.Parameters.Clear();
|
using var connection = OpenConnection();
|
||||||
_cmdGetGuid.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
using var cmd = connection.CreateCommand();
|
||||||
var result = _cmdGetGuid.ExecuteScalar();
|
|
||||||
|
cmd.CommandText = SqlGetGuid;
|
||||||
|
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
||||||
|
var result = cmd.ExecuteScalar();
|
||||||
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetSourcePath(Guid guid)
|
public string? GetSourcePath(Guid guid)
|
||||||
{
|
{
|
||||||
_cmdGetPath.Parameters.Clear();
|
using var connection = OpenConnection();
|
||||||
_cmdGetPath.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
using var cmd = connection.CreateCommand();
|
||||||
return _cmdGetPath.ExecuteScalar() as string;
|
cmd.CommandText = SqlGetPath;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
return cmd.ExecuteScalar() as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Upsert(AssetMeta meta, string sourcePath)
|
private void UpsertInternal(AssetMeta meta, string sourcePath, Guid? parentGuid, string? kind, string? displayName, string? stablePath)
|
||||||
{
|
{
|
||||||
lock (_writeLock)
|
using var connection = OpenConnection();
|
||||||
{
|
using var cmd = connection.CreateCommand();
|
||||||
_cmdUpsert.Parameters.Clear();
|
cmd.CommandText = SqlUpsert;
|
||||||
_cmdUpsert.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray());
|
cmd.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray());
|
||||||
_cmdUpsert.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
||||||
_cmdUpsert.Parameters.AddWithValue("@handler_id", meta.HandlerTypeId?.ToByteArray() ?? (object)DBNull.Value);
|
cmd.Parameters.AddWithValue("@asset_type_id", meta.AssetTypeId?.ToByteArray() ?? (object)DBNull.Value);
|
||||||
_cmdUpsert.Parameters.AddWithValue("@version", meta.HandlerVersion);
|
cmd.Parameters.AddWithValue("@version", meta.HandlerVersion);
|
||||||
_cmdUpsert.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value);
|
cmd.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value);
|
||||||
_cmdUpsert.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value);
|
cmd.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value);
|
||||||
_cmdUpsert.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value);
|
cmd.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value);
|
||||||
_cmdUpsert.ExecuteNonQuery();
|
cmd.Parameters.AddWithValue("@parent_guid", parentGuid?.ToByteArray() ?? (object)DBNull.Value);
|
||||||
}
|
cmd.Parameters.AddWithValue("@subasset_kind", (object?)kind ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@display_name", (object?)displayName ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@stable_path", (object?)stablePath ?? DBNull.Value);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Upsert(AssetMeta meta, string sourcePath) => UpsertInternal(meta, sourcePath, null, null, null, null);
|
||||||
|
|
||||||
|
public void UpsertSubAsset(Guid parentGuid, AssetMeta meta, string sourcePath, string kind, string displayName, string stablePath)
|
||||||
|
=> UpsertInternal(meta, sourcePath, parentGuid, kind, displayName, stablePath);
|
||||||
|
|
||||||
public bool Remove(Guid guid)
|
public bool Remove(Guid guid)
|
||||||
{
|
{
|
||||||
lock (_writeLock)
|
var subAssets = GetSubAssets(guid);
|
||||||
|
foreach (var sub in subAssets)
|
||||||
{
|
{
|
||||||
_cmdDelete.Parameters.Clear();
|
Remove(sub.Guid);
|
||||||
_cmdDelete.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
|
||||||
return _cmdDelete.ExecuteNonQuery() > 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid GetHandlerTypeId(Guid guid)
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlDelete;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
return cmd.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid GetAssetTypeId(Guid guid)
|
||||||
{
|
{
|
||||||
_cmdGetHandlerTypeId.Parameters.Clear();
|
using var connection = OpenConnection();
|
||||||
_cmdGetHandlerTypeId.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
using var cmd = connection.CreateCommand();
|
||||||
var result = _cmdGetHandlerTypeId.ExecuteScalar();
|
|
||||||
|
cmd.CommandText = SqlGetAssetTypeId;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
var result = cmd.ExecuteScalar();
|
||||||
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DateTime? GetImportedAt(Guid guid)
|
public DateTime? GetImportedAt(Guid guid)
|
||||||
{
|
{
|
||||||
_cmdGetImportedAt.Parameters.Clear();
|
using var connection = OpenConnection();
|
||||||
_cmdGetImportedAt.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
using var cmd = connection.CreateCommand();
|
||||||
var result = _cmdGetImportedAt.ExecuteScalar();
|
|
||||||
|
|
||||||
if (result is long ticks)
|
cmd.CommandText = SqlGetImportedAt;
|
||||||
{
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
return new DateTime(ticks, DateTimeKind.Utc);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
var result = cmd.ExecuteScalar();
|
||||||
|
return result is long ticks ? new DateTime(ticks, DateTimeKind.Utc) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
|
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
|
||||||
{
|
{
|
||||||
lock (_writeLock)
|
using var connection = OpenConnection();
|
||||||
{
|
using var tx = connection.BeginTransaction();
|
||||||
using var tx = _connection.BeginTransaction();
|
|
||||||
_cmdClearDeps.Transaction = tx;
|
using (var clearCmd = connection.CreateCommand())
|
||||||
_cmdClearDeps.Parameters.Clear();
|
{
|
||||||
_cmdClearDeps.Parameters.AddWithValue("@guid", assetId.ToByteArray());
|
clearCmd.Transaction = tx;
|
||||||
_cmdClearDeps.ExecuteNonQuery();
|
clearCmd.CommandText = SqlClearDeps;
|
||||||
|
clearCmd.Parameters.AddWithValue("@guid", assetId.ToByteArray());
|
||||||
|
clearCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dependencies.Length > 0)
|
||||||
|
{
|
||||||
|
using var insertCmd = connection.CreateCommand();
|
||||||
|
insertCmd.Transaction = tx;
|
||||||
|
insertCmd.CommandText = SqlInsertDep;
|
||||||
|
var fromParam = insertCmd.Parameters.Add("@from", SqliteType.Blob);
|
||||||
|
var toParam = insertCmd.Parameters.Add("@to", SqliteType.Blob);
|
||||||
|
fromParam.Value = assetId.ToByteArray();
|
||||||
|
|
||||||
_cmdInsertDep.Transaction = tx;
|
|
||||||
foreach (var dep in dependencies)
|
foreach (var dep in dependencies)
|
||||||
{
|
{
|
||||||
_cmdInsertDep.Parameters.Clear();
|
toParam.Value = dep.ToByteArray();
|
||||||
_cmdInsertDep.Parameters.AddWithValue("@from", assetId.ToByteArray());
|
insertCmd.ExecuteNonQuery();
|
||||||
_cmdInsertDep.Parameters.AddWithValue("@to", dep.ToByteArray());
|
}
|
||||||
_cmdInsertDep.ExecuteNonQuery();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit();
|
tx.Commit();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public List<Guid> GetReferencers(Guid guid)
|
public List<Guid> GetReferencers(Guid guid)
|
||||||
{
|
{
|
||||||
_cmdGetReferencers.Parameters.Clear();
|
using var connection = OpenConnection();
|
||||||
_cmdGetReferencers.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
using var reader = _cmdGetReferencers.ExecuteReader();
|
cmd.CommandText = SqlGetReferencers;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
var list = new List<Guid>();
|
var list = new List<Guid>();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
@@ -223,10 +253,13 @@ public sealed partial class AssetCatalog : IDisposable
|
|||||||
|
|
||||||
public List<Guid> GetDependencies(Guid guid)
|
public List<Guid> GetDependencies(Guid guid)
|
||||||
{
|
{
|
||||||
_cmdGetDependencies.Parameters.Clear();
|
using var connection = OpenConnection();
|
||||||
_cmdGetDependencies.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
using var reader = _cmdGetDependencies.ExecuteReader();
|
cmd.CommandText = SqlGetDependencies;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
var list = new List<Guid>();
|
var list = new List<Guid>();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
@@ -238,25 +271,93 @@ public sealed partial class AssetCatalog : IDisposable
|
|||||||
|
|
||||||
public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll()
|
public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll()
|
||||||
{
|
{
|
||||||
using var reader = _cmdEnumerate.ExecuteReader();
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlEnumerate;
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
yield return (new Guid((byte[])reader[0]), reader.GetString(1));
|
yield return (new Guid((byte[])reader[0]), reader.GetString(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public IEnumerable<Guid> EnumerateByTypes(params Guid[] assetTypeIds)
|
||||||
{
|
{
|
||||||
_cmdGetGuid.Dispose();
|
if (assetTypeIds.Length == 0)
|
||||||
_cmdGetPath.Dispose();
|
{
|
||||||
_cmdUpsert.Dispose();
|
yield break;
|
||||||
_cmdDelete.Dispose();
|
}
|
||||||
_cmdGetHandlerTypeId.Dispose();
|
|
||||||
_cmdGetReferencers.Dispose();
|
using var connection = OpenConnection();
|
||||||
_cmdGetDependencies.Dispose();
|
using var cmd = connection.CreateCommand();
|
||||||
_cmdInsertDep.Dispose();
|
|
||||||
_cmdClearDeps.Dispose();
|
var parameterNames = new List<string>(assetTypeIds.Length);
|
||||||
_cmdEnumerate.Dispose();
|
for (var i = 0; i < assetTypeIds.Length; i++)
|
||||||
_connection.Dispose();
|
{
|
||||||
|
var paramName = $"@typeId{i}";
|
||||||
|
parameterNames.Add(paramName);
|
||||||
|
cmd.Parameters.AddWithValue(paramName, assetTypeIds[i].ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.CommandText = $"SELECT guid FROM assets WHERE asset_type_id IN ({string.Join(", ", parameterNames)})";
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
yield return new Guid((byte[])reader[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SubAssetInfo> GetSubAssets(Guid parentGuid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlEnumerateSubAssets;
|
||||||
|
cmd.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
var list = new List<SubAssetInfo>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
list.Add(new SubAssetInfo(
|
||||||
|
new Guid((byte[])reader[0]),
|
||||||
|
new Guid((byte[])reader[1]),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetString(3),
|
||||||
|
reader.GetString(4),
|
||||||
|
reader.GetString(5),
|
||||||
|
new Guid((byte[])reader[6])));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveSubAssetsExcept(Guid parentGuid, ReadOnlySpan<Guid> keepGuids)
|
||||||
|
{
|
||||||
|
if (keepGuids.Length == 0)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = SqlDeleteSubAssetsForParent;
|
||||||
|
cmd.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep = new HashSet<Guid>(keepGuids.Length);
|
||||||
|
foreach (var guid in keepGuids)
|
||||||
|
{
|
||||||
|
keep.Add(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var subAsset in GetSubAssets(parentGuid))
|
||||||
|
{
|
||||||
|
if (!keep.Contains(subAsset.Guid))
|
||||||
|
{
|
||||||
|
Remove(subAsset.Guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@ using Ghost.Core;
|
|||||||
using Ghost.Core.Utilities;
|
using Ghost.Core.Utilities;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Services;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
{
|
{
|
||||||
IncludeSubdirectories = true,
|
IncludeSubdirectories = true,
|
||||||
EnableRaisingEvents = true,
|
EnableRaisingEvents = true,
|
||||||
NotifyFilter = NotifyFilters.LastWrite
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
|
||||||
};
|
};
|
||||||
|
|
||||||
_watcher.Created += OnFileSystemEvent;
|
_watcher.Created += OnFileSystemEvent;
|
||||||
@@ -81,6 +83,11 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
|
|
||||||
foreach (var (guid, path) in _catalog.EnumerateAll())
|
foreach (var (guid, path) in _catalog.EnumerateAll())
|
||||||
{
|
{
|
||||||
|
if (path.Contains('#', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!foundGuids.Contains(guid))
|
if (!foundGuids.Contains(guid))
|
||||||
{
|
{
|
||||||
_catalog.Remove(guid);
|
_catalog.Remove(guid);
|
||||||
@@ -130,6 +137,8 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
|
var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
|
||||||
var fileExists = File.Exists(e.FullPath);
|
var fileExists = File.Exists(e.FullPath);
|
||||||
|
|
||||||
@@ -158,6 +167,8 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
_catalog.Remove(guid);
|
_catalog.Remove(guid);
|
||||||
changeType = AssetChangeType.Deleted;
|
changeType = AssetChangeType.Deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.DebugAssert(e.ChangeType == WatcherChangeTypes.Deleted);
|
||||||
}
|
}
|
||||||
else if (guid == Guid.Empty)
|
else if (guid == Guid.Empty)
|
||||||
{
|
{
|
||||||
@@ -177,6 +188,11 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warning($"FileSystemEvent exception: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnFileSystemRenameEvent(object sender, RenamedEventArgs e)
|
private void OnFileSystemRenameEvent(object sender, RenamedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -212,13 +228,18 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlerTypeId = handler?.EditorAssetTypeID;
|
var assetTypeId = Guid.Empty;
|
||||||
|
if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo))
|
||||||
|
{
|
||||||
|
assetTypeId = handlerInfo.EditorAssetTypeID;
|
||||||
|
}
|
||||||
|
|
||||||
var meta = new AssetMeta
|
var meta = new AssetMeta
|
||||||
{
|
{
|
||||||
Guid = Guid.NewGuid(),
|
Guid = Guid.NewGuid(),
|
||||||
HandlerTypeId = handlerTypeId,
|
AssetTypeId = assetTypeId,
|
||||||
HandlerVersion = 1,
|
HandlerVersion = 1,
|
||||||
Settings = handler?.CreateDefaultSettings()
|
Settings = handler?.CreateDefaultSettings(ext)
|
||||||
};
|
};
|
||||||
|
|
||||||
_ignoreMetaWrites[metaPath] = true;
|
_ignoreMetaWrites[metaPath] = true;
|
||||||
@@ -402,11 +423,41 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
return await Task.WhenAll(tasks);
|
return await Task.WhenAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Result> OpenAssetAsync(Guid id)
|
||||||
|
{
|
||||||
|
var path = GetAssetPath(id);
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure("Asset not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenAssetAsync(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Result> OpenAssetAsync(string assetPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = TypeCache.GetMethodsWithAttribute<AssetOpenHandlerAttribute>()?
|
||||||
|
.FirstOrDefault(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>()?.Extensions.Contains(Path.GetExtension(assetPath)) ?? false);
|
||||||
|
|
||||||
|
if (method == null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure("No handler for this asset type."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Task<Result>)method.Invoke(null, new object[] { assetPath })!;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure($"Failed to open asset: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_watcher.Dispose();
|
_watcher.Dispose();
|
||||||
_importCoordinator.Dispose();
|
_importCoordinator.Dispose();
|
||||||
_catalog.Dispose();
|
|
||||||
_loadLock.Dispose();
|
_loadLock.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/Editor/Ghost.Editor.Core/Services/DirtyTrackerService.cs
Normal file
77
src/Editor/Ghost.Editor.Core/Services/DirtyTrackerService.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal class DirtyTrackerService : IDirtyTrackerService
|
||||||
|
{
|
||||||
|
private readonly IUndoService _undoService;
|
||||||
|
private readonly Dictionary<Guid, int> _cleanVersions = new();
|
||||||
|
private readonly HashSet<GhostObject> _trackedObjects = new();
|
||||||
|
|
||||||
|
public DirtyTrackerService(IUndoService undoService)
|
||||||
|
{
|
||||||
|
_undoService = undoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkDirty(GhostObject obj)
|
||||||
|
{
|
||||||
|
// When marked dirty, we just ensure it is tracked.
|
||||||
|
// Its "clean version" remains whatever it was (or 0 if it was never saved).
|
||||||
|
// If it was never saved and just got modified, its clean version is assumed to be 0 (or something that won't match GlobalVersion).
|
||||||
|
|
||||||
|
if (!_cleanVersions.ContainsKey(obj.InstanceID))
|
||||||
|
{
|
||||||
|
// If we've never seen it, and it's being marked dirty,
|
||||||
|
// its "clean state" is whatever state existed BEFORE this edit (which caused GlobalVersion to increment).
|
||||||
|
// Actually, if it's a brand new edit, UndoService will push an operation and increment GlobalVersion.
|
||||||
|
// If the object was clean at the *current* version before the edit, we should record its clean version as (GlobalVersion - 1),
|
||||||
|
// but since UndoService.RecordObject increments GlobalVersion, the timing matters.
|
||||||
|
// Let's just say its clean version is 0. If GlobalVersion is > 0, it will be dirty.
|
||||||
|
_cleanVersions[obj.InstanceID] = _undoService.GlobalVersion - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_trackedObjects.Add(obj);
|
||||||
|
|
||||||
|
if (obj is IAsset asset)
|
||||||
|
{
|
||||||
|
EditorApplication.GetService<IAssetRegistry>().SetAssetDirty(asset.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsDirty(GhostObject obj)
|
||||||
|
{
|
||||||
|
if (_cleanVersions.TryGetValue(obj.InstanceID, out var cleanVersion))
|
||||||
|
{
|
||||||
|
return cleanVersion != _undoService.GlobalVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not tracked, it's clean.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkClean(GhostObject obj)
|
||||||
|
{
|
||||||
|
_cleanVersions[obj.InstanceID] = _undoService.GlobalVersion;
|
||||||
|
_trackedObjects.Add(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<GhostObject> GetDirtyObjects()
|
||||||
|
{
|
||||||
|
var dirtyObjects = new List<GhostObject>();
|
||||||
|
|
||||||
|
// Remove dead references
|
||||||
|
_trackedObjects.RemoveWhere(obj => GhostObject.Find(obj.InstanceID) == null);
|
||||||
|
|
||||||
|
foreach (var obj in _trackedObjects)
|
||||||
|
{
|
||||||
|
if (IsDirty(obj))
|
||||||
|
{
|
||||||
|
dirtyObjects.Add(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirtyObjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Services;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
@@ -37,8 +37,12 @@ internal class EditorContentProvider : IContentProvider
|
|||||||
|
|
||||||
public AssetType GetAssetType(Guid guid)
|
public AssetType GetAssetType(Guid guid)
|
||||||
{
|
{
|
||||||
var handlerID = _catalog.GetHandlerTypeId(guid);
|
var assetTypeID = _catalog.GetAssetTypeId(guid);
|
||||||
var handler = AssetHandlerRegistry.GetByAssetTypeId(handlerID);
|
if (AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(assetTypeID, out var info))
|
||||||
return handler?.RuntimeAssetType ?? AssetType.Unknown;
|
{
|
||||||
|
return info.RuntimeAssetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AssetType.Unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal sealed class EditorShaderCompilerBridge : IShaderCompilationBridge
|
||||||
|
{
|
||||||
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IShaderCompiler _compiler;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<ulong, Guid> _shaderIdToAssetId = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, Dictionary<int, string>[]> _assetKeywordMappings = new();
|
||||||
|
private Task? _shaderDictionaryPopulated;
|
||||||
|
|
||||||
|
public event ShaderVariantCompiledHandler? OnShaderVariantCompiled;
|
||||||
|
public event Action<ulong>? OnShaderInvalidated;
|
||||||
|
|
||||||
|
public EditorShaderCompilerBridge(IAssetRegistry assetRegistry, IServiceProvider serviceProvider, IShaderCompiler shaderCompiler)
|
||||||
|
{
|
||||||
|
_assetRegistry = assetRegistry;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_compiler = shaderCompiler;
|
||||||
|
|
||||||
|
_assetRegistry.OnAssetImported += OnAssetImported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAssetImported(object? sender, Guid guid)
|
||||||
|
{
|
||||||
|
var path = _assetRegistry.GetAssetPath(guid);
|
||||||
|
if (path != null && (path.EndsWith(".gshdr") || path.EndsWith(".gcomp")))
|
||||||
|
{
|
||||||
|
var result = _assetRegistry.LoadAssetAsync(guid).AsTask().Result;
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var nameHash = ExtractNameHash(result.Value);
|
||||||
|
if (nameHash != 0)
|
||||||
|
{
|
||||||
|
_shaderIdToAssetId[nameHash] = guid;
|
||||||
|
BuildKeywordMappings(result.Value, guid);
|
||||||
|
|
||||||
|
OnShaderInvalidated?.Invoke(nameHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static ulong ExtractNameHash(IAsset asset)
|
||||||
|
{
|
||||||
|
if (asset is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
return RHIUtility.GetShaderID(graphicsAsset.Descriptor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
return RHIUtility.GetShaderID(computeAsset.Descriptor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task EnsureShaderDictionaryPopulatedAsync()
|
||||||
|
{
|
||||||
|
var existing = Volatile.Read(ref _shaderDictionaryPopulated);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var original = Interlocked.CompareExchange(ref _shaderDictionaryPopulated, tcs.Task, null);
|
||||||
|
if (original != null)
|
||||||
|
{
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var catalog = _assetRegistry.GetAssetCatalog();
|
||||||
|
var assetGuids = catalog.EnumerateByTypes(typeof(GraphicsShaderAsset).GUID, typeof(ComputeShaderAsset).GUID);
|
||||||
|
|
||||||
|
foreach (var assetGuid in assetGuids)
|
||||||
|
{
|
||||||
|
var result = await _assetRegistry.LoadAssetAsync(assetGuid);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var nameHash = ExtractNameHash(result.Value);
|
||||||
|
if (nameHash != 0)
|
||||||
|
{
|
||||||
|
_shaderIdToAssetId[nameHash] = assetGuid;
|
||||||
|
BuildKeywordMappings(result.Value, assetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.SetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildKeywordMappings(IAsset asset, Guid assetId)
|
||||||
|
{
|
||||||
|
if (asset is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
var passes = graphicsAsset.Descriptor.Passes;
|
||||||
|
var mappings = new Dictionary<int, string>[passes.Length];
|
||||||
|
for (var i = 0; i < passes.Length; i++)
|
||||||
|
{
|
||||||
|
mappings[i] = BuildKeywordMappingFromGroups(passes[i].keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
_assetKeywordMappings[assetId] = mappings;
|
||||||
|
}
|
||||||
|
else if (asset is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
var entryCount = computeAsset.Descriptor.ShaderCodes.Length;
|
||||||
|
var mappings = new Dictionary<int, string>[entryCount];
|
||||||
|
var sharedMapping = BuildKeywordMappingFromGroups(computeAsset.Descriptor.Keywords);
|
||||||
|
for (var i = 0; i < entryCount; i++)
|
||||||
|
{
|
||||||
|
mappings[i] = sharedMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
_assetKeywordMappings[assetId] = mappings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<int, string> BuildKeywordMappingFromGroups(KeywordsGroup[] groups)
|
||||||
|
{
|
||||||
|
var mapping = new Dictionary<int, string>();
|
||||||
|
var localIndex = 0;
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group.keywords == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.space != KeywordSpace.Local)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kw in group.keywords)
|
||||||
|
{
|
||||||
|
mapping[localIndex++] = kw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] BuildVariantDefines(LocalKeywordSet keywordMask, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
if (keywordMapping == null || keywordMapping.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var defines = new List<string>(keywordMapping.Count);
|
||||||
|
foreach (var (localIndex, keywordName) in keywordMapping)
|
||||||
|
{
|
||||||
|
if (keywordMask.IsKeywordEnabled(localIndex))
|
||||||
|
{
|
||||||
|
defines.Add(keywordName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defines.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReadOnlySpan<string> CombineDefines(ReadOnlySpan<string> staticDefines, ReadOnlySpan<string> variantDefines)
|
||||||
|
{
|
||||||
|
if (variantDefines.Length == 0)
|
||||||
|
{
|
||||||
|
return staticDefines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staticDefines.Length == 0)
|
||||||
|
{
|
||||||
|
return variantDefines;
|
||||||
|
}
|
||||||
|
|
||||||
|
var combined = new string[staticDefines.Length + variantDefines.Length];
|
||||||
|
staticDefines.CopyTo(combined);
|
||||||
|
variantDefines.CopyTo(combined.AsSpan(staticDefines.Length));
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestCompilation(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask)
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await EnsureShaderDictionaryPopulatedAsync();
|
||||||
|
|
||||||
|
if (!_shaderIdToAssetId.TryGetValue(shaderId, out var assetId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetResult = await _assetRegistry.LoadAssetAsync(assetId);
|
||||||
|
if (assetResult.IsFailure)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, string>? keywordMapping = null;
|
||||||
|
if (_assetKeywordMappings.TryGetValue(assetId, out var mappings) && passIndex < mappings.Length)
|
||||||
|
{
|
||||||
|
keywordMapping = mappings[passIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetResult.Value is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
var pass = graphicsAsset.Descriptor.Passes[passIndex];
|
||||||
|
await CompileGraphicsPassAsync(shaderId, passIndex, variantKey, keywordMask, pass, graphicsAsset.Descriptor.ShaderModel, keywordMapping);
|
||||||
|
}
|
||||||
|
else if (assetResult.Value is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
await CompileComputePassAsync(shaderId, passIndex, variantKey, keywordMask, computeAsset.Descriptor, passIndex, keywordMapping);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Task CompileGraphicsPassAsync(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask, PassDescriptor descriptor, ShaderModel shaderModel, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
var variantDefines = BuildVariantDefines(keywordMask, keywordMapping);
|
||||||
|
|
||||||
|
var additionalConfig = new ShaderCompilationConfig
|
||||||
|
{
|
||||||
|
defines = variantDefines,
|
||||||
|
model = shaderModel,
|
||||||
|
optimizeLevel = CompilerOptimizeLevel.O3,
|
||||||
|
options = CompilerOption.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var compileResult = _compiler.CompileShaderPass(ref descriptor, ref additionalConfig, AllocationHandle.Persistent);
|
||||||
|
if (compileResult.IsFailure)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to compile graphics shader {shaderId}: {compileResult.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var compiled = compileResult.Value;
|
||||||
|
|
||||||
|
var stageCount = 0;
|
||||||
|
if (compiled.asResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.msResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.psResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byteCodes = stackalloc ShaderByteCode[stageCount];
|
||||||
|
var idx = 0;
|
||||||
|
if (compiled.asResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.asResult.GetUnsafePtr(), size = (ulong)compiled.asResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.msResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.msResult.GetUnsafePtr(), size = (ulong)compiled.msResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.psResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.psResult.GetUnsafePtr(), size = (ulong)compiled.psResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
OnShaderVariantCompiled?.Invoke(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(byteCodes, stageCount));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Task CompileComputePassAsync(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask, ComputeShaderDescriptor descriptor, int entryIndex, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
var variantDefines = BuildVariantDefines(keywordMask, keywordMapping);
|
||||||
|
var fullDefines = CombineDefines(descriptor.Defines, variantDefines);
|
||||||
|
|
||||||
|
var code = descriptor.ShaderCodes[entryIndex];
|
||||||
|
var config = new ShaderCompilationConfig
|
||||||
|
{
|
||||||
|
shaderCode = code.code,
|
||||||
|
entryPoint = code.entryPoint,
|
||||||
|
stage = ShaderStage.ComputeShader,
|
||||||
|
defines = fullDefines,
|
||||||
|
model = descriptor.ShaderModel,
|
||||||
|
optimizeLevel = CompilerOptimizeLevel.O3,
|
||||||
|
options = CompilerOption.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var compileResult = _compiler.Compile(ref config, AllocationHandle.Persistent);
|
||||||
|
if (compileResult.IsFailure)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to compile compute shader {shaderId}: {compileResult.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bytecodeArray = compileResult.Value;
|
||||||
|
|
||||||
|
var byteCode = new ShaderByteCode
|
||||||
|
{
|
||||||
|
pCode = (byte*)bytecodeArray.GetUnsafePtr(),
|
||||||
|
size = (ulong)bytecodeArray.Length
|
||||||
|
};
|
||||||
|
|
||||||
|
OnShaderVariantCompiled?.Invoke(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(ref byteCode));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_assetRegistry.OnAssetImported -= OnAssetImported;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Editor/Ghost.Editor.Core/Services/EditorTickEngine.cs
Normal file
89
src/Editor/Ghost.Editor.Core/Services/EditorTickEngine.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public sealed class EditorTickEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly DispatcherQueueTimer _timer;
|
||||||
|
private bool _isStarted;
|
||||||
|
|
||||||
|
// Time data
|
||||||
|
private TimeData _timeData;
|
||||||
|
private long _startTimestamp;
|
||||||
|
private long _lastFrameTimestamp;
|
||||||
|
|
||||||
|
public event Action? OnSafeZone;
|
||||||
|
public event Action? OnSystemUpdate;
|
||||||
|
public event Action? OnInspectorSync;
|
||||||
|
public event Action? OnFireEvents;
|
||||||
|
|
||||||
|
public EditorTickEngine(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_timer = EditorApplication.DispatcherQueue.CreateTimer();
|
||||||
|
_timer.Interval = TimeSpan.FromMilliseconds(16); // ~60Hz
|
||||||
|
_timer.Tick += OnTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
_lastFrameTimestamp = _startTimestamp;
|
||||||
|
_timeData = new TimeData();
|
||||||
|
|
||||||
|
_timer.Start();
|
||||||
|
_isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(DispatcherQueueTimer sender, object args)
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
var dt = (float)(now - _lastFrameTimestamp) / Stopwatch.Frequency;
|
||||||
|
var elapsed = (double)(now - _startTimestamp) / Stopwatch.Frequency;
|
||||||
|
|
||||||
|
_timeData = new TimeData
|
||||||
|
{
|
||||||
|
FrameCount = _timeData.FrameCount + 1,
|
||||||
|
DeltaTime = dt,
|
||||||
|
ElapsedTime = elapsed
|
||||||
|
};
|
||||||
|
|
||||||
|
_lastFrameTimestamp = now;
|
||||||
|
|
||||||
|
// Safe Zone (Drain Commands & ECB)
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
OnSafeZone?.Invoke();
|
||||||
|
|
||||||
|
// Editor Systems
|
||||||
|
_worldService.EditorWorld.SystemManager.UpdateAll(_timeData);
|
||||||
|
OnSystemUpdate?.Invoke();
|
||||||
|
|
||||||
|
// Inspector Sync
|
||||||
|
OnInspectorSync?.Invoke();
|
||||||
|
|
||||||
|
// Fire Events
|
||||||
|
_worldService.FirePendingEvents();
|
||||||
|
OnFireEvents?.Invoke();
|
||||||
|
|
||||||
|
_worldService.EditorWorld.AdvanceVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isStarted && _timer != null)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
_timer.Tick -= OnTick;
|
||||||
|
_isStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
315
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public interface IEditorWorldService : IDisposable
|
||||||
|
{
|
||||||
|
World EditorWorld { get; }
|
||||||
|
ObservableCollection<SceneNode> RootNodes { get; }
|
||||||
|
|
||||||
|
event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
event Action<Entity>? EntityDestroyed;
|
||||||
|
event Action<Entity, Entity, Entity>? EntityParentChanged;
|
||||||
|
event Action<Entity, string>? EntityNameChanged;
|
||||||
|
event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
void ChangeEntityScene(Entity entity, ushort sceneID);
|
||||||
|
void CreateDefaultScene();
|
||||||
|
void CreateEntity(string name, ushort sceneID, Entity parent = default);
|
||||||
|
void Defer(Action action);
|
||||||
|
void DestroyEntity(Entity entity);
|
||||||
|
void FirePendingEvents();
|
||||||
|
void FlushCommands();
|
||||||
|
ushort GetEntitySceneID(Entity entity);
|
||||||
|
SceneAsset? GetAssetForScene(ushort sceneID);
|
||||||
|
void RegisterSceneAsset(ushort sceneID, SceneAsset asset);
|
||||||
|
void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null);
|
||||||
|
Error RemoveParent(Entity child);
|
||||||
|
void RenameEntity(Entity entity, string newName);
|
||||||
|
Error SetParent(Entity child, Entity parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EditorWorldService : IEditorWorldService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<Action> _deferredActions = new();
|
||||||
|
private readonly ConcurrentQueue<Action> _pendingEvents = new();
|
||||||
|
private readonly ConcurrentDictionary<ushort, SceneAsset> _sceneAssetMap = new();
|
||||||
|
|
||||||
|
public World EditorWorld
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<SceneNode> RootNodes
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
public event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
public event Action<Entity>? EntityDestroyed;
|
||||||
|
public event Action<Entity, Entity, Entity>? EntityParentChanged; // (child, oldParent, newParent)
|
||||||
|
public event Action<Entity, string>? EntityNameChanged;
|
||||||
|
public event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
public EditorWorldService(JobScheduler? jobScheduler = null)
|
||||||
|
{
|
||||||
|
EditorWorld = World.Create(jobScheduler, 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Defer(Action action)
|
||||||
|
{
|
||||||
|
_deferredActions.Enqueue(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FlushCommands()
|
||||||
|
{
|
||||||
|
while (_deferredActions.TryDequeue(out var action))
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FirePendingEvents()
|
||||||
|
{
|
||||||
|
while (_pendingEvents.TryDequeue(out var evt))
|
||||||
|
{
|
||||||
|
evt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateEntity(string name, ushort sceneID, Entity parent = default)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var entity = EditorWorld.EntityManager.CreateEntity();
|
||||||
|
|
||||||
|
EditorWorld.EntityManager.AddComponent(entity, new Engine.Components.Hierarchy
|
||||||
|
{
|
||||||
|
parent = Entity.Invalid,
|
||||||
|
firstChild = Entity.Invalid,
|
||||||
|
nextSibling = Entity.Invalid
|
||||||
|
});
|
||||||
|
|
||||||
|
EditorWorld.EntityManager.AddSharedComponent(entity, new Engine.Components.SceneID
|
||||||
|
{
|
||||||
|
value = sceneID
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
HierarchyUtility.SetParent(EditorWorld, entity, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(() =>
|
||||||
|
{
|
||||||
|
EntityCreated?.Invoke(entity, name, sceneID);
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
EntityParentChanged?.Invoke(entity, Entity.Invalid, parent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DestroyEntity(Entity entity)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
DestroyEntityRecursive(entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyEntityRecursive(Entity entity)
|
||||||
|
{
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
ref var hierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(entity);
|
||||||
|
var child = hierarchy.firstChild;
|
||||||
|
while (child.IsValid)
|
||||||
|
{
|
||||||
|
ref var childHierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child);
|
||||||
|
var next = childHierarchy.nextSibling;
|
||||||
|
DestroyEntityRecursive(child);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HierarchyUtility.RemoveParent(EditorWorld, entity);
|
||||||
|
EditorWorld.EntityManager.DestroyEntity(entity);
|
||||||
|
_pendingEvents.Enqueue(() => EntityDestroyed?.Invoke(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSceneIDRecursive(Entity entity, ushort sceneID)
|
||||||
|
{
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(entity))
|
||||||
|
{
|
||||||
|
EditorWorld.EntityManager.SetSharedComponent(entity, new Engine.Components.SceneID { value = sceneID });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
ref var hierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(entity);
|
||||||
|
var child = hierarchy.firstChild;
|
||||||
|
while (child.IsValid)
|
||||||
|
{
|
||||||
|
ref var childHierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child);
|
||||||
|
var next = childHierarchy.nextSibling;
|
||||||
|
UpdateSceneIDRecursive(child, sceneID);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ChangeEntityScene(Entity entity, ushort sceneID)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
|
||||||
|
UpdateSceneIDRecursive(entity, sceneID);
|
||||||
|
_pendingEvents.Enqueue(() => EntityParentChanged?.Invoke(entity, Entity.Invalid, Entity.Invalid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error SetParent(Entity child, Entity parent)
|
||||||
|
{
|
||||||
|
if (!child.IsValid) return Error.InvalidArgument;
|
||||||
|
|
||||||
|
Error err = Error.None;
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
err = HierarchyUtility.IsValidParent(EditorWorld, child, parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(child))
|
||||||
|
{
|
||||||
|
err = Error.NotFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err != Error.None)
|
||||||
|
{
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var oldParent = Entity.Invalid;
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(child))
|
||||||
|
{
|
||||||
|
oldParent = EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child).parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
HierarchyUtility.SetParent(EditorWorld, child, parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
HierarchyUtility.RemoveParent(EditorWorld, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.IsValid && EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(parent))
|
||||||
|
{
|
||||||
|
var locRes = EditorWorld.EntityManager.GetEntityLocation(parent);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref EditorWorld.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
var chunkView = new ChunkView(in archetype, in chunk);
|
||||||
|
var parentSceneID = chunkView.GetSharedComponent<Engine.Components.SceneID>().value;
|
||||||
|
UpdateSceneIDRecursive(child, parentSceneID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(() => EntityParentChanged?.Invoke(child, oldParent, parent));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Error.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error RemoveParent(Entity child)
|
||||||
|
{
|
||||||
|
return SetParent(child, Entity.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetEntitySceneID(Entity entity)
|
||||||
|
{
|
||||||
|
if (!entity.IsValid)
|
||||||
|
{
|
||||||
|
return Scene.INVALID_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(entity))
|
||||||
|
{
|
||||||
|
var locRes = EditorWorld.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref EditorWorld.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
var chunkView = new ChunkView(in archetype, in chunk);
|
||||||
|
return chunkView.GetSharedComponent<Engine.Components.SceneID>().value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scene.INVALID_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneAsset? GetAssetForScene(ushort sceneID)
|
||||||
|
{
|
||||||
|
_sceneAssetMap.TryGetValue(sceneID, out var asset);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterSceneAsset(ushort sceneID, SceneAsset asset)
|
||||||
|
{
|
||||||
|
_sceneAssetMap[sceneID] = asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RenameEntity(Entity entity, string newName)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
_pendingEvents.Enqueue(() => EntityNameChanged?.Invoke(entity, newName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateDefaultScene()
|
||||||
|
{
|
||||||
|
var scene = SceneManager.CreateScene();
|
||||||
|
CreateEntity("Entity", scene.ID);
|
||||||
|
}
|
||||||
|
public void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var sceneNodes = SceneGraphBuilder.Build(EditorWorld, initialNames);
|
||||||
|
_pendingEvents.Enqueue(() =>
|
||||||
|
{
|
||||||
|
RootNodes.Clear();
|
||||||
|
foreach (var node in sceneNodes)
|
||||||
|
{
|
||||||
|
RootNodes.Add(node);
|
||||||
|
}
|
||||||
|
SceneGraphRebuilt?.Invoke();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
World.Destroy(EditorWorld.ID);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,16 @@ internal sealed partial class ImportCoordinator : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
|
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return _importChannel.Writer.WriteAsync(job, token);
|
return _importChannel.Writer.WriteAsync(job, token);
|
||||||
}
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task WorkerLoop(CancellationToken token)
|
private async Task WorkerLoop(CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -95,38 +102,77 @@ internal sealed partial class ImportCoordinator : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var handler = meta.HandlerTypeId.HasValue
|
var handler = meta.AssetTypeId.HasValue
|
||||||
? AssetHandlerRegistry.GetByAssetTypeId(meta.HandlerTypeId.Value)
|
? AssetHandlerRegistry.GetByAssetTypeId(meta.AssetTypeId.Value)
|
||||||
: AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath));
|
: AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath));
|
||||||
|
|
||||||
var contentHash = await ComputeFileHashAsync(job.SourcePath, token);
|
var contentHash = await ComputeFileHashAsync(job.SourcePath, token);
|
||||||
var settingsHash = ComputeSettingsHash(meta.Settings);
|
var settingsHash = ComputeSettingsHash(meta.Settings);
|
||||||
|
var handlerVersion = AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(meta.AssetTypeId ?? Guid.Empty, out var info)
|
||||||
|
? info.Version
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Check if we can skip (if not a manual reimport)
|
// Check if we can skip (if not a manual reimport)
|
||||||
if (job.Reason != ImportReason.ManualReimport &&
|
if (job.Reason != ImportReason.ManualReimport &&
|
||||||
meta.ContentHash == contentHash &&
|
meta.ContentHash == contentHash &&
|
||||||
meta.SettingsHash == settingsHash &&
|
meta.SettingsHash == settingsHash &&
|
||||||
meta.HandlerVersion == AssetHandlerRegistry.GetVersionByAssetTypeId(meta.HandlerTypeId ?? Guid.Empty))
|
meta.HandlerVersion == handlerVersion)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var importResult = Result.Success();
|
var importResult = Result.Success();
|
||||||
|
var subAssets = Array.Empty<ImportedSubAsset>();
|
||||||
if (handler is IImportableAssetHandler importable)
|
if (handler is IImportableAssetHandler importable)
|
||||||
{
|
{
|
||||||
var targetPath = GetImportedAssetPath(job.AssetGuid);
|
var targetPath = GetImportedAssetPath(job.AssetGuid);
|
||||||
importResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token);
|
var subAssetResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token);
|
||||||
|
importResult = subAssetResult;
|
||||||
|
if (subAssetResult.IsSuccess)
|
||||||
|
{
|
||||||
|
subAssets = subAssetResult.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (importResult.IsSuccess)
|
if (importResult.IsSuccess)
|
||||||
{
|
{
|
||||||
meta.ContentHash = contentHash;
|
meta.ContentHash = contentHash;
|
||||||
meta.SettingsHash = settingsHash;
|
meta.SettingsHash = settingsHash;
|
||||||
meta.HandlerVersion = AssetHandlerRegistry.GetVersionByAssetTypeId(meta.HandlerTypeId ?? Guid.Empty);
|
meta.HandlerVersion = handlerVersion;
|
||||||
meta.LastImportedUtc = DateTime.UtcNow;
|
meta.LastImportedUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
await AssetMetaIO.WriteAsync(job.MetaPath, meta, token);
|
await AssetMetaIO.WriteAsync(job.MetaPath, meta, token);
|
||||||
|
|
||||||
|
if (subAssets.Length > 0)
|
||||||
|
{
|
||||||
|
var dependencies = new Guid[subAssets.Length];
|
||||||
|
for (var i = 0; i < subAssets.Length; i++)
|
||||||
|
{
|
||||||
|
var subAsset = subAssets[i];
|
||||||
|
dependencies[i] = subAsset.Guid;
|
||||||
|
|
||||||
|
var subMeta = new AssetMeta
|
||||||
|
{
|
||||||
|
Guid = subAsset.Guid,
|
||||||
|
AssetTypeId = subAsset.AssetTypeId,
|
||||||
|
HandlerVersion = meta.HandlerVersion,
|
||||||
|
ContentHash = contentHash,
|
||||||
|
SettingsHash = settingsHash,
|
||||||
|
LastImportedUtc = meta.LastImportedUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
_catalog.UpsertSubAsset(job.AssetGuid, subMeta, subAsset.VirtualSourcePath, subAsset.Kind, subAsset.DisplayName, subAsset.StablePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
_catalog.RemoveSubAssetsExcept(job.AssetGuid, dependencies);
|
||||||
|
_catalog.SetDependencies(job.AssetGuid, dependencies);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_catalog.RemoveSubAssetsExcept(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
|
||||||
|
_catalog.SetDependencies(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
OnImportCompleted?.Invoke(null, job.AssetGuid);
|
OnImportCompleted?.Invoke(null, job.AssetGuid);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -170,6 +216,15 @@ internal sealed partial class ImportCoordinator : IDisposable
|
|||||||
{
|
{
|
||||||
_importChannel.Writer.TryComplete();
|
_importChannel.Writer.TryComplete();
|
||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Task.WaitAll(_workers);
|
||||||
|
}
|
||||||
|
catch (AggregateException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Syncs the inspector model from ECS data on every editor tick (Phase 3).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InspectorSyncService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EditorTickEngine _tickEngine;
|
||||||
|
private ISyncableInspectorModel? _activeModel;
|
||||||
|
private bool _isStarted;
|
||||||
|
|
||||||
|
public InspectorSyncService(EditorTickEngine tickEngine)
|
||||||
|
{
|
||||||
|
_tickEngine = tickEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickEngine.OnInspectorSync += OnInspectorSync;
|
||||||
|
_isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind(ISyncableInspectorModel model)
|
||||||
|
{
|
||||||
|
_activeModel = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind()
|
||||||
|
{
|
||||||
|
_activeModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInspectorSync()
|
||||||
|
{
|
||||||
|
if (_activeModel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeModel.Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
_tickEngine.OnInspectorSync -= OnInspectorSync;
|
||||||
|
_isStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
192
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal class SceneGraphSyncService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly Dictionary<Entity, EntityNode> _nodeMap = new();
|
||||||
|
|
||||||
|
public SceneGraphSyncService(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_worldService.EntityCreated += OnEntityCreated;
|
||||||
|
_worldService.EntityDestroyed += OnEntityDestroyed;
|
||||||
|
_worldService.EntityParentChanged += OnEntityParentChanged;
|
||||||
|
_worldService.EntityNameChanged += OnEntityNameChanged;
|
||||||
|
_worldService.SceneGraphRebuilt += OnSceneGraphRebuilt;
|
||||||
|
|
||||||
|
// Initialize node map from current root nodes
|
||||||
|
OnSceneGraphRebuilt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetNode(Entity entity, out EntityNode node)
|
||||||
|
{
|
||||||
|
return _nodeMap.TryGetValue(entity, out node!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_worldService.EntityCreated -= OnEntityCreated;
|
||||||
|
_worldService.EntityDestroyed -= OnEntityDestroyed;
|
||||||
|
_worldService.EntityParentChanged -= OnEntityParentChanged;
|
||||||
|
_worldService.EntityNameChanged -= OnEntityNameChanged;
|
||||||
|
_worldService.SceneGraphRebuilt -= OnSceneGraphRebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSceneGraphRebuilt()
|
||||||
|
{
|
||||||
|
_nodeMap.Clear();
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
PopulateNodeMapRecursive(sceneNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateNodeMapRecursive(SceneGraphNode node)
|
||||||
|
{
|
||||||
|
if (node is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_nodeMap[entityNode.Entity] = entityNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
PopulateNodeMapRecursive(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityCreated(Entity entity, string name, ushort sceneID)
|
||||||
|
{
|
||||||
|
if (_nodeMap.ContainsKey(entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default, add to the scene's root collection
|
||||||
|
var sceneNode = FindOrCreateSceneNode(sceneID);
|
||||||
|
|
||||||
|
var node = new EntityNode(_worldService.EditorWorld, entity, name, sceneNode);
|
||||||
|
_nodeMap[entity] = node;
|
||||||
|
|
||||||
|
sceneNode.Children.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityDestroyed(Entity entity)
|
||||||
|
{
|
||||||
|
if (!_nodeMap.TryGetValue(entity, out var node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively remove from node map
|
||||||
|
RemoveNodeAndDescendantsRecursive(node);
|
||||||
|
|
||||||
|
// Remove from its parent's Children collection (or from RootNodes if it was a scene's root entity)
|
||||||
|
RemoveNodeFromParent(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveNodeFromParent(EntityNode node)
|
||||||
|
{
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
if (sceneNode.Children.Remove(node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveNodeFromChildrenRecursive(sceneNode.Children, node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RemoveNodeFromChildrenRecursive(System.Collections.ObjectModel.ObservableCollection<SceneGraphNode> children, EntityNode target)
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
if (child.Children.Remove(target))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveNodeFromChildrenRecursive(child.Children, target))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveNodeAndDescendantsRecursive(EntityNode node)
|
||||||
|
{
|
||||||
|
_nodeMap.Remove(node.Entity);
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
if (child is EntityNode childEntityNode)
|
||||||
|
{
|
||||||
|
RemoveNodeAndDescendantsRecursive(childEntityNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityParentChanged(Entity child, Entity oldParent, Entity newParent)
|
||||||
|
{
|
||||||
|
if (!_nodeMap.TryGetValue(child, out var childNode))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from the old parent collection (wherever it currently is)
|
||||||
|
RemoveNodeFromParent(childNode);
|
||||||
|
|
||||||
|
// Add to the new parent collection (prepend at index 0 to match HierarchyUtility firstChild behavior)
|
||||||
|
if (newParent.IsValid && _nodeMap.TryGetValue(newParent, out var newParentNode))
|
||||||
|
{
|
||||||
|
newParentNode.Children.Insert(0, childNode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Add to the scene's root collection
|
||||||
|
if (_worldService.EditorWorld.EntityManager.HasComponent<SceneID>(child))
|
||||||
|
{
|
||||||
|
var sceneID = _worldService.GetEntitySceneID(child);
|
||||||
|
if (sceneID != Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
var sceneNode = FindOrCreateSceneNode(sceneID);
|
||||||
|
sceneNode.Children.Insert(0, childNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityNameChanged(Entity entity, string newName)
|
||||||
|
{
|
||||||
|
if (_nodeMap.TryGetValue(entity, out var node))
|
||||||
|
{
|
||||||
|
node.Name = newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SceneNode FindOrCreateSceneNode(ushort sceneID)
|
||||||
|
{
|
||||||
|
foreach (var existing in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
if (existing.Scene.ID == sceneID)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sceneName = $"NewScene ({sceneID})";
|
||||||
|
var newSceneNode = new SceneNode(_worldService.EditorWorld, new Scene(sceneID), sceneName);
|
||||||
|
_worldService.RootNodes.Add(newSceneNode);
|
||||||
|
return newSceneNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal sealed class SceneSaveData
|
||||||
|
{
|
||||||
|
public uint FormatVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public List<EntitySaveData> Entities
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EntitySaveData
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = "Entity";
|
||||||
|
|
||||||
|
public Dictionary<string, JsonElement> Components
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Serialize shared components.
|
||||||
|
internal class SceneSerializationService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, FieldInfo[]> s_entityFieldsCache = new();
|
||||||
|
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||||
|
{
|
||||||
|
IncludeFields = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new EntityJsonConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class EntityJsonConverter : JsonConverter<Entity>
|
||||||
|
{
|
||||||
|
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var localId = reader.GetInt32();
|
||||||
|
return new Entity(localId, localId >= 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteNumberValue(value.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
|
private readonly SceneGraphSyncService _syncService;
|
||||||
|
|
||||||
|
public SceneSerializationService(IEditorWorldService worldService, IAssetRegistry assetRegistry, SceneGraphSyncService syncService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
_assetRegistry = assetRegistry;
|
||||||
|
_syncService = syncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static int FileLocalIndexOf(Dictionary<Entity, int> reverseMap, Entity entity)
|
||||||
|
{
|
||||||
|
if (reverseMap.TryGetValue(entity, out var index))
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldInfo[] GetEntityFields(Type type)
|
||||||
|
{
|
||||||
|
if (!s_entityFieldsCache.TryGetValue(type, out var fields))
|
||||||
|
{
|
||||||
|
var list = new List<FieldInfo>();
|
||||||
|
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
if (field.FieldType == typeof(Entity))
|
||||||
|
{
|
||||||
|
list.Add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = list.ToArray();
|
||||||
|
s_entityFieldsCache[type] = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemapEntityFieldsToLocal(object boxed, Type type, Dictionary<Entity, int> reverseMap)
|
||||||
|
{
|
||||||
|
var entityFields = GetEntityFields(type);
|
||||||
|
foreach (var field in entityFields)
|
||||||
|
{
|
||||||
|
var entity = (Entity)field.GetValue(boxed)!;
|
||||||
|
var localIndex = FileLocalIndexOf(reverseMap, entity);
|
||||||
|
field.SetValue(boxed, new Entity(localIndex, localIndex >= 0 ? 0 : -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemapLocalFieldsToEntity(object boxed, Type type, Dictionary<int, Entity> forwardMap)
|
||||||
|
{
|
||||||
|
var entityFields = GetEntityFields(type);
|
||||||
|
foreach (var field in entityFields)
|
||||||
|
{
|
||||||
|
var localAsEntity = (Entity)field.GetValue(boxed)!;
|
||||||
|
var localIndex = localAsEntity.ID;
|
||||||
|
if (!forwardMap.TryGetValue(localIndex, out var entity))
|
||||||
|
{
|
||||||
|
entity = Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.SetValue(boxed, entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Binary Serialization
|
||||||
|
|
||||||
|
private static uint GetTypeNameHash(string typeName)
|
||||||
|
{
|
||||||
|
var hash = 2166136261u;
|
||||||
|
for (var i = 0; i < typeName.Length; i++)
|
||||||
|
{
|
||||||
|
var c = typeName[i];
|
||||||
|
hash ^= c;
|
||||||
|
hash *= 16777619u;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe void SerializeToBinary(SceneSaveData data, Stream targetStream)
|
||||||
|
{
|
||||||
|
using var writer = new BinaryWriter(targetStream, Encoding.UTF8, true);
|
||||||
|
|
||||||
|
var header = new SceneContentHeader
|
||||||
|
{
|
||||||
|
magic = SceneContentHeader.MAGIC,
|
||||||
|
version = SceneContentHeader.VERSION,
|
||||||
|
entityCount = data.Entities.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
writer.Write(MemoryMarshal.AsBytes(new ReadOnlySpan<SceneContentHeader>(ref header)));
|
||||||
|
|
||||||
|
if (data.Entities == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entity in data.Entities)
|
||||||
|
{
|
||||||
|
if (entity.Components == null)
|
||||||
|
{
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(entity.Components.Count);
|
||||||
|
|
||||||
|
foreach (var (typeName, componentElement) in entity.Components)
|
||||||
|
{
|
||||||
|
var typeHash = GetTypeNameHash(typeName);
|
||||||
|
var componentType = TypeCache.GetTypes(typeName);
|
||||||
|
if (componentType == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentType == null)
|
||||||
|
{
|
||||||
|
writer.Write(typeHash);
|
||||||
|
|
||||||
|
var nameBytes = Encoding.UTF8.GetBytes(typeName);
|
||||||
|
writer.Write(nameBytes.Length);
|
||||||
|
writer.Write(nameBytes);
|
||||||
|
|
||||||
|
var jsonBytes = Encoding.UTF8.GetBytes(componentElement.GetRawText());
|
||||||
|
writer.Write(jsonBytes.Length);
|
||||||
|
writer.Write(jsonBytes);
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxed = componentElement.Deserialize(componentType, s_jsonOptions);
|
||||||
|
if (boxed == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(componentType);
|
||||||
|
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)compInfo.size, (nuint)compInfo.alignment, scope.AllocationHandle);
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
|
||||||
|
var entityFieldOffsets = GetEntityFields(componentType);
|
||||||
|
var offsets = new int[entityFieldOffsets.Length];
|
||||||
|
for (var i = 0; i < entityFieldOffsets.Length; i++)
|
||||||
|
{
|
||||||
|
offsets[i] = (int)Marshal.OffsetOf(componentType, entityFieldOffsets[i].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(typeHash);
|
||||||
|
|
||||||
|
var nameBytes2 = Encoding.UTF8.GetBytes(typeName);
|
||||||
|
writer.Write(nameBytes2.Length);
|
||||||
|
writer.Write(nameBytes2);
|
||||||
|
|
||||||
|
writer.Write((int)buffer.Size);
|
||||||
|
writer.Write(buffer.AsSpan<byte>());
|
||||||
|
writer.Write(offsets.Length);
|
||||||
|
|
||||||
|
foreach (var off in offsets)
|
||||||
|
{
|
||||||
|
writer.Write(off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Scene File Deserialization (static, used by handler too)
|
||||||
|
|
||||||
|
public static async ValueTask<SceneSaveData?> DeserializeSceneFileAsync(string jsonPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(jsonPath, token);
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var root = document.RootElement;
|
||||||
|
var data = new SceneSaveData
|
||||||
|
{
|
||||||
|
FormatVersion = root.TryGetProperty("formatVersion", out var v) ? v.GetUInt32() : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.TryGetProperty("entities", out var entitiesElement))
|
||||||
|
{
|
||||||
|
foreach (var entityElement in entitiesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var entityData = new EntitySaveData();
|
||||||
|
|
||||||
|
if (entityElement.TryGetProperty("name", out var nameElement))
|
||||||
|
{
|
||||||
|
entityData.Name = nameElement.GetString() ?? "Entity";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityElement.TryGetProperty("components", out var componentsElement))
|
||||||
|
{
|
||||||
|
foreach (var componentProperty in componentsElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
entityData.Components[componentProperty.Name] = componentProperty.Value.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Entities.Add(entityData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Load Scene into Editor World
|
||||||
|
|
||||||
|
public unsafe void LoadSceneIntoEditorWorld(SceneSaveData data, SceneLoadingType loadingType = SceneLoadingType.Single, Action<Scene>? onComplete = null)
|
||||||
|
{
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (loadingType == SceneLoadingType.Single)
|
||||||
|
{
|
||||||
|
_worldService.EditorWorld.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
var activeScene = SceneManager.CreateScene();
|
||||||
|
|
||||||
|
var entityCount = data.Entities.Count;
|
||||||
|
var forwardMap = new Dictionary<int, Entity>(entityCount);
|
||||||
|
if (entityCount == 0)
|
||||||
|
{
|
||||||
|
goto RebuildAndReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = AllocationManager.CreateStackScope();
|
||||||
|
var typeIds = new UnsafeArray<UnsafeList<Identifier<IComponent>>>(entityCount, scope.AllocationHandle);
|
||||||
|
for (var i = 0; i < typeIds.Length; i++)
|
||||||
|
{
|
||||||
|
typeIds[i] = new UnsafeList<Identifier<IComponent>>(16, scope.AllocationHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
var entityData = data.Entities[fileIndex];
|
||||||
|
ref var list = ref typeIds[fileIndex];
|
||||||
|
|
||||||
|
list.Add(ComponentRegistry.GetOrRegisterComponentID<SceneID>());
|
||||||
|
|
||||||
|
foreach (var (typeName, _) in entityData.Components)
|
||||||
|
{
|
||||||
|
var compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compId = RegisterComponentByType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(compId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentSet = new ComponentSetView(list);
|
||||||
|
var entity = world.EntityManager.CreateEntity(componentSet);
|
||||||
|
forwardMap[fileIndex] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var buffer = new MemoryBlock(1024, 16, scope.AllocationHandle);
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
if (!forwardMap.TryGetValue(fileIndex, out var entity))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.EntityManager.SetSharedComponent(entity, new SceneID { value = activeScene.ID });
|
||||||
|
|
||||||
|
var entityData = data.Entities[fileIndex];
|
||||||
|
|
||||||
|
foreach (var (typeName, componentElement) in entityData.Components)
|
||||||
|
{
|
||||||
|
var compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentType = ComponentRegistry.s_runtimeIDToType[compId];
|
||||||
|
|
||||||
|
if (_syncService.TryGetNode(entity, out var node))
|
||||||
|
{
|
||||||
|
node.BuildComponents();
|
||||||
|
var compNode = node.Components.FirstOrDefault(c => c.ComponentType == componentType);
|
||||||
|
if (compNode != null)
|
||||||
|
{
|
||||||
|
compNode.Deserialize(componentElement, s_jsonOptions, (boxed) =>
|
||||||
|
{
|
||||||
|
RemapLocalFieldsToEntity(boxed, componentType, forwardMap);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct deserialization
|
||||||
|
var boxedLegacy = componentElement.Deserialize(componentType, s_jsonOptions);
|
||||||
|
if (boxedLegacy == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemapLocalFieldsToEntity(boxedLegacy, componentType, forwardMap);
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(boxedLegacy, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
|
||||||
|
world.EntityManager.SetComponent(entity, compId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope.Dispose();
|
||||||
|
|
||||||
|
for (var i = 0; i < typeIds.Length; i++)
|
||||||
|
{
|
||||||
|
typeIds[i].Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
typeIds.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildAndReturn:
|
||||||
|
var initialNames = new Dictionary<Entity, string>();
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
if (forwardMap.TryGetValue(fileIndex, out var entity))
|
||||||
|
{
|
||||||
|
initialNames[entity] = data.Entities[fileIndex].Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_worldService.RebuildSceneGraph(initialNames);
|
||||||
|
onComplete?.Invoke(activeScene);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Identifier<IComponent> RegisterComponentByType(Type type)
|
||||||
|
{
|
||||||
|
var getOrRegisterMethod = typeof(ComponentRegistry).GetMethod(
|
||||||
|
"GetOrRegisterComponentID",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static,
|
||||||
|
Array.Empty<Type>());
|
||||||
|
|
||||||
|
if (getOrRegisterMethod == null)
|
||||||
|
{
|
||||||
|
return Identifier<IComponent>.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
return Identifier<IComponent>.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericMethod = getOrRegisterMethod.MakeGenericMethod(type);
|
||||||
|
return (Identifier<IComponent>)genericMethod.Invoke(null, null)!;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Save Scene from Editor World
|
||||||
|
|
||||||
|
public unsafe void SaveSceneFromEditorWorld(string filePath, Scene scene)
|
||||||
|
{
|
||||||
|
var bytes = SerializeSceneToMemory(scene);
|
||||||
|
File.WriteAllBytes(filePath, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe byte[] SerializeSceneToMemory(Scene scene)
|
||||||
|
{
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var entities = SceneManager.GetSceneEntities(world, scene, scope.AllocationHandle);
|
||||||
|
|
||||||
|
using var sorted = SortEntitiesByHierarchy(world, entities, scope.AllocationHandle);
|
||||||
|
|
||||||
|
var reverseMap = new Dictionary<Entity, int>();
|
||||||
|
for (var i = 0; i < sorted.Count; i++)
|
||||||
|
{
|
||||||
|
reverseMap[sorted[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new SceneSaveData
|
||||||
|
{
|
||||||
|
FormatVersion = SceneContentHeader.VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteNumber("formatVersion", SceneContentHeader.VERSION);
|
||||||
|
writer.WriteStartArray("entities");
|
||||||
|
|
||||||
|
foreach (var entity in sorted)
|
||||||
|
{
|
||||||
|
var locationResult = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (!locationResult.IsSuccess)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
|
||||||
|
var entityName = "Entity";
|
||||||
|
SceneGraph.EntityNode? node = null;
|
||||||
|
if (_syncService != null && _syncService.TryGetNode(entity, out node))
|
||||||
|
{
|
||||||
|
entityName = node.Name;
|
||||||
|
}
|
||||||
|
writer.WriteString("name", entityName);
|
||||||
|
|
||||||
|
writer.WriteStartObject("components");
|
||||||
|
|
||||||
|
if (node != null)
|
||||||
|
{
|
||||||
|
node.BuildComponents(); // Ensure latest
|
||||||
|
|
||||||
|
foreach (var compNode in node.Components)
|
||||||
|
{
|
||||||
|
var type = compNode.ComponentType;
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
writer.WritePropertyName(fullName);
|
||||||
|
compNode.Serialize(writer, s_jsonOptions, (boxed) =>
|
||||||
|
{
|
||||||
|
RemapEntityFieldsToLocal(boxed, type, reverseMap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var layout in archetype._layouts)
|
||||||
|
{
|
||||||
|
var type = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
||||||
|
if (type == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(layout.componentID);
|
||||||
|
|
||||||
|
var pData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
|
||||||
|
if (pData == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxed = Marshal.PtrToStructure((nint)pData, type);
|
||||||
|
if (boxed == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemapEntityFieldsToLocal(boxed, type, reverseMap);
|
||||||
|
|
||||||
|
writer.WritePropertyName(fullName);
|
||||||
|
JsonSerializer.Serialize(writer, boxed, type, s_jsonOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndArray();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.Flush();
|
||||||
|
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnsafeList<Entity> SortEntitiesByHierarchy(World world, ReadOnlySpan<Entity> entities, AllocationHandle allocationHandle)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
|
||||||
|
using var entitySet = new UnsafeHashSet<Entity>(entities.Length, scope.AllocationHandle);
|
||||||
|
using var roots = new UnsafeList<Entity>(32, scope.AllocationHandle);
|
||||||
|
var childrenMap = new UnsafeHashMap<Entity, UnsafeList<Entity>>(32, scope.AllocationHandle);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
if (!world.EntityManager.HasComponent<Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var hierarchy = ref world.EntityManager.GetComponent<Hierarchy>(entity);
|
||||||
|
if (hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
|
||||||
|
{
|
||||||
|
ref var list = ref childrenMap.GetValueRefOrAddDefault(hierarchy.parent, out var exist);
|
||||||
|
if (!exist)
|
||||||
|
{
|
||||||
|
list = new UnsafeList<Entity>(4, allocationHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = new UnsafeList<Entity>(entities.Length, allocationHandle);
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
AddEntityAndDescendants(ref sorted, root, in childrenMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var kvp in childrenMap)
|
||||||
|
{
|
||||||
|
kvp.Value.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
childrenMap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEntityAndDescendants(ref UnsafeList<Entity> sorted, Entity entity, ref readonly UnsafeHashMap<Entity, UnsafeList<Entity>> childrenMap)
|
||||||
|
{
|
||||||
|
sorted.Add(entity);
|
||||||
|
if (childrenMap.TryGetValue(entity, out var children))
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
AddEntityAndDescendants(ref sorted, child, in childrenMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
567
src/Editor/Ghost.Editor.Core/Services/UndoService.cs
Normal file
567
src/Editor/Ghost.Editor.Core/Services/UndoService.cs
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Collections;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public enum LifecycleEvent { Created, Destroyed }
|
||||||
|
|
||||||
|
public interface IUndoService
|
||||||
|
{
|
||||||
|
IEnumerable<UndoOperation> UndoOperations { get; }
|
||||||
|
IEnumerable<UndoOperation> RedoOperations { get; }
|
||||||
|
|
||||||
|
int GlobalVersion { get; }
|
||||||
|
|
||||||
|
event Action? UndoRedoPerformed;
|
||||||
|
|
||||||
|
void RecordObject(GhostObject obj, string actionName);
|
||||||
|
void RecordEntityComponent(ComponentNode node, string actionName);
|
||||||
|
void RecordEntityStructure(EntityNode node, string actionName);
|
||||||
|
void RecordEntityLifecycle(EntityNode node, LifecycleEvent type);
|
||||||
|
|
||||||
|
void BeginTransaction(string name);
|
||||||
|
void EndTransaction();
|
||||||
|
void PerformUndo();
|
||||||
|
void PerformRedo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class UndoOperation
|
||||||
|
{
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string ActionName { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Creates an operation that holds the *current* state, so it can be pushed to Redo.
|
||||||
|
public abstract UndoOperation CreateReciprocal(IEditorWorldService worldService);
|
||||||
|
public abstract void Revert(IEditorWorldService worldService);
|
||||||
|
|
||||||
|
public virtual bool CanMerge(UndoOperation other) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ObjectStateOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public byte[] State { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var obj = GhostObject.Find(InstanceID);
|
||||||
|
var reciprocal = new ObjectStateOperation { GroupId = GroupId, ActionName = ActionName, InstanceID = InstanceID };
|
||||||
|
if (obj != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(ms);
|
||||||
|
obj.SerializeState(writer);
|
||||||
|
reciprocal.State = ms.ToArray();
|
||||||
|
}
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var obj = GhostObject.Find(InstanceID);
|
||||||
|
if (obj != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(State);
|
||||||
|
using var reader = new BinaryReader(ms);
|
||||||
|
obj.DeserializeState(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is ObjectStateOperation op)
|
||||||
|
{
|
||||||
|
return op.InstanceID == InstanceID && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityComponentOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public int ComponentId { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public unsafe override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(InstanceID) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? Entity;
|
||||||
|
|
||||||
|
var reciprocal = new EntityComponentOperation { GroupId = GroupId, ActionName = ActionName, Entity = targetEntity, InstanceID = InstanceID, ComponentId = ComponentId };
|
||||||
|
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(ComponentId));
|
||||||
|
if (pComp != null)
|
||||||
|
{
|
||||||
|
var size = ComponentRegistry.GetComponentInfo(new Identifier<IComponent>(ComponentId)).size;
|
||||||
|
var data = new byte[size];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pComp, pDst, size, size);
|
||||||
|
}
|
||||||
|
reciprocal.ComponentData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var cId = ComponentId;
|
||||||
|
var data = ComponentData;
|
||||||
|
var instId = InstanceID;
|
||||||
|
var fallbackEntity = Entity;
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(instId) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? fallbackEntity;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(cId));
|
||||||
|
if (pComp != null)
|
||||||
|
{
|
||||||
|
fixed (byte* pSrc = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pSrc, pComp, data.Length, data.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is EntityComponentOperation op)
|
||||||
|
{
|
||||||
|
if (op.Entity == Entity && op.ComponentId == ComponentId)
|
||||||
|
{
|
||||||
|
// Explicit transaction merge
|
||||||
|
if (op.GroupId != 0 && op.GroupId == GroupId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based merge fallback for non-transactional continuous edits (e.g. 500ms)
|
||||||
|
if (op.GroupId == 0)
|
||||||
|
{
|
||||||
|
return Math.Abs((op.Timestamp - Timestamp).TotalMilliseconds) < 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityStructureOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public int ArchetypeID { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] SharedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public int SharedDataHash { get; set; }
|
||||||
|
|
||||||
|
public unsafe static EntityStructureOperation Capture(IEditorWorldService worldService, EntityNode node)
|
||||||
|
{
|
||||||
|
var entity = node.Entity;
|
||||||
|
var op = new EntityStructureOperation { Entity = entity, InstanceID = node.InstanceID };
|
||||||
|
var locRes = worldService.EditorWorld.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
op.ArchetypeID = locRes.Value.archetypeID;
|
||||||
|
ref var archetype = ref worldService.EditorWorld.ComponentManager.GetArchetypeReference(op.ArchetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
|
||||||
|
// Compute size of all unmanaged components
|
||||||
|
var totalSize = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
totalSize += archetype._layouts[i].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new byte[totalSize];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
var layout = archetype._layouts[i];
|
||||||
|
var pSrc = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
|
||||||
|
Buffer.MemoryCopy(pSrc, pDst + offset, layout.size, layout.size);
|
||||||
|
offset += layout.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.ComponentData = data;
|
||||||
|
|
||||||
|
if (chunk._groupIndex >= 0 && chunk._groupIndex < archetype._chunkGroups.Count)
|
||||||
|
{
|
||||||
|
var group = archetype._chunkGroups[chunk._groupIndex];
|
||||||
|
op.SharedData = group.sharedData.AsSpan().ToArray();
|
||||||
|
op.SharedDataHash = group.sharedDataHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
if (GhostObject.Find(InstanceID) is not EntityNode node)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reciprocal = Capture(worldService, node);
|
||||||
|
reciprocal.GroupId = GroupId;
|
||||||
|
reciprocal.ActionName = ActionName;
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var instId = InstanceID;
|
||||||
|
var fallbackEntity = Entity;
|
||||||
|
var archId = ArchetypeID;
|
||||||
|
var compData = ComponentData;
|
||||||
|
var sharedData = SharedData;
|
||||||
|
var sharedHash = SharedDataHash;
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(instId) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? fallbackEntity;
|
||||||
|
|
||||||
|
var world = worldService.EditorWorld;
|
||||||
|
var locRes = world.EntityManager.GetEntityLocation(targetEntity);
|
||||||
|
if (!locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
return; // Entity destroyed? Should use Lifecycle undo for that.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locRes.Value.archetypeID != archId)
|
||||||
|
{
|
||||||
|
ref var targetArchetype = ref world.ComponentManager.GetArchetypeReference(archId);
|
||||||
|
|
||||||
|
// Build ComponentSetView from the target archetype
|
||||||
|
var it = targetArchetype._signature.GetIterator();
|
||||||
|
var components = new List<Identifier<IComponent>>();
|
||||||
|
while (it.Next(out var compId))
|
||||||
|
{
|
||||||
|
components.Add(new Identifier<IComponent>(compId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var set = new ComponentSetView(components.ToArray(), sharedData ?? Array.Empty<byte>());
|
||||||
|
world.EntityManager.MigrateEntity(targetEntity, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Overwrite unmanaged memory
|
||||||
|
locRes = world.EntityManager.GetEntityLocation(targetEntity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
|
||||||
|
fixed (byte* pSrcBase = compData)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
var layout = archetype._layouts[i];
|
||||||
|
var pDst = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
|
||||||
|
Buffer.MemoryCopy(pSrcBase + offset, pDst, layout.size, layout.size);
|
||||||
|
offset += layout.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is EntityStructureOperation op)
|
||||||
|
{
|
||||||
|
return op.Entity == Entity && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityLifecycleOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public LifecycleEvent EventType { get; set; }
|
||||||
|
|
||||||
|
// State for destruction
|
||||||
|
public int ArchetypeID { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] SharedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public int SharedDataHash { get; set; }
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var reciprocal = new EntityLifecycleOperation
|
||||||
|
{
|
||||||
|
GroupId = GroupId,
|
||||||
|
ActionName = ActionName,
|
||||||
|
Entity = Entity,
|
||||||
|
InstanceID = InstanceID,
|
||||||
|
EventType = EventType == LifecycleEvent.Created ? LifecycleEvent.Destroyed : LifecycleEvent.Created,
|
||||||
|
ArchetypeID = ArchetypeID,
|
||||||
|
ComponentData = ComponentData,
|
||||||
|
SharedData = SharedData,
|
||||||
|
SharedDataHash = SharedDataHash
|
||||||
|
};
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (EventType == LifecycleEvent.Created)
|
||||||
|
{
|
||||||
|
// Revert a Creation = Destroy
|
||||||
|
var node = GhostObject.Find(InstanceID) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? Entity;
|
||||||
|
worldService.EditorWorld.EntityManager.DestroyEntity(targetEntity);
|
||||||
|
// The InstanceID GhostObject will be naturally unlinked, handles become null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Revert a Destruction = Recreate
|
||||||
|
var newEntity = worldService.EditorWorld.EntityManager.CreateEntity();
|
||||||
|
|
||||||
|
// TODO: Apply the ArchetypeID, ComponentData, SharedData to the newEntity.
|
||||||
|
// We'd add the components using the archetype signature, then memcopy the bytes.
|
||||||
|
|
||||||
|
// Fix the Node reference
|
||||||
|
if (GhostObject.Find(InstanceID) is not EntityNode node)
|
||||||
|
{
|
||||||
|
node = new EntityNode(worldService.EditorWorld, newEntity, "Resurrected", null);
|
||||||
|
// Force the InstanceID using backing field
|
||||||
|
var backingField = typeof(EntityNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
|
||||||
|
?? typeof(SceneGraphNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
backingField?.SetValue(node, InstanceID);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update the entity property of the existing node (using reflection since it's init/readonly)
|
||||||
|
var entityField = typeof(EntityNode).GetField("<Entity>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
entityField?.SetValue(node, newEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UndoService : IUndoService
|
||||||
|
{
|
||||||
|
public event Action? UndoRedoPerformed;
|
||||||
|
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly RingBuffer<UndoOperation> _undoStack = new(50);
|
||||||
|
private readonly Stack<UndoOperation> _redoStack = new();
|
||||||
|
|
||||||
|
private int _nextGroupId = 1;
|
||||||
|
private int _activeGroupId = 0;
|
||||||
|
|
||||||
|
public int GlobalVersion { get; private set; } = 0;
|
||||||
|
|
||||||
|
public IEnumerable<UndoOperation> UndoOperations => _undoStack;
|
||||||
|
public IEnumerable<UndoOperation> RedoOperations => _redoStack;
|
||||||
|
|
||||||
|
public UndoService(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginTransaction(string name)
|
||||||
|
{
|
||||||
|
_activeGroupId = _nextGroupId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndTransaction()
|
||||||
|
{
|
||||||
|
_activeGroupId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PushOperation(UndoOperation op)
|
||||||
|
{
|
||||||
|
bool isTransaction = _activeGroupId != 0;
|
||||||
|
op.GroupId = isTransaction ? _activeGroupId : 0;
|
||||||
|
|
||||||
|
UndoOperation? top = _undoStack.Count > 0 ? _undoStack.Peek() : null;
|
||||||
|
if (top != null && op.CanMerge(top))
|
||||||
|
{
|
||||||
|
// Extend the merge window by updating the timestamp
|
||||||
|
top.Timestamp = op.Timestamp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTransaction)
|
||||||
|
{
|
||||||
|
op.GroupId = _nextGroupId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoStack.Push(op);
|
||||||
|
_redoStack.Clear(); // Any new action clears the redo stack
|
||||||
|
GlobalVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordObject(GhostObject obj, string actionName)
|
||||||
|
{
|
||||||
|
var op = new ObjectStateOperation()
|
||||||
|
{
|
||||||
|
ActionName = actionName,
|
||||||
|
InstanceID = obj.InstanceID
|
||||||
|
};
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(ms);
|
||||||
|
obj.SerializeState(writer);
|
||||||
|
op.State = ms.ToArray();
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: We may want to have unified api RecordObject that can handle everything.
|
||||||
|
public unsafe void RecordEntityComponent(ComponentNode node, string actionName)
|
||||||
|
{
|
||||||
|
var op = new EntityComponentOperation
|
||||||
|
{
|
||||||
|
ActionName = actionName,
|
||||||
|
Entity = node.EntityNode.Entity,
|
||||||
|
ComponentId = node.Descriptor.ComponentId,
|
||||||
|
InstanceID = node.EntityNode.InstanceID
|
||||||
|
};
|
||||||
|
|
||||||
|
var pComp = node.GetComponentPointer();
|
||||||
|
var size = node.Descriptor.Size;
|
||||||
|
var data = new byte[size];
|
||||||
|
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pComp, pDst, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
op.ComponentData = data;
|
||||||
|
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityStructure(EntityNode node, string actionName)
|
||||||
|
{
|
||||||
|
var op = EntityStructureOperation.Capture(_worldService, node);
|
||||||
|
op.ActionName = actionName;
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityLifecycle(EntityNode node, LifecycleEvent type)
|
||||||
|
{
|
||||||
|
var op = new EntityLifecycleOperation
|
||||||
|
{
|
||||||
|
ActionName = type == LifecycleEvent.Created ? "Create Entity" : "Destroy Entity",
|
||||||
|
Entity = node.Entity,
|
||||||
|
InstanceID = node.InstanceID,
|
||||||
|
EventType = type
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type == LifecycleEvent.Destroyed)
|
||||||
|
{
|
||||||
|
// Capture state before destruction
|
||||||
|
var structure = EntityStructureOperation.Capture(_worldService, node);
|
||||||
|
op.ArchetypeID = structure.ArchetypeID;
|
||||||
|
op.ComponentData = structure.ComponentData;
|
||||||
|
op.SharedData = structure.SharedData;
|
||||||
|
op.SharedDataHash = structure.SharedDataHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PerformUndo()
|
||||||
|
{
|
||||||
|
if (_undoStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetGroup = _undoStack.Peek().GroupId;
|
||||||
|
var toUndo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
while (_undoStack.Count > 0 && _undoStack.Peek().GroupId == targetGroup)
|
||||||
|
{
|
||||||
|
toUndo.Add(_undoStack.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
var toRedo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
// Revert in reverse order (which is standard for stack pop, but we popped them into a list)
|
||||||
|
// Wait, the list has them in reverse chronological order (newest at index 0).
|
||||||
|
// We should execute them in that order.
|
||||||
|
foreach (var op in toUndo)
|
||||||
|
{
|
||||||
|
// Snapshot current state for Redo BEFORE reverting
|
||||||
|
var reciprocal = op.CreateReciprocal(_worldService);
|
||||||
|
toRedo.Add(reciprocal);
|
||||||
|
|
||||||
|
op.Revert(_worldService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to Redo stack (we push the oldest action first so it comes off last on redo)
|
||||||
|
toRedo.Reverse();
|
||||||
|
foreach (var op in toRedo)
|
||||||
|
{
|
||||||
|
_redoStack.Push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalVersion--;
|
||||||
|
|
||||||
|
// Flush ECS commands before UI updates
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
|
||||||
|
UndoRedoPerformed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PerformRedo()
|
||||||
|
{
|
||||||
|
if (_redoStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetGroup = _redoStack.Peek().GroupId;
|
||||||
|
var toRedo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
while (_redoStack.Count > 0 && _redoStack.Peek().GroupId == targetGroup)
|
||||||
|
{
|
||||||
|
toRedo.Add(_redoStack.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
toRedo.Reverse();
|
||||||
|
|
||||||
|
var toUndo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
foreach (var op in toRedo)
|
||||||
|
{
|
||||||
|
var reciprocal = op.CreateReciprocal(_worldService);
|
||||||
|
toUndo.Add(reciprocal);
|
||||||
|
op.Revert(_worldService); // Revert actually means Apply in this symmetric design
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var op in toUndo)
|
||||||
|
{
|
||||||
|
_undoStack.Push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalVersion++;
|
||||||
|
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
|
||||||
|
UndoRedoPerformed?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/Editor/Ghost.Editor.Core/Utilities/BindingUtility.cs
Normal file
61
src/Editor/Ghost.Editor.Core/Utilities/BindingUtility.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class BindingUtility
|
||||||
|
{
|
||||||
|
public static void BindTwoWay<T>(this INotifyValueChanged<T> control, PropertyNode<T> node)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(node.Value);
|
||||||
|
control.OnValueChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
node.ComponentNode.EntityNode.Modify();
|
||||||
|
node.SetValueFromUI(e.NewValue);
|
||||||
|
};
|
||||||
|
node.OnValueChanged += control.SetValueWithoutNotify;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindTwoWay<T, U>(this INotifyValueChanged<T> control, PropertyNode<U> node, Func<PropertyNode<U>, T> getter, Action<PropertyNode<U>, T> setter)
|
||||||
|
where U : unmanaged
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(getter(node));
|
||||||
|
control.OnValueChanged += (_, args) =>
|
||||||
|
{
|
||||||
|
node.ComponentNode.EntityNode.Modify();
|
||||||
|
setter(node, args.NewValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(getter(node));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindOneWay<T>(this INotifyValueChanged<T> control, PropertyNode<T> node)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(node.Value);
|
||||||
|
node.OnValueChanged += control.SetValueWithoutNotify;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindOneWay<T, U>(this INotifyValueChanged<T> control, PropertyNode<U> node, Func<PropertyNode<U>, T> getter)
|
||||||
|
where U : unmanaged
|
||||||
|
{
|
||||||
|
node.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(getter(node));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindOneWay<T>(this FrameworkElement element, DependencyProperty dp, PropertyNode<T> node)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
node.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
element.SetValue(dp, newVal);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Editor/Ghost.Editor.Core/Utilities/PathUtility.cs
Normal file
37
src/Editor/Ghost.Editor.Core/Utilities/PathUtility.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class PathUtility
|
||||||
|
{
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static string Normalize(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static string GetUniqueName(string path)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(path);
|
||||||
|
directory ??= ".";
|
||||||
|
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
|
||||||
|
var uniqueName = fileName;
|
||||||
|
var counter = 1;
|
||||||
|
|
||||||
|
while (File.Exists(Path.Combine(directory, uniqueName + extension)))
|
||||||
|
{
|
||||||
|
uniqueName = $"{fileName} ({counter++})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(directory, uniqueName + extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ internal static class ShaderCompilerUtility
|
|||||||
};
|
};
|
||||||
|
|
||||||
var compiled = new UnsafeArray<UnsafeArray<byte>>(descriptor.ShaderCodes.Length, allocationHandle);
|
var compiled = new UnsafeArray<UnsafeArray<byte>>(descriptor.ShaderCodes.Length, allocationHandle);
|
||||||
for (int i = 0; i < descriptor.ShaderCodes.Length; i++)
|
for (var i = 0; i < descriptor.ShaderCodes.Length; i++)
|
||||||
{
|
{
|
||||||
config.shaderCode = descriptor.ShaderCodes[i].code;
|
config.shaderCode = descriptor.ShaderCodes[i].code;
|
||||||
config.entryPoint = descriptor.ShaderCodes[i].entryPoint;
|
config.entryPoint = descriptor.ShaderCodes[i].entryPoint;
|
||||||
@@ -150,7 +150,7 @@ internal static class ShaderCompilerUtility
|
|||||||
var result = shaderCompiler.Compile(ref config, allocationHandle);
|
var result = shaderCompiler.Compile(ref config, allocationHandle);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
for (int j = 0; j < i; j++)
|
for (var j = 0; j < i; j++)
|
||||||
{
|
{
|
||||||
compiled[j].Dispose();
|
compiled[j].Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ public static class TypeCache
|
|||||||
private static Dictionary<nint, List<int>> FindTypesWithAttribute()
|
private static Dictionary<nint, List<int>> FindTypesWithAttribute()
|
||||||
{
|
{
|
||||||
var dict = new Dictionary<nint, List<int>>();
|
var dict = new Dictionary<nint, List<int>>();
|
||||||
for (int i = 0; i < s_types.Length; i++)
|
for (var i = 0; i < s_types.Length; i++)
|
||||||
{
|
{
|
||||||
TypeInfo? type = s_types[i];
|
var type = s_types[i];
|
||||||
var attrs = type.GetCustomAttributes<DiscoverableAttributeBase>(false);
|
var attrs = type.GetCustomAttributes<DiscoverableAttributeBase>(false);
|
||||||
foreach (var attr in attrs)
|
foreach (var attr in attrs)
|
||||||
{
|
{
|
||||||
@@ -85,12 +85,6 @@ public static class TypeCache
|
|||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void Initialize()
|
|
||||||
{
|
|
||||||
// Intentionally left blank.
|
|
||||||
// This method exists to force the static constructor to run.
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void Reload()
|
internal static void Reload()
|
||||||
{
|
{
|
||||||
s_types = LoadTypes();
|
s_types = LoadTypes();
|
||||||
@@ -103,6 +97,11 @@ public static class TypeCache
|
|||||||
return s_types;
|
return s_types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static TypeInfo? GetTypes(string typeFullName)
|
||||||
|
{
|
||||||
|
return s_types.FirstOrDefault(t => t.FullName == typeFullName);
|
||||||
|
}
|
||||||
|
|
||||||
public static IEnumerable<MethodInfo>? GetMethodsWithAttribute<T>()
|
public static IEnumerable<MethodInfo>? GetMethodsWithAttribute<T>()
|
||||||
where T : DiscoverableAttributeBase
|
where T : DiscoverableAttributeBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Editor.Models;
|
using Ghost.Editor.Models;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
@@ -58,16 +58,20 @@ internal static class ActivationHandler
|
|||||||
var opts = new AllocationManagerDesc
|
var opts = new AllocationManagerDesc
|
||||||
{
|
{
|
||||||
ArenaCapacity = 1024 * 1024 * 1024, // 1 GB. Arena using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
ArenaCapacity = 1024 * 1024 * 1024, // 1 GB. Arena using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
||||||
StackCapacity = 1024 * 1024 * 32, // 32 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
StackCapacity = 64 * 1024 * 1024, // 64 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
||||||
FreeListChunkSize = 64 * 1024 * 1024,
|
FreeListChunkSize = 64 * 1024,
|
||||||
FreeListDefaultAlignment = 8,
|
FreeListDefaultAlignment = 8,
|
||||||
FreeListConcurrencyLevel = Environment.ProcessorCount
|
TLSFInitialChunkSize = 32 * 1024 * 1024,
|
||||||
|
TLSFAlignment = 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
AllocationManager.Initialize(opts);
|
AllocationManager.Initialize(opts);
|
||||||
|
|
||||||
var assetRegistry = App.GetService<IAssetRegistry>();
|
var assetRegistry = App.GetService<IAssetRegistry>();
|
||||||
var engineCore = App.GetService<EngineCore>();
|
var engineCore = App.GetService<EngineCore>();
|
||||||
|
var editorTick = App.GetService<EditorTickEngine>();
|
||||||
|
|
||||||
|
editorTick.Start();
|
||||||
|
|
||||||
assetRegistry.OnAssetImported += (sender, e) =>
|
assetRegistry.OnAssetImported += (sender, e) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ using Ghost.Core;
|
|||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Services;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using Ghost.Editor.ViewModels.Controls;
|
using Ghost.Editor.ViewModels.Controls;
|
||||||
using Ghost.Editor.ViewModels.Windows;
|
using Ghost.Editor.ViewModels.Windows;
|
||||||
using Ghost.Editor.Views.Windows;
|
using Ghost.Editor.Views.Windows;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Graphics.Core;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
@@ -51,8 +53,6 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
TypeCache.Initialize();
|
|
||||||
|
|
||||||
Host = Microsoft.Extensions.Hosting.Host.
|
Host = Microsoft.Extensions.Hosting.Host.
|
||||||
CreateDefaultBuilder().
|
CreateDefaultBuilder().
|
||||||
UseContentRoot(AppContext.BaseDirectory).
|
UseContentRoot(AppContext.BaseDirectory).
|
||||||
@@ -65,39 +65,22 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IInspectorService, InspectorService>();
|
services.AddSingleton<IInspectorService, InspectorService>();
|
||||||
services.AddSingleton<IPreviewService, PreviewService>();
|
services.AddSingleton<IPreviewService, PreviewService>();
|
||||||
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
||||||
services.AddSingleton<IContentProvider, EditorContentProvider>();
|
services.AddSingleton<IShaderCompiler, DXCShaderCompiler>();
|
||||||
|
services.AddSingleton<IEditorWorldService, EditorWorldService>();
|
||||||
|
services.AddSingleton<IUndoService, UndoService>();
|
||||||
|
services.AddSingleton<IDirtyTrackerService, DirtyTrackerService>();
|
||||||
|
|
||||||
services.AddSingleton<EngineCore>();
|
services.AddSingleton<InspectorSyncService>();
|
||||||
|
services.AddSingleton<EditorTickEngine>();
|
||||||
|
services.AddSingleton<SceneSerializationService>();
|
||||||
|
services.AddSingleton<SceneGraphSyncService>();
|
||||||
|
|
||||||
|
services.AddSingleton<IContentProvider, EditorContentProvider>();
|
||||||
|
services.AddSingleton<IShaderCompilationBridge, EditorShaderCompilerBridge>();
|
||||||
|
|
||||||
services.AddSingleton<EngineEditorViewModel>();
|
services.AddSingleton<EngineEditorViewModel>();
|
||||||
|
|
||||||
services.AddTransient<ContentBrowserViewModel>();
|
services.AddTransient<ContentBrowserViewModel>();
|
||||||
|
|
||||||
// TODO: Use source generators to generate this code at compile time instead of using reflection at runtime.
|
|
||||||
foreach (var type in TypeCache.GetTypes())
|
|
||||||
{
|
|
||||||
var data = type.GetCustomAttributesData().FirstOrDefault(a => a.AttributeType == typeof(EditorInjectionAttribute));
|
|
||||||
if (data is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lifeTime = (EditorInjectionAttribute.ServiceLifetime)data.ConstructorArguments[0].Value!;
|
|
||||||
var implementationType = (Type)data.ConstructorArguments[1].Value!;
|
|
||||||
var serviceType = type.IsInterface ? type.AsType() : implementationType;
|
|
||||||
|
|
||||||
switch (lifeTime)
|
|
||||||
{
|
|
||||||
case EditorInjectionAttribute.ServiceLifetime.Singleton:
|
|
||||||
services.AddSingleton(serviceType, implementationType);
|
|
||||||
break;
|
|
||||||
case EditorInjectionAttribute.ServiceLifetime.Transient:
|
|
||||||
services.AddTransient(serviceType, implementationType);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -153,6 +136,7 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Logger.Error(ex);
|
||||||
Environment.Exit(ex.HResult);
|
Environment.Exit(ex.HResult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +153,7 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debugger.BreakForUserUnhandledException(ex);
|
Logger.Error(ex);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
using Ghost.Editor.Core.Inspector;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Components;
|
|
||||||
|
|
||||||
//[CustomEditor(typeof(Hierarchy))]
|
|
||||||
internal class HierarchyEditor : ComponentEditor
|
|
||||||
{
|
|
||||||
public void Create(ComponentObject componentObject, StackPanel container)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(ComponentObject componentObject)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Destroy(ComponentObject componentObject)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
using Ghost.Editor.Core.Controls;
|
using Ghost.Editor.Core.Controls;
|
||||||
using Ghost.Editor.Core.Inspector;
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Ghost.Engine.Components;
|
using Ghost.Engine.Components;
|
||||||
using Ghost.Engine.Utilities;
|
using Ghost.Engine.Utilities;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
@@ -15,52 +17,60 @@ internal class LocalToWorldEditor : ComponentEditor
|
|||||||
private Float3Field _rotationField = null!;
|
private Float3Field _rotationField = null!;
|
||||||
private Float3Field _scaleField = null!;
|
private Float3Field _scaleField = null!;
|
||||||
|
|
||||||
public override void Create(StackPanel container)
|
public override void Create(Panel root, ComponentNode componentNode)
|
||||||
{
|
{
|
||||||
_translationField = new Float3Field();
|
_translationField = new Float3Field();
|
||||||
_rotationField = new Float3Field();
|
_rotationField = new Float3Field();
|
||||||
_scaleField = new Float3Field();
|
_scaleField = new Float3Field();
|
||||||
|
|
||||||
_translationField.OnValueChanged += (s, e) =>
|
root.Children.Add(new PropertyField() { Label = "Position", Content = _translationField });
|
||||||
|
root.Children.Add(new PropertyField() { Label = "Rotation", Content = _rotationField });
|
||||||
|
root.Children.Add(new PropertyField() { Label = "Scale", Content = _scaleField });
|
||||||
|
|
||||||
|
var property = componentNode.GetProperty<float4x4>(nameof(LocalToWorld.matrix));
|
||||||
|
|
||||||
|
_translationField.BindTwoWay(property,
|
||||||
|
getter: node =>
|
||||||
{
|
{
|
||||||
ref var data = ref ComponentObject.GetData<LocalToWorld>();
|
return node.Value.c3.xyz;
|
||||||
data.matrix.c3.xyz = e.NewValue;
|
},
|
||||||
};
|
setter: (node, val) =>
|
||||||
|
|
||||||
_rotationField.OnValueChanged += (s, e) =>
|
|
||||||
{
|
{
|
||||||
ref var data = ref ComponentObject.GetData<LocalToWorld>();
|
var data = node.Value;
|
||||||
var newRotation = quaternion.EulerXYZ(e.NewValue * math.TORADIANS);
|
data.c3.xyz = val;
|
||||||
|
node.SetValueFromUI(data);
|
||||||
|
});
|
||||||
|
|
||||||
data.matrix.GetTRS(out var oldTranslation, out var _, out var oldScale);
|
_rotationField.BindTwoWay(property,
|
||||||
data.matrix = float4x4.TRS(oldTranslation, newRotation, oldScale);
|
getter: node =>
|
||||||
};
|
|
||||||
|
|
||||||
_scaleField.OnValueChanged += (s, e) =>
|
|
||||||
{
|
{
|
||||||
ref var data = ref ComponentObject.GetData<LocalToWorld>();
|
node.Value.GetTRS(out _, out var rotation, out _);
|
||||||
var newScale = e.NewValue;
|
return math.degrees(math.EulerXYZ(rotation));
|
||||||
|
},
|
||||||
data.matrix.GetTRS(out var oldTranslation, out var oldRotation, out var _);
|
setter: (node, val) =>
|
||||||
data.matrix = float4x4.TRS(oldTranslation, oldRotation, newScale);
|
|
||||||
};
|
|
||||||
|
|
||||||
container.Children.Add(new PropertyField() { Label = "Position", Content = _translationField });
|
|
||||||
container.Children.Add(new PropertyField() { Label = "Rotation", Content = _rotationField });
|
|
||||||
container.Children.Add(new PropertyField() { Label = "Scale", Content = _scaleField });
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Update()
|
|
||||||
{
|
{
|
||||||
var data = ComponentObject.GetData<LocalToWorld>();
|
var data = node.Value;
|
||||||
data.matrix.GetTRS(out var position, out var rotation, out var scale);
|
var newRotation = quaternion.EulerXYZ(val * math.TORADIANS);
|
||||||
|
data.GetTRS(out var oldTranslation, out _, out var oldScale);
|
||||||
|
data = float4x4.TRS(oldTranslation, newRotation, oldScale);
|
||||||
|
node.SetValueFromUI(data);
|
||||||
|
});
|
||||||
|
|
||||||
_translationField.Value = position;
|
_scaleField.BindTwoWay(property,
|
||||||
_rotationField.Value = math.degrees(math.EulerXYZ(rotation));
|
getter: node =>
|
||||||
_scaleField.Value = scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Destroy()
|
|
||||||
{
|
{
|
||||||
|
var matrix = node.Value;
|
||||||
|
var scaleX = math.length(matrix.c0.xyz);
|
||||||
|
var scaleY = math.length(matrix.c1.xyz);
|
||||||
|
var scaleZ = math.length(matrix.c2.xyz);
|
||||||
|
return new float3(scaleX, scaleY, scaleZ);
|
||||||
|
},
|
||||||
|
setter: (node, val) =>
|
||||||
|
{
|
||||||
|
var data = node.Value;
|
||||||
|
data.GetTRS(out var oldTranslation, out var oldRotation, out _);
|
||||||
|
data = float4x4.TRS(oldTranslation, oldRotation, val);
|
||||||
|
node.SetValueFromUI(data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Editor.Views.Controls;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
|
||||||
namespace Ghost.Editor.Views.Controls;
|
namespace Ghost.Editor.ContextMenu;
|
||||||
|
|
||||||
internal partial class ContentBrowser
|
internal static class ContentBrowserContextMenu
|
||||||
{
|
{
|
||||||
[ContextMenuItem("project-browser", "Show in Explorer")]
|
[ContextMenuItem("project-browser", "Show in Explorer")]
|
||||||
private static void ShowInExplorer()
|
private static void ShowInExplorer()
|
||||||
{
|
{
|
||||||
var path = LastFocused?.ViewModel.CurrentDirectoryPath;
|
var path = ContentBrowser.LastFocused?.ViewModel.CurrentDirectoryPath;
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -26,7 +30,7 @@ internal partial class ContentBrowser
|
|||||||
{
|
{
|
||||||
// TODO: Use AssetService
|
// TODO: Use AssetService
|
||||||
|
|
||||||
var viewModel = LastFocused?.ViewModel;
|
var viewModel = ContentBrowser.LastFocused?.ViewModel;
|
||||||
if (viewModel is null)
|
if (viewModel is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -50,4 +54,28 @@ internal partial class ContentBrowser
|
|||||||
// Refresh the view model to show the new folder
|
// Refresh the view model to show the new folder
|
||||||
viewModel.NavigateToDirectory(currentDir);
|
viewModel.NavigateToDirectory(currentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("project-browser", "Create/Asset/Scene")]
|
||||||
|
private static void CreateSceneAsset()
|
||||||
|
{
|
||||||
|
var viewModel = ContentBrowser.LastFocused?.ViewModel;
|
||||||
|
if (viewModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentDir = viewModel.CurrentDirectoryPath;
|
||||||
|
if (!Directory.Exists(currentDir))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newScenePath = PathUtility.GetUniqueName(Path.Combine(currentDir, "New Scene.gscene"));
|
||||||
|
var tempScene = SceneManager.CreateScene();
|
||||||
|
|
||||||
|
var sceneSerializationService = App.GetService<SceneSerializationService>();
|
||||||
|
sceneSerializationService.SaveSceneFromEditorWorld(newScenePath, tempScene);
|
||||||
|
|
||||||
|
SceneManager.DestroyScene(tempScene, App.GetService<IEditorWorldService>().EditorWorld);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
34
src/Editor/Ghost.Editor/ContextMenu/EditPageMenu.cs
Normal file
34
src/Editor/Ghost.Editor/ContextMenu/EditPageMenu.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Windows.System;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.ContextMenu;
|
||||||
|
|
||||||
|
internal static class EditPageContextMenu
|
||||||
|
{
|
||||||
|
[Shortcut(VirtualKey.S, VirtualKeyModifiers.Control)]
|
||||||
|
[ContextMenuItem("edit-page-menu", "File/Save")]
|
||||||
|
private static async void MenuBar_Save()
|
||||||
|
{
|
||||||
|
if (EditorApplication.State != EditorState.Idle)
|
||||||
|
{
|
||||||
|
Logger.Warning("Cannot save while the editor is busy.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await App.GetService<Ghost.Editor.Core.Contracts.IAssetRegistry>().SaveDirtyAssetsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("edit-page-menu", "Edit/Undo", priority: 1, group: 1)]
|
||||||
|
private static void MenuBar_Undo()
|
||||||
|
{
|
||||||
|
App.GetService<IUndoService>().PerformUndo();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("edit-page-menu", "Edit/Redo", priority: 0, group: 1)]
|
||||||
|
private static void MenuBar_Redo()
|
||||||
|
{
|
||||||
|
App.GetService<IUndoService>().PerformRedo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||||
<UseWinUI>true</UseWinUI>
|
<UseWinUI>true</UseWinUI>
|
||||||
<EnableMsixTooling>true</EnableMsixTooling>
|
<EnableMsixTooling>true</EnableMsixTooling>
|
||||||
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages -->
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
<langversion>preview</langversion>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Views\Controls\Hierarchy.xaml" />
|
<None Remove="Views\Controls\Hierarchy.xaml" />
|
||||||
|
<None Remove="Views\Controls\Inspector.xaml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.251219" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
<PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
|
||||||
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -141,6 +141,9 @@
|
|||||||
<None Update="Assets\icon.ico">
|
<None Update="Assets\icon.ico">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<Page Update="Views\Controls\Inspector.xaml">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
</Page>
|
||||||
<Page Update="Views\Controls\LogViewer.xaml">
|
<Page Update="Views\Controls\LogViewer.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
</Page>
|
</Page>
|
||||||
@@ -200,7 +203,6 @@
|
|||||||
</Page>
|
</Page>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="ContextMenu\" />
|
|
||||||
<Folder Include="ViewModels\Pages\" />
|
<Folder Include="ViewModels\Pages\" />
|
||||||
<Folder Include="Views\Pages\" />
|
<Folder Include="Views\Pages\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -215,17 +217,45 @@
|
|||||||
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)'=='Debug_Editor'">
|
||||||
|
<XamlDebuggingInformation>True</XamlDebuggingInformation>
|
||||||
|
<DisableXbfLineInfo>False</DisableXbfLineInfo>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Publish Properties -->
|
<!-- Publish Properties -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(Configuration)'=='Debug_Editor'">False</PublishReadyToRun>
|
||||||
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
||||||
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
|
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<PublishAot>False</PublishAot>
|
|
||||||
<PublishTrimmed>False</PublishTrimmed>
|
|
||||||
<RootNamespace>Ghost.Editor</RootNamespace>
|
<RootNamespace>Ghost.Editor</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|x64'">
|
||||||
|
<Optimize>True</Optimize>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'">
|
||||||
|
<Optimize>True</Optimize>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Ghost.Core.Utilities;
|
using Ghost.Engine.Streaming;
|
||||||
using Ghost.Engine;
|
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace Ghost.Editor.Models;
|
namespace Ghost.Editor.Models;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Ghost.Core.Utilities;
|
|
||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Ghost.Editor.Models;
|
using Ghost.Editor.Models;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
@@ -90,8 +89,12 @@ internal partial class ContentBrowserViewModel : ObservableObject
|
|||||||
if (!isDir)
|
if (!isDir)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(fullPath);
|
var ext = Path.GetExtension(fullPath);
|
||||||
assetType = AssetHandlerRegistry.GetRuntimeAssetTypeByExtension(ext);
|
if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var info))
|
||||||
|
{
|
||||||
|
assetType = info.RuntimeAssetType;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Files.Add(new ExplorerItem(Path.GetFileName(fullPath), fullPath, isDir, assetType));
|
Files.Add(new ExplorerItem(Path.GetFileName(fullPath), fullPath, isDir, assetType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +147,7 @@ internal partial class ContentBrowserViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ext = Path.GetExtension(file);
|
var ext = Path.GetExtension(file);
|
||||||
var assetType = AssetHandlerRegistry.GetRuntimeAssetTypeByExtension(ext);
|
var assetType = AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo) ? handlerInfo.RuntimeAssetType : AssetType.Unknown;
|
||||||
|
|
||||||
var fileItem = new ExplorerItem(Path.GetFileName(file), file, false, assetType);
|
var fileItem = new ExplorerItem(Path.GetFileName(file), file, false, assetType);
|
||||||
Files.Add(fileItem);
|
Files.Add(fileItem);
|
||||||
@@ -153,7 +156,7 @@ internal partial class ContentBrowserViewModel : ObservableObject
|
|||||||
CurrentDirectoryPath = Path.GetFullPath(path);
|
CurrentDirectoryPath = Path.GetFullPath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal (ExplorerItem?, int) OpenSelected()
|
internal async ValueTask<(ExplorerItem?, int)> OpenSelected()
|
||||||
{
|
{
|
||||||
if (SelectedItem == null)
|
if (SelectedItem == null)
|
||||||
{
|
{
|
||||||
@@ -168,7 +171,12 @@ internal partial class ContentBrowserViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// _assetRegistry.OpenAsset(SelectedItem.FullName);
|
var result = await _assetRegistry.OpenAssetAsync(SelectedItem.Path);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return (null, -1);
|
||||||
|
}
|
||||||
|
|
||||||
return (null, 1);
|
return (null, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</GridView.ItemTemplate>
|
</GridView.ItemTemplate>
|
||||||
<GridView.ContextFlyout>
|
<GridView.ContextFlyout>
|
||||||
<ghost:ContextFlyout Tag="project-browser" />
|
<ghost:ContextFlyout ContextMenuTag="project-browser" />
|
||||||
</GridView.ContextFlyout>
|
</GridView.ContextFlyout>
|
||||||
</GridView>
|
</GridView>
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
private void ProjectBrowser_Loaded(object sender, RoutedEventArgs e)
|
private void ProjectBrowser_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_inspectorService.OnSelectionChanged += _inspectorService_OnSelectionChanged;
|
//_inspectorService.OnSelectionChanged += _inspectorService_OnSelectionChanged;
|
||||||
GettingFocus += ProjectBrowser_GettingFocus;
|
GettingFocus += ProjectBrowser_GettingFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
//_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
||||||
GettingFocus -= ProjectBrowser_GettingFocus;
|
GettingFocus -= ProjectBrowser_GettingFocus;
|
||||||
|
|
||||||
if (LastFocused == this)
|
if (LastFocused == this)
|
||||||
@@ -62,14 +62,14 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
//private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
||||||
{
|
//{
|
||||||
if (e.Source is not ContentBrowserViewModel)
|
// if (e.Source is not ContentBrowserViewModel)
|
||||||
{
|
// {
|
||||||
PART_FilesView.DeselectAll();
|
// PART_FilesView.DeselectAll();
|
||||||
PART_DirectoriesView.SelectedNodes.Clear();
|
// PART_DirectoriesView.SelectedNodes.Clear();
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
private void PART_DirectoriesView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
|
private void PART_DirectoriesView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
_isUpdatingSelection = true;
|
_isUpdatingSelection = true;
|
||||||
|
|
||||||
PART_FilesView.DeselectAll();
|
//PART_FilesView.DeselectAll();
|
||||||
if (args.AddedItems.Count > 0 && args.AddedItems[0] is ExplorerItem selectedItem)
|
if (args.AddedItems.Count > 0 && args.AddedItems[0] is ExplorerItem selectedItem)
|
||||||
{
|
{
|
||||||
ViewModel.SelectedItem = selectedItem;
|
ViewModel.SelectedItem = selectedItem;
|
||||||
@@ -99,7 +99,7 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
_isUpdatingSelection = true;
|
_isUpdatingSelection = true;
|
||||||
|
|
||||||
PART_DirectoriesView.SelectedNodes.Clear();
|
//PART_DirectoriesView.SelectedNodes.Clear();
|
||||||
if (PART_FilesView.SelectedItem is ExplorerItem selectedItem)
|
if (PART_FilesView.SelectedItem is ExplorerItem selectedItem)
|
||||||
{
|
{
|
||||||
ViewModel.SelectedItem = selectedItem;
|
ViewModel.SelectedItem = selectedItem;
|
||||||
@@ -127,7 +127,7 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
ViewModel.SelectedItem = selectedItem;
|
ViewModel.SelectedItem = selectedItem;
|
||||||
|
|
||||||
var navigatedItem = ViewModel.OpenSelected();
|
var navigatedItem = await ViewModel.OpenSelected();
|
||||||
if (navigatedItem.Item1 != null)
|
if (navigatedItem.Item1 != null)
|
||||||
{
|
{
|
||||||
if (navigatedItem.Item2 == 0)
|
if (navigatedItem.Item2 == 0)
|
||||||
|
|||||||
@@ -6,57 +6,108 @@
|
|||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:local="using:Ghost.Editor.Views.Controls"
|
xmlns:local="using:Ghost.Editor.Views.Controls"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:sg="using:Ghost.Editor.Core.SceneGraph"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<local:SceneGraphTemplateSelector
|
||||||
|
x:Key="SceneGraphTemplateSelector"
|
||||||
|
EntityNodeTemplate="{StaticResource EntityNodeTemplate}"
|
||||||
|
SceneNodeTemplate="{StaticResource SceneNodeTemplate}" />
|
||||||
|
|
||||||
|
<DataTemplate x:Key="SceneNodeTemplate" x:DataType="sg:SceneNode">
|
||||||
|
<TreeViewItem
|
||||||
|
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
|
||||||
|
IsExpanded="True"
|
||||||
|
ItemsSource="{x:Bind Children, Mode=OneWay}">
|
||||||
|
<TreeViewItem.ContextFlyout>
|
||||||
|
<MenuFlyout>
|
||||||
|
<MenuFlyoutItem Click="OnCreateEntityClick" Text="Create Entity" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</TreeViewItem.ContextFlyout>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
|
<TextBlock Margin="10,0,0,0" Text="{x:Bind Name, Mode=OneWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<DataTemplate x:Key="EntityNodeTemplate" x:DataType="sg:EntityNode">
|
||||||
|
<TreeViewItem AutomationProperties.Name="{x:Bind Name, Mode=OneWay}" ItemsSource="{x:Bind Children, Mode=OneWay}">
|
||||||
|
<TreeViewItem.ContextFlyout>
|
||||||
|
<MenuFlyout>
|
||||||
|
<MenuFlyoutItem Click="OnCreateChildClick" Text="Create Child" />
|
||||||
|
<MenuFlyoutItem Click="OnDeleteEntityClick" Text="Delete" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</TreeViewItem.ContextFlyout>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{x:Bind Name, Mode=OneWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<Grid
|
<StackPanel
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Height="40"
|
Padding="8,2,4,4"
|
||||||
Padding="8,8,8,4"
|
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
|
||||||
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
|
<Grid>
|
||||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
|
||||||
BorderThickness="0,0,0,1">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<AutoSuggestBox
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
PlaceholderText="Search"
|
HorizontalAlignment="Left"
|
||||||
QueryIcon="Find" />
|
VerticalAlignment="Center"
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||||
<AppBarSeparator />
|
Style="{StaticResource BodyLargeStrongTextBlockStyle}"
|
||||||
<Button Style="{ThemeResource ToolbarButton}">
|
Text="Hierarchy" />
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
<Button
|
||||||
|
Grid.Column="1"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Style="{ThemeResource ToolbarButton}">
|
||||||
|
<FontIcon Glyph="" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Row="1" Padding="4">
|
<Grid Margin="0,2">
|
||||||
<ListView>
|
<Grid.ColumnDefinitions>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<ColumnDefinition Width="Auto" />
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
<ColumnDefinition Width="*" />
|
||||||
<TextBlock Text="Test" />
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<FontIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
FontSize="{StaticResource ToolbarFontIconFontSize}"
|
||||||
|
Glyph="" />
|
||||||
|
<TextBox Grid.Column="1" PlaceholderText="Search item..." />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border Margin="-8,8,-4,-4" Style="{StaticResource HorizontalStrongDivider}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
<TreeView
|
||||||
<TextBlock Text="Test" />
|
x:Name="SceneTreeView"
|
||||||
</StackPanel>
|
Grid.Row="1"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
Margin="4,2,0,2"
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
AllowDrop="True"
|
||||||
<TextBlock Text="Test" />
|
CanDrag="True"
|
||||||
</StackPanel>
|
CanDragItems="True"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
CanReorderItems="True"
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
DragItemsCompleted="OnTreeViewDragItemsCompleted"
|
||||||
<TextBlock Text="Test" />
|
DragItemsStarting="OnTreeViewDragItemsStarting"
|
||||||
</StackPanel>
|
ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}"
|
||||||
</ListView>
|
KeyDown="OnTreeViewKeyDown"
|
||||||
</Border>
|
SelectionMode="Single" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,14 +1,228 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Input;
|
||||||
// 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.Views.Controls;
|
namespace Ghost.Editor.Views.Controls;
|
||||||
|
|
||||||
public sealed partial class Hierarchy : UserControl
|
public sealed partial class Hierarchy : UserControl
|
||||||
{
|
{
|
||||||
|
private readonly IInspectorService _inspectorService;
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly SceneGraphSyncService _syncService;
|
||||||
|
private EntityNode? _draggedNode;
|
||||||
|
|
||||||
public Hierarchy()
|
public Hierarchy()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
_inspectorService = App.GetService<IInspectorService>();
|
||||||
|
|
||||||
|
// We resolve SceneGraphSyncService here to force the DI container to instantiate it.
|
||||||
|
// This ensures the singleton hooks into EditorWorldService events and starts populating RootNodes.
|
||||||
|
_syncService = App.GetService<SceneGraphSyncService>();
|
||||||
|
|
||||||
|
_worldService = App.GetService<IEditorWorldService>();
|
||||||
|
|
||||||
|
SceneTreeView.ItemsSource = _worldService.RootNodes;
|
||||||
|
|
||||||
|
SceneTreeView.ItemInvoked += OnTreeViewItemInvoked;
|
||||||
|
SceneTreeView.SelectionChanged += OnTreeViewSelectionChanged;
|
||||||
|
|
||||||
|
Unloaded += OnUnloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.InvokedItem is IInspectable inspectable)
|
||||||
|
{
|
||||||
|
_inspectorService.SetSelected(inspectable, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewSelectionChanged(object sender, TreeViewSelectionChangedEventArgs args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewKeyDown(object sender, KeyRoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == global::Windows.System.VirtualKey.Delete)
|
||||||
|
{
|
||||||
|
if (SceneTreeView.SelectedItem is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_worldService.DestroyEntity(entityNode.Entity);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewDragItemsStarting(TreeView sender, TreeViewDragItemsStartingEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.Items.Count > 0 && args.Items[0] is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_draggedNode = entityNode;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_draggedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewDragItemsCompleted(TreeView sender, TreeViewDragItemsCompletedEventArgs args)
|
||||||
|
{
|
||||||
|
var entityNode = args.Items.Count > 0 ? args.Items[0] as EntityNode : _draggedNode;
|
||||||
|
_draggedNode = null;
|
||||||
|
|
||||||
|
if (entityNode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.DropResult != global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.NewParentItem is not SceneGraphNode newParent)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newParent == entityNode)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = Error.None;
|
||||||
|
|
||||||
|
if (newParent is EntityNode parentEntityNode)
|
||||||
|
{
|
||||||
|
if (HierarchyUtility.IsAncestor(_worldService.EditorWorld, parentEntityNode.Entity, entityNode.Entity))
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentParent = GetCurrentParent(entityNode);
|
||||||
|
if (currentParent == parentEntityNode.Entity)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _worldService.SetParent(entityNode.Entity, parentEntityNode.Entity);
|
||||||
|
}
|
||||||
|
else if (newParent is SceneNode sceneNode)
|
||||||
|
{
|
||||||
|
var currentParent = GetCurrentParent(entityNode);
|
||||||
|
var sceneChanged = _worldService.GetEntitySceneID(entityNode.Entity) != sceneNode.Scene.ID;
|
||||||
|
if (!currentParent.IsValid && !sceneChanged)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentParent.IsValid)
|
||||||
|
{
|
||||||
|
result = _worldService.RemoveParent(entityNode.Entity);
|
||||||
|
if (result != Error.None)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sceneChanged)
|
||||||
|
{
|
||||||
|
_worldService.ChangeEntityScene(entityNode.Entity, sceneNode.Scene.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != Error.None)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCreateEntityClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is SceneNode sceneNode)
|
||||||
|
{
|
||||||
|
_worldService.CreateEntity("Entity", sceneNode.Scene.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCreateChildClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
var sceneID = _worldService.GetEntitySceneID(entityNode.Entity);
|
||||||
|
if (sceneID != Engine.Core.Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
_worldService.CreateEntity("Entity", sceneID, parent: entityNode.Entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeleteEntityClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_worldService.DestroyEntity(entityNode.Entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Entity GetCurrentParent(EntityNode entityNode)
|
||||||
|
{
|
||||||
|
if (!_worldService.EditorWorld.EntityManager.HasComponent<Ghost.Engine.Components.Hierarchy>(entityNode.Entity))
|
||||||
|
{
|
||||||
|
return Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _worldService.EditorWorld.EntityManager.GetComponent<Ghost.Engine.Components.Hierarchy>(entityNode.Entity).parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildSceneGraphFromECS()
|
||||||
|
{
|
||||||
|
var names = new Dictionary<Entity, string>();
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
CaptureEntityNames(sceneNode, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldService.RebuildSceneGraph(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CaptureEntityNames(SceneGraphNode node, Dictionary<Entity, string> names)
|
||||||
|
{
|
||||||
|
if (node is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
names[entityNode.Entity] = entityNode.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
CaptureEntityNames(child, names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SceneTreeView.ItemInvoked -= OnTreeViewItemInvoked;
|
||||||
|
SceneTreeView.SelectionChanged -= OnTreeViewSelectionChanged;
|
||||||
|
Unloaded -= OnUnloaded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user