38 Commits

Author SHA1 Message Date
d4238e3086 Remove Ghost.Shader.Test project and refine debug/editor code
- Deleted Ghost.Shader.Test project and its source files.
- Updated ComponentDescriptorRegistry to simplify type resolution and added a TODO for source generators.
- Changed Logging.DebugAssert to throw only in DEBUG builds.
- Limited s_runtimeIDToType to GHOST_EDITOR builds in Component and ManagedComponent registries.
2026-06-02 17:40:58 +09:00
f552a4e9e1 feat(editor): implement scene save support and dirty state tracking
- Migrate GhostObject to Ghost.Editor.Core to support weak-reference instance tracking, modification hooks, and state serialization
- Implement IDirtyTrackerService to track dirty objects and assets based on UndoService version tracking
- Update SceneAssetHandler to save scene assets via SceneSerializationService and register scene instances on load
- Refactor entity hierarchy sorting in SceneSerializationService to use high-performance UnsafeList and UnsafeHashMap structures
- Introduce ShortcutAttribute and update MenuUtility to support keyboard accelerators on menu items
- Map Ctrl+S shortcut to save dirty assets using the new File/Save command
2026-06-02 17:28:20 +09:00
5b34da6d6c feat(editor): implement MenuContextBar and add Priority support to ContextMenuItem
- Refactored ContextFlyout menu building logic into MenuUtility.
- Implemented MenuContextBar to render root nodes as MenuBarItems and children as drop-down submenus.
- Added Priority to ContextMenuItemAttribute to allow controlling the sorting order of items within the same group.
2026-06-01 20:05:36 +09:00
1f1e21905e fix(editor): fix UI feedback loop and duplicate undo records in inspector
- Fix state clobbering bug where Float3Field.SyncFromValue() prematurely reset SuppressChangedEvent, causing nested SetValueWithoutNotify calls to inadvertently fire OnValueChanged.
- Add immediate initial sync to BindingUtility.BindTwoWay to prevent post-render async value events from triggering setters.
- Implement time-based safety net in UndoService to merge rapid redundant operations (500ms window).
- Decouple LocalToWorldEditor and custom ComponentEditors from PropertyBinding<T> in favor of BindTwoWay.
- Update ComponentNode and EntityInspectorModel to fetch initial values directly on instantiation.
2026-06-01 13:21:59 +09:00
acd8e60ffb fix(editor): correct inspector loop synchronization erasing local UI states
- Fixed \EntityInspectorModel.Sync\ where \SyncFromECS\ would overwrite dirty \PropertyNode\ values before they could be flushed.
- Removed \IsDirty\ state and delayed \Flush\ mechanics from \PropertyNode\, opting to push \SetValueFromUI\ updates instantly to the ECS defer queue.
- Corrected \LocalToWorldEditor\ to safely mutate ECS data by replacing struct data via \SetComponent\ rather than direct memory-unsafe pointer ref mutation.
- Cleaned up obsolete \Flush\ calls across inspector draw routines.
2026-05-31 12:59:13 +09:00
c6e58b057c feat(editor): implement undo/redo architecture with ECS synchronization
- Standardized GhostObject as an abstract class for trackable scene objects, generating InstanceIDs and hooking into an internal object registry via WeakReferences.

- Built a robust UndoService using a cyclic RingBuffer<UndoOperation> for command history and symmetric Apply/Revert reciprocals.

- Fixed Ghost.Entities.Archetype performance issue by keeping bounds checks unconditionally inside GetLayout for safe control flow query paths, resolving a fatal AccessViolationException in unmanaged test runs.

- Resolved race conditions in the test suite by deleting MSTestSettings.cs to eliminate [Parallelize] vs [DoNotParallelize] ambiguity.

- Introduced rigorous UndoServiceTests and UndoServiceEcsTests guaranteeing lifecycle persistence, component state rollback, and structural mutation integrity in both managed POCOs and raw ECS chunks.
2026-05-30 15:10:31 +09:00
34dc6fc8c9 refactor codebase 2026-05-30 12:01:20 +09:00
d9343a94dc refactor(editor): implement deferred execution tick architecture
- Introduced \EditorTickEngine\ with a strict 4-phase loop (Safe Zone, ECS Systems, Inspector Sync, Event Firing) to prevent cross-thread violations and ECS pointer invalidation.
- Refactored \EditorWorldService\ to use \ConcurrentQueue<Action>\ for UI-driven ECS mutations via \Defer()\.
- Updated \EntityCommandBuffer\ to support mapping negative temporary Entity IDs during deferred playback.
- Fixed \SceneGraphBuilder\ component checks to prevent crashes when evaluating layout boundaries.
- Adjusted all \EditorSyncTests\ and \SceneSerializationTests\ to manually call \FlushCommands\ and \FirePendingEvents\.
- Temporarily \#if false\-guarded \AssetMetaTests\ and \ImportCoordinatorTests\ to prevent CI flakiness due to incomplete AssetSystem integration.
2026-05-29 16:31:15 +09:00
b84ee586bf fix(ecs): optimize hidden enableable mask resolution in EntityQuery and fix T4 compilation errors 2026-05-29 14:34:36 +09:00
52dfa12e84 Remove test projects and sample app code, cleanup core
Removed all test and sample application files, including Ghost.Entities.Test, Ghost.Graphics.Test, related XAML, assets, and project files. Updated solution to exclude deleted projects. Adjusted tests to comment out allocation manager usage. Updated ComponentDescriptorTests for camelCase property names and display name checks. Minor cleanup in core engine files for resource management and destructors. This streamlines the repository to focus on core engine/runtime code.
2026-05-28 18:32:12 +09:00
326aee2b1c Make FMOD structs/methods readonly, update tests/components
Refactored FMOD and FMOD Studio C# bindings to mark struct methods and property getters as readonly, improving immutability and enabling compiler optimizations. Updated usage of handle for readonly struct members. Simplified using statements with C# 8+ syntax. Updated test/component code: added EditorWorldService.RootNodes, disabled SharedComponentStore, revised EntityQueryTest/SerializationTest for new job scheduler, switched Mesh/Position to IComponentData, and adjusted SystemTest initialization. Minor code style cleanups throughout.
2026-05-28 14:37:48 +09:00
2cbe1ca789 Refactor hierarchy drag-and-drop and cleanup inspector
Refactored HandleDrawer to use a static UpdateUI method for clarity. Removed unused PropertyDrawerContext. Added EntityCreated event to EditorWorldService. Updated Hierarchy.xaml to modern TreeView drag-and-drop events. Overhauled drag-and-drop logic in Hierarchy.xaml.cs for better validation, cycle prevention, and scene graph consistency. Introduced helper methods for parent retrieval and scene graph rebuilding. Improved error handling throughout drag-and-drop operations.
2026-05-27 20:08:40 +09:00
1fe080dd87 refactor(editor): unify PropertyModel and IFieldMiddleware into PropertyNode
This refactoring eliminates transient PropertyModel allocations and synchronizes UI data binding directly with the SceneGraph ComponentNode's persistent PropertyNode array. All PropertyDrawers have been updated to consume PropertyNode<T>. Additionally, this commit stages and includes various updates across the engine, graphics, and third-party wrappers as requested.
2026-05-26 19:13:59 +09:00
211ea2254d feat(inspector): rewrite to zero-boxing generic property models
Refactor the ECS component inspector architecture to eliminate per-frame GC allocations (boxing).

- Replace object-based PropertyModel with generic PropertyModel<T>
- Introduce IPropertyModel interface for erased type holding
- Create PropertyDrawerRegistry and ComponentEditorRegistry
- Implement recursive nested struct support in PropertyDescriptor
- Fix EntityDrawer to update text dynamically on sync
- Delete obsolete object-boxing UI bindings
2026-05-25 14:41:04 +09:00
914dde2448 feat(inspector): redesign data-driven ECS inspector
Replaced the hardcoded ComponentView approach with a data-driven ECS inspector that works directly with memory pointers. Introduced ComponentDescriptor and PropertyDescriptor to cache reflection metadata and C# struct layout offsets. Added InspectorSyncService which hooks into CompositionTarget.Rendering to safely synchronize oid* chunk memory to UI via PropertyBinding. Added pluggable PropertyDrawer architecture for rendering scalar properties (Float, Int, Bool, Enum). Updated EntityManager with GetEntityArchetype to easily iterate through component signatures for an entity.
2026-05-24 15:06:50 +09:00
5222e801b9 Add asset open handler system and async open support
Introduced AssetOpenHandlerAttribute for extension-based asset open logic.
Implemented async OpenAssetAsync methods in IAssetRegistry and AssetRegistry, using reflection to invoke handlers.
Updated ContentBrowser and ViewModel to support async asset opening.
Refactored Hierarchy initialization and XAML for clarity.
Logger now throws exceptions in DEBUG for errors/asserts.
Removed unused code and cleaned up usings.
2026-05-23 13:05:25 +09:00
a1c5ccf937 Refactor scene loading/materialization & asset streaming
- Overhauled scene loading with async, incremental, and cancellable operations using new types (SceneLoadStatus, SceneLoadOptions, etc.)
- Added thread-safe management of pending/loaded scenes and per-frame materialization budgeting
- Changed mesh header offsets from ulong to long; updated all related code to use stream positions directly
- Improved resource release logic for mesh/texture asset entries
- Refactored asset loading jobs and made reimport flag atomic
- Stream utility now always aligns memory blocks to 16 bytes
- Misc: fixed mock content headers, cleaned up code, and improved interface visibility
2026-05-22 18:21:16 +09:00
6b501efda0 Refactor and expand scene/entity hierarchy system
- SceneGraphBuilder: support initial entity names in tree
- EditorWorldService: full CRUD, events, scene graph rebuild
- SceneGraphSyncService: event-driven sync, node map, TryGetNode
- SceneSerializationService: serialize/deserialize names, sync
- Hierarchy UI: context menus, drag-and-drop, delete, no polling
- SceneManager: fix component type ID handling
- Add SceneGraphSync unit tests
- Remove obsolete asset handler/importer attributes
- Scene hierarchy is now reactive, robust, and testable
2026-05-22 17:55:25 +09:00
a40140cabd Refactor editor DI, update scene graph infra & deps
- Removed `[EditorInjectionAttribute]` and switched to direct service registration in `App.xaml.cs`
- Deleted `EditorIconSource` and moved icon handling to XAML
- Moved and enhanced `PathUtility` for path normalization and unique naming
- Renamed `SceneManager.UnloadScene` to `DestroyScene` for clarity
- Optimized `EntityQuery.Any()` with bitmask logic
- Improved `SceneGraphBuilder` and `SceneGraphSyncService` for better scene/entity handling
- Simplified entity field detection in `SceneSerializationService`
- Removed unused `TypeCache.Initialize()`
- Updated `ContentBrowser` to support scene asset creation and adjusted selection logic
- Upgraded NuGet package versions in project files
- Changed code generators to use `#if GHOST_EDITOR`
- Made `ShaderVariantCompiledHandler` safe
- Updated or removed scene graph planning docs to match new architecture
2026-05-22 15:32:30 +09:00
7dac1e4437 refactor: rewrite scene streaming & shader bridge
This commit overhauls the scene management and streaming architecture to use a chunk-based asynchronous loading paradigm, and completely decouples the graphics runtime from editor compilation services to eliminate circular dependencies.

Shader Bridge Decoupling:
- Resolved circular runtime dependency between Ghost.Graphics and Ghost.Editor.Core.
- Shifted EditorShaderCompilerBridge from querying EngineCore to a decoupled event-driven model using IShaderCompilationBridge.
- Introduced custom stack-friendly delegate ShaderVariantCompiledHandler to handle ReadOnlySpan<ShaderByteCode> compilation buffers while maintaining zero-allocation constraints.
- Updated ShaderLibrary to self-manage bytecode caching and stale pipeline eviction by listening to compiler events.

Scene & Streaming Overhaul:
- Re-implemented Scene.cs with a high-performance two-phase asynchronous scene loading architecture.
- Replaced outdated per-entity component processing with chunk-level shared data management using the ISharedComponent paradigm for SceneID.
- Optimized ECS Query.cs and Archetype.cs to handle streaming chunk operations efficiently.
- Updated AssetManager, ResourceStreamingProcessor, and asset entries (SceneAssetEntry, MeshAssetEntry, TextureAssetEntry) to support the new streaming workflow.
2026-05-21 21:43:41 +09:00
e04c7eb6a7 Refactor ECS: split IComponent into Data/Shared types
Refactored ECS to distinguish IComponentData and ISharedComponent. Updated all component implementations and method constraints accordingly. Changed SceneID handling to use shared components and updated related APIs and tests. Fixed BufferReader pointer advancement and initialized EntityManager managed storage.
2026-05-18 10:51:02 +09:00
84c936bb7a Refactor safety checks, buffer reading, and scene loading
Replaces GHOST_EDITOR with GHOST_SAFETY_CHECKS for debug checks. Introduces IBufferReader and StreamBufferReader to unify buffer/stream reading. Refactors SceneManager.ParseSceneData to support both buffer and stream sources, improving error handling and resource management. Updates usages of ReadMemory to ReadBuffer. Expands and modernizes SceneSerializationTests with async and expressive assertions. Adds ergonomic QueryBuilder methods. Applies minor code style and safety improvements throughout.
2026-05-17 23:56:53 +09:00
18505cdff6 Refactor scene loading, shared components, and cleanup
- Split scene loading into parsing and materialization steps
- Make SceneID an ISharedComponent; add SharedComponentSet
- Centralize archetype/cleanup logic for entity destruction
- Add batch DestroyEntities to EntityCommandBuffer
- Use shared component filtering for SceneID queries
- Move AssetType/AssetState enums to AssetEntry.cs
- Remove ManagedEntity/ScriptComponent logic
- Misc: Write<T> signature, AsSpan, code style, GC fixes
2026-05-16 21:07:54 +09:00
f85cf4edde add ICleanupComponent support at DestroyEntities 2026-05-16 10:29:42 +09:00
982ce7d8e0 Add robust shared component support to ECS
- Introduce ISharedComponent and remove legacy shared wrapper types
- Archetype now supports chunk groups for shared values
- EntityManager: Add/Remove/Set shared component APIs with correct migration
- EntityCommandBuffer: shared component operations supported
- ChunkView: GetSharedComponent<T>() for fast shared access
- ComponentSet/ComponentSetView: track type and shared data hashes
- Query and entity counting handle chunk groups/shared components
- Switch ReadOnlyUnsafeCollection to ReadOnlyView throughout
- Scene/SceneGraph: SceneID is now ushort, not struct
- BufferWriter/Reader and SpanWriter/Reader API improvements
- World: thread-local ECBs use UnsafeArray
- Add comprehensive unit tests for shared component features
- Update dependencies, clean up legacy code, improve comments
2026-05-15 19:17:55 +09:00
cf5af7ee50 Refactor asset/scene loading to use streams, update APIs
Refactored asset and scene loading to operate on streams instead of raw memory blocks, improving flexibility and efficiency. Replaced World.Clear(TimeData) with World.Reset() and updated all usages. Updated SceneManager and asset entry APIs to use streams, added new StreamUtility methods, and removed IDisposable from Scene. Refactored SystemManager to remove TimeData from lifecycle methods. Performed related code cleanups and updated tests.
2026-05-14 21:51:31 +09:00
60b807abd7 Refactor Scene/SceneID to value-based model, move loading
Refactored Scene and SceneID to use a value-based approach, with SceneID now storing a ushort value instead of a Scene struct. Updated all usages to reference the new value and ID properties. Moved scene loading logic from SceneLoader (SceneAssetEntry.cs) into SceneManager for better consolidation. Updated entity creation, serialization, and tests to use the new SceneID pattern. Cleaned up obsolete code and improved comments regarding scene streaming and loading workflow. Added IDisposable to Scene and related structs for resource management.
2026-05-13 19:48:31 +09:00
cbaa129d9e Refactor ECS core, improve thread safety, add tests
- Switch Scene IDs to ushort, update invalid value logic
- Add thread safety to SceneManager and ComponentRegistry
- Add GHOST_ZERO_INIT_COMPONENT define for editor configs
- Update mesh header counts to use int, not uint
- Add Collect methods for archetype/component cleanup
- Add With/Without/AsView to ComponentSet for easier use
- Refactor World creation/destruction, version handling
- Refactor ResourceStreamingContext to use init-only props
- Add unit tests for entity queries and world lifecycle
- Minor code cleanups and formatting fixes
2026-05-13 13:36:51 +09:00
bb07644580 Refactor asset streaming & resource management system
- Introduce Ghost.Engine.Streaming namespace and split asset entry logic into type-specific classes (TextureAssetEntry, MeshAssetEntry, SceneAssetEntry)
- Make AssetEntry abstract; add AssetEntryFactory for type dispatch
- Update AssetManager and ResourceStreamingProcessor for new entry model, supporting uploadable and processable assets
- Redesign scene/mesh asset loading, serialization, and binary formats with versioning (SceneContentHeader, MeshContentHeader)
- Move SceneLoadingType to Ghost.Engine and make public
- Inline performance-critical APIs with MethodImplOptions.AggressiveInlining
- Add deep cloning and improved resource management for Mesh and meshlet data
- Allow nullable log messages in Logger
- Update Misaki.HighPerformance package references
- Remove obsolete files (Asset.cs, ActivationHandler.cs, old mesh logic)
- Improve resource release logic in ResourceManager
- Update RenderContext and ResourceStreamingContext for new streaming model
- Add UnsafeArray/UnsafeList clone utilities
- Update scene serialization/deserialization for new format
- Update tests for new APIs, asset states, and formats
2026-05-12 22:51:51 +09:00
314b0111f0 fix(scene): avoid scene ID collision on load
Do not serialize the SceneID component.
Generate a new SceneID when loading a scene file dynamically to prevent ID collisions.
Update related tests and scene graph builders.
2026-05-10 22:50:05 +09:00
a95ff01366 feat: add scene serialization (JSON + binary) with import pipeline
Implement scene save/load for editor and runtime.
Editor JSON (.gscene) uses Utf8JsonWriter for inline component objects.
Runtime binary (.imported) stores marshalled component data with
entity field offset metadata for AOT-safe remapping.

- SceneSerializationService: save from EditorWorld, load into EditorWorld
- SceneAsset + SceneAssetHandler: .gscene import/pack pipeline
- AssetManager.Scene + SceneLoader: runtime binary deserialization
- Scene: [JsonConstructor] + [JsonIgnore] for round-trip
- Component: GetComponentIDByName for editor-side type lookup
- 10 unit tests (save, load, round-trip, unknown comp, invalid version)

Also guard DSLShaderCompiler editor code with #if GHOST_EDITOR,
add GC.SuppressFinalize to EditorWorldService, and switch Archetype
debug fields from #if GHOST_EDITOR to #if DEBUG.
2026-05-10 16:21:56 +09:00
7e1db7b908 change #if DEBUG || GHOST_EDITOR to #if GHOST_EDITOR 2026-05-10 12:01:06 +09:00
2ea3c509b0 feat: implement scene graph system with ECS hierarchy support
Add hierarchical scene graph for editor with TreeView UI, runtime
HierarchyUtility for parent/child linked-list management, and
incremental sync between editor world and scene graph nodes.

- SceneGraphNode/SceneNode/EntityNode with World, Scene, Entity refs
- SceneGraphBuilder — construct tree from ECS World queries
- HierarchyUtility — SetParent, RemoveParent, IsAncestor, cascade destroy
- EditorWorldService + SceneGraphSyncService — editor world lifecycle & incremental sync
- Hierarchy.xaml — TreeView with DataTemplate + SceneGraphTemplateSelector
- 25 unit tests covering hierarchy ops and scene graph building
2026-05-10 00:14:55 +09:00
e2bc68d359 Refactor UI panels, shader logic, and project settings
Refactored Hierarchy and Inspector panels into separate controls with improved styling and modularity. Cached EngineCore in EditorShaderCompilerBridge and updated shader cache logic for correctness. Renamed static field in PropertyField for consistency. Enhanced test coverage and fixed IDisposable implementation. Added XAML debugging properties for Debug_Editor and cleaned up obsolete scripts.
2026-05-09 15:20:22 +09:00
1cc65e8218 refactor(shader): rewrite editor shader compilation bridge with keyword resolution
- Add AssetCatalog.EnumerateByTypes for filtered SQL queries
- Thread LocalKeywordSet through IShaderCompilationBridge API so the
  bridge can resolve keyword bitmask to string defines at compile time
- Eager/lazy popluation of shader-id-to-asset-id map eliminating
  full catalog scan per compilation miss
- Build keyword mapping from PassDescriptor groups to reconstruct
  localIndex -> keywordName in the bridge
- Use CompileShaderPass extension for multi-stage AS/MS/PS compilation
  with correct ShaderModel from descriptor
- Remove double-load of shader asset in compilation flow
- Update test mock to match new interface signature
2026-05-08 19:36:24 +09:00
d0076c852f Add editor shader compilation bridge & pipeline cache mgmt
Introduced EditorShaderCompilerBridge and IShaderCompilationBridge for async shader variant compilation and cache invalidation in the editor. Refactored ShaderLibrary to support the bridge, updating hash/caching logic and triggering compilation on cache misses. Changed pipeline library to use ulong content hashes and added stale pipeline eviction. Updated EngineCore and render code to integrate the new system. Added unit tests for ShaderLibrary cache and bridge behavior. Minor improvements to shader property code generation and test generator.
2026-05-08 17:06:37 +09:00
ba8694ed0c Add shader property reflection and resource handles
Refactor shader property system to support runtime reflection via ShaderPropertyType and ShaderPropertyFieldInfo. Introduce strongly-typed Texture2D/3D and Buffer handle structs. Update ShaderPropertiesGenerator to emit field metadata and register it. Move mesh content structs to AssetManager.Mesh.cs and mark as internal. Update DSLShaderCompiler and registry for new property API. Remove obsolete files and clean up namespaces. Add sample TestShaderProperty struct.
2026-05-08 15:37:30 +09:00
80e820a858 Add Editor configs, refactor test core, DXC test
Added Debug_Editor/Release_Editor configs to all projects and solution. Refactored test utilities into Ghost.TestCore and updated references. Introduced DXCBindingTest for shader compilation. Updated conditional compilation to use GHOST_EDITOR. Improved platform mappings and performed minor code cleanup.
2026-05-08 13:59:36 +09:00
362 changed files with 22181 additions and 10025 deletions

2
.gitignore vendored
View File

@@ -13,7 +13,7 @@
AGENTS.md AGENTS.md
.opencode/ .opencode/
.code-review-graph/ .code-review-graph/
.github/instructions/ .antigravitycli/
ref/ ref/
docfx/ docfx/

17
src/Directory.Build.props Normal file
View 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>

View File

@@ -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>

View File

@@ -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
} }
} }

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Engine; using Ghost.Engine.Streaming;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
@@ -32,7 +32,7 @@ public sealed class CustomAssetHandlerAttribute : Attribute
} = true; } = true;
} }
public interface IAsset : IDisposable public abstract class IAsset : GhostObject
{ {
public Guid ID public Guid ID
{ {
@@ -48,6 +48,14 @@ 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;

View File

@@ -1,4 +1,4 @@
using Ghost.Engine; using Ghost.Engine.Streaming;
using System.Collections.Concurrent; using System.Collections.Concurrent;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;

View File

@@ -1,9 +1,6 @@
using Ghost.Graphics.RHI; using Ghost.Graphics.RHI;
using Misaki.HighPerformance.LowLevel.Collections; using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics; using Misaki.HighPerformance.Mathematics;
using System;
using System.Collections.Generic;
using System.Text;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;

View File

@@ -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;
@@ -13,11 +12,9 @@ using Misaki.HighPerformance.Mathematics;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using TLSFPool = Misaki.HighPerformance.LowLevel.Buffer.MemoryPool<Misaki.HighPerformance.LowLevel.Buffer.TLSF, Misaki.HighPerformance.LowLevel.Buffer.TLSF.CreationOptions>;
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
{ {
@@ -38,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)]
@@ -321,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
@@ -354,15 +357,20 @@ internal readonly unsafe struct MeshParsingJob : IJob
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, AllocationHandle.TLSF); ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
return Result.Success();
} }
} }
internal 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);
}
} }

View File

@@ -14,6 +14,7 @@ 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 System.Runtime.InteropServices;
using TerraFX.Interop.Windows;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
@@ -164,7 +165,7 @@ public unsafe struct ClodCluster
internal static unsafe partial class MeshProcessor internal static unsafe partial class MeshProcessor
{ {
private delegate int ClodOutputDelegate(MeshletContext context, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters); 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)
{ {
@@ -431,7 +432,7 @@ internal 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, AllocationHandle allocationHandle) 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); using var subset = new UnsafeArray<SloppyVertex>(indices.Count, allocationHandle);
using var subset_locks = new UnsafeArray<byte>(indices.Count, allocationHandle); using var subset_locks = new UnsafeArray<byte>(indices.Count, allocationHandle);
@@ -469,7 +470,7 @@ internal static unsafe partial class MeshProcessor
} }
private static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh, private static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint targetCount, float* error, ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint targetCount, float* error,
AllocationHandle allocationHandle) AllocationHandle allocationHandle)
{ {
var lod = new UnsafeArray<uint>(indices.Count, allocationHandle); var lod = new UnsafeArray<uint>(indices.Count, allocationHandle);
@@ -703,16 +704,31 @@ internal static unsafe partial class MeshProcessor
public int materialIndex; public int materialIndex;
} }
private static int MeshletOutputCallback(MeshletContext context, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters) private static int MeshletOutputCallback(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters)
{ {
var meshletData = context.data; var meshletData = context.data;
var materialIndex = context.materialIndex; var materialIndex = context.materialIndex;
// Ensure lists are initialized // Ensure lists are initialized
if (!meshletData->groups.IsCreated) meshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.TLSF); if (!meshletData->groups.IsCreated)
if (!meshletData->meshlets.IsCreated) meshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.TLSF); {
if (!meshletData->meshletVertices.IsCreated) meshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.TLSF); meshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.TLSF);
if (!meshletData->meshletTriangles.IsCreated) meshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.TLSF); }
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
{ {
@@ -770,15 +786,14 @@ internal static unsafe partial class MeshProcessor
internal static partial class MeshProcessor internal static partial class MeshProcessor
{ {
private class MeshletBuildJob
private struct MeshletBuildJob : IJob
{ {
public ClodConfig clodConfig; public ClodConfig clodConfig;
public ClodMesh clodMesh; public ClodMesh clodMesh;
public MeshletContext context; public MeshletContext context;
public readonly void Execute(ref readonly JobExecutionContext ctx) public void Execute()
{ {
Build(in clodConfig, in clodMesh, context, MeshletOutputCallback); Build(in clodConfig, in clodMesh, context, MeshletOutputCallback);
} }
@@ -789,9 +804,7 @@ internal static 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 async Task<DisposablePtr<MeshletMeshData>> BuildMeshletsAsync(JobScheduler jobScheduler, public static async Task<DisposablePtr<MeshletMeshData>> BuildMeshletsAsync(ReadOnlyView<Vertex> vertices, ReadOnlyView<uint> indices, ReadOnlyView<MaterialPartInfo> parts, CancellationToken token)
ReadOnlyUnsafeCollection<Vertex> vertices, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<MaterialPartInfo> parts,
CancellationToken token)
{ {
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.");
@@ -821,8 +834,6 @@ internal static partial class MeshProcessor
simplifyFallbackSloppy = true, simplifyFallbackSloppy = true,
}; };
var jobs = new MeshletBuildJob[parts.Length];
IntPtr meshletData; IntPtr meshletData;
unsafe unsafe
{ {
@@ -836,6 +847,7 @@ internal static partial class MeshProcessor
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 unsafe
{ {
@@ -859,21 +871,15 @@ internal static partial class MeshProcessor
materialIndex = part.materialIndex materialIndex = part.materialIndex
}; };
var job = new MeshletBuildJob job = new MeshletBuildJob
{ {
clodConfig = config, clodConfig = config,
clodMesh = clodMesh, clodMesh = clodMesh,
context = context context = context
}; };
jobs[i] = job;
}
} }
foreach (var job in jobs) await Task.Run(job.Execute, token);
{
var handle = jobScheduler.Schedule(in job);
await jobScheduler.WaitAsync(handle, token);
} }
unsafe unsafe
@@ -956,8 +962,15 @@ internal static partial class MeshProcessor
var extents = centroidMax - centroidMin; var extents = centroidMax - centroidMin;
var splitAxis = 0; var splitAxis = 0;
if (extents.y > extents.x && extents.y > extents.z) splitAxis = 1; if (extents.y > extents.x && extents.y > extents.z)
if (extents.z > extents.x && extents.z > extents.y) splitAxis = 2; {
splitAxis = 1;
}
if (extents.z > extents.x && extents.z > extents.y)
{
splitAxis = 2;
}
var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f; var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f;
@@ -1008,8 +1021,15 @@ internal static partial class MeshProcessor
{ {
gathered.Clear(); gathered.Clear();
var node = binaryNodes[nodeIndex]; var node = binaryNodes[nodeIndex];
if (node.leftChild != -1) gathered.Add(node.leftChild); if (node.leftChild != -1)
if (node.rightChild != -1) gathered.Add(node.rightChild); {
gathered.Add(node.leftChild);
}
if (node.rightChild != -1)
{
gathered.Add(node.rightChild);
}
while (gathered.Count < 4) while (gathered.Count < 4)
{ {
@@ -1034,12 +1054,22 @@ internal static partial class MeshProcessor
} }
} }
if (largestInternalIndex == -1) break; // all gathered are leaves if (largestInternalIndex == -1)
{
break; // all gathered are leaves
}
gathered.RemoveAt(listIndexToRemove); gathered.RemoveAt(listIndexToRemove);
var largestNode = binaryNodes[largestInternalIndex]; var largestNode = binaryNodes[largestInternalIndex];
if (largestNode.leftChild != -1) gathered.Add(largestNode.leftChild); if (largestNode.leftChild != -1)
if (largestNode.rightChild != -1) gathered.Add(largestNode.rightChild); {
gathered.Add(largestNode.leftChild);
}
if (largestNode.rightChild != -1)
{
gathered.Add(largestNode.rightChild);
}
} }
} }
@@ -1137,20 +1167,19 @@ internal static partial class MeshProcessor
} }
} }
private unsafe struct BuildClusterLodHierarchyJob : IJob private unsafe class BuildClusterLodHierarchyJob
{ {
public MeshletMeshData* meshletData; public MeshletMeshData* meshletData;
public readonly void Execute(ref readonly JobExecutionContext ctx) public void Execute()
{ {
using var scope = AllocationManager.CreateStackScope(); using var meshletIndices = new UnsafeArray<int>(meshletData->meshletCount, AllocationHandle.TLSF);
using var meshletIndices = new UnsafeArray<int>(meshletData->meshletCount, scope.AllocationHandle);
for (var i = 0; i < meshletData->meshletCount; i++) for (var i = 0; i < meshletData->meshletCount; i++)
{ {
meshletIndices[i] = i; meshletIndices[i] = i;
} }
var binaryNodes = new UnsafeList<TempBinaryNode>(meshletData->meshletCount * 2, scope.AllocationHandle); var binaryNodes = new UnsafeList<TempBinaryNode>(meshletData->meshletCount * 2, AllocationHandle.TLSF);
try try
{ {
@@ -1201,14 +1230,13 @@ internal static partial class MeshProcessor
/// Builds a cluster LOD hierarchy from the input meshlet data. /// Builds a cluster LOD hierarchy from the input meshlet data.
/// </summary> /// </summary>
/// <param name="meshletData">The meshlet data.</param> /// <param name="meshletData">The meshlet data.</param>
public static async Task BuildClusterLodHierarchyAsync(JobScheduler jobScheduler, SharedPtr<MeshletMeshData> meshletData, CancellationToken token) public static Task BuildClusterLodHierarchyAsync(SharedPtr<MeshletMeshData> meshletData, CancellationToken token)
{ {
if (meshletData.GetRef().meshletCount == 0) if (meshletData.GetRef().meshletCount == 0)
{ {
return; return Task.CompletedTask;
} }
JobHandle handle;
unsafe unsafe
{ {
var job = new BuildClusterLodHierarchyJob var job = new BuildClusterLodHierarchyJob
@@ -1216,9 +1244,7 @@ internal static partial class MeshProcessor
meshletData = meshletData.Get() meshletData = meshletData.Get()
}; };
handle = jobScheduler.Schedule(in job); return Task.Run(job.Execute, token);
} }
await jobScheduler.WaitAsync(handle, token);
} }
} }

View File

@@ -1,10 +1,9 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Core.Utilities; using Ghost.Core.Utilities;
using Ghost.Editor.Core.Services; using Ghost.Editor.Core.Services;
using Ghost.Engine; using Ghost.Engine.Streaming;
using Ghost.Graphics.Core; using Ghost.Graphics.Core;
using Ghost.Graphics.RHI; using Ghost.Graphics.RHI;
using Misaki.HighPerformance.Jobs;
using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections; using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics; using Misaki.HighPerformance.Mathematics;
@@ -14,7 +13,6 @@ using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using TerraFX.Interop.Mimalloc;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
@@ -71,54 +69,25 @@ public sealed class ModelManifestMetadata
internal sealed class ImportedModelAsset : IAsset internal sealed class ImportedModelAsset : IAsset
{ {
public Guid ID
{
get;
}
public Guid TypeID => typeof(MeshAsset).GUID;
public IAssetSettings? Settings
{
get;
}
public ModelManifest Manifest public ModelManifest Manifest
{ {
get; get;
} }
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest) public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
: base(id, typeof(ModelAsset).GUID, settings)
{ {
ID = id;
Settings = settings;
Manifest = manifest; Manifest = manifest;
} }
public void Dispose()
{
}
} }
[Guid(GUID)] [Guid(GUID)]
public abstract class MeshAsset : IAsset public abstract class ModelAsset : IAsset
{ {
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A"; public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
private MeshNode _root; private MeshNode _root;
public Guid ID
{
get;
}
public IAssetSettings Settings
{
get;
}
public Guid TypeID => typeof(MeshAsset).GUID;
public MeshNode Root public MeshNode Root
{ {
get => _root; get => _root;
@@ -129,19 +98,20 @@ public abstract class MeshAsset : IAsset
} }
} }
internal MeshAsset(MeshNode root, Guid id, MeshAssetSettings settings) internal ModelAsset(MeshNode root, Guid id, ModelAssetSettings settings)
: base(id, typeof(ModelAsset).GUID, settings)
{ {
_root = root; _root = root;
ID = id;
Settings = settings;
} }
public void Dispose() protected override void Dispose(bool disposing)
{
if (disposing)
{ {
_root?.Dispose(); _root?.Dispose();
} }
} }
}
public enum CoordinateAxis public enum CoordinateAxis
{ {
@@ -160,7 +130,7 @@ public enum VertexDataSource
ComputedIfMissing ComputedIfMissing
} }
public class MeshAssetSettings : IAssetSettings public class ModelAssetSettings : IAssetSettings
{ {
public VertexDataSource NormalDataSource public VertexDataSource NormalDataSource
{ {
@@ -173,7 +143,7 @@ public class MeshAssetSettings : IAssetSettings
} = VertexDataSource.ComputedIfMissing; } = VertexDataSource.ComputedIfMissing;
} }
internal class ObjAssetSettings : MeshAssetSettings internal class ObjAssetSettings : ModelAssetSettings
{ {
public CoordinateAxis ObjectUpAxis public CoordinateAxis ObjectUpAxis
{ {
@@ -196,12 +166,12 @@ internal class ObjAssetSettings : MeshAssetSettings
} = 1.0f; } = 1.0f;
} }
internal class FbxAssetSettings : MeshAssetSettings internal class FbxAssetSettings : ModelAssetSettings
{ {
} }
[CustomAssetHandler(AssetTypeId = MeshAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })] [CustomAssetHandler(AssetTypeId = ModelAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler internal class ModelAssetHandler : IImportableAssetHandler, IPackableAssetHandler
{ {
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
{ {
@@ -209,8 +179,6 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}; };
private readonly JobScheduler _jobScheduler = EditorApplication.GetService<EngineCore>().JobScheduler;
public IAssetSettings? CreateDefaultSettings(string ext) public IAssetSettings? CreateDefaultSettings(string ext)
{ {
if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase)) if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase))
@@ -264,10 +232,12 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
var meshSettings = ResolveSettings(sourcePath, settings); var meshSettings = ResolveSettings(sourcePath, settings);
using var root = new MeshNode(); using var root = new MeshNode();
var result = await MeshProcessor.ParseMeshAsync(root, sourcePath, AllocationHandle.TLSF, meshSettings, token).ConfigureAwait(false);
var parseJob = new MeshParsingJob(root, sourcePath, AllocationHandle.Persistent, meshSettings); if (result.IsFailure)
var handle = _jobScheduler.Schedule(in parseJob); {
await _jobScheduler.WaitAsync(handle, token); return Result.Failure(result.Message);
}
var manifest = new ModelManifest var manifest = new ModelManifest
{ {
@@ -295,9 +265,9 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet.")); return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
} }
private static MeshAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings) private static ModelAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
{ {
if (settings is MeshAssetSettings meshSettings) if (settings is ModelAssetSettings meshSettings)
{ {
return meshSettings; return meshSettings;
} }
@@ -354,7 +324,7 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
node.Name, node.Name,
stablePath, stablePath,
$"{sourcePath}#Mesh/{stablePath}", $"{sourcePath}#Mesh/{stablePath}",
typeof(MeshAsset).GUID)); typeof(ModelAsset).GUID));
} }
else if (node is LightMeshNode) else if (node is LightMeshNode)
{ {
@@ -376,24 +346,24 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token) private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token)
{ {
using var meshletData = await MeshProcessor.BuildMeshletsAsync(_jobScheduler, geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false); using var meshletData = await MeshProcessor.BuildMeshletsAsync(geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
await MeshProcessor.BuildClusterLodHierarchyAsync(_jobScheduler, meshletData.Share(), token).ConfigureAwait(false); await MeshProcessor.BuildClusterLodHierarchyAsync(meshletData.Share(), token).ConfigureAwait(false);
var bounds = ComputeBounds(geometry.Vertices); var bounds = ComputeBounds(geometry.Vertices);
var header = new MeshContentHeader var header = new MeshContentHeader
{ {
magic = MeshContentHeader.MAGIC, magic = MeshContentHeader.MAGIC,
version = MeshContentHeader.VERSION, version = MeshContentHeader.VERSION,
vertexCount = (uint)geometry.Vertices.Count, vertexCount = geometry.Vertices.Count,
indexCount = (uint)geometry.Indices.Count, indexCount = geometry.Indices.Count,
materialPartCount = (uint)geometry.MaterialParts.Length, materialPartCount = geometry.MaterialParts.Length,
meshletCount = (uint)meshletData.GetRef().meshlets.Count, meshletCount = meshletData.GetRef().meshlets.Count,
meshletGroupCount = (uint)meshletData.GetRef().groups.Count, meshletGroupCount = meshletData.GetRef().groups.Count,
meshletHierarchyNodeCount = (uint)meshletData.GetRef().hierarchyNodes.Count, meshletHierarchyNodeCount = meshletData.GetRef().hierarchyNodes.Count,
meshletVertexCount = (uint)meshletData.GetRef().meshletVertices.Count, meshletVertexCount = meshletData.GetRef().meshletVertices.Count,
meshletTriangleCount = (uint)meshletData.GetRef().meshletTriangles.Count, meshletTriangleCount = meshletData.GetRef().meshletTriangles.Count,
materialSlotCount = (uint)meshletData.GetRef().materialSlotCount, materialSlotCount = meshletData.GetRef().materialSlotCount,
lodLevelCount = (uint)meshletData.GetRef().lodLevelCount, lodLevelCount = meshletData.GetRef().lodLevelCount,
boundsMin = bounds.Min, boundsMin = bounds.Min,
boundsMax = bounds.Max, boundsMax = bounds.Max,
}; };
@@ -401,28 +371,28 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None); using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
stream.Write(header); stream.Write(header);
header.vertexOffset = (ulong)stream.Position; header.vertexOffset = stream.Position;
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token); await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
header.indexOffset = (ulong)stream.Position; header.indexOffset = stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token); await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
header.materialPartOffset = (ulong)stream.Position; header.materialPartOffset = stream.Position;
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan()); WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
header.meshletOffset = (ulong)stream.Position; header.meshletOffset = stream.Position;
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token); await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
header.meshletGroupOffset = (ulong)stream.Position; header.meshletGroupOffset = stream.Position;
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token); await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
header.meshletHierarchyNodeOffset = (ulong)stream.Position; header.meshletHierarchyNodeOffset = stream.Position;
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token); await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
header.meshletVertexOffset = (ulong)stream.Position; header.meshletVertexOffset = stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token); await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
header.meshletTriangleOffset = (ulong)stream.Position; header.meshletTriangleOffset = stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token); await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token);
stream.Position = 0; stream.Position = 0;

View 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
{
}

View 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}");
}
}
}

View File

@@ -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,32 +28,16 @@ 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.

View File

@@ -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);
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -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);
} }

View File

@@ -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();
}

View File

@@ -9,5 +9,8 @@ public interface IInspectable
UIElement? CreateHeader(); UIElement? CreateHeader();
UIElement? CreateInspector(); IInspectorModel CreateInspectorModel();
// void OnSelected();
// void OnDeselected();
} }

View 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();
}

View File

@@ -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);
} }

View File

@@ -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;
} }
} }

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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);
} }
} }

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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)
{ {

View 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);
}
}
}

View 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;
}
}

View 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="&#xE8A7;" 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="&#xE711;" FontSize="10" />
</Button>
</Grid>
</UserControl>

View 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();
}
}

View File

@@ -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;
} }
} }

View File

@@ -1,9 +1,18 @@
using Ghost.Core.Utilities; 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";
@@ -54,6 +63,11 @@ 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); projectPath = PathUtility.Normalize(projectPath);
@@ -89,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()

View File

@@ -10,17 +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>
<Content Remove="Assets\MeshNode.cs" /> <Content Remove="Assets\MeshNode.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentIcons.WinUI" Version="2.1.324" /> <PackageReference Include="FluentIcons.WinUI" Version="2.1.328" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<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="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>
@@ -43,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>

View 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);
}
}

View File

@@ -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());
}
}

View File

@@ -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]);
}
}

View File

@@ -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()
{
}
} }

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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();
}
}

View 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;
}
}

View 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);
}

View 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;
}
}

View File

@@ -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
};
}

View 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);
}
});
}
}
}

View File

@@ -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
}
public override UIElement? CreateInspector()
{ {
throw new NotImplementedException(); ColumnSpacing = 8,
} };
public override DataTemplate GetSceneHierarchyTemplate() root.ColumnDefinitions.Add(new ColumnDefinition
{ {
var template = @" Width = new GridLength(1, GridUnitType.Star)
<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}""> root.ColumnDefinitions.Add(new ColumnDefinition
<StackPanel Margin=""10,0"" Orientation=""Horizontal""> {
<FontIcon FontSize=""14"" Glyph=""&#xF158;"" /> Width = GridLength.Auto,
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" /> MinWidth = 20
</StackPanel> });
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template); 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 IInspectorModel CreateInspectorModel()
{
return new Inspector.EntityInspectorModel(World, Entity);
} }
} }

View 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;
}
}
}
}
}
}

View 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);
}
}

View File

@@ -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

View 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})";
}
}

View File

@@ -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;
public abstract DataTemplate GetSceneHierarchyTemplate(); Children.CollectionChanged += OnChildrenChanged;
}
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();
} }

View File

@@ -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=""&#xF156;""/>
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
</StackPanel>
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
} }
} }

View File

@@ -89,6 +89,7 @@ public sealed partial class AssetCatalog
); );
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_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,
@@ -281,6 +282,33 @@ public sealed partial class AssetCatalog
} }
} }
public IEnumerable<Guid> EnumerateByTypes(params Guid[] assetTypeIds)
{
if (assetTypeIds.Length == 0)
{
yield break;
}
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
var parameterNames = new List<string>(assetTypeIds.Length);
for (var i = 0; i < assetTypeIds.Length; i++)
{
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) public List<SubAssetInfo> GetSubAssets(Guid parentGuid)
{ {
using var connection = OpenConnection(); using var connection = OpenConnection();

View File

@@ -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;
@@ -188,7 +190,7 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex); Logger.Warning($"FileSystemEvent exception: {ex.Message}");
} }
} }
@@ -421,6 +423,37 @@ 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();

View 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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View 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;
}
}
}

View 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);
}
}

View File

@@ -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)
{ {
@@ -209,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();
} }
} }

View File

@@ -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;
}
}
}

View 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;
}
}

View File

@@ -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()
{
}
}

View 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();
}
}

View 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);
};
}
}

View 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);
}
}

View File

@@ -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();
} }

View File

@@ -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
{ {

View File

@@ -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,10 +58,10 @@ 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 * 64, // 64 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, FreeListChunkSize = 64 * 1024,
FreeListDefaultAlignment = 8, FreeListDefaultAlignment = 8,
TLSFInitialChunkSize = 64 * 1024, TLSFInitialChunkSize = 32 * 1024 * 1024,
TLSFAlignment = 8, TLSFAlignment = 8,
}; };
@@ -69,6 +69,9 @@ internal static class ActivationHandler
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) =>
{ {

View File

@@ -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();

View File

@@ -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)
{
}
}

View File

@@ -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);
});
} }
} }

View File

@@ -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);
}
} }

View 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();
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
@@ -157,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)
{ {
@@ -172,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);
} }
} }

View File

@@ -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>

View File

@@ -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)

View File

@@ -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="&#xF156;" />
<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="&#xF158;" />
<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="&#xE8F4;" /> <Button
Grid.Column="1"
HorizontalAlignment="Right"
Style="{ThemeResource ToolbarButton}">
<FontIcon Glyph="&#xE712;" />
</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="&#xF159;" /> <ColumnDefinition Width="*" />
<TextBlock Text="Test" /> </Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,0,4,0"
FontSize="{StaticResource ToolbarFontIconFontSize}"
Glyph="&#xE721;" />
<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="&#xF159;" /> <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="&#xF159;" /> AllowDrop="True"
<TextBlock Text="Test" /> CanDrag="True"
</StackPanel> CanDragItems="True"
<StackPanel Orientation="Horizontal" Spacing="4"> CanReorderItems="True"
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="&#xF159;" /> DragItemsCompleted="OnTreeViewDragItemsCompleted"
<TextBlock Text="Test" /> DragItemsStarting="OnTreeViewDragItemsStarting"
</StackPanel> ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}"
</ListView> KeyDown="OnTreeViewKeyDown"
</Border> SelectionMode="Single" />
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -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;
} }
} }

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Ghost.Editor.Views.Controls.Inspector"
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:inspector="using:Ghost.Editor.Core.Inspector"
xmlns:local="using:Ghost.Editor.Views.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Inspector Header -->
<Grid
Grid.Row="0"
Padding="12,12,8,12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource ControlElevationBorderBrush}"
BorderThickness="0,0,0,1"
ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentControl
x:Name="IconPresenter"
Grid.Column="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
HorizontalContentAlignment="Stretch" />
<ContentControl
x:Name="HeaderPresenter"
Grid.Column="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
HorizontalContentAlignment="Stretch" />
<DropDownButton
Grid.Column="2"
Padding="2"
Style="{ThemeResource ToolbarButton}">
<DropDownButton.Flyout>
<MenuFlyout Placement="Bottom">
<MenuFlyoutItem Text="Send" />
</MenuFlyout>
</DropDownButton.Flyout>
<FontIcon FontSize="12" Glyph="&#xF8B0;" />
</DropDownButton>
</Grid>
<!-- Content -->
<ScrollView x:Name="ContentScrollView" Grid.Row="1">
<StackPanel
x:Name="InspectorContentContainer"
Padding="0,4,0,12"
Orientation="Vertical"
Spacing="4" />
</ScrollView>
</Grid>
</UserControl>

View File

@@ -0,0 +1,89 @@
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Views.Controls;
public sealed partial class Inspector : UserControl
{
private readonly IInspectorService _inspectorService;
private readonly InspectorSyncService _syncService;
private IInspectorModel? _currentModel;
public Inspector()
{
InitializeComponent();
_inspectorService = App.GetService<IInspectorService>();
_syncService = App.GetService<InspectorSyncService>();
_inspectorService.OnSelectionChanged += InspectorService_OnSelectionChanged;
Loaded += Inspector_Loaded;
Unloaded += Inspector_Unloaded;
if (_inspectorService.Selected != null)
{
BuildInspector(_inspectorService.Selected);
}
}
private void Inspector_Loaded(object sender, RoutedEventArgs e)
{
_syncService.Start();
}
private void Inspector_Unloaded(object sender, RoutedEventArgs e)
{
_syncService.Unbind();
_currentModel?.Dispose();
_currentModel = null;
}
private void InspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
{
BuildInspector(e.Selected);
}
private void BuildInspector(IInspectable? inspectable)
{
// Cleanup old
_syncService.Unbind();
_currentModel?.Dispose();
_currentModel = null;
InspectorContentContainer.Children.Clear();
if (inspectable == null)
{
IconPresenter.Content = null;
HeaderPresenter.Content = null;
return;
}
// Set header
var icon = inspectable.CreateIcon();
if (icon != null)
{
IconPresenter.Content = new IconSourceElement { IconSource = icon };
}
else
{
IconPresenter.Content = new FontIcon { Glyph = "\uF158", FontSize = 18 };
}
HeaderPresenter.Content = inspectable.CreateHeader();
// Build body
_currentModel = inspectable.CreateInspectorModel();
if (_currentModel != null)
{
InspectorContentContainer.Children.Add(_currentModel.BuildUI());
if (_currentModel is ISyncableInspectorModel syncableModel)
{
_syncService.Bind(syncableModel);
syncableModel.Sync(); // Initial sync
}
}
}
}

View File

@@ -0,0 +1,35 @@
using Ghost.Editor.Core.SceneGraph;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Views.Controls;
public partial class SceneGraphTemplateSelector : DataTemplateSelector
{
public DataTemplate? SceneNodeTemplate
{
get; set;
}
public DataTemplate? EntityNodeTemplate
{
get; set;
}
protected override DataTemplate SelectTemplateCore(object item)
{
var result = item switch
{
SceneNode => SceneNodeTemplate,
EntityNode => EntityNodeTemplate,
_ => base.SelectTemplateCore(item)
};
return result!;
}
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
{
return SelectTemplateCore(item);
}
}

View File

@@ -4,6 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Ghost.Editor.Views.Controls" xmlns:controls="using:Ghost.Editor.Views.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ghost="using:Ghost.Editor.Core.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Background="{ThemeResource LayerFillColorDefaultBrush}" Background="{ThemeResource LayerFillColorDefaultBrush}"
NavigationCacheMode="Enabled" NavigationCacheMode="Enabled"
@@ -48,7 +49,7 @@
<Border Height="12" Style="{StaticResource VerticalDivider}" /> <Border Height="12" Style="{StaticResource VerticalDivider}" />
<MenuBar> <!--<MenuBar x:Name="TopMenuBar">
<MenuBarItem Title="File"> <MenuBarItem Title="File">
<MenuFlyoutItem Text="New" /> <MenuFlyoutItem Text="New" />
<MenuFlyoutItem Text="Open..." /> <MenuFlyoutItem Text="Open..." />
@@ -58,6 +59,7 @@
<MenuBarItem Title="Edit"> <MenuBarItem Title="Edit">
<MenuFlyoutItem Text="Undo" /> <MenuFlyoutItem Text="Undo" />
<MenuFlyoutItem Text="Redo" />
<MenuFlyoutItem Text="Cut" /> <MenuFlyoutItem Text="Cut" />
<MenuFlyoutItem Text="Copy" /> <MenuFlyoutItem Text="Copy" />
<MenuFlyoutItem Text="Paste" /> <MenuFlyoutItem Text="Paste" />
@@ -66,7 +68,8 @@
<MenuBarItem Title="Help"> <MenuBarItem Title="Help">
<MenuFlyoutItem Text="About" /> <MenuFlyoutItem Text="About" />
</MenuBarItem> </MenuBarItem>
</MenuBar> </MenuBar>-->
<ghost:MenuContextBar ContextMenuTag="edit-page-menu" />
</StackPanel> </StackPanel>
<StackPanel <StackPanel
@@ -95,67 +98,12 @@
<ColumnDefinition Width="0.25*" MaxWidth="350" /> <ColumnDefinition Width="0.25*" MaxWidth="350" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Hierarchy --> <Border
<Grid
Grid.Column="0" Grid.Column="0"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="0,0,1,0"> BorderThickness="0,0,1,0">
<Grid.RowDefinitions> <controls:Hierarchy />
<RowDefinition Height="Auto" /> </Border>
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
Padding="8,2,4,4"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Style="{StaticResource BodyLargeStrongTextBlockStyle}"
Text="Hierarchy" />
<Button
Grid.Column="1"
HorizontalAlignment="Right"
Style="{ThemeResource ToolbarButton}">
<FontIcon Glyph="&#xE712;" />
</Button>
</Grid>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,0,4,0"
FontSize="{StaticResource ToolbarFontIconFontSize}"
Glyph="&#xE721;" />
<TextBox Grid.Column="1" PlaceholderText="Sreach item..." />
</Grid>
<Border Margin="-8,8,-4,-4" Style="{StaticResource HorizontalStrongDivider}" />
</StackPanel>
<ListView Grid.Row="1" Padding="4,2,0,2">
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
</ListView>
</Grid>
<!-- Scene and Content --> <!-- Scene and Content -->
<Grid Grid.Column="1"> <Grid Grid.Column="1">
@@ -195,7 +143,6 @@
Stretch="UniformToFill" /> Stretch="UniformToFill" />
</Border> </Border>
<!-- Content Brower --> <!-- Content Brower -->
<Border <Border
Grid.Row="2" Grid.Row="2"
@@ -213,105 +160,12 @@
</Grid> </Grid>
<!-- Inspector --> <!-- Inspector -->
<Grid <Border
Grid.Column="2" Grid.Column="2"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="1,0,0,0"> BorderThickness="1,0,0,0">
<Grid.RowDefinitions> <controls:Inspector />
<RowDefinition Height="Auto" /> </Border>
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Inspector Header -->
<Grid
Grid.Row="0"
Padding="12,12,8,12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource ControlElevationBorderBrush}"
BorderThickness="0,0,0,1"
ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
FontSize="18"
Glyph="&#xF158;" />
<TextBox
Grid.Column="1"
FontSize="14"
Text="Name" />
<DropDownButton
Grid.Column="2"
Padding="2"
Style="{ThemeResource ToolbarButton}">
<DropDownButton.Flyout>
<MenuFlyout Placement="Bottom">
<MenuFlyoutItem Text="Send" />
<MenuFlyoutItem Text="Reply" />
<MenuFlyoutItem Text="Reply All" />
</MenuFlyout>
</DropDownButton.Flyout>
<FontIcon FontSize="12" Glyph="&#xF8B0;" />
</DropDownButton>
</Grid>
<!-- Content -->
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MaxHeight="150" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Padding="8,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="Components" />
<Button Grid.Column="1" Style="{ThemeResource ToolbarButton}">
<FontIcon FontSize="{StaticResource ToolbarFontIconFontSize}" Glyph="&#xE710;" />
</Button>
<Button Grid.Column="2" Style="{ThemeResource ToolbarButton}">
<FontIcon FontSize="{StaticResource ToolbarFontIconFontSize}" Glyph="&#xE738;" />
</Button>
</Grid>
<AutoSuggestBox
Grid.Row="1"
Margin="8,0"
PlaceholderText="Search components..." />
<!-- Components List -->
<ListView
Grid.Row="2"
Padding="4,2,0,2"
SelectionMode="Extended">
<TextBlock Text="Test" />
<TextBlock Text="Test" />
<TextBlock Text="Test" />
</ListView>
<!-- Component Properties for Selected Component -->
<ScrollView
Grid.Row="3"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="0,1,0,0">
<ItemsRepeater />
</ScrollView>
</Grid>
</Grid>
</Grid> </Grid>
</Grid> </Grid>
</Page> </Page>

View File

@@ -1,5 +1,6 @@
using Ghost.Editor.Views.Controls; using Ghost.Editor.Views.Controls;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using System.Reflection;
namespace Ghost.Editor.Views.Pages; namespace Ghost.Editor.Views.Pages;
@@ -15,6 +16,17 @@ public sealed partial class EditPage : Page
ContentBrowserPresenter.Content = GetContentBrowser(); ContentBrowserPresenter.Content = GetContentBrowser();
} }
private static MenuFlyoutItem CreateNewMenuItem(string name, MethodInfo methodInfo)
{
var menuItem = new MenuFlyoutItem { Text = name };
menuItem.Click += (s, e) =>
{
methodInfo.Invoke(null, null);
};
return menuItem;
}
private ContentBrowser GetContentBrowser() private ContentBrowser GetContentBrowser()
{ {
return _contentBrowser ??= new ContentBrowser(); return _contentBrowser ??= new ContentBrowser();

View File

@@ -1,5 +1,9 @@
<Solution> <Solution>
<Configurations> <Configurations>
<BuildType Name="Debug" />
<BuildType Name="Debug_Editor" />
<BuildType Name="Release" />
<BuildType Name="Release_Editor" />
<Platform Name="ARM64" /> <Platform Name="ARM64" />
<Platform Name="x64" /> <Platform Name="x64" />
<Platform Name="x86" /> <Platform Name="x86" />
@@ -9,12 +13,18 @@
<Project Path="Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj"> <Project Path="Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj">
<Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" /> <Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" /> <Platform Solution="Debug_Editor|x86" Project="x64" />
<Platform Solution="Debug|x86" Project="x86" />
<Platform Solution="Release_Editor|x86" Project="x64" />
<Platform Solution="Release|x86" Project="x86" />
</Project> </Project>
<Project Path="Editor/Ghost.Editor/Ghost.Editor.csproj"> <Project Path="Editor/Ghost.Editor/Ghost.Editor.csproj">
<Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" /> <Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" /> <Platform Solution="Debug_Editor|x86" Project="x64" />
<Platform Solution="Debug|x86" Project="x86" />
<Platform Solution="Release_Editor|x86" Project="x64" />
<Platform Solution="Release|x86" Project="x86" />
<Deploy /> <Deploy />
</Project> </Project>
</Folder> </Folder>
@@ -36,21 +46,14 @@
<Project Path="Runtime/Ghost.Graphics/Ghost.Graphics.csproj" /> <Project Path="Runtime/Ghost.Graphics/Ghost.Graphics.csproj" />
</Folder> </Folder>
<Folder Name="/Test/"> <Folder Name="/Test/">
<Project Path="Test/Ghost.Entities.Test/Ghost.Entities.Test.csproj" />
<Project Path="Test/Ghost.Graphics.Test/Ghost.Graphics.Test.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
<Deploy />
</Project>
<Project Path="Test/Ghost.MicroTest/Ghost.MicroTest.csproj" Id="8c8ffa4b-e1e4-46a1-9221-7b508a109edd" /> <Project Path="Test/Ghost.MicroTest/Ghost.MicroTest.csproj" Id="8c8ffa4b-e1e4-46a1-9221-7b508a109edd" />
<Project Path="Test/Ghost.Shader.Test/Ghost.Shader.Test.csproj" /> <Project Path="Test/Ghost.TestCore/Ghost.TestCore.csproj" />
<Project Path="Test/Ghost.Test.Core/Ghost.Test.Core.csproj" />
<Project Path="Test/Ghost.UnitTest/Ghost.UnitTest.csproj" Id="4da45668-456b-4dcc-acd8-6bfe154e6837"> <Project Path="Test/Ghost.UnitTest/Ghost.UnitTest.csproj" Id="4da45668-456b-4dcc-acd8-6bfe154e6837">
<Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" /> <Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" /> <Platform Solution="*|x86" Project="x86" />
<Deploy /> <Deploy Solution="Debug|*" />
<Deploy Solution="Release|*" />
</Project> </Project>
</Folder> </Folder>
<Folder Name="/Tools/"> <Folder Name="/Tools/">

View File

@@ -0,0 +1,45 @@
namespace Ghost.Core.Attributes;
/// <summary>
/// Marks a field as read-only in the inspector.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class ReadOnlyInInspectorAttribute : Attribute
{
}
/// <summary>
/// Hides a field from the inspector entirely.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class HideInInspectorAttribute : Attribute
{
}
/// <summary>
/// Overrides the display name for a field in the inspector.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class InspectorNameAttribute : Attribute
{
public string Name { get; }
public InspectorNameAttribute(string name)
{
Name = name;
}
}
/// <summary>
/// Groups fields under a collapsible header in the inspector.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class InspectorGroupAttribute : Attribute
{
public string GroupName { get; }
public InspectorGroupAttribute(string groupName)
{
GroupName = groupName;
}
}

Some files were not shown because too many files have changed in this diff Show More