50 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
b42398bbce Refactor asset handler system and catalog for safety
- Introduced AssetHandlerInfo struct for handler registration and lookup, enabling handler caching and decoupling instantiation from extension/type.
- Changed CustomAssetHandlerAttribute to use required named properties; updated source generator.
- Replaced HandlerTypeId with AssetTypeId throughout metadata, catalog, and sub-asset records for clarity.
- Refactored asset catalog to use connection pooling and local command creation for thread safety.
- Updated asset handler interfaces and implementations to align with new registration system and removed redundant properties.
- Migrated mesh import and meshlet building to async JobScheduler jobs; switched to TLSF allocator and improved safety checks.
- Made meshlet/LOD hierarchy building async and job-based with better memory management.
- Updated usages and tests for new APIs; refreshed project references and package versions.
- Improved documentation and code comments for clarity.
2026-05-08 11:50:06 +09:00
d052ca848f Refactor resource management and update project configs
- Use `using` for MeshNode disposal in MeshAssetHandler
- Switch to `ref` UnsafeList in meshlet hierarchy methods for perf
- Ensure proper disposal of UnsafeList<int> and TempBinaryNode
- Add launchSettings.json for Ghost.Editor.Core debugging
- Update GhostEngine.slnx with platform mappings for Editor.Core
- Remove MHP_ENABLE_SAFETY_CHECKS from Debug|AnyCPU in csproj
2026-05-07 00:23:51 +09:00
744b058e6a Refactor mesh asset handling and memory allocation
- Unified FBX/OBJ logic into MeshAssetHandler and moved mesh node classes to MeshNode.cs
- Updated IAssetHandler to use CreateDefaultSettings(string ext)
- Made MeshAsset the abstract base, removed FBXAsset
- Switched mesh import/processing to use memory pools and explicit AllocationHandle
- Standardized manifest serialization options
- Improved error handling and normalized project paths
- Updated tests, project files, and AssetReference struct
2026-05-05 21:12:15 +09:00
5de480e231 Refactor asset import API and mesh streaming pipeline
- Standardize IImportableAssetHandler.ImportAsync to return sub-asset results
- Remove ISubAssetImportableAssetHandler, merge into main interface
- Update FBX/Texture handlers for new import contract
- Add StreamUtility for efficient (async) binary writes
- Refactor meshlet/LOD building to use ref structs and safe memory
- Use new streaming utilities in mesh import/export and tests
- AssetCatalog.Remove now recursively deletes sub-assets
- Improve asset registry file watcher for better change detection
- Log unhandled exceptions in App instead of breaking
- Add interpolated collection support to NativeMemoryManager
- Update project references and fix minor bugs
2026-05-05 17:19:24 +09:00
8d3e1c91d7 Add sub-asset import and mesh asset support
- Implement sub-asset import for mesh/model assets with manifest generation and deterministic GUIDs
- Extend AssetCatalog for sub-asset tracking and management
- Update AssetRegistry and ImportCoordinator for sub-asset workflows
- Add mesh asset parsing, GPU upload, and resource management
- Update mesh data structures for meshlet groups/hierarchy and LODs
- Improve tests for sub-asset import and mesh handling
- Enhance mocks for mesh asset testing and resource mapping
- Fix path handling and native DLL loading issues
- Miscellaneous bug fixes and refactoring
2026-05-04 21:25:03 +09:00
bffe05f0ef Added LICENSE file. 2026-05-04 16:44:02 +09:00
220db828a0 Remove MHP_ENABLE_STACKTRACE from Debug constants 2026-05-03 17:06:43 +09:00
d2bf2f12a2 Refactor asset streaming, error handling, and unit tests
- Add new compile-time constants and update package versions
- Refactor AssetEntry upload logic to return Result and propagate errors
- Enhance error handling in ResourceStreamingProcessor uploads
- Make ResourceStreamingContext a readonly struct
- Implement IDisposable and finalizers for resource cleanup
- Overhaul AssetManagerTest with async tests and improved mocks
- Add mock implementations for graphics interfaces for testing
- Refactor MockingCommandBuffer and MockingResourceDatabase for better simulation
- Update internals visibility for unit testing
2026-05-03 17:05:52 +09:00
e7fedfd35a Update asset system for deferred allocation & add unit tests
Modernize Misaki.HighPerformance dependencies. Refactor texture asset creation to use deferred resource slots via CreateEmpty(). Remove fallback resource fields and update texture resolution logic. Add CreateEmpty() to resource database interfaces. Introduce comprehensive unit tests with mocks for asset management. Enable unsafe code in tests.
2026-05-02 22:54:58 +09:00
e384a2f38c feat(meshlet): add cluster LOD hierarchy & API upgrades
Implemented meshlet cluster LOD hierarchy with binary-to-4-ary conversion. Updated MeshletHierarchyNode to 4-ary structure. Enhanced SIMD optimizations in GGX mipmap generation. ResourceManager mesh/material creation now supports dynamic buffers and optional naming. Upgraded SPMD package to 1.3.2. Performed minor code cleanups and doc improvements.
2026-05-01 15:06:27 +09:00
0eaf7cd51d Refactor material palette system with GPU indirection
Major overhaul of material palette management:
- Added two-buffer indirection (PaletteOffsetBuffer, MaterialIndexBuffer) for GPU material lookup, with incremental upload and resizing.
- MaterialPaletteStore now tracks dirty ranges, supports deferred slot reclamation, and exposes CPU-side arrays for upload.
- ResourceManager manages persistent GPU buffers and uploads only dirty subranges per frame.
- Updated HLSL and C# structs to use palette indices.
- Refactored systems/components to use new palette index and release logic.
- Added RenderContext.UploadBufferRange for partial uploads.
Minor: Fixed StbIApi interop signatures, updated test namespaces, and performed code cleanups.
2026-04-28 18:22:09 +09:00
631638f3fb feat: implement material palette management and core mesh asset handling infrastructure 2026-04-27 22:55:55 +09:00
395 changed files with 26010 additions and 16595 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/

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright © 2026 Enjie Huang
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,7 +0,0 @@
Use this instructions when writing a git commit message
The first line should be a single line with no more than 50 characters that summary the changes. The second line should be blank. Start at the third line for actual changes.
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space. The type feat MUST be used when a commit adds a new feature to your application or library. The type fix MUST be used when a commit represents a bug fix for your application. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser) A description MUST immediately follow the colon and space after the typescope prefix. The description is a short summary of the code changes, e.g., fix array parsing issue when multiple spaces were contained in string. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description. A commit body is free-form and MAY consist of any number of newline separated paragraphs. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a or # separator, followed by a string value (this is inspired by the git trailer convention). A footers token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token. A footers value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer tokenseparator pair is observed. Breaking changes MUST be indicated in the typescope prefix of a commit, or as an entry in the footer. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE environment variables now take precedence over config files. If included in the typescope prefix, breaking changes MUST be indicated by a ! immediately before the . If ! is used, BREAKING CHANGE MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change. Types other than feat and fix MAY be used in your commit messages, e.g., docs update ref docs. The units of information that make up Conventional Commits MUST NOT be treated as case-sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.

17
src/Directory.Build.props Normal file
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,23 +0,0 @@
namespace Ghost.DSL;
public struct ShaderPropertyInfo
{
public string shaderName;
public string code;
public uint size;
}
public static class ShaderPropertiesRegistry
{
private static readonly Dictionary<string, ShaderPropertyInfo> s_nameToCode = new Dictionary<string, ShaderPropertyInfo>(StringComparer.Ordinal);
public static void Register(string name, string code, uint size)
{
s_nameToCode[name] = new ShaderPropertyInfo { shaderName = name, code = code, size = size };
}
public static bool TryGetInfo(string name, out ShaderPropertyInfo info)
{
return s_nameToCode.TryGetValue(name, out info);
}
}

View File

@@ -1,17 +1,38 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Engine; using Ghost.Engine.Streaming;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
[AttributeUsage(AttributeTargets.Class)] [AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetHandlerAttribute : Attribute public sealed class CustomAssetHandlerAttribute : Attribute
{ {
public CustomAssetHandlerAttribute(string assetTypeID, string[] supportedExtensions, int version = 1) public required string AssetTypeId
{ {
get; set;
} }
public required AssetType RuntimeAssetType
{
get; set;
}
public required string[] Extensions
{
get; set;
}
public int Version
{
get; set;
} = 1;
public bool AllowCaching
{
get; set;
} = true;
} }
public interface IAsset : IDisposable public abstract class IAsset : GhostObject
{ {
public Guid ID public Guid ID
{ {
@@ -27,16 +48,21 @@ public interface IAsset : IDisposable
{ {
get; get;
} }
protected IAsset(Guid id, Guid typeId, IAssetSettings? settings)
:base(id)
{
ID = id;
TypeID = typeId;
Settings = settings;
}
} }
public interface IAssetExportOptions; public interface IAssetExportOptions;
public interface IAssetHandler public interface IAssetHandler
{ {
AssetType RuntimeAssetType { get; } IAssetSettings? CreateDefaultSettings(string ext);
Guid EditorAssetTypeID { get; }
IAssetSettings? CreateDefaultSettings();
ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default); ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default); ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default);
@@ -44,11 +70,11 @@ public interface IAssetHandler
public interface IImportableAssetHandler : IAssetHandler public interface IImportableAssetHandler : IAssetHandler
{ {
bool CanExport { get; } ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default);
} }
public readonly record struct ImportedSubAsset(Guid Guid, string Kind, string DisplayName, string StablePath, string VirtualSourcePath, Guid AssetTypeId);
public interface IPackableAssetHandler : IAssetHandler public interface IPackableAssetHandler : IAssetHandler
{ {
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default); ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);

View File

@@ -1,36 +1,54 @@
using Ghost.Engine; using Ghost.Engine.Streaming;
using System.Collections.Concurrent;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
public readonly struct AssetHandlerInfo
{
public Type HandlerType { get; init; }
public AssetType RuntimeAssetType { get; init; }
public Guid EditorAssetTypeID { get; init; }
public int Version { get; init; }
}
public static class AssetHandlerRegistry public static class AssetHandlerRegistry
{ {
private static readonly Dictionary<string, IAssetHandler> s_byExtension; private static readonly Dictionary<string, AssetHandlerInfo> s_byExtension;
private static readonly Dictionary<string, AssetType> s_typeByExtension; private static readonly Dictionary<Guid, AssetHandlerInfo> s_byTypeId;
private static readonly Dictionary<Guid, IAssetHandler> s_byTypeId;
private static readonly Dictionary<Guid, int> s_versionByTypeId;
private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes; private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes;
private static readonly ConcurrentDictionary<Type, IAssetHandler?> s_handlerCache;
static AssetHandlerRegistry() static AssetHandlerRegistry()
{ {
s_byExtension = new Dictionary<string, IAssetHandler>(StringComparer.OrdinalIgnoreCase); s_byExtension = new Dictionary<string, AssetHandlerInfo>(StringComparer.OrdinalIgnoreCase);
s_typeByExtension = new Dictionary<string, AssetType>(StringComparer.OrdinalIgnoreCase); s_byTypeId = new Dictionary<Guid, AssetHandlerInfo>();
s_byTypeId = new Dictionary<Guid, IAssetHandler>();
s_versionByTypeId = new Dictionary<Guid, int>();
s_iAssetSettingsTypes = new List<(Type Type, string Name)>(); s_iAssetSettingsTypes = new List<(Type Type, string Name)>();
s_handlerCache = new ConcurrentDictionary<Type, IAssetHandler?>();
} }
public static void RegisterHandler(IAssetHandler handler, Guid assetTypeId, ReadOnlySpan<string> extensions, int version) public static void RegisterHandler(Type handlerType, Guid assetTypeId, AssetType runtimeAssetType, int version, bool allowCaching, params ReadOnlySpan<string> extensions)
{ {
s_byTypeId[assetTypeId] = handler; var info = new AssetHandlerInfo
s_versionByTypeId[assetTypeId] = version; {
HandlerType = handlerType,
RuntimeAssetType = runtimeAssetType,
EditorAssetTypeID = assetTypeId,
Version = version
};
s_byTypeId[assetTypeId] = info;
foreach (var ext in extensions) foreach (var ext in extensions)
{ {
var normalizedExt = ext.StartsWith('.') ? ext : "." + ext; var normalizedExt = ext.StartsWith('.') ? ext : "." + ext;
s_byExtension[normalizedExt] = handler; s_byExtension[normalizedExt] = info;
s_typeByExtension[normalizedExt] = handler.RuntimeAssetType; }
if (allowCaching)
{
s_handlerCache[handlerType] = null;
} }
} }
@@ -47,36 +65,59 @@ public static class AssetHandlerRegistry
} }
var normalized = extension.StartsWith('.') ? extension : "." + extension; var normalized = extension.StartsWith('.') ? extension : "." + extension;
s_byExtension.TryGetValue(normalized, out var handler); if (!s_byExtension.TryGetValue(normalized, out var info))
return handler; {
return null;
}
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
{
try
{
return (IAssetHandler?)Activator.CreateInstance(t);
}
catch
{
return null;
}
});
} }
public static IAssetHandler? GetByAssetTypeId(Guid typeId) public static IAssetHandler? GetByAssetTypeId(Guid typeId)
{ {
s_byTypeId.TryGetValue(typeId, out var handler); if (!s_byTypeId.TryGetValue(typeId, out var info))
return handler; {
return null;
}
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
{
try
{
return (IAssetHandler?)Activator.CreateInstance(t);
}
catch
{
return null;
}
});
} }
public static int GetVersionByAssetTypeId(Guid typeId) public static bool TryGetHandlerInfoByAssetTypeId(Guid typeId, out AssetHandlerInfo info)
{ {
s_versionByTypeId.TryGetValue(typeId, out var version); return s_byTypeId.TryGetValue(typeId, out info);
return version;
} }
public static IEnumerable<string> GetSupportedExtensions() public static bool TryGetHandlerInfoByExtension(string extension, out AssetHandlerInfo info)
{
return s_byExtension.Keys;
}
public static AssetType GetRuntimeAssetTypeByExtension(string extension)
{ {
if (string.IsNullOrEmpty(extension)) if (string.IsNullOrEmpty(extension))
{ {
return AssetType.Unknown; info = default;
return false;
} }
var normalized = extension.StartsWith('.') ? extension : "." + extension; var normalized = extension.StartsWith('.') ? extension : "." + extension;
return s_typeByExtension.GetValueOrDefault(normalized, AssetType.Unknown); return s_byExtension.TryGetValue(normalized, out info);
} }
public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes() public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes()

View File

@@ -24,9 +24,9 @@ public sealed class AssetMeta
public required Guid Guid { get; init; } public required Guid Guid { get; init; }
/// <summary> /// <summary>
/// The Guid that identifies which IAssetHandler processes this asset. /// The Guid that identifies type id of asset.
/// </summary> /// </summary>
public Guid? HandlerTypeId { get; set; } public Guid? AssetTypeId { get; set; }
/// <summary> /// <summary>
/// Version of the handler that last imported this asset. /// Version of the handler that last imported this asset.
@@ -69,7 +69,7 @@ internal static class AssetMetaIO
public const string META_EXTENSION_NAME = "gmeta"; public const string META_EXTENSION_NAME = "gmeta";
public const string META_EXTENSION = ".gmeta"; public const string META_EXTENSION = ".gmeta";
private static readonly JsonSerializerOptions s_options = new() internal static readonly JsonSerializerOptions s_options = new()
{ {
WriteIndented = true, WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -136,6 +136,7 @@ internal static class AssetMetaIO
} }
File.Move(tempPath, metaPath); File.Move(tempPath, metaPath);
} }
public static string GetMetaPath(string sourceFilePath) public static string GetMetaPath(string sourceFilePath)

View File

@@ -1,276 +0,0 @@
using Ghost.Core;
using Ghost.Engine;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Assets;
public class MeshNode : IDisposable
{
public required string Name
{
get; set;
}
public float4x4 LocalTransform
{
get; set;
}
public MeshNode? Parent
{
get; set;
}
public IReadOnlyCollection<MeshNode> Children
{
get; set;
} = Array.Empty<MeshNode>();
~MeshNode()
{
Dispose(false);
}
protected virtual void Dispose(bool disposing)
{
}
public void Dispose()
{
foreach (var child in Children)
{
child.Dispose();
}
Parent = null;
Children = Array.Empty<MeshNode>();
Dispose(true);
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Describes one material partition within a unified vertex/index buffer.
/// </summary>
public struct MaterialPartInfo
{
/// <summary> The material slot index (from ufbx face_material). </summary>
public int materialIndex;
/// <summary> Byte offset into the unified index buffer. </summary>
public int indexStart;
/// <summary> Number of indices belonging to this part. </summary>
public int indexCount;
/// <summary> Byte offset into the unified vertex buffer. </summary>
public int vertexStart;
/// <summary> Number of unique vertices belonging to this part. </summary>
public int vertexCount;
}
public class GeometryMeshNode : MeshNode
{
private UnsafeList<Vertex> _vertices;
private UnsafeList<uint> _indices;
private UnsafeArray<MaterialPartInfo> _materialParts;
public UnsafeList<Vertex> Vertices
{
get => _vertices;
set
{
_vertices.Dispose();
_vertices = value;
}
}
public UnsafeList<uint> Indices
{
get => _indices;
set
{
_indices.Dispose();
_indices = value;
}
}
public UnsafeArray<MaterialPartInfo> MaterialParts
{
get => _materialParts;
set
{
_materialParts.Dispose();
_materialParts = value;
}
}
protected override void Dispose(bool disposing)
{
_vertices.Dispose();
_indices.Dispose();
_materialParts.Dispose();
}
}
public class LightMeshNode : MeshNode
{
public float3 Color
{
get; set;
}
public float Intensity
{
get; set;
}
}
public abstract class MeshAsset : IAsset
{
private MeshNode _root;
public Guid ID
{
get;
}
public IAssetSettings Settings
{
get;
}
public Guid TypeID => typeof(MeshAsset).GUID;
public MeshNode Root
{
get => _root;
set
{
_root?.Dispose();
_root = value;
}
}
internal MeshAsset(MeshNode root, Guid id, MeshAssetSettings settings)
{
_root = root;
ID = id;
Settings = settings;
}
public void Dispose()
{
_root?.Dispose();
}
}
[Guid(GUID)]
public partial class FBXAsset : MeshAsset
{
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
internal FBXAsset(MeshNode root, Guid id, FbxAssetSettings settings)
: base(root, id, settings)
{
}
}
public enum CoordinateAxis
{
PositiveX,
PositiveY,
PositiveZ,
NegativeX,
NegativeY,
NegativeZ
}
public enum VertexDataSource
{
Imported,
Computed,
ComputedIfMissing
}
public class MeshAssetSettings : IAssetSettings
{
public VertexDataSource NormalDataSource
{
get; set;
} = VertexDataSource.ComputedIfMissing;
public VertexDataSource TangentDataSource
{
get; set;
} = VertexDataSource.ComputedIfMissing;
public bool BuildMeshlets
{
get; set;
} = true;
}
internal class ObjAssetSettings : MeshAssetSettings
{
public CoordinateAxis ObjectUpAxis
{
get; set;
} = CoordinateAxis.PositiveY;
public CoordinateAxis ObjectForwardAxis
{
get; set;
} = CoordinateAxis.NegativeZ;
public CoordinateAxis ObjectRightAxis
{
get; set;
} = CoordinateAxis.PositiveX;
public float UnitMeterScale
{
get; set;
} = 1.0f;
}
internal class FbxAssetSettings : MeshAssetSettings
{
}
internal class FBXAssetHandler : IImportableAssetHandler
{
public AssetType RuntimeAssetType => AssetType.Mesh;
public Guid EditorAssetTypeID => typeof(FBXAsset).GUID;
public bool CanExport => false;
public IAssetSettings? CreateDefaultSettings()
{
throw new NotImplementedException();
}
public ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,181 @@
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics;
namespace Ghost.Editor.Core.Assets;
public class MeshNode : IDisposable
{
public string Name
{
get; set;
} = string.Empty;
public float4x4 LocalTransform
{
get; set;
}
public MeshNode? Parent
{
get; set;
}
public IReadOnlyCollection<MeshNode> Children
{
get; set;
} = Array.Empty<MeshNode>();
~MeshNode()
{
Dispose(false);
}
public MeshNode Clone()
{
return (MeshNode)MemberwiseClone();
}
protected virtual void Dispose(bool disposing)
{
}
public void Dispose()
{
foreach (var child in Children)
{
child.Dispose();
}
Parent = null;
Children = Array.Empty<MeshNode>();
Dispose(true);
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Describes one material partition within a unified vertex/index buffer.
/// </summary>
public struct MaterialPartInfo
{
/// <summary> The material slot index (from ufbx face_material). </summary>
public int materialIndex;
/// <summary> Byte offset into the unified index buffer. </summary>
public int indexStart;
/// <summary> Number of indices belonging to this part. </summary>
public int indexCount;
/// <summary> Byte offset into the unified vertex buffer. </summary>
public int vertexStart;
/// <summary> Number of unique vertices belonging to this part. </summary>
public int vertexCount;
}
public class GeometryMeshNode : MeshNode
{
private UnsafeList<Vertex> _vertices;
private UnsafeList<uint> _indices;
private UnsafeArray<MaterialPartInfo> _materialParts;
public UnsafeList<Vertex> Vertices
{
get => _vertices;
set
{
_vertices.Dispose();
_vertices = value;
}
}
public UnsafeList<uint> Indices
{
get => _indices;
set
{
_indices.Dispose();
_indices = value;
}
}
public UnsafeArray<MaterialPartInfo> MaterialParts
{
get => _materialParts;
set
{
_materialParts.Dispose();
_materialParts = value;
}
}
protected override void Dispose(bool disposing)
{
_vertices.Dispose();
_indices.Dispose();
_materialParts.Dispose();
}
}
public class LightMeshNode : MeshNode
{
public float3 Color
{
get; set;
}
public float Intensity
{
get; set;
}
}
public sealed class ModelManifest
{
public Guid AssetId
{
get; set;
}
public ModelManifestNode Root
{
get; set;
} = new ModelManifestNode();
public List<ModelManifestSubAsset> Meshes
{
get; set;
} = new List<ModelManifestSubAsset>();
public List<ModelManifestMetadata> Metadata
{
get; set;
} = new List<ModelManifestMetadata>();
}
public sealed class ModelManifestNode
{
public string Name
{
get; set;
} = string.Empty;
public string StablePath
{
get; set;
} = string.Empty;
public float4x4 LocalTransform
{
get; set;
}
public Guid MeshGuid
{
get; set;
}
public List<ModelManifestNode> Children
{
get; set;
} = new List<ModelManifestNode>();
}

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;
@@ -15,7 +14,7 @@ using System.Text;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
internal readonly unsafe struct MeshParsingWorkItem : IJob internal unsafe class MeshParsingJob
{ {
private struct GeometryPart : IDisposable private struct GeometryPart : IDisposable
{ {
@@ -32,19 +31,24 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
} }
} }
private readonly MeshNode _rootNode;
private readonly string _filePath; private readonly string _filePath;
private readonly AllocationHandle _allocationHandle; private readonly AllocationHandle _allocationHandle;
private readonly MeshAssetSettings _settings; private readonly ModelAssetSettings _settings;
private readonly TaskCompletionSource<Result<MeshNode>> _taskCompletionSource;
public readonly Task<Result<MeshNode>> Task => _taskCompletionSource.Task; private readonly TaskCompletionSource<Result> _taskCompletionSource;
public MeshParsingWorkItem(string filePath, AllocationHandle allocationHandle, MeshAssetSettings settings) public Task<Result> Task => _taskCompletionSource.Task;
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, ModelAssetSettings settings)
{ {
_rootNode = rootNode;
_filePath = filePath; _filePath = filePath;
_allocationHandle = allocationHandle; _allocationHandle = allocationHandle;
_settings = settings; _settings = settings;
_taskCompletionSource = new TaskCompletionSource<Result<MeshNode>>();
_taskCompletionSource = new TaskCompletionSource<Result>(TaskCreationOptions.RunContinuationsAsynchronously);
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -81,19 +85,17 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
); );
} }
private MeshNode ParseHierarchy(ufbx_node* node) private void ParseHierarchy(ufbx_node* node, MeshNode self, AllocationHandle allocationHandle)
{ {
var children = new List<MeshNode>(); var children = new List<MeshNode>();
var meshNode = new MeshNode
{ self.Name = node->name.ToString();
Name = node->name.ToString(), self.LocalTransform = ToFloat4x4(node->local_transform.translation, node->local_transform.rotation, node->local_transform.scale);
LocalTransform = ToFloat4x4(node->local_transform.translation, node->local_transform.rotation, node->local_transform.scale), self.Children = children;
Children = children
};
if (node->mesh != null) if (node->mesh != null)
{ {
var geoNode = ParseGeometry(node->mesh); var geoNode = ParseGeometry(node->mesh, allocationHandle);
if (geoNode != null) if (geoNode != null)
{ {
children.Add(geoNode); children.Add(geoNode);
@@ -104,13 +106,15 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
for (var i = 0u; i < node->children.count; i++) for (var i = 0u; i < node->children.count; i++)
{ {
children.Add(ParseHierarchy(node->children.data[i])); var childNode = new MeshNode();
} ParseHierarchy(node->children.data[i], childNode, allocationHandle);
childNode.Parent = self;
return meshNode; children.Add(childNode);
}
} }
private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh) private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh, AllocationHandle allocationHandle)
{ {
if (pMesh->num_faces == 0) if (pMesh->num_faces == 0)
{ {
@@ -121,18 +125,18 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
// Bucket faces by material // Bucket faces by material
using var materialBuckets = new UnsafeArray<UnsafeList<Vertex>>(numMaterials, AllocationHandle.FreeList); using var materialBuckets = new UnsafeArray<UnsafeList<Vertex>>(numMaterials, allocationHandle);
using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, AllocationHandle.FreeList); using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, AllocationHandle.FreeList); using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
for (var i = 0; i < numMaterials; i++) for (var i = 0; i < numMaterials; i++)
{ {
materialBuckets[i] = new UnsafeList<Vertex>(1024, AllocationHandle.FreeList); materialBuckets[i] = new UnsafeList<Vertex>(10240, allocationHandle);
} }
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u); var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, AllocationHandle.FreeList); using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, allocationHandle);
for (var j = 0u; j < pMesh->num_faces; j++) for (var j = 0u; j < pMesh->num_faces; j++)
{ {
@@ -193,7 +197,7 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
// Per-material weld + optimize, collect intermediate results // Per-material weld + optimize, collect intermediate results
using var partResults = new UnsafeList<GeometryPart>(numMaterials, AllocationHandle.FreeList); using var partResults = new UnsafeList<GeometryPart>(numMaterials, allocationHandle);
for (var m = 0; m < numMaterials; m++) for (var m = 0; m < numMaterials; m++)
{ {
@@ -206,8 +210,8 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
var numIndices = (uint)flatVertices.Count; var numIndices = (uint)flatVertices.Count;
using var weldedIndices = new UnsafeArray<uint>((int)numIndices, AllocationHandle.FreeList); using var weldedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, AllocationHandle.FreeList); using var cachedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
var stream = new ufbx_vertex_stream var stream = new ufbx_vertex_stream
{ {
@@ -227,8 +231,8 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices); MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
// Allocate temporary per-part buffers (will be merged then disposed) // Allocate temporary per-part buffers (will be merged then disposed)
var partVertices = new UnsafeList<Vertex>((int)numUniqueVertices, AllocationHandle.FreeList); var partVertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocationHandle);
var partIndices = new UnsafeList<uint>((int)numIndices, AllocationHandle.FreeList); var partIndices = new UnsafeList<uint>((int)numIndices, allocationHandle);
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(partVertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex)); var finalVertexCount = MeshOptApi.OptimizeVertexFetch(partVertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
@@ -320,7 +324,7 @@ internal readonly unsafe struct MeshParsingWorkItem : 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
@@ -346,25 +350,27 @@ internal readonly unsafe struct MeshParsingWorkItem : IJob
load_Opts.obj_search_mtl_by_filename = true; load_Opts.obj_search_mtl_by_filename = true;
} }
using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(_filePath) + 1, AllocationHandle.FreeList); using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(_filePath) + 1, AllocationHandle.TLSF);
var count = Encoding.UTF8.GetBytes(_filePath, str.AsSpan()); var count = Encoding.UTF8.GetBytes(_filePath, str.AsSpan());
str[count] = 0; str[count] = 0;
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error)); using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
if (scene.Get() == null) if (scene.Get() == null)
{ {
_taskCompletionSource.SetResult(Result.Failure(error.description.ToString())); return Result.Failure(error.description.ToString());
return;
} }
var rootNode = ParseHierarchy(scene.Get()->root_node); ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
rootNode.Name = Path.GetFileNameWithoutExtension(_filePath);
_taskCompletionSource.SetResult(Result.Success(rootNode)); return Result.Success();
} }
} }
public partial class MeshProcessor internal static partial class MeshProcessor
{ {
public static Task<Result> ParseMeshAsync(MeshNode root, string sourcePath, AllocationHandle allocationHandle, ModelAssetSettings meshSettings, CancellationToken token = default)
{
var parseJob = new MeshParsingJob(root, sourcePath, allocationHandle, meshSettings);
return Task.Run(parseJob.Execute, token);
}
} }

View File

@@ -1,17 +1,20 @@
// Source: https://github.com/zeux/meshoptimizer/blob/master/demo/clusterlod.h // Source: https://github.com/zeux/meshoptimizer/blob/master/demo/clusterlod.h
// Translated from C++ to C#. // Translated from C++ to C#.
// TODO: This file should be moved to editor project since there is no reason we need to build meshlets and LOD at runtime.
using Ghost.Core; using Ghost.Core;
using Ghost.Graphics.Core; using Ghost.Graphics.Core;
using Ghost.Graphics.RHI; using Ghost.Graphics.RHI;
using Ghost.MeshOptimizer; using Ghost.MeshOptimizer;
using Misaki.HighPerformance.Jobs;
using Misaki.HighPerformance.LowLevel;
using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections; using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.LowLevel.Utilities;
using Misaki.HighPerformance.Mathematics; using Misaki.HighPerformance.Mathematics;
using Misaki.HighPerformance.Mathematics.Geometry; using Misaki.HighPerformance.Mathematics.Geometry;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using TerraFX.Interop.Windows;
namespace Ghost.Editor.Core.Assets; namespace Ghost.Editor.Core.Assets;
@@ -160,15 +163,10 @@ public unsafe struct ClodCluster
public nuint localIndexCount; public nuint localIndexCount;
} }
/// <summary> internal static unsafe partial class MeshProcessor
/// Delegate type for processing generated LOD groups.
/// </summary>
public unsafe delegate int ClodOutputDelegate(void* context, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters);
// FIX: UnsafeList and UnsafeArray are not same as std::vector.
public static unsafe partial class MeshProcessor
{ {
private delegate int ClodOutputDelegate(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters);
private static ClodBounds ComputeBounds(ref readonly ClodMesh mesh, UnsafeList<uint> indices, float error) private static ClodBounds ComputeBounds(ref readonly ClodMesh mesh, UnsafeList<uint> indices, float error)
{ {
var bounds = MeshOptApi.ComputeClusterBounds((uint*)indices.GetUnsafePtr(), (nuint)indices.Count, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride); var bounds = MeshOptApi.ComputeClusterBounds((uint*)indices.GetUnsafePtr(), (nuint)indices.Count, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
@@ -180,9 +178,9 @@ public static unsafe partial class MeshProcessor
}; };
} }
private static ClodBounds MergeBounds(UnsafeList<Cluster> clusters, UnsafeList<int> group) private static ClodBounds MergeBounds(UnsafeList<Cluster> clusters, UnsafeList<int> group, AllocationHandle allocationHandle)
{ {
using var boundsList = new UnsafeArray<ClodBounds>(group.Count, AllocationHandle.FreeList); using var boundsList = new UnsafeArray<ClodBounds>(group.Count, allocationHandle);
for (var j = 0; j < group.Count; j++) for (var j = 0; j < group.Count; j++)
{ {
boundsList[j] = (clusters[group[j]].bounds); boundsList[j] = (clusters[group[j]].bounds);
@@ -210,13 +208,13 @@ public static unsafe partial class MeshProcessor
}; };
} }
private static UnsafeList<Cluster> Clusterize(ref readonly ClodConfig config, ref readonly ClodMesh mesh, uint* indices, nuint indexCount) private static UnsafeList<Cluster> Clusterize(ref readonly ClodConfig config, ref readonly ClodMesh mesh, uint* indices, nuint indexCount, AllocationHandle allocationHandle)
{ {
var maxMeshlets = MeshOptApi.BuildMeshletsBound(indexCount, config.maxVertices, config.minTriangles); var maxMeshlets = MeshOptApi.BuildMeshletsBound(indexCount, config.maxVertices, config.minTriangles);
using var meshlets = new UnsafeArray<meshopt_Meshlet>((int)maxMeshlets, AllocationHandle.FreeList); using var meshlets = new UnsafeArray<meshopt_Meshlet>((int)maxMeshlets, allocationHandle);
using var meshletVertices = new UnsafeArray<uint>((int)indexCount, AllocationHandle.FreeList); using var meshletVertices = new UnsafeArray<uint>((int)indexCount, allocationHandle);
using var meshletTriangles = new UnsafeArray<byte>((int)indexCount, AllocationHandle.FreeList); using var meshletTriangles = new UnsafeArray<byte>((int)indexCount, allocationHandle);
var pMeshlets = (meshopt_Meshlet*)meshlets.GetUnsafePtr(); var pMeshlets = (meshopt_Meshlet*)meshlets.GetUnsafePtr();
var pMeshletVertices = (uint*)meshletVertices.GetUnsafePtr(); var pMeshletVertices = (uint*)meshletVertices.GetUnsafePtr();
@@ -244,7 +242,7 @@ public static unsafe partial class MeshProcessor
); );
} }
var clusters = new UnsafeList<Cluster>((int)meshletCount, AllocationHandle.FreeList); var clusters = new UnsafeList<Cluster>((int)meshletCount, allocationHandle);
for (nuint i = 0; i < meshletCount; i++) for (nuint i = 0; i < meshletCount; i++)
{ {
@@ -263,9 +261,9 @@ public static unsafe partial class MeshProcessor
var cluster = new Cluster var cluster = new Cluster
{ {
vertices = meshlet.vertex_count, vertices = meshlet.vertex_count,
indices = new UnsafeList<uint>((int)(meshlet.triangle_count * 3), AllocationHandle.FreeList), indices = new UnsafeList<uint>((int)(meshlet.triangle_count * 3), allocationHandle),
uniqueVertices = new UnsafeList<uint>((int)meshlet.vertex_count, AllocationHandle.FreeList), uniqueVertices = new UnsafeList<uint>((int)meshlet.vertex_count, allocationHandle),
localIndices = new UnsafeList<byte>((int)(meshlet.triangle_count * 3), AllocationHandle.FreeList), localIndices = new UnsafeList<byte>((int)(meshlet.triangle_count * 3), allocationHandle),
group = -1, group = -1,
refined = -1 refined = -1
}; };
@@ -332,12 +330,12 @@ public static unsafe partial class MeshProcessor
} }
} }
private static UnsafeList<UnsafeList<int>> Partition(ref readonly ClodConfig config, ref readonly ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> pending, UnsafeArray<uint> remap) private static UnsafeList<UnsafeList<int>> Partition(ref readonly ClodConfig config, ref readonly ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> pending, UnsafeArray<uint> remap, AllocationHandle allocationHandle)
{ {
if (pending.Count <= (int)config.partitionSize) if (pending.Count <= (int)config.partitionSize)
{ {
var single = new UnsafeList<UnsafeList<int>>(1, AllocationHandle.FreeList); var single = new UnsafeList<UnsafeList<int>>(1, allocationHandle);
var pendingcpy = new UnsafeList<int>(pending.Count, AllocationHandle.FreeList); var pendingcpy = new UnsafeList<int>(pending.Count, allocationHandle);
pendingcpy.AddRange(pending.AsSpan()); pendingcpy.AddRange(pending.AsSpan());
single.Add(pendingcpy); single.Add(pendingcpy);
@@ -351,8 +349,8 @@ public static unsafe partial class MeshProcessor
totalIndexCount += (nuint)clusters[pending[i]].indices.Count; totalIndexCount += (nuint)clusters[pending[i]].indices.Count;
} }
using var clusterIndices = new UnsafeList<uint>((int)totalIndexCount, AllocationHandle.FreeList); using var clusterIndices = new UnsafeList<uint>((int)totalIndexCount, allocationHandle);
using var clusterCounts = new UnsafeList<uint>(pending.Count, AllocationHandle.FreeList); using var clusterCounts = new UnsafeList<uint>(pending.Count, allocationHandle);
nuint offset = 0; nuint offset = 0;
for (var i = 0; i < pending.Count; i++) for (var i = 0; i < pending.Count; i++)
@@ -367,7 +365,7 @@ public static unsafe partial class MeshProcessor
offset += (nuint)cluster.indices.Count; offset += (nuint)cluster.indices.Count;
} }
using var clusterPart = new UnsafeArray<uint>(pending.Count, AllocationHandle.FreeList); using var clusterPart = new UnsafeArray<uint>(pending.Count, allocationHandle);
var partitionCount = MeshOptApi.PartitionClusters( var partitionCount = MeshOptApi.PartitionClusters(
(uint*)clusterPart.GetUnsafePtr(), (uint*)clusterPart.GetUnsafePtr(),
@@ -381,10 +379,10 @@ public static unsafe partial class MeshProcessor
config.partitionSize config.partitionSize
); );
var partitions = new UnsafeList<UnsafeList<int>>((int)partitionCount, AllocationHandle.FreeList); var partitions = new UnsafeList<UnsafeList<int>>((int)partitionCount, allocationHandle);
for (nuint i = 0; i < partitionCount; i++) for (nuint i = 0; i < partitionCount; i++)
{ {
partitions.Add(new UnsafeList<int>((int)(config.partitionSize + config.partitionSize / 3), AllocationHandle.FreeList)); partitions.Add(new UnsafeList<int>((int)(config.partitionSize + config.partitionSize / 3), allocationHandle));
} }
for (var i = 0; i < pending.Count; i++) for (var i = 0; i < pending.Count; i++)
@@ -395,9 +393,12 @@ public static unsafe partial class MeshProcessor
return partitions; return partitions;
} }
private static int OutputGroup(ref readonly ClodConfig config, ref readonly ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> group, ClodBounds simplified, int depth, void* outputContext, ClodOutputDelegate? outputCallback) private static int OutputGroup(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
UnsafeList<Cluster> clusters, UnsafeList<int> group, ClodBounds simplified, int depth,
MeshletContext outputContext, ClodOutputDelegate? outputCallback,
AllocationHandle allocationHandle)
{ {
using var groupClusters = new UnsafeList<ClodCluster>(group.Count, AllocationHandle.FreeList); using var groupClusters = new UnsafeList<ClodCluster>(group.Count, allocationHandle);
for (var i = 0; i < group.Count; i++) for (var i = 0; i < group.Count; i++)
{ {
@@ -431,10 +432,10 @@ public static unsafe partial class MeshProcessor
public uint id; public uint id;
} }
private static void SimplifyFallback(ref UnsafeArray<uint> lod, ref readonly ClodMesh mesh, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint target_count, float* error) private static void SimplifyFallback(ref UnsafeArray<uint> lod, ref readonly ClodMesh mesh, ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint target_count, float* error, AllocationHandle allocationHandle)
{ {
using var subset = new UnsafeArray<SloppyVertex>(indices.Count, AllocationHandle.FreeList); using var subset = new UnsafeArray<SloppyVertex>(indices.Count, allocationHandle);
using var subset_locks = new UnsafeArray<byte>(indices.Count, AllocationHandle.FreeList); using var subset_locks = new UnsafeArray<byte>(indices.Count, allocationHandle);
lod.Resize(indices.Count); lod.Resize(indices.Count);
@@ -468,9 +469,11 @@ public static unsafe partial class MeshProcessor
} }
} }
public static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint targetCount, float* error) private static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint targetCount, float* error,
AllocationHandle allocationHandle)
{ {
var lod = new UnsafeArray<uint>(indices.Count, AllocationHandle.FreeList); var lod = new UnsafeArray<uint>(indices.Count, allocationHandle);
if (targetCount >= (nuint)indices.Count) if (targetCount >= (nuint)indices.Count)
{ {
@@ -535,7 +538,7 @@ public static unsafe partial class MeshProcessor
if ((nuint)lod.Length > targetCount && config.simplifyFallbackSloppy) if ((nuint)lod.Length > targetCount && config.simplifyFallbackSloppy)
{ {
SimplifyFallback(ref lod, in mesh, indices, locks, targetCount, error); SimplifyFallback(ref lod, in mesh, indices, locks, targetCount, error, allocationHandle);
*error *= config.simplifyErrorFactorSloppy; *error *= config.simplifyErrorFactorSloppy;
} }
@@ -579,12 +582,12 @@ public static unsafe partial class MeshProcessor
/// <param name="outputContext">Optional context pointer passed to the output callback.</param> /// <param name="outputContext">Optional context pointer passed to the output callback.</param>
/// <param name="outputCallback">Delegate invoked for each generated LOD group.</param> /// <param name="outputCallback">Delegate invoked for each generated LOD group.</param>
/// <returns>The total count of generated clusters.</returns> /// <returns>The total count of generated clusters.</returns>
public static nuint Build(ref readonly ClodConfig config, ref readonly ClodMesh mesh, void* outputContext, ClodOutputDelegate? outputCallback) private static nuint Build(ref readonly ClodConfig config, ref readonly ClodMesh mesh, MeshletContext outputContext, ClodOutputDelegate? outputCallback)
{ {
Logger.DebugAssert(mesh.vertexAttributesStride % sizeof(float) == 0, "vertexAttributesStride must be a multiple of sizeof(float)"); Logger.DebugAssert(mesh.vertexAttributesStride % sizeof(float) == 0, "vertexAttributesStride must be a multiple of sizeof(float)");
using var locks = new UnsafeArray<byte>((int)mesh.vertexCount, AllocationHandle.FreeList, AllocationOption.Clear); ; using var locks = new UnsafeArray<byte>((int)mesh.vertexCount, AllocationHandle.TLSF, AllocationOption.Clear); ;
using var remap = new UnsafeArray<uint>((int)mesh.vertexCount, AllocationHandle.FreeList); using var remap = new UnsafeArray<uint>((int)mesh.vertexCount, AllocationHandle.TLSF);
MeshOptApi.GeneratePositionRemap((uint*)remap.GetUnsafePtr(), mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride); MeshOptApi.GeneratePositionRemap((uint*)remap.GetUnsafePtr(), mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
@@ -607,14 +610,14 @@ public static unsafe partial class MeshProcessor
} }
} }
using var clusters = Clusterize(in config, in mesh, mesh.indices, mesh.indexCount); using var clusters = Clusterize(in config, in mesh, mesh.indices, mesh.indexCount, AllocationHandle.TLSF);
for (var i = 0; i < clusters.Count; i++) for (var i = 0; i < clusters.Count; i++)
{ {
clusters[i].bounds = ComputeBounds(in mesh, clusters[i].indices, 0.0f); clusters[i].bounds = ComputeBounds(in mesh, clusters[i].indices, 0.0f);
} }
using var pending = new UnsafeList<int>(clusters.Count, AllocationHandle.FreeList); using var pending = new UnsafeList<int>(clusters.Count, AllocationHandle.TLSF);
for (var i = 0; i < clusters.Count; i++) for (var i = 0; i < clusters.Count; i++)
{ {
pending.Add(i); pending.Add(i);
@@ -624,14 +627,14 @@ public static unsafe partial class MeshProcessor
while (pending.Count > 1) while (pending.Count > 1)
{ {
using var groups = Partition(in config, in mesh, clusters, pending, remap); using var groups = Partition(in config, in mesh, clusters, pending, remap, AllocationHandle.TLSF);
pending.Clear(); pending.Clear();
LockBoundary(locks, groups, clusters, remap, mesh.vertexLock); LockBoundary(locks, groups, clusters, remap, mesh.vertexLock);
for (var i = 0; i < groups.Count; i++) for (var i = 0; i < groups.Count; i++)
{ {
using var merged = new UnsafeList<uint>(groups[i].Count * (int)config.maxTriangles * 3, AllocationHandle.FreeList); using var merged = new UnsafeList<uint>(groups[i].Count * (int)config.maxTriangles * 3, AllocationHandle.TLSF);
for (var j = 0; j < groups[i].Count; j++) for (var j = 0; j < groups[i].Count; j++)
{ {
var clusterIndices = clusters[groups[i][j]].indices; var clusterIndices = clusters[groups[i][j]].indices;
@@ -639,28 +642,28 @@ public static unsafe partial class MeshProcessor
} }
var targetSize = (nuint)(merged.Count / 3 * config.simplifyRatio * 3.0f); var targetSize = (nuint)(merged.Count / 3 * config.simplifyRatio * 3.0f);
var bounds = MergeBounds(clusters, groups[i]); var bounds = MergeBounds(clusters, groups[i], AllocationHandle.TLSF);
var error = 0.0f; var error = 0.0f;
using var simplified = Simplify(in config, in mesh, merged.AsReadOnly(), locks.AsReadOnly(), targetSize, &error); using var simplified = Simplify(in config, in mesh, merged.AsReadOnly(), locks.AsReadOnly(), targetSize, &error, AllocationHandle.TLSF);
if ((nuint)simplified.Length > (nuint)(merged.Count * config.simplifyThreshold)) if ((nuint)simplified.Length > (nuint)(merged.Count * config.simplifyThreshold))
{ {
bounds.error = float.MaxValue; bounds.error = float.MaxValue;
OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback); OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback, AllocationHandle.TLSF);
continue; continue;
} }
bounds.error = Math.Max(bounds.error * config.simplifyErrorMergePrevious, error) + error * config.simplifyErrorMergeAdditive; bounds.error = Math.Max(bounds.error * config.simplifyErrorMergePrevious, error) + error * config.simplifyErrorMergeAdditive;
var refined = OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback); var refined = OutputGroup(in config, in mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback, AllocationHandle.TLSF);
for (var j = 0; j < groups[i].Count; j++) for (var j = 0; j < groups[i].Count; j++)
{ {
clusters[groups[i][j]].Dispose(); clusters[groups[i][j]].Dispose();
} }
using var split = Clusterize(in config, in mesh, (uint*)simplified.GetUnsafePtr(), (nuint)simplified.Length); using var split = Clusterize(in config, in mesh, (uint*)simplified.GetUnsafePtr(), (nuint)simplified.Length, AllocationHandle.TLSF);
for (var j = 0; j < split.Count; j++) for (var j = 0; j < split.Count; j++)
{ {
split[j].refined = refined; split[j].refined = refined;
@@ -682,7 +685,7 @@ public static unsafe partial class MeshProcessor
{ {
var bounds = clusters[pending[0]].bounds; var bounds = clusters[pending[0]].bounds;
bounds.error = float.MaxValue; bounds.error = float.MaxValue;
OutputGroup(in config, in mesh, clusters, pending, bounds, depth, outputContext, outputCallback); OutputGroup(in config, in mesh, clusters, pending, bounds, depth, outputContext, outputCallback, AllocationHandle.TLSF);
} }
var finalClusterCount = (nuint)clusters.Count; var finalClusterCount = (nuint)clusters.Count;
@@ -701,28 +704,42 @@ public static unsafe partial class MeshProcessor
public int materialIndex; public int materialIndex;
} }
private static int MeshletOutputCallback(void* contextPtr, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters) private static int MeshletOutputCallback(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters)
{ {
var context = (MeshletContext*)contextPtr; var meshletData = context.data;
var pMeshletData = context->data; var materialIndex = context.materialIndex;
var materialIndex = context->materialIndex;
// Ensure lists are initialized // Ensure lists are initialized
if (!pMeshletData->groups.IsCreated) pMeshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.Persistent); if (!meshletData->groups.IsCreated)
if (!pMeshletData->meshlets.IsCreated) pMeshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.Persistent); {
if (!pMeshletData->meshletVertices.IsCreated) pMeshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.Persistent); meshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.TLSF);
if (!pMeshletData->meshletTriangles.IsCreated) pMeshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.Persistent); }
if (!meshletData->meshlets.IsCreated)
{
meshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.TLSF);
}
if (!meshletData->meshletVertices.IsCreated)
{
meshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.TLSF);
}
if (!meshletData->meshletTriangles.IsCreated)
{
meshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.TLSF);
}
var meshletGroup = new MeshletGroup var meshletGroup = new MeshletGroup
{ {
boundingSphere = new SphereBounds(group.simplified.center, group.simplified.radius), boundingSphere = new SphereBounds(group.simplified.center, group.simplified.radius),
boundingBox = new AABB(group.simplified.center - group.simplified.radius, group.simplified.center + group.simplified.radius), boundingBox = new AABB(group.simplified.center - group.simplified.radius, group.simplified.center + group.simplified.radius),
parentError = group.simplified.error, parentError = group.simplified.error,
meshletStartIndex = (uint)pMeshletData->meshlets.Count, meshletStartIndex = (uint)meshletData->meshlets.Count,
meshletCount = (uint)clusters.Count, meshletCount = (uint)clusters.Count,
lodLevel = (uint)group.depth lodLevel = (uint)group.depth
}; };
pMeshletData->groups.Add(meshletGroup); meshletData->groups.Add(meshletGroup);
for (var i = 0; i < clusters.Count; i++) for (var i = 0; i < clusters.Count; i++)
{ {
@@ -735,20 +752,20 @@ public static unsafe partial class MeshProcessor
boundingBox = new AABB(cluster.bounds.center - cluster.bounds.radius, cluster.bounds.center + cluster.bounds.radius), boundingBox = new AABB(cluster.bounds.center - cluster.bounds.radius, cluster.bounds.center + cluster.bounds.radius),
vertexCount = (byte)cluster.vertexCount, vertexCount = (byte)cluster.vertexCount,
triangleCount = (byte)(cluster.localIndexCount / 3), triangleCount = (byte)(cluster.localIndexCount / 3),
vertexOffset = (uint)pMeshletData->meshletVertices.Count, vertexOffset = (uint)meshletData->meshletVertices.Count,
triangleOffset = (uint)pMeshletData->meshletTriangles.Count, triangleOffset = (uint)meshletData->meshletTriangles.Count,
groupIndex = (uint)pMeshletData->groups.Count - 1, groupIndex = (uint)meshletData->groups.Count - 1,
clusterError = cluster.bounds.error, clusterError = cluster.bounds.error,
parentError = group.simplified.error, parentError = group.simplified.error,
localMaterialIndex = (byte)materialIndex, localMaterialIndex = (byte)materialIndex,
lodLevel = (byte)group.depth, lodLevel = (byte)group.depth,
}; };
pMeshletData->meshlets.Add(meshlet); meshletData->meshlets.Add(meshlet);
// Add unique vertices // Add unique vertices
for (nuint j = 0; j < cluster.vertexCount; j++) for (nuint j = 0; j < cluster.vertexCount; j++)
{ {
pMeshletData->meshletVertices.Add(cluster.uniqueVertices[j]); meshletData->meshletVertices.Add(cluster.uniqueVertices[j]);
} }
// Add local triangles (packed into uints) // Add local triangles (packed into uints)
var triangleCount = cluster.localIndexCount / 3; var triangleCount = cluster.localIndexCount / 3;
@@ -758,11 +775,28 @@ public static unsafe partial class MeshProcessor
uint i1 = cluster.localIndices[j * 3 + 1]; uint i1 = cluster.localIndices[j * 3 + 1];
uint i2 = cluster.localIndices[j * 3 + 2]; uint i2 = cluster.localIndices[j * 3 + 2];
var packedTriangle = i0 | (i1 << 8) | (i2 << 16); var packedTriangle = i0 | (i1 << 8) | (i2 << 16);
pMeshletData->meshletTriangles.Add(packedTriangle); meshletData->meshletTriangles.Add(packedTriangle);
} }
} }
return 0; return meshletData->groups.Count - 1;
}
}
internal static partial class MeshProcessor
{
private class MeshletBuildJob
{
public ClodConfig clodConfig;
public ClodMesh clodMesh;
public MeshletContext context;
public void Execute()
{
Build(in clodConfig, in clodMesh, context, MeshletOutputCallback);
}
} }
/// <summary> /// <summary>
@@ -770,9 +804,8 @@ public static unsafe partial class MeshProcessor
/// Each <see cref="MaterialPartInfo"/> describes a material partition's index range within the unified buffer. /// Each <see cref="MaterialPartInfo"/> describes a material partition's index range within the unified buffer.
/// Meshlets are built per-part and tagged with the corresponding <c>localMaterialIndex</c>. /// Meshlets are built per-part and tagged with the corresponding <c>localMaterialIndex</c>.
/// </summary> /// </summary>
public static void BuildMeshlets(MeshletMeshData* pMeshletData, ReadOnlyUnsafeCollection<Vertex> vertices, ReadOnlyUnsafeCollection<uint> indices, ReadOnlySpan<MaterialPartInfo> parts) public static async Task<DisposablePtr<MeshletMeshData>> BuildMeshletsAsync(ReadOnlyView<Vertex> vertices, ReadOnlyView<uint> indices, ReadOnlyView<MaterialPartInfo> parts, CancellationToken token)
{ {
Logger.DebugAssert(pMeshletData->meshletCount == 0, "Meshlet data is not empty.");
Logger.DebugAssert(vertices.Count > 0, "Mesh must have vertices to build meshlets."); Logger.DebugAssert(vertices.Count > 0, "Mesh must have vertices to build meshlets.");
Logger.DebugAssert(indices.Count > 0, "Mesh must have indices to build meshlets."); Logger.DebugAssert(indices.Count > 0, "Mesh must have indices to build meshlets.");
Logger.DebugAssert(parts.Length > 0, "Must have at least one material part."); Logger.DebugAssert(parts.Length > 0, "Must have at least one material part.");
@@ -801,57 +834,417 @@ public static unsafe partial class MeshProcessor
simplifyFallbackSloppy = true, simplifyFallbackSloppy = true,
}; };
for (var i = 0; i < parts.Length; i++) IntPtr meshletData;
unsafe
{ {
ref readonly var part = ref parts[i]; // NOTE: We use NativeMemory here instead of MemoryUtility (use mimalloc internally) because this is a async method and may run a random thread pool thread which never dies.
// This will case mimalloc to allocate new heaps that hardly ever get freed, leading to memory bloat. Using NativeMemory ensures that we use the shared heap which doesn't have this issue.
// Each part references a slice of the global index buffer, meshletData = (IntPtr)NativeMemory.AllocZeroed(MemoryUtility.SizeOf<MeshletMeshData>());
// but vertex positions are the full unified buffer so global indices remain valid.
var clodMesh = new ClodMesh
{
vertexPositions = (float*)Unsafe.AsPointer(in vertices[0].position),
vertexCount = (nuint)vertices.Count,
vertexPositionsStride = (nuint)sizeof(Vertex),
vertexAttributes = (float*)Unsafe.AsPointer(in vertices[0].normal),
vertexAttributesStride = (nuint)sizeof(Vertex),
indices = (uint*)indices.GetUnsafePtr() + part.indexStart,
indexCount = (nuint)part.indexCount,
attributeProtectMask = 0, // TODO: Protect UVs at material boundaries.
};
var context = new MeshletContext
{
data = pMeshletData,
materialIndex = part.materialIndex
};
Build(in config, in clodMesh, &context, MeshletOutputCallback);
} }
pMeshletData->meshletCount = pMeshletData->meshlets.IsCreated ? pMeshletData->meshlets.Count : 0; try
if (pMeshletData->groups.IsCreated && pMeshletData->groups.Count > 0)
{ {
var maxLodLevel = 0u; for (var i = 0; i < parts.Length; i++)
for (var j = 0; j < pMeshletData->groups.Count; j++)
{ {
maxLodLevel = Math.Max(maxLodLevel, pMeshletData->groups[j].lodLevel); ref readonly var part = ref parts[i];
MeshletBuildJob job;
unsafe
{
// Each part references a slice of the global index buffer,
// but vertex positions are the full unified buffer so global indices remain valid.
var clodMesh = new ClodMesh
{
vertexPositions = (float*)Unsafe.AsPointer(in vertices[0].position),
vertexCount = (nuint)vertices.Count,
vertexPositionsStride = (nuint)sizeof(Vertex),
vertexAttributes = (float*)Unsafe.AsPointer(in vertices[0].normal),
vertexAttributesStride = (nuint)sizeof(Vertex),
indices = (uint*)indices.GetUnsafePtr() + part.indexStart,
indexCount = (nuint)part.indexCount,
attributeProtectMask = 0, // TODO: Protect UVs at material boundaries.
};
var context = new MeshletContext
{
data = (MeshletMeshData*)meshletData,
materialIndex = part.materialIndex
};
job = new MeshletBuildJob
{
clodConfig = config,
clodMesh = clodMesh,
context = context
};
}
await Task.Run(job.Execute, token);
} }
pMeshletData->lodLevelCount = (int)maxLodLevel + 1; unsafe
} {
var pMeshletData = (MeshletMeshData*)meshletData;
pMeshletData->meshletCount = pMeshletData->meshlets.IsCreated ? pMeshletData->meshlets.Count : 0;
var maxMaterialSlot = 0; if (pMeshletData->groups.IsCreated && pMeshletData->groups.Count > 0)
for (var j = 0; j < parts.Length; j++) {
var maxLodLevel = 0u;
for (var j = 0; j < pMeshletData->groups.Count; j++)
{
maxLodLevel = Math.Max(maxLodLevel, pMeshletData->groups[j].lodLevel);
}
pMeshletData->lodLevelCount = (int)maxLodLevel + 1;
}
var maxMaterialSlot = 0;
for (var j = 0; j < parts.Length; j++)
{
maxMaterialSlot = Math.Max(maxMaterialSlot, parts[j].materialIndex);
}
pMeshletData->materialSlotCount = maxMaterialSlot + 1;
return new DisposablePtr<MeshletMeshData>(pMeshletData);
}
}
catch
{ {
maxMaterialSlot = Math.Max(maxMaterialSlot, parts[j].materialIndex); unsafe
} {
NativeMemory.Free((void*)meshletData);
}
pMeshletData->materialSlotCount = maxMaterialSlot + 1; throw;
}
} }
public static void BuildClusterLodHierarchy() private struct TempBinaryNode
{ {
// TODO: Implement a function that builds a cluster LOD hierarchy for a mesh, which can be used for efficient rendering of large meshes with varying levels of detail. public AABB bounds;
public float maxParentError;
public int leftChild;
public int rightChild;
public int meshletIndex;
}
private static int BuildBinaryTree(ref UnsafeList<TempBinaryNode> nodes, UnsafeArray<int> meshletIndices, int start, int end, ReadOnlySpan<Meshlet> meshlets)
{
if (start == end - 1)
{
var meshletIndex = meshletIndices[start];
ref readonly var m = ref meshlets[meshletIndex];
var node = new TempBinaryNode
{
bounds = m.boundingBox,
maxParentError = m.parentError,
leftChild = -1,
rightChild = -1,
meshletIndex = meshletIndex
};
var nodeIndex = nodes.Count;
nodes.Add(node);
return nodeIndex;
}
// Compute centroid bounds
var centroidMin = new float3(float.MaxValue);
var centroidMax = new float3(float.MinValue);
for (var i = start; i < end; i++)
{
var m = meshlets[meshletIndices[i]];
var center = m.boundingBox.Center;
centroidMin = math.min(centroidMin, center);
centroidMax = math.max(centroidMax, center);
}
var extents = centroidMax - centroidMin;
var splitAxis = 0;
if (extents.y > extents.x && extents.y > extents.z)
{
splitAxis = 1;
}
if (extents.z > extents.x && extents.z > extents.y)
{
splitAxis = 2;
}
var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f;
// Partition
var mid = start;
for (var i = start; i < end; i++)
{
var center = meshlets[meshletIndices[i]].boundingBox.Center;
if (center[splitAxis] < splitPoint)
{
var temp = meshletIndices[mid];
meshletIndices[mid] = meshletIndices[i];
meshletIndices[i] = temp;
mid++;
}
}
if (mid == start || mid == end)
{
mid = start + (end - start) / 2;
}
var left = BuildBinaryTree(ref nodes, meshletIndices, start, mid, meshlets);
var right = BuildBinaryTree(ref nodes, meshletIndices, mid, end, meshlets);
var leftNode = nodes[left];
var rightNode = nodes[right];
var mergedBounds = new AABB(
math.min(leftNode.bounds.Min, rightNode.bounds.Min),
math.max(leftNode.bounds.Max, rightNode.bounds.Max)
);
var internalNodeIndex = nodes.Count;
nodes.Add(new TempBinaryNode
{
bounds = mergedBounds,
maxParentError = Math.Max(leftNode.maxParentError, rightNode.maxParentError),
leftChild = left,
rightChild = right,
meshletIndex = -1
});
return internalNodeIndex;
}
private static void GatherChildren(UnsafeList<TempBinaryNode> binaryNodes, int nodeIndex, ref UnsafeList<int> gathered)
{
gathered.Clear();
var node = binaryNodes[nodeIndex];
if (node.leftChild != -1)
{
gathered.Add(node.leftChild);
}
if (node.rightChild != -1)
{
gathered.Add(node.rightChild);
}
while (gathered.Count < 4)
{
var largestInternalIndex = -1;
var maxSurfaceArea = -1.0f;
var listIndexToRemove = -1;
for (var i = 0; i < gathered.Count; i++)
{
var childIdx = gathered[i];
var childNode = binaryNodes[childIdx];
if (childNode.leftChild != -1) // is internal
{
var extents = childNode.bounds.Extents;
var sa = extents.x * extents.y + extents.y * extents.z + extents.z * extents.x;
if (sa > maxSurfaceArea)
{
maxSurfaceArea = sa;
largestInternalIndex = childIdx;
listIndexToRemove = i;
}
}
}
if (largestInternalIndex == -1)
{
break; // all gathered are leaves
}
gathered.RemoveAt(listIndexToRemove);
var largestNode = binaryNodes[largestInternalIndex];
if (largestNode.leftChild != -1)
{
gathered.Add(largestNode.leftChild);
}
if (largestNode.rightChild != -1)
{
gathered.Add(largestNode.rightChild);
}
}
}
private static int CollapseTo4Ary(UnsafeList<TempBinaryNode> binaryNodes, int binaryNodeIndex, UnsafeList<MeshletHierarchyNode> hierarchyNodes)
{
var node = binaryNodes[binaryNodeIndex];
if (node.leftChild == -1)
{
return -1;
}
var scope = AllocationManager.CreateStackScope();
var gathered = new UnsafeList<int>(4, scope.AllocationHandle);
try
{
GatherChildren(binaryNodes, binaryNodeIndex, ref gathered);
var bvhNode = new MeshletHierarchyNode();
var minX = new float4(float.PositiveInfinity);
var minY = new float4(float.PositiveInfinity);
var minZ = new float4(float.PositiveInfinity);
var maxX = new float4(float.NegativeInfinity);
var maxY = new float4(float.NegativeInfinity);
var maxZ = new float4(float.NegativeInfinity);
var maxParentError = new float4(0);
var nodeData = new uint4(0xFFFFFFFF);
var outNodeIndex = hierarchyNodes.Count;
hierarchyNodes.Add(bvhNode); // Reserve slot
for (var i = 0; i < gathered.Count; i++)
{
var childIdx = gathered[i];
var childNode = binaryNodes[childIdx];
uint data = 0;
if (childNode.leftChild == -1)
{
data = (uint)childNode.meshletIndex;
}
else
{
var child4AryIndex = CollapseTo4Ary(binaryNodes, childIdx, hierarchyNodes);
data = (1u << 31) | (uint)child4AryIndex;
}
if (i == 0)
{
minX.x = childNode.bounds.Min.x; minY.x = childNode.bounds.Min.y; minZ.x = childNode.bounds.Min.z;
maxX.x = childNode.bounds.Max.x; maxY.x = childNode.bounds.Max.y; maxZ.x = childNode.bounds.Max.z;
maxParentError.x = childNode.maxParentError;
nodeData.x = data;
}
else if (i == 1)
{
minX.y = childNode.bounds.Min.x; minY.y = childNode.bounds.Min.y; minZ.y = childNode.bounds.Min.z;
maxX.y = childNode.bounds.Max.x; maxY.y = childNode.bounds.Max.y; maxZ.y = childNode.bounds.Max.z;
maxParentError.y = childNode.maxParentError;
nodeData.y = data;
}
else if (i == 2)
{
minX.z = childNode.bounds.Min.x; minY.z = childNode.bounds.Min.y; minZ.z = childNode.bounds.Min.z;
maxX.z = childNode.bounds.Max.x; maxY.z = childNode.bounds.Max.y; maxZ.z = childNode.bounds.Max.z;
maxParentError.z = childNode.maxParentError;
nodeData.z = data;
}
else if (i == 3)
{
minX.w = childNode.bounds.Min.x; minY.w = childNode.bounds.Min.y; minZ.w = childNode.bounds.Min.z;
maxX.w = childNode.bounds.Max.x; maxY.w = childNode.bounds.Max.y; maxZ.w = childNode.bounds.Max.z;
maxParentError.w = childNode.maxParentError;
nodeData.w = data;
}
}
bvhNode.minX = minX;
bvhNode.minY = minY;
bvhNode.minZ = minZ;
bvhNode.maxX = maxX;
bvhNode.maxY = maxY;
bvhNode.maxZ = maxZ;
bvhNode.maxParentError = maxParentError;
bvhNode.nodeData = nodeData;
hierarchyNodes[outNodeIndex] = bvhNode;
return outNodeIndex;
}
finally
{
gathered.Dispose();
scope.Dispose();
}
}
private unsafe class BuildClusterLodHierarchyJob
{
public MeshletMeshData* meshletData;
public void Execute()
{
using var meshletIndices = new UnsafeArray<int>(meshletData->meshletCount, AllocationHandle.TLSF);
for (var i = 0; i < meshletData->meshletCount; i++)
{
meshletIndices[i] = i;
}
var binaryNodes = new UnsafeList<TempBinaryNode>(meshletData->meshletCount * 2, AllocationHandle.TLSF);
try
{
var rootIndex = BuildBinaryTree(ref binaryNodes, meshletIndices, 0, meshletIndices.Length, meshletData->meshlets);
if (!meshletData->hierarchyNodes.IsCreated)
{
meshletData->hierarchyNodes = new UnsafeList<MeshletHierarchyNode>(meshletData->meshletCount, AllocationHandle.TLSF);
}
if (binaryNodes[rootIndex].leftChild == -1)
{
var bvhNode = new MeshletHierarchyNode();
bvhNode.minX = new float4(float.PositiveInfinity);
bvhNode.minY = new float4(float.PositiveInfinity);
bvhNode.minZ = new float4(float.PositiveInfinity);
bvhNode.maxX = new float4(float.NegativeInfinity);
bvhNode.maxY = new float4(float.NegativeInfinity);
bvhNode.maxZ = new float4(float.NegativeInfinity);
bvhNode.maxParentError = new float4(0);
bvhNode.nodeData = new uint4(0xFFFFFFFF);
var childNode = binaryNodes[rootIndex];
bvhNode.minX.x = childNode.bounds.Min.x;
bvhNode.minY.x = childNode.bounds.Min.y;
bvhNode.minZ.x = childNode.bounds.Min.z;
bvhNode.maxX.x = childNode.bounds.Max.x;
bvhNode.maxY.x = childNode.bounds.Max.y;
bvhNode.maxZ.x = childNode.bounds.Max.z;
bvhNode.maxParentError.x = childNode.maxParentError;
bvhNode.nodeData.x = (uint)childNode.meshletIndex;
meshletData->hierarchyNodes.Add(bvhNode);
}
else
{
CollapseTo4Ary(binaryNodes, rootIndex, meshletData->hierarchyNodes);
}
}
finally
{
binaryNodes.Dispose();
}
}
}
/// <summary>
/// Builds a cluster LOD hierarchy from the input meshlet data.
/// </summary>
/// <param name="meshletData">The meshlet data.</param>
public static Task BuildClusterLodHierarchyAsync(SharedPtr<MeshletMeshData> meshletData, CancellationToken token)
{
if (meshletData.GetRef().meshletCount == 0)
{
return Task.CompletedTask;
}
unsafe
{
var job = new BuildClusterLodHierarchyJob
{
meshletData = meshletData.Get()
};
return Task.Run(job.Execute, token);
}
} }
} }

View File

@@ -0,0 +1,475 @@
using Ghost.Core;
using Ghost.Core.Utilities;
using Ghost.Editor.Core.Services;
using Ghost.Engine.Streaming;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics;
using Misaki.HighPerformance.Mathematics.Geometry;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
namespace Ghost.Editor.Core.Assets;
public sealed class ModelManifestSubAsset
{
public Guid Guid
{
get; set;
}
public string Name
{
get; set;
} = string.Empty;
public string StablePath
{
get; set;
} = string.Empty;
public int MaterialSlotCount
{
get; set;
}
public int VertexCount
{
get; set;
}
public int IndexCount
{
get; set;
}
}
public sealed class ModelManifestMetadata
{
public string Kind
{
get; set;
} = string.Empty;
public string Name
{
get; set;
} = string.Empty;
public string StablePath
{
get; set;
} = string.Empty;
}
internal sealed class ImportedModelAsset : IAsset
{
public ModelManifest Manifest
{
get;
}
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
: base(id, typeof(ModelAsset).GUID, settings)
{
Manifest = manifest;
}
}
[Guid(GUID)]
public abstract class ModelAsset : IAsset
{
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
private MeshNode _root;
public MeshNode Root
{
get => _root;
set
{
_root?.Dispose();
_root = value;
}
}
internal ModelAsset(MeshNode root, Guid id, ModelAssetSettings settings)
: base(id, typeof(ModelAsset).GUID, settings)
{
_root = root;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_root?.Dispose();
}
}
}
public enum CoordinateAxis
{
PositiveX,
PositiveY,
PositiveZ,
NegativeX,
NegativeY,
NegativeZ
}
public enum VertexDataSource
{
Imported,
Computed,
ComputedIfMissing
}
public class ModelAssetSettings : IAssetSettings
{
public VertexDataSource NormalDataSource
{
get; set;
} = VertexDataSource.ComputedIfMissing;
public VertexDataSource TangentDataSource
{
get; set;
} = VertexDataSource.ComputedIfMissing;
}
internal class ObjAssetSettings : ModelAssetSettings
{
public CoordinateAxis ObjectUpAxis
{
get; set;
} = CoordinateAxis.PositiveY;
public CoordinateAxis ObjectForwardAxis
{
get; set;
} = CoordinateAxis.NegativeZ;
public CoordinateAxis ObjectRightAxis
{
get; set;
} = CoordinateAxis.PositiveX;
public float UnitMeterScale
{
get; set;
} = 1.0f;
}
internal class FbxAssetSettings : ModelAssetSettings
{
}
[CustomAssetHandler(AssetTypeId = ModelAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
internal class ModelAssetHandler : IImportableAssetHandler, IPackableAssetHandler
{
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public IAssetSettings? CreateDefaultSettings(string ext)
{
if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase))
{
return new ObjAssetSettings();
}
else if (string.Equals(ext, ".fbx", StringComparison.OrdinalIgnoreCase))
{
return new FbxAssetSettings();
}
return null;
}
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
var importedPath = ImportCoordinator.GetImportedAssetPath(id);
if (!File.Exists(importedPath))
{
return Result.Failure<IAsset>("Imported model manifest does not exist.");
}
try
{
await using var stream = new FileStream(importedPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var manifest = await JsonSerializer.DeserializeAsync<ModelManifest>(stream, s_jsonOptions, token).ConfigureAwait(false);
return manifest != null
? Result.Success<IAsset>(new ImportedModelAsset(id, settings, manifest))
: Result.Failure<IAsset>("Failed to deserialize model manifest.");
}
catch (Exception ex)
{
return Result.Failure<IAsset>(ex.Message);
}
}
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
{
return ValueTask.FromResult(Result.Failure("Saving model assets is not supported yet."));
}
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
if (!File.Exists(sourcePath))
{
return Result.Failure<ImportedSubAsset[]>("Source file does not exist.");
}
try
{
var meshSettings = ResolveSettings(sourcePath, settings);
using var root = new MeshNode();
var result = await MeshProcessor.ParseMeshAsync(root, sourcePath, AllocationHandle.TLSF, meshSettings, token).ConfigureAwait(false);
if (result.IsFailure)
{
return Result.Failure(result.Message);
}
var manifest = new ModelManifest
{
AssetId = id,
};
var importedSubAssets = new List<ImportedSubAsset>();
manifest.Root = await WriteNodeAsync(id, sourcePath, root, string.Empty, manifest, importedSubAssets, token).ConfigureAwait(false);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, manifest, s_jsonOptions, token).ConfigureAwait(false);
return importedSubAssets.ToArray();
}
catch (Exception ex)
{
return Result.Failure<ImportedSubAsset[]>($"Failed to import mesh asset: {ex.Message}");
}
}
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
}
private static ModelAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
{
if (settings is ModelAssetSettings meshSettings)
{
return meshSettings;
}
return Path.GetExtension(sourcePath).Equals(".obj", StringComparison.OrdinalIgnoreCase)
? new ObjAssetSettings()
: new FbxAssetSettings();
}
private async ValueTask<ModelManifestNode> WriteNodeAsync(
Guid parentGuid,
string sourcePath,
MeshNode node,
string parentPath,
ModelManifest manifest,
List<ImportedSubAsset> importedSubAssets,
CancellationToken token)
{
token.ThrowIfCancellationRequested();
var stablePath = string.IsNullOrEmpty(parentPath)
? SanitizePathSegment(node.Name)
: $"{parentPath}/{SanitizePathSegment(node.Name)}";
var manifestNode = new ModelManifestNode
{
Name = node.Name,
StablePath = stablePath,
LocalTransform = node.LocalTransform,
};
if (node is GeometryMeshNode geometry)
{
var meshGuid = CreateDeterministicSubAssetGuid(parentGuid, "Mesh", stablePath);
var meshPath = ImportCoordinator.GetImportedAssetPath(meshGuid);
Directory.CreateDirectory(Path.GetDirectoryName(meshPath)!);
var (materialSlotCount, lodLevelCount) = await WriteMeshContentAsync(meshPath, geometry, token).ConfigureAwait(false);
manifestNode.MeshGuid = meshGuid;
manifest.Meshes.Add(new ModelManifestSubAsset
{
Guid = meshGuid,
Name = node.Name,
StablePath = stablePath,
MaterialSlotCount = materialSlotCount,
VertexCount = geometry.Vertices.Count,
IndexCount = geometry.Indices.Count,
});
importedSubAssets.Add(new ImportedSubAsset(
meshGuid,
"Mesh",
node.Name,
stablePath,
$"{sourcePath}#Mesh/{stablePath}",
typeof(ModelAsset).GUID));
}
else if (node is LightMeshNode)
{
manifest.Metadata.Add(new ModelManifestMetadata
{
Kind = "Light",
Name = node.Name,
StablePath = stablePath,
});
}
foreach (var child in node.Children)
{
manifestNode.Children.Add(await WriteNodeAsync(parentGuid, sourcePath, child, stablePath, manifest, importedSubAssets, token).ConfigureAwait(false));
}
return manifestNode;
}
private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token)
{
using var meshletData = await MeshProcessor.BuildMeshletsAsync(geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
await MeshProcessor.BuildClusterLodHierarchyAsync(meshletData.Share(), token).ConfigureAwait(false);
var bounds = ComputeBounds(geometry.Vertices);
var header = new MeshContentHeader
{
magic = MeshContentHeader.MAGIC,
version = MeshContentHeader.VERSION,
vertexCount = geometry.Vertices.Count,
indexCount = geometry.Indices.Count,
materialPartCount = geometry.MaterialParts.Length,
meshletCount = meshletData.GetRef().meshlets.Count,
meshletGroupCount = meshletData.GetRef().groups.Count,
meshletHierarchyNodeCount = meshletData.GetRef().hierarchyNodes.Count,
meshletVertexCount = meshletData.GetRef().meshletVertices.Count,
meshletTriangleCount = meshletData.GetRef().meshletTriangles.Count,
materialSlotCount = meshletData.GetRef().materialSlotCount,
lodLevelCount = meshletData.GetRef().lodLevelCount,
boundsMin = bounds.Min,
boundsMax = bounds.Max,
};
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
stream.Write(header);
header.vertexOffset = stream.Position;
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
header.indexOffset = stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
header.materialPartOffset = stream.Position;
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
header.meshletOffset = stream.Position;
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
header.meshletGroupOffset = stream.Position;
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
header.meshletHierarchyNodeOffset = stream.Position;
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
header.meshletVertexOffset = stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
header.meshletTriangleOffset = stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token);
stream.Position = 0;
stream.Write(header);
stream.Flush();
return (meshletData.GetRef().materialSlotCount, meshletData.GetRef().lodLevelCount);
}
private static AABB ComputeBounds(UnsafeList<Vertex> vertices)
{
var min = new float3(float.MaxValue);
var max = new float3(float.MinValue);
for (var i = 0; i < vertices.Count; i++)
{
var p = vertices[i].position;
min = math.min(min, p);
max = math.max(max, p);
}
return new AABB(min, max);
}
private static Guid CreateDeterministicSubAssetGuid(Guid parentGuid, string kind, string stablePath)
{
var bytes = Encoding.UTF8.GetBytes($"{parentGuid:N}:{kind}:{stablePath}");
Span<byte> hash = stackalloc byte[16];
var hashValue = XxHash128.HashToUInt128(bytes);
Unsafe.WriteUnaligned(ref hash[0], hashValue);
hash[6] = (byte)((hash[6] & 0x0F) | 0x50);
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
return new Guid(hash);
}
private static string SanitizePathSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "Node";
}
var chars = value.ToCharArray();
for (var i = 0; i < value.Length; i++)
{
if (chars[i] == '/' || chars[i] == '\\' || chars[i] == '#')
{
chars[i] = '_';
}
}
return new string(chars);
}
private static void WriteMaterialParts(Stream stream, ReadOnlySpan<MaterialPartInfo> parts)
{
if (parts.IsEmpty)
{
return;
}
var buffer = parts.Length <= 64
? stackalloc MeshContentMaterialPart[parts.Length]
: new MeshContentMaterialPart[parts.Length];
for (var i = 0; i < parts.Length; i++)
{
buffer[i] = new MeshContentMaterialPart
{
materialIndex = parts[i].materialIndex,
indexStart = parts[i].indexStart,
indexCount = parts[i].indexCount,
vertexStart = parts[i].vertexStart,
vertexCount = parts[i].vertexCount,
};
}
stream.Write(buffer);
}
}

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,42 +28,23 @@ public sealed partial class ComputeShaderAsset : IAsset
{ {
public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A"; public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A";
public Guid ID
{
get;
}
public IAssetSettings? Settings
{
get;
}
public Guid TypeID => typeof(ComputeShaderAsset).GUID;
public ComputeShaderDescriptor Descriptor public ComputeShaderDescriptor Descriptor
{ {
get; get;
} }
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id) internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
: base(id, typeof(ComputeShaderAsset).GUID, null)
{ {
ID = id;
Descriptor = descriptor; Descriptor = descriptor;
} }
public void Dispose()
{
}
} }
// Shader does not handle import/export via asset registry, it will handled by the hot reload system. // Shader does not handle import/export via asset registry, it will handled by the hot reload system.
[CustomAssetHandler(GraphicsShaderAsset.GUID, [".gshdr"], 1)] [CustomAssetHandler(AssetTypeId = GraphicsShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gshdr" })]
internal class GraphicsShaderAssetHandler : IPackableAssetHandler internal class GraphicsShaderAssetHandler : IPackableAssetHandler
{ {
public AssetType RuntimeAssetType => AssetType.Shader; public IAssetSettings? CreateDefaultSettings(string ext)
public Guid EditorAssetTypeID => typeof(GraphicsShaderAsset).GUID;
public IAssetSettings? CreateDefaultSettings()
{ {
return null; return null;
} }
@@ -109,17 +74,14 @@ internal class GraphicsShaderAssetHandler : IPackableAssetHandler
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default) public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{ {
throw new NotImplementedException(); return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
} }
} }
[CustomAssetHandler(ComputeShaderAsset.GUID, [".gcomp"], 1)] [CustomAssetHandler(AssetTypeId = ComputeShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gcomp" })]
internal class ComputeShaderAssetHandler : IPackableAssetHandler internal class ComputeShaderAssetHandler : IPackableAssetHandler
{ {
public AssetType RuntimeAssetType => AssetType.Shader; public IAssetSettings? CreateDefaultSettings(string ext)
public Guid EditorAssetTypeID => typeof(ComputeShaderAsset).GUID;
public IAssetSettings? CreateDefaultSettings()
{ {
return null; return null;
} }
@@ -149,6 +111,6 @@ internal class ComputeShaderAssetHandler : IPackableAssetHandler
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default) public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{ {
throw new NotImplementedException(); return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
} }
} }

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);
} }
} }
@@ -249,7 +232,7 @@ public class TextureAssetSettings : IAssetSettings
} = new SamplerSettings(); } = new SamplerSettings();
} }
[CustomAssetHandler(TextureAsset.GUID, [".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr"], 1)] [CustomAssetHandler(AssetTypeId = TextureAsset.GUID, RuntimeAssetType = AssetType.Texture, Extensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHandler internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHandler
{ {
internal struct TextureInfo internal struct TextureInfo
@@ -263,11 +246,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
public bool isHDR; public bool isHDR;
} }
public bool CanExport => false; public IAssetSettings? CreateDefaultSettings(string ext)
public AssetType RuntimeAssetType => AssetType.Texture;
public Guid EditorAssetTypeID => typeof(TextureAsset).GUID;
public IAssetSettings? CreateDefaultSettings()
{ {
return new TextureAssetSettings(); return new TextureAssetSettings();
} }
@@ -406,17 +385,18 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
try try
{ {
var ext = Path.GetExtension(targetStream.Name); var ext = Path.GetExtension(targetStream.Name);
var result = 0;
unsafe unsafe
{ {
switch (ext) switch (ext)
{ {
case ".png": case ".png":
StbIApi.WritePngToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 0); result = StbIApi.WritePngToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 0);
break; break;
case ".jpg": case ".jpg":
StbIApi.WriteJpgToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 90); result = StbIApi.WriteJpgToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 90);
break; break;
// TODO: Add support for other image formats // TODO: Add support for other image formats
@@ -426,7 +406,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
} }
} }
return Result.Success(); return result != 0 ? Result.Success() : Result.Failure("Failed to write image data.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -439,7 +419,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
}, token).ConfigureAwait(false); }, token).ConfigureAwait(false);
} }
public async ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default) public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{ {
if (!File.Exists(sourcePath)) if (!File.Exists(sourcePath))
{ {
@@ -463,7 +443,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
if (result.IsFailure) if (result.IsFailure)
{ {
return result; return Result.Failure(result.Message);
} }
var (cachePath, mip) = result.Value; var (cachePath, mip) = result.Value;
@@ -485,7 +465,7 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
await ddsStream.CopyToAsync(targetStream, token).ConfigureAwait(false); await ddsStream.CopyToAsync(targetStream, token).ConfigureAwait(false);
await targetStream.FlushAsync(token).ConfigureAwait(false); await targetStream.FlushAsync(token).ConfigureAwait(false);
return Result.Success(); return Result.Success(Array.Empty<ImportedSubAsset>());
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -493,13 +473,8 @@ internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHand
} }
} }
public ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default)
{
return ValueTask.FromResult(Result.Failure("Exporting texture assets is not supported yet."));
}
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default) public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{ {
throw new NotImplementedException(); return ValueTask.FromResult(Result.Failure("Packing texture assets is not supported yet."));
} }
} }

View File

@@ -1,6 +1,7 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Engine; using Ghost.Engine;
using Ghost.Nvtt; using Ghost.Nvtt;
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,25 +14,23 @@ namespace Ghost.Editor.Core.Assets;
internal static partial class TextureProcessor internal static partial class TextureProcessor
{ {
private class NvttPipelineTask : IThreadPoolWorkItem private struct NvttPipelineJob : IJob
{ {
private readonly string _outputPath; private readonly Wrapper<Result<int>> _result;
private readonly string _outputPath;
private readonly TextureAssetHandler.TextureInfo _textureInfo; private readonly TextureAssetHandler.TextureInfo _textureInfo;
private readonly TextureAssetSettings _settings; private readonly TextureAssetSettings _settings;
private UnsafeArray<MipLevel> _mipLevels; private UnsafeArray<MipLevel> _mipLevels;
private readonly TaskCompletionSource<Result<int>> _completionSource; public NvttPipelineJob(Wrapper<Result<int>> result, string outputPath, TextureAssetHandler.TextureInfo textureInfo, TextureAssetSettings settings, UnsafeArray<MipLevel> mipLevels)
public Task<Result<int>> Task => _completionSource.Task;
public NvttPipelineTask(string outputPath, TextureAssetHandler.TextureInfo textureInfo, TextureAssetSettings settings, UnsafeArray<MipLevel> mipLevels)
{ {
_result = result;
_outputPath = outputPath; _outputPath = outputPath;
_textureInfo = textureInfo; _textureInfo = textureInfo;
_settings = settings; _settings = settings;
_mipLevels = mipLevels; _mipLevels = mipLevels;
_completionSource = new TaskCompletionSource<Result<int>>();
} }
private unsafe Result<int> RunMipGenCompressionPipeline() private unsafe Result<int> RunMipGenCompressionPipeline()
@@ -226,11 +225,12 @@ internal static partial class TextureProcessor
return Result.Success(maxCubeMips); return Result.Success(maxCubeMips);
} }
public void Execute() public void Execute(ref readonly JobExecutionContext context)
{ {
Result<int> finalResult;
try try
{ {
Result<int> finalResult;
if (_settings.Basic.TextureShape == TextureShape.TextureCube) if (_settings.Basic.TextureShape == TextureShape.TextureCube)
{ {
finalResult = RunCubeMapCompressionPipeline(); finalResult = RunCubeMapCompressionPipeline();
@@ -239,13 +239,13 @@ internal static partial class TextureProcessor
{ {
finalResult = RunMipGenCompressionPipeline(); finalResult = RunMipGenCompressionPipeline();
} }
_result.Value = finalResult;
} }
catch (Exception ex) catch (Exception ex)
{ {
finalResult = Result.Failure($"Compression threw an exception: {ex.Message}"); Logger.Error($"Exception during NVTT compression: {ex}");
} }
_completionSource.SetResult(finalResult);
} }
} }
@@ -360,15 +360,17 @@ internal static partial class TextureProcessor
baseCubeData.Dispose(); baseCubeData.Dispose();
} }
var workItem = new NvttPipelineTask(cachePath, textureInfo, settings, mipLevels); var result = new Wrapper<Result<int>>();
ThreadPool.UnsafeQueueUserWorkItem(workItem, true); var nvttJob = new NvttPipelineJob(result, cachePath, textureInfo, settings, mipLevels);
var result = await workItem.Task.WaitAsync(cancellationToken).ConfigureAwait(false); var nvttJobHandle = scheduler.Schedule(in nvttJob);
if (result.IsFailure) await scheduler.WaitAsync(nvttJobHandle, cancellationToken);
if (result.Value.IsFailure)
{ {
return Result.Failure(result.Message); return Result.Failure(result.Value.Message);
} }
return (cachePath, result.Value); return (cachePath, result.Value.Value);
} }
finally finally
{ {

View File

@@ -34,7 +34,7 @@ internal static partial class TextureProcessor
public int numMipLevels; public int numMipLevels;
public int channelCount; public int channelCount;
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut) private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut)
{ {
var x = i / N; var x = i / N;
@@ -43,23 +43,18 @@ internal static partial class TextureProcessor
} }
// GGX Importance Sampling // GGX Importance Sampling
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness) private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness)
{ {
var a = roughness * roughness; // Disney remap roughness for better visual linearity var a = roughness * roughness; // Disney remap roughness for better visual linearity
var phi = 2.0f * PI * Xi.x; var phi = 2.0f * PI * Xi.x;
// Clamp the inside of the cosTheta Sqrt to prevent NaN on division precision edges var cosTheta = TFloat.Sqrt((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y));
var cosThetaInner = TFloat.Max((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y), TFloat.Zero); var sinTheta = TFloat.Sqrt(1.0f - cosTheta * cosTheta);
var cosTheta = TFloat.Sqrt(cosThetaInner);
// Clamp the inside of sinTheta to prevent sqrt of negative floating-point errors
var sinThetaInner = TFloat.Max(1.0f - cosTheta * cosTheta, TFloat.Zero);
var sinTheta = TFloat.Sqrt(sinThetaInner);
// Spherical to Cartesian coordinates (Halfway vector) // Spherical to Cartesian coordinates (Halfway vector)
var (sinPhi, cosPhi) = TFloat.SinCos(phi); TFloat.SinCos(phi, out var sinPhi, out var cosPhi);
var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta); var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta);
// Tangent space to World space // Tangent space to World space
@@ -73,13 +68,13 @@ internal static partial class TextureProcessor
return MathV.Normalize(sampleVec); return MathV.Normalize(sampleVec);
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static float3 CubemapUVToDir(int face, float u, float v) private static float3 CubemapUVToDir(int face, float u, float v)
{ {
var sc = 2.0f * u - 1.0f; var sc = 2.0f * u - 1.0f;
var tc = 1.0f - 2.0f * v; var tc = 1.0f - 2.0f * v;
float x = 0, y = 0, z = 0; float x = 0.0f, y = 0.0f, z = 0.0f;
switch (face) switch (face)
{ {
case 0: x = 1.0f; y = tc; z = -sc; break; case 0: x = 1.0f; y = tc; z = -sc; break;
@@ -93,7 +88,7 @@ internal static partial class TextureProcessor
return normalize(float3(x, y, z)); return normalize(float3(x, y, z));
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir) private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir)
{ {
var absX = TFloat.Abs(dir.x); var absX = TFloat.Abs(dir.x);
@@ -140,6 +135,7 @@ internal static partial class TextureProcessor
return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1); return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1);
} }
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public void Execute(int loopIndex, ref readonly JobExecutionContext ctx) public void Execute(int loopIndex, ref readonly JobExecutionContext ctx)
{ {
var m = 0; var m = 0;
@@ -226,7 +222,7 @@ internal static partial class TextureProcessor
} }
var totalWeight = 0.0f; var totalWeight = 0.0f;
var prefilteredColor = float3(0, 0, 0); var prefilteredColor = float3(0.0f, 0.0f, 0.0f);
for (var i = 0; i < TFloat.LaneWidth; i++) for (var i = 0; i < TFloat.LaneWidth; i++)
{ {

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
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) private 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,8 +1,18 @@
using Ghost.Editor.Core.Utilities;
using Microsoft.UI.Dispatching; using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using System.Diagnostics.CodeAnalysis;
namespace Ghost.Editor.Core; namespace Ghost.Editor.Core;
public enum EditorState
{
Idle,
Playing,
Paused,
Compiling,
}
public static class EditorApplication public static class EditorApplication
{ {
public const string ASSETS_FOLDER_NAME = "Assets"; public const string ASSETS_FOLDER_NAME = "Assets";
@@ -53,8 +63,15 @@ public static class EditorApplication
} }
} }
public static EditorState State
{
get; internal set;
} = EditorState.Idle;
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName) internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
{ {
projectPath = PathUtility.Normalize(projectPath);
Environment.CurrentDirectory = projectPath; Environment.CurrentDirectory = projectPath;
s_serviceProvider = serviceProvider; s_serviceProvider = serviceProvider;
@@ -86,12 +103,25 @@ public static class EditorApplication
public static T GetService<T>() public static T GetService<T>()
where T : class where T : class
{ {
if (s_serviceProvider?.GetService(typeof(T)) is not T service) if (TryGetService<T>(out var service))
{ {
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); return service;
} }
return service; throw new ArgumentException("Requested service of type " + typeof(T).FullName + " is not registered.");
}
public static bool TryGetService<T>([NotNullWhen(true)] out T? service)
where T : class
{
if (s_serviceProvider?.GetService(typeof(T)) is T resolvedService)
{
service = resolvedService;
return true;
}
service = null;
return false;
} }
internal static void Shutdown() internal static void Shutdown()

View File

@@ -10,14 +10,41 @@
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks> <AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages --> <NoWarn>$(NoWarn);MVVMTK0050</NoWarn>
<langversion>preview</langversion> <Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|x64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|ARM64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|x64'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'">
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentIcons.WinUI" Version="2.1.324" /> <Content Remove="Assets\MeshNode.cs" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" /> </ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" /> <ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" /> <PackageReference Include="FluentIcons.WinUI" Version="2.1.328" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" /> <PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
</ItemGroup> </ItemGroup>
@@ -30,6 +57,7 @@
<ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.csproj" /> <ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.csproj" />
<ProjectReference Include="..\..\ThridParty\Ghost.Ufbx\Ghost.Ufbx.csproj" /> <ProjectReference Include="..\..\ThridParty\Ghost.Ufbx\Ghost.Ufbx.csproj" />
<ProjectReference Include="..\..\ThridParty\Ghost.StbI\Ghost.StbI.csproj" /> <ProjectReference Include="..\..\ThridParty\Ghost.StbI\Ghost.StbI.csproj" />
<ProjectReference Include="..\Ghost.DSL\Ghost.DSL.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -39,8 +67,5 @@
<Page Update="Controls\BasicInput\Vector3Field.xaml"> <Page Update="Controls\BasicInput\Vector3Field.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
</Page> </Page>
<Page Update="Controls\Internal\ComponentView.xaml">
<SubType>Designer</SubType>
</Page>
</ItemGroup> </ItemGroup>
</Project> </Project>

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

@@ -0,0 +1,8 @@
{
"profiles": {
"Ghost.Editor.Core": {
"commandName": "Project",
"debugEngines": "managed"
}
}
}

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
{
ColumnSpacing = 8,
};
root.ColumnDefinitions.Add(new ColumnDefinition
{
Width = new GridLength(1, GridUnitType.Star)
});
root.ColumnDefinitions.Add(new ColumnDefinition
{
Width = GridLength.Auto,
MinWidth = 20
});
var nameBox = new TextBox
{
Text = Name,
FontSize = 14,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
};
nameBox.SetBinding(TextBox.TextProperty, new Microsoft.UI.Xaml.Data.Binding
{
Source = this,
Path = new PropertyPath(nameof(Name)),
Mode = Microsoft.UI.Xaml.Data.BindingMode.TwoWay,
UpdateSourceTrigger = Microsoft.UI.Xaml.Data.UpdateSourceTrigger.PropertyChanged
});
var entityBlock = new TextBlock
{
Text = $"{Entity.ID}:{Entity.Generation}",
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Right,
};
Grid.SetColumn(nameBox, 0);
Grid.SetColumn(entityBlock, 1);
root.Children.Add(nameBox);
root.Children.Add(entityBlock);
return root;
} }
public override UIElement? CreateInspector() public override IInspectorModel CreateInspectorModel()
{ {
throw new NotImplementedException(); return new Inspector.EntityInspectorModel(World, Entity);
}
public override DataTemplate GetSceneHierarchyTemplate()
{
var template = @"
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:Key=""EntityTemplate"" x:DataType=""sg:SceneGraphNode"">
<TreeViewItem AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}"" ItemsSource=""{x:Bind Children, Mode=OneWay}"">
<StackPanel Margin=""10,0"" Orientation=""Horizontal"">
<FontIcon FontSize=""14"" Glyph=""&#xF158;"" />
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
</StackPanel>
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
} }
} }

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;
Children.CollectionChanged += OnChildrenChanged;
}
public abstract DataTemplate GetSceneHierarchyTemplate(); private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.OldItems != null)
{
foreach (SceneGraphNode oldItem in e.OldItems)
{
if (oldItem.Parent == this)
{
oldItem.Parent = null;
}
}
}
if (e.NewItems != null)
{
foreach (SceneGraphNode newItem in e.NewItems)
{
newItem.Parent = this;
}
}
}
public virtual SceneNode? GetOwningSceneNode()
{
return null;
}
public override void Modify()
{
base.Modify(); // Marks this node dirty via base GhostObject logic
var sceneNode = GetOwningSceneNode();
if (sceneNode != null)
{
var worldService = EditorApplication.GetService<IEditorWorldService>();
var sceneAsset = worldService.GetAssetForScene(sceneNode.Scene.ID);
if (sceneAsset != null)
{
EditorApplication.GetService<IDirtyTrackerService>().MarkDirty(sceneAsset);
}
}
}
public override void SerializeState(BinaryWriter writer)
{
writer.Write(Name);
}
public override void DeserializeState(BinaryReader reader)
{
Name = reader.ReadString();
}
public virtual IconSource? CreateIcon()
{
return null;
}
public virtual UIElement? CreateHeader()
{
return null;
}
public abstract IInspectorModel CreateInspectorModel();
} }

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

@@ -5,94 +5,91 @@ namespace Ghost.Editor.Core.Services;
/// <summary> /// <summary>
/// Thread-safe SQLite-backed asset catalog. /// Thread-safe SQLite-backed asset catalog.
/// Replaces the in-memory dictionary approach with persistent storage. /// Uses connection pooling and local command creation for safe multi-threaded access.
/// </summary> /// </summary>
public sealed partial class AssetCatalog : IDisposable public sealed partial class AssetCatalog
{ {
private readonly SqliteConnection _connection; public readonly record struct SubAssetInfo(Guid Guid, Guid ParentGuid, string Kind, string DisplayName, string StablePath, string SourcePath, Guid AssetTypeId);
private readonly Lock _writeLock = new();
// Prepared statements private readonly string _connectionString;
private readonly SqliteCommand _cmdGetGuid;
private readonly SqliteCommand _cmdGetPath;
private readonly SqliteCommand _cmdUpsert;
private readonly SqliteCommand _cmdDelete;
private readonly SqliteCommand _cmdGetHandlerTypeId; private const string SqlGetGuid = "SELECT guid FROM assets WHERE source_path = @path";
private readonly SqliteCommand _cmdGetReferencers; private const string SqlGetPath = "SELECT source_path FROM assets WHERE guid = @guid";
private readonly SqliteCommand _cmdGetDependencies; private const string SqlGetAssetTypeId = "SELECT asset_type_id FROM assets WHERE guid = @guid";
private readonly SqliteCommand _cmdGetImportedAt; private const string SqlGetImportedAt = "SELECT imported_at_ms FROM assets WHERE guid = @guid";
private const string SqlUpsert = @"
private readonly SqliteCommand _cmdInsertDep; INSERT INTO assets (guid, source_path, asset_type_id, handler_version, content_hash, settings_hash, imported_at_ms, parent_guid, subasset_kind, display_name, stable_path)
private readonly SqliteCommand _cmdClearDeps; VALUES (@guid, @path, @asset_type_id, @version, @content_hash, @settings_hash, @imported_at_ms, @parent_guid, @subasset_kind, @display_name, @stable_path)
private readonly SqliteCommand _cmdEnumerate; ON CONFLICT(guid) DO UPDATE SET
source_path = excluded.source_path,
asset_type_id = excluded.asset_type_id,
handler_version = excluded.handler_version,
content_hash = excluded.content_hash,
settings_hash = excluded.settings_hash,
imported_at_ms = excluded.imported_at_ms,
parent_guid = excluded.parent_guid,
subasset_kind = excluded.subasset_kind,
display_name = excluded.display_name,
stable_path = excluded.stable_path";
private const string SqlDelete = "DELETE FROM assets WHERE guid = @guid";
private const string SqlGetReferencers = "SELECT from_guid FROM dependencies WHERE to_guid = @guid";
private const string SqlGetDependencies = "SELECT to_guid FROM dependencies WHERE from_guid = @guid";
private const string SqlInsertDep = "INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)";
private const string SqlClearDeps = "DELETE FROM dependencies WHERE from_guid = @guid";
private const string SqlEnumerate = "SELECT guid, source_path FROM assets";
private const string SqlEnumerateSubAssets = "SELECT guid, parent_guid, subasset_kind, display_name, stable_path, source_path, asset_type_id FROM assets WHERE parent_guid = @parent_guid ORDER BY stable_path";
private const string SqlDeleteSubAssetsForParent = "DELETE FROM assets WHERE parent_guid = @parent_guid";
public AssetCatalog(string dbPath) public AssetCatalog(string dbPath)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var connString = new SqliteConnectionStringBuilder var builder = new SqliteConnectionStringBuilder
{ {
DataSource = dbPath, DataSource = dbPath,
Cache = SqliteCacheMode.Shared, ForeignKeys = true,
}.ToString(); Pooling = true,
};
_connectionString = builder.ToString();
_connection = new SqliteConnection(connString); // Initial setup
_connection.Open(); using var connection = OpenConnection();
using (var cmd = connection.CreateCommand())
using (var pragma = _connection.CreateCommand())
{ {
pragma.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"; cmd.CommandText = "PRAGMA journal_mode = WAL;";
pragma.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
CreateSchema(); CreateSchemaInternal(connection);
_cmdGetGuid = CreateCommand("SELECT guid FROM assets WHERE source_path = @path");
_cmdGetPath = CreateCommand("SELECT source_path FROM assets WHERE guid = @guid");
_cmdGetHandlerTypeId = CreateCommand("SELECT handler_type_id FROM assets WHERE guid = @guid");
_cmdGetImportedAt = CreateCommand("SELECT imported_at_ms FROM assets WHERE guid = @guid");
_cmdUpsert = CreateCommand(@"
INSERT INTO assets (guid, source_path, handler_type_id, handler_version, content_hash, settings_hash, imported_at_ms)
VALUES (@guid, @path, @handler_id, @version, @content_hash, @settings_hash, @imported_at_ms)
ON CONFLICT(guid) DO UPDATE SET
source_path = excluded.source_path,
handler_type_id = excluded.handler_type_id,
handler_version = excluded.handler_version,
content_hash = excluded.content_hash,
settings_hash = excluded.settings_hash,
imported_at_ms = excluded.imported_at_ms");
_cmdDelete = CreateCommand("DELETE FROM assets WHERE guid = @guid");
_cmdGetReferencers = CreateCommand("SELECT from_guid FROM dependencies WHERE to_guid = @guid");
_cmdGetDependencies = CreateCommand("SELECT to_guid FROM dependencies WHERE from_guid = @guid");
_cmdInsertDep = CreateCommand("INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)");
_cmdClearDeps = CreateCommand("DELETE FROM dependencies WHERE from_guid = @guid");
_cmdEnumerate = CreateCommand("SELECT guid, source_path FROM assets");
} }
private SqliteCommand CreateCommand(string sql) private SqliteConnection OpenConnection()
{ {
var cmd = _connection.CreateCommand(); var connection = new SqliteConnection(_connectionString);
cmd.CommandText = sql; connection.Open();
return cmd; return connection;
} }
private void CreateSchema() private static void CreateSchemaInternal(SqliteConnection connection)
{ {
using var cmd = _connection.CreateCommand(); using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS assets ( CREATE TABLE IF NOT EXISTS assets (
guid BLOB(16) PRIMARY KEY NOT NULL, guid BLOB (16) PRIMARY KEY NOT NULL,
source_path TEXT NOT NULL, source_path TEXT NOT NULL,
handler_type_id BLOB(16), asset_type_id BLOB (16),
handler_version INTEGER NOT NULL DEFAULT 0, handler_version INTEGER NOT NULL DEFAULT 0,
content_hash TEXT, content_hash TEXT,
settings_hash TEXT, settings_hash TEXT,
imported_at_ms INTEGER imported_at_ms INTEGER,
parent_guid BLOB (16),
subasset_kind TEXT,
display_name TEXT,
stable_path TEXT
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path); CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid);
CREATE INDEX IF NOT EXISTS idx_assets_type_id ON assets(asset_type_id);
CREATE TABLE IF NOT EXISTS dependencies ( CREATE TABLE IF NOT EXISTS dependencies (
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
@@ -122,96 +119,129 @@ public sealed partial class AssetCatalog : IDisposable
public Guid GetGuid(string sourcePath) public Guid GetGuid(string sourcePath)
{ {
_cmdGetGuid.Parameters.Clear(); using var connection = OpenConnection();
_cmdGetGuid.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath)); using var cmd = connection.CreateCommand();
var result = _cmdGetGuid.ExecuteScalar();
cmd.CommandText = SqlGetGuid;
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
var result = cmd.ExecuteScalar();
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty; return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
} }
public string? GetSourcePath(Guid guid) public string? GetSourcePath(Guid guid)
{ {
_cmdGetPath.Parameters.Clear(); using var connection = OpenConnection();
_cmdGetPath.Parameters.AddWithValue("@guid", guid.ToByteArray()); using var cmd = connection.CreateCommand();
return _cmdGetPath.ExecuteScalar() as string; cmd.CommandText = SqlGetPath;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
return cmd.ExecuteScalar() as string;
} }
public void Upsert(AssetMeta meta, string sourcePath) private void UpsertInternal(AssetMeta meta, string sourcePath, Guid? parentGuid, string? kind, string? displayName, string? stablePath)
{ {
lock (_writeLock) using var connection = OpenConnection();
{ using var cmd = connection.CreateCommand();
_cmdUpsert.Parameters.Clear(); cmd.CommandText = SqlUpsert;
_cmdUpsert.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray()); cmd.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray());
_cmdUpsert.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath)); cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
_cmdUpsert.Parameters.AddWithValue("@handler_id", meta.HandlerTypeId?.ToByteArray() ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("@asset_type_id", meta.AssetTypeId?.ToByteArray() ?? (object)DBNull.Value);
_cmdUpsert.Parameters.AddWithValue("@version", meta.HandlerVersion); cmd.Parameters.AddWithValue("@version", meta.HandlerVersion);
_cmdUpsert.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value);
_cmdUpsert.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value);
_cmdUpsert.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value);
_cmdUpsert.ExecuteNonQuery(); cmd.Parameters.AddWithValue("@parent_guid", parentGuid?.ToByteArray() ?? (object)DBNull.Value);
} cmd.Parameters.AddWithValue("@subasset_kind", (object?)kind ?? DBNull.Value);
cmd.Parameters.AddWithValue("@display_name", (object?)displayName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@stable_path", (object?)stablePath ?? DBNull.Value);
cmd.ExecuteNonQuery();
} }
public void Upsert(AssetMeta meta, string sourcePath) => UpsertInternal(meta, sourcePath, null, null, null, null);
public void UpsertSubAsset(Guid parentGuid, AssetMeta meta, string sourcePath, string kind, string displayName, string stablePath)
=> UpsertInternal(meta, sourcePath, parentGuid, kind, displayName, stablePath);
public bool Remove(Guid guid) public bool Remove(Guid guid)
{ {
lock (_writeLock) var subAssets = GetSubAssets(guid);
foreach (var sub in subAssets)
{ {
_cmdDelete.Parameters.Clear(); Remove(sub.Guid);
_cmdDelete.Parameters.AddWithValue("@guid", guid.ToByteArray());
return _cmdDelete.ExecuteNonQuery() > 0;
} }
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlDelete;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
return cmd.ExecuteNonQuery() > 0;
} }
public Guid GetHandlerTypeId(Guid guid) public Guid GetAssetTypeId(Guid guid)
{ {
_cmdGetHandlerTypeId.Parameters.Clear(); using var connection = OpenConnection();
_cmdGetHandlerTypeId.Parameters.AddWithValue("@guid", guid.ToByteArray()); using var cmd = connection.CreateCommand();
var result = _cmdGetHandlerTypeId.ExecuteScalar();
cmd.CommandText = SqlGetAssetTypeId;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
var result = cmd.ExecuteScalar();
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty; return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
} }
public DateTime? GetImportedAt(Guid guid) public DateTime? GetImportedAt(Guid guid)
{ {
_cmdGetImportedAt.Parameters.Clear(); using var connection = OpenConnection();
_cmdGetImportedAt.Parameters.AddWithValue("@guid", guid.ToByteArray()); using var cmd = connection.CreateCommand();
var result = _cmdGetImportedAt.ExecuteScalar();
if (result is long ticks) cmd.CommandText = SqlGetImportedAt;
{ cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
return new DateTime(ticks, DateTimeKind.Utc);
}
return null; var result = cmd.ExecuteScalar();
return result is long ticks ? new DateTime(ticks, DateTimeKind.Utc) : null;
} }
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies) public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
{ {
lock (_writeLock) using var connection = OpenConnection();
{ using var tx = connection.BeginTransaction();
using var tx = _connection.BeginTransaction();
_cmdClearDeps.Transaction = tx; using (var clearCmd = connection.CreateCommand())
_cmdClearDeps.Parameters.Clear(); {
_cmdClearDeps.Parameters.AddWithValue("@guid", assetId.ToByteArray()); clearCmd.Transaction = tx;
_cmdClearDeps.ExecuteNonQuery(); clearCmd.CommandText = SqlClearDeps;
clearCmd.Parameters.AddWithValue("@guid", assetId.ToByteArray());
clearCmd.ExecuteNonQuery();
}
if (dependencies.Length > 0)
{
using var insertCmd = connection.CreateCommand();
insertCmd.Transaction = tx;
insertCmd.CommandText = SqlInsertDep;
var fromParam = insertCmd.Parameters.Add("@from", SqliteType.Blob);
var toParam = insertCmd.Parameters.Add("@to", SqliteType.Blob);
fromParam.Value = assetId.ToByteArray();
_cmdInsertDep.Transaction = tx;
foreach (var dep in dependencies) foreach (var dep in dependencies)
{ {
_cmdInsertDep.Parameters.Clear(); toParam.Value = dep.ToByteArray();
_cmdInsertDep.Parameters.AddWithValue("@from", assetId.ToByteArray()); insertCmd.ExecuteNonQuery();
_cmdInsertDep.Parameters.AddWithValue("@to", dep.ToByteArray());
_cmdInsertDep.ExecuteNonQuery();
} }
tx.Commit();
} }
tx.Commit();
} }
public List<Guid> GetReferencers(Guid guid) public List<Guid> GetReferencers(Guid guid)
{ {
_cmdGetReferencers.Parameters.Clear(); using var connection = OpenConnection();
_cmdGetReferencers.Parameters.AddWithValue("@guid", guid.ToByteArray()); using var cmd = connection.CreateCommand();
using var reader = _cmdGetReferencers.ExecuteReader(); cmd.CommandText = SqlGetReferencers;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
using var reader = cmd.ExecuteReader();
var list = new List<Guid>(); var list = new List<Guid>();
while (reader.Read()) while (reader.Read())
{ {
@@ -223,10 +253,13 @@ public sealed partial class AssetCatalog : IDisposable
public List<Guid> GetDependencies(Guid guid) public List<Guid> GetDependencies(Guid guid)
{ {
_cmdGetDependencies.Parameters.Clear(); using var connection = OpenConnection();
_cmdGetDependencies.Parameters.AddWithValue("@guid", guid.ToByteArray()); using var cmd = connection.CreateCommand();
using var reader = _cmdGetDependencies.ExecuteReader(); cmd.CommandText = SqlGetDependencies;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
using var reader = cmd.ExecuteReader();
var list = new List<Guid>(); var list = new List<Guid>();
while (reader.Read()) while (reader.Read())
{ {
@@ -238,25 +271,93 @@ public sealed partial class AssetCatalog : IDisposable
public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll() public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll()
{ {
using var reader = _cmdEnumerate.ExecuteReader(); using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlEnumerate;
using var reader = cmd.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
yield return (new Guid((byte[])reader[0]), reader.GetString(1)); yield return (new Guid((byte[])reader[0]), reader.GetString(1));
} }
} }
public void Dispose() public IEnumerable<Guid> EnumerateByTypes(params Guid[] assetTypeIds)
{ {
_cmdGetGuid.Dispose(); if (assetTypeIds.Length == 0)
_cmdGetPath.Dispose(); {
_cmdUpsert.Dispose(); yield break;
_cmdDelete.Dispose(); }
_cmdGetHandlerTypeId.Dispose();
_cmdGetReferencers.Dispose(); using var connection = OpenConnection();
_cmdGetDependencies.Dispose(); using var cmd = connection.CreateCommand();
_cmdInsertDep.Dispose();
_cmdClearDeps.Dispose(); var parameterNames = new List<string>(assetTypeIds.Length);
_cmdEnumerate.Dispose(); for (var i = 0; i < assetTypeIds.Length; i++)
_connection.Dispose(); {
var paramName = $"@typeId{i}";
parameterNames.Add(paramName);
cmd.Parameters.AddWithValue(paramName, assetTypeIds[i].ToByteArray());
}
cmd.CommandText = $"SELECT guid FROM assets WHERE asset_type_id IN ({string.Join(", ", parameterNames)})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
yield return new Guid((byte[])reader[0]);
}
}
public List<SubAssetInfo> GetSubAssets(Guid parentGuid)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlEnumerateSubAssets;
cmd.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
using var reader = cmd.ExecuteReader();
var list = new List<SubAssetInfo>();
while (reader.Read())
{
list.Add(new SubAssetInfo(
new Guid((byte[])reader[0]),
new Guid((byte[])reader[1]),
reader.GetString(2),
reader.GetString(3),
reader.GetString(4),
reader.GetString(5),
new Guid((byte[])reader[6])));
}
return list;
}
public void RemoveSubAssetsExcept(Guid parentGuid, ReadOnlySpan<Guid> keepGuids)
{
if (keepGuids.Length == 0)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlDeleteSubAssetsForParent;
cmd.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
cmd.ExecuteNonQuery();
return;
}
var keep = new HashSet<Guid>(keepGuids.Length);
foreach (var guid in keepGuids)
{
keep.Add(guid);
}
foreach (var subAsset in GetSubAssets(parentGuid))
{
if (!keep.Contains(subAsset.Guid))
{
Remove(subAsset.Guid);
}
}
} }
} }

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;
@@ -49,7 +51,7 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
{ {
IncludeSubdirectories = true, IncludeSubdirectories = true,
EnableRaisingEvents = true, EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.LastWrite NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
}; };
_watcher.Created += OnFileSystemEvent; _watcher.Created += OnFileSystemEvent;
@@ -81,6 +83,11 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
foreach (var (guid, path) in _catalog.EnumerateAll()) foreach (var (guid, path) in _catalog.EnumerateAll())
{ {
if (path.Contains('#', StringComparison.Ordinal))
{
continue;
}
if (!foundGuids.Contains(guid)) if (!foundGuids.Contains(guid))
{ {
_catalog.Remove(guid); _catalog.Remove(guid);
@@ -130,51 +137,60 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
return; return;
} }
var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath); try
var fileExists = File.Exists(e.FullPath);
if (ext == AssetMetaIO.META_EXTENSION)
{ {
if (fileExists) var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
var fileExists = File.Exists(e.FullPath);
if (ext == AssetMetaIO.META_EXTENSION)
{ {
var meta = await AssetMetaIO.ReadAsync(e.FullPath); if (fileExists)
if (meta != null)
{ {
_catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath)); var meta = await AssetMetaIO.ReadAsync(e.FullPath);
await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), relativePath, ImportReason.SettingsChanged)); if (meta != null)
{
_catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath));
await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), relativePath, ImportReason.SettingsChanged));
}
} }
return;
} }
return;
}
var changeType = AssetChangeType.None; var changeType = AssetChangeType.None;
var guid = _catalog.GetGuid(relativePath); var guid = _catalog.GetGuid(relativePath);
if (!fileExists) if (!fileExists)
{
// The file is no longer on disk. Wait safely completed.
if (guid != Guid.Empty)
{ {
_catalog.Remove(guid); // The file is no longer on disk. Wait safely completed.
changeType = AssetChangeType.Deleted; if (guid != Guid.Empty)
{
_catalog.Remove(guid);
changeType = AssetChangeType.Deleted;
}
Logger.DebugAssert(e.ChangeType == WatcherChangeTypes.Deleted);
}
else if (guid == Guid.Empty)
{
// The file exists but isn't located inside our catalog yet -> Essentially a Creation
await HandleNewSourceFileAsync(relativePath);
changeType = AssetChangeType.Created;
}
else
{
// The file exists and is tracked in the catalog, but triggered an event -> Modification
await _importCoordinator.EnqueueAsync(new ImportJob(guid, relativePath, AssetMetaIO.GetMetaPath(relativePath), ImportReason.SourceChanged));
changeType = AssetChangeType.Modified;
}
if (changeType != AssetChangeType.None)
{
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
} }
} }
else if (guid == Guid.Empty) catch (Exception ex)
{ {
// The file exists but isn't located inside our catalog yet -> Essentially a Creation Logger.Warning($"FileSystemEvent exception: {ex.Message}");
await HandleNewSourceFileAsync(relativePath);
changeType = AssetChangeType.Created;
}
else
{
// The file exists and is tracked in the catalog, but triggered an event -> Modification
await _importCoordinator.EnqueueAsync(new ImportJob(guid, relativePath, AssetMetaIO.GetMetaPath(relativePath), ImportReason.SourceChanged));
changeType = AssetChangeType.Modified;
}
if (changeType != AssetChangeType.None)
{
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
} }
} }
@@ -212,13 +228,18 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
return; return;
} }
var handlerTypeId = handler?.EditorAssetTypeID; var assetTypeId = Guid.Empty;
if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo))
{
assetTypeId = handlerInfo.EditorAssetTypeID;
}
var meta = new AssetMeta var meta = new AssetMeta
{ {
Guid = Guid.NewGuid(), Guid = Guid.NewGuid(),
HandlerTypeId = handlerTypeId, AssetTypeId = assetTypeId,
HandlerVersion = 1, HandlerVersion = 1,
Settings = handler?.CreateDefaultSettings() Settings = handler?.CreateDefaultSettings(ext)
}; };
_ignoreMetaWrites[metaPath] = true; _ignoreMetaWrites[metaPath] = true;
@@ -402,11 +423,41 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
return await Task.WhenAll(tasks); return await Task.WhenAll(tasks);
} }
public Task<Result> OpenAssetAsync(Guid id)
{
var path = GetAssetPath(id);
if (path == null)
{
return Task.FromResult(Result.Failure("Asset not found."));
}
return OpenAssetAsync(path);
}
public Task<Result> OpenAssetAsync(string assetPath)
{
try
{
var method = TypeCache.GetMethodsWithAttribute<AssetOpenHandlerAttribute>()?
.FirstOrDefault(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>()?.Extensions.Contains(Path.GetExtension(assetPath)) ?? false);
if (method == null)
{
return Task.FromResult(Result.Failure("No handler for this asset type."));
}
return (Task<Result>)method.Invoke(null, new object[] { assetPath })!;
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure($"Failed to open asset: {ex.Message}"));
}
}
public void Dispose() public void Dispose()
{ {
_watcher.Dispose(); _watcher.Dispose();
_importCoordinator.Dispose(); _importCoordinator.Dispose();
_catalog.Dispose();
_loadLock.Dispose(); _loadLock.Dispose();
} }
} }

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;
@@ -37,8 +37,12 @@ internal class EditorContentProvider : IContentProvider
public AssetType GetAssetType(Guid guid) public AssetType GetAssetType(Guid guid)
{ {
var handlerID = _catalog.GetHandlerTypeId(guid); var assetTypeID = _catalog.GetAssetTypeId(guid);
var handler = AssetHandlerRegistry.GetByAssetTypeId(handlerID); if (AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(assetTypeID, out var info))
return handler?.RuntimeAssetType ?? AssetType.Unknown; {
return info.RuntimeAssetType;
}
return AssetType.Unknown;
} }
} }

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

@@ -56,7 +56,14 @@ internal sealed partial class ImportCoordinator : IDisposable
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default) public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
{ {
return _importChannel.Writer.WriteAsync(job, token); try
{
return _importChannel.Writer.WriteAsync(job, token);
}
catch (ChannelClosedException)
{
return ValueTask.CompletedTask;
}
} }
private async Task WorkerLoop(CancellationToken token) private async Task WorkerLoop(CancellationToken token)
@@ -95,38 +102,77 @@ internal sealed partial class ImportCoordinator : IDisposable
return; return;
} }
var handler = meta.HandlerTypeId.HasValue var handler = meta.AssetTypeId.HasValue
? AssetHandlerRegistry.GetByAssetTypeId(meta.HandlerTypeId.Value) ? AssetHandlerRegistry.GetByAssetTypeId(meta.AssetTypeId.Value)
: AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath)); : AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath));
var contentHash = await ComputeFileHashAsync(job.SourcePath, token); var contentHash = await ComputeFileHashAsync(job.SourcePath, token);
var settingsHash = ComputeSettingsHash(meta.Settings); var settingsHash = ComputeSettingsHash(meta.Settings);
var handlerVersion = AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(meta.AssetTypeId ?? Guid.Empty, out var info)
? info.Version
: 0;
// Check if we can skip (if not a manual reimport) // Check if we can skip (if not a manual reimport)
if (job.Reason != ImportReason.ManualReimport && if (job.Reason != ImportReason.ManualReimport &&
meta.ContentHash == contentHash && meta.ContentHash == contentHash &&
meta.SettingsHash == settingsHash && meta.SettingsHash == settingsHash &&
meta.HandlerVersion == AssetHandlerRegistry.GetVersionByAssetTypeId(meta.HandlerTypeId ?? Guid.Empty)) meta.HandlerVersion == handlerVersion)
{ {
return; return;
} }
var importResult = Result.Success(); var importResult = Result.Success();
var subAssets = Array.Empty<ImportedSubAsset>();
if (handler is IImportableAssetHandler importable) if (handler is IImportableAssetHandler importable)
{ {
var targetPath = GetImportedAssetPath(job.AssetGuid); var targetPath = GetImportedAssetPath(job.AssetGuid);
importResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token); var subAssetResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token);
importResult = subAssetResult;
if (subAssetResult.IsSuccess)
{
subAssets = subAssetResult.Value;
}
} }
if (importResult.IsSuccess) if (importResult.IsSuccess)
{ {
meta.ContentHash = contentHash; meta.ContentHash = contentHash;
meta.SettingsHash = settingsHash; meta.SettingsHash = settingsHash;
meta.HandlerVersion = AssetHandlerRegistry.GetVersionByAssetTypeId(meta.HandlerTypeId ?? Guid.Empty); meta.HandlerVersion = handlerVersion;
meta.LastImportedUtc = DateTime.UtcNow; meta.LastImportedUtc = DateTime.UtcNow;
await AssetMetaIO.WriteAsync(job.MetaPath, meta, token); await AssetMetaIO.WriteAsync(job.MetaPath, meta, token);
if (subAssets.Length > 0)
{
var dependencies = new Guid[subAssets.Length];
for (var i = 0; i < subAssets.Length; i++)
{
var subAsset = subAssets[i];
dependencies[i] = subAsset.Guid;
var subMeta = new AssetMeta
{
Guid = subAsset.Guid,
AssetTypeId = subAsset.AssetTypeId,
HandlerVersion = meta.HandlerVersion,
ContentHash = contentHash,
SettingsHash = settingsHash,
LastImportedUtc = meta.LastImportedUtc,
};
_catalog.UpsertSubAsset(job.AssetGuid, subMeta, subAsset.VirtualSourcePath, subAsset.Kind, subAsset.DisplayName, subAsset.StablePath);
}
_catalog.RemoveSubAssetsExcept(job.AssetGuid, dependencies);
_catalog.SetDependencies(job.AssetGuid, dependencies);
}
else
{
_catalog.RemoveSubAssetsExcept(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
_catalog.SetDependencies(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
}
OnImportCompleted?.Invoke(null, job.AssetGuid); OnImportCompleted?.Invoke(null, job.AssetGuid);
} }
else else
@@ -170,6 +216,15 @@ internal sealed partial class ImportCoordinator : IDisposable
{ {
_importChannel.Writer.TryComplete(); _importChannel.Writer.TryComplete();
_cts.Cancel(); _cts.Cancel();
try
{
Task.WaitAll(_workers);
}
catch (AggregateException)
{
}
_cts.Dispose(); _cts.Dispose();
} }
} }

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,16 +58,20 @@ internal static class ActivationHandler
var opts = new AllocationManagerDesc var opts = new AllocationManagerDesc
{ {
ArenaCapacity = 1024 * 1024 * 1024, // 1 GB. Arena using virtual memory, so this is just a reservation and won't actually consume physical memory until used. ArenaCapacity = 1024 * 1024 * 1024, // 1 GB. Arena using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
StackCapacity = 1024 * 1024 * 32, // 32 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used. StackCapacity = 64 * 1024 * 1024, // 64 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
FreeListChunkSize = 64 * 1024 * 1024, FreeListChunkSize = 64 * 1024,
FreeListDefaultAlignment = 8, FreeListDefaultAlignment = 8,
FreeListConcurrencyLevel = Environment.ProcessorCount TLSFInitialChunkSize = 32 * 1024 * 1024,
TLSFAlignment = 8,
}; };
AllocationManager.Initialize(opts); AllocationManager.Initialize(opts);
var assetRegistry = App.GetService<IAssetRegistry>(); var assetRegistry = App.GetService<IAssetRegistry>();
var engineCore = App.GetService<EngineCore>(); var engineCore = App.GetService<EngineCore>();
var editorTick = App.GetService<EditorTickEngine>();
editorTick.Start();
assetRegistry.OnAssetImported += (sender, e) => assetRegistry.OnAssetImported += (sender, e) =>
{ {

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();
@@ -153,6 +136,7 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex);
Environment.Exit(ex.HResult); Environment.Exit(ex.HResult);
} }
} }
@@ -169,7 +153,7 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
Debugger.BreakForUserUnhandledException(ex); Logger.Error(ex);
} }
finally finally
{ {

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 });
ref var data = ref ComponentObject.GetData<LocalToWorld>(); root.Children.Add(new PropertyField() { Label = "Scale", Content = _scaleField });
data.matrix.c3.xyz = e.NewValue;
};
_rotationField.OnValueChanged += (s, e) => var property = componentNode.GetProperty<float4x4>(nameof(LocalToWorld.matrix));
{
ref var data = ref ComponentObject.GetData<LocalToWorld>();
var newRotation = quaternion.EulerXYZ(e.NewValue * math.TORADIANS);
data.matrix.GetTRS(out var oldTranslation, out var _, out var oldScale); _translationField.BindTwoWay(property,
data.matrix = float4x4.TRS(oldTranslation, newRotation, oldScale); getter: node =>
}; {
return node.Value.c3.xyz;
},
setter: (node, val) =>
{
var data = node.Value;
data.c3.xyz = val;
node.SetValueFromUI(data);
});
_scaleField.OnValueChanged += (s, e) => _rotationField.BindTwoWay(property,
{ getter: node =>
ref var data = ref ComponentObject.GetData<LocalToWorld>(); {
var newScale = e.NewValue; node.Value.GetTRS(out _, out var rotation, out _);
return math.degrees(math.EulerXYZ(rotation));
},
setter: (node, val) =>
{
var data = node.Value;
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);
});
data.matrix.GetTRS(out var oldTranslation, out var oldRotation, out var _); _scaleField.BindTwoWay(property,
data.matrix = float4x4.TRS(oldTranslation, oldRotation, newScale); getter: node =>
}; {
var matrix = node.Value;
container.Children.Add(new PropertyField() { Label = "Position", Content = _translationField }); var scaleX = math.length(matrix.c0.xyz);
container.Children.Add(new PropertyField() { Label = "Rotation", Content = _rotationField }); var scaleY = math.length(matrix.c1.xyz);
container.Children.Add(new PropertyField() { Label = "Scale", Content = _scaleField }); var scaleZ = math.length(matrix.c2.xyz);
} return new float3(scaleX, scaleY, scaleZ);
},
public override void Update() setter: (node, val) =>
{ {
var data = ComponentObject.GetData<LocalToWorld>(); var data = node.Value;
data.matrix.GetTRS(out var position, out var rotation, out var scale); data.GetTRS(out var oldTranslation, out var oldRotation, out _);
data = float4x4.TRS(oldTranslation, oldRotation, val);
_translationField.Value = position; node.SetValueFromUI(data);
_rotationField.Value = math.degrees(math.EulerXYZ(rotation)); });
_scaleField.Value = scale;
}
public override void Destroy()
{
} }
} }

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;
@@ -90,8 +89,12 @@ internal partial class ContentBrowserViewModel : ObservableObject
if (!isDir) if (!isDir)
{ {
var ext = Path.GetExtension(fullPath); var ext = Path.GetExtension(fullPath);
assetType = AssetHandlerRegistry.GetRuntimeAssetTypeByExtension(ext); if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var info))
{
assetType = info.RuntimeAssetType;
}
} }
Files.Add(new ExplorerItem(Path.GetFileName(fullPath), fullPath, isDir, assetType)); Files.Add(new ExplorerItem(Path.GetFileName(fullPath), fullPath, isDir, assetType));
} }
} }
@@ -144,7 +147,7 @@ internal partial class ContentBrowserViewModel : ObservableObject
} }
var ext = Path.GetExtension(file); var ext = Path.GetExtension(file);
var assetType = AssetHandlerRegistry.GetRuntimeAssetTypeByExtension(ext); var assetType = AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo) ? handlerInfo.RuntimeAssetType : AssetType.Unknown;
var fileItem = new ExplorerItem(Path.GetFileName(file), file, false, assetType); var fileItem = new ExplorerItem(Path.GetFileName(file), file, false, assetType);
Files.Add(fileItem); Files.Add(fileItem);
@@ -153,7 +156,7 @@ internal partial class ContentBrowserViewModel : ObservableObject
CurrentDirectoryPath = Path.GetFullPath(path); CurrentDirectoryPath = Path.GetFullPath(path);
} }
internal (ExplorerItem?, int) OpenSelected() internal async ValueTask<(ExplorerItem?, int)> OpenSelected()
{ {
if (SelectedItem == null) if (SelectedItem == null)
{ {
@@ -168,7 +171,12 @@ internal partial class ContentBrowserViewModel : ObservableObject
} }
else else
{ {
// _assetRegistry.OpenAsset(SelectedItem.FullName); var result = await _assetRegistry.OpenAssetAsync(SelectedItem.Path);
if (result.IsFailure)
{
return (null, -1);
}
return (null, 1); return (null, 1);
} }
} }

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}" <Grid.ColumnDefinitions>
BorderThickness="0,0,0,1"> <ColumnDefinition Width="*" />
<Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> </Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
</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>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4"> <FontIcon
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="&#xF159;" /> Grid.Column="0"
<TextBlock Text="Test" /> Margin="0,0,4,0"
</StackPanel> FontSize="{StaticResource ToolbarFontIconFontSize}"
<StackPanel Orientation="Horizontal" Spacing="4"> Glyph="&#xE721;" />
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="&#xF159;" /> <TextBox Grid.Column="1" PlaceholderText="Search item..." />
<TextBlock Text="Test" /> </Grid>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4"> <Border Margin="-8,8,-4,-4" Style="{StaticResource HorizontalStrongDivider}" />
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="&#xF159;" /> </StackPanel>
<TextBlock Text="Test" />
</StackPanel> <TreeView
</ListView> x:Name="SceneTreeView"
</Border> Grid.Row="1"
Margin="4,2,0,2"
AllowDrop="True"
CanDrag="True"
CanDragItems="True"
CanReorderItems="True"
DragItemsCompleted="OnTreeViewDragItemsCompleted"
DragItemsStarting="OnTreeViewDragItemsStarting"
ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}"
KeyDown="OnTreeViewKeyDown"
SelectionMode="Single" />
</Grid> </Grid>
</UserControl> </UserControl>

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