64 Commits

Author SHA1 Message Date
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
e3a02437c3 Refactor mesh/texture pipeline for unified buffers & cubemaps
- Switch mesh import to unified vertex/index buffers with multi-material partitioning (`MaterialPartInfo`)
- Update `GeometryMeshNode` and meshlet builder for unified buffer layout
- Refactor cubemap texture pipeline: packed faces, improved GGX mip generation, equirect->cubemap conversion, and cubemap sampling
- Change MeshBuilder normal/tangent utilities to use `Span<T>`
- Add mimalloc allocator dependency and enable in Debug/Release
- Misc bug fixes, resource management, and code cleanup
2026-04-26 21:40:24 +09:00
5903ddda2b Refactor mesh import, meshlet, and asset handler systems
- Mesh import now builds full node hierarchy and splits geometry by material, with robust normal/tangent handling
- Meshlet generation supports material indices for correct assignment
- Refactored texture cube map compression and mipmap handling
- Updated asset handler registration to new namespace
- Enabled asset reimport on import events
- Improved code quality, resource management, and formatting
2026-04-26 14:49:58 +09:00
1a91811621 Refactor asset pipeline to use file paths, improve import
- Switched asset handler interfaces and implementations to use file paths instead of FileStreams for all operations.
- Refactored mesh asset structure and parsing, moved meshlet logic to MeshProcessor, and introduced hierarchical MeshNode types.
- Updated texture asset handling: switched to bits-per-channel, improved mipmap/cubemap generation, and SPMD HDRI support.
- Updated shader asset handlers to use file paths and split code compilation logic.
- Improved asset registry: added event debouncing, better path handling, and import time/hash tracking.
- Added source generator for IAssetSettings registration to support polymorphic JSON serialization.
- Updated dependencies and tests; various minor fixes and cleanups.
2026-04-25 18:23:21 +09:00
4757c0c91a Replace Magick.NET with stb_image; refactor asset pipeline
- Switched image loading/saving from Magick.NET to native stb_image (Ghost.StbI), removing Magick.NET dependency.
- Added Ghost.StbI project with native DLL, P/Invoke bindings, and wrapper.
- Refactored TextureAssetHandler and TextureProcessor for stb_image, memory-mapped IO, and HDR/16-bit support.
- Split IAssetHandler into IImportableAssetHandler and IPackableAssetHandler; updated interfaces to use FileStream.
- Added shader and mesh asset handlers (GraphicsShaderAssetHandler, ComputeShaderAssetHandler, FBXAssetHandler).
- Improved asset registry/catalog path handling and naming consistency.
- Updated asset import pipeline to use new interfaces and trigger engine reimport.
- Enhanced UI toolbar button styles and EditPage layout.
- Added StbIBindingTest, DisableRuntimeMarshalling, and native wrapper attributes.
- Updated wrapper generator for regex derivesFrom; added stbi.json config.
- Removed Magick.NET reference; added Ghost.StbI and Ghost.Ufbx references.
- Miscellaneous bugfixes and code cleanup.
2026-04-24 00:40:27 +09:00
3533d3367f feat: Implement LogViewer control and integrate into EditPage
- Added LogViewer control to display log messages with filtering options.
- Integrated LogViewer into EditPage for better log management.
- Updated EngineEditorWindow to navigate to EditPage.
- Enhanced Logger implementation for improved performance and stack trace capturing.
- Introduced PathUtility for path normalization.
- Refactored AssetManager to correct shader asset type naming.
- Removed obsolete AssetHandlerRegistryTests and cleaned up related tests.
- Updated ImportCoordinatorTests for streamlined asset import process.
2026-04-22 20:25:14 +09:00
884611181a Refactor instance update flow, asset registry, and texture IO
- Renamed AddInstanceRequest to UpdateInstanceRequest; unified add/update logic for GPU instances
- Introduced UpdateGPUInstanceSystem to handle changed MeshInstance components
- Replaced QueryBuilder.Create() with QueryBuilder.New() for consistency
- Switched versioning in ChunkView HasChanged/HasStructuralChanged to uint
- Added extension-to-AssetType mapping in AssetHandlerRegistry
- Changed TextureAssetHandler/Processor to use nint for image data
- Enhanced DDS cache: read mipmap count, handle invalid files
- Updated ProjectBrowserViewModel to use IAssetRegistry
- Upgraded Misaki.HighPerformance and System.IO.Hashing packages
- Set DependencyChainCapacity in JobSchedulerDesc
- Fixed instance buffer logic in GhostRenderPipeline
- Miscellaneous cleanups and namespace improvements
2026-04-22 15:36:49 +09:00
cb4092179f refactor(core): asset pipeline overhaul & dock removal
- Introduced IAsset interface and refactored asset loading/saving.
- Migrated TextureContentHeader to Ghost.Engine; updated usage.
- Rewrote AssetRegistry, AssetCatalog, ImportCoordinator for new asset flow.
- Added thread-safe ConcurrentHashSet utility.
- Improved EditorApplication folder management/init.
- Updated TextureAssetHandler/TextureProcessor for new import/export.
- Added EditorContentProvider for asset access.
- Updated AssetManager to use new AssetType enum; removed GCHandle.
- Removed all custom docking controls and templates.
- Deleted obsolete ViewModels/Pages (Console, Hierarchy, Inspector, Project).
- Renamed ProjectBrowser to ContentBrowser; updated references.
- Updated NuGet packages, Result conversions, and commit instructions.
- General cleanup: namespaces, dead code, structure.
2026-04-21 23:20:29 +09:00
c249a389e3 Refactor AssetHandlerRegistry, modernize editor window UI
Refactored AssetHandlerRegistry to a static class and updated all usages. Replaced the docking-based EngineEditorWindow with a new grid/panel layout, adding modern toolbar, menu bar, and status bar. Introduced new divider styles and improved UI details. Removed obsolete and unused files, updated project references, and made minor code and UI/UX improvements.
2026-04-20 16:54:49 +09:00
ed00f205b0 feat: implement asynchronous asset management system with texture streaming support 2026-04-20 01:09:59 +09:00
4f5556ee1b feat: implement core graphics rendering system and D3D12 RHI backend infrastructure 2026-04-18 21:03:05 +09:00
abd5ad74d5 Refactor asset pipeline: new registry, loader, and runtime
Major overhaul of asset system:
- Split assets into source, .gmeta (JSON), and cooked .imported binaries
- Replaced Asset base class; added TextureAsset, TextureLoader
- AssetManager now uses job-based, dependency-aware loading
- Unified IAssetHandler API; removed legacy handler interfaces
- Updated D3D12 allocator and graphics code for new resource model
- Improved error handling, memory management, and GPU upload logic
- Updated docs and removed obsolete code/interfaces
2026-04-18 01:46:37 +09:00
13bf1501e4 feat(asset): asset manager + registration generator
Add runtime AssetManager and AssetHandlerRegistrationGenerator source generator.
Update editor asset handler types and services to work with new registration
mechanism and asset catalog. Remove legacy contracts ICloneable and IReleasable.

Files added:
- src/Runtime/Ghost.Engine/AssetManager.cs
- src/Runtime/Ghost.Generator/AssetHandlerRegistrationGenerator.cs

Major edits:
- Editor asset handler classes and services (Asset*, Texture*, Registry)
- Runtime Handle.cs and project files
- Render graph executor and tests updated accordingly

This commit introduces the foundation for the modern asset pipeline
including generated registration of asset handlers and a centralized
runtime AssetManager that will drive asset lifecycle.
2026-04-15 22:21:00 +09:00
6615fe794e feat(asset): modern asset system with SQLite catalog
Refactored asset management to use a persistent, thread-safe SQLite-backed AssetCatalog, replacing in-memory dictionaries.
Added AssetHandlerRegistry for O(1) handler lookup, ImportCoordinator for async background importing, and robust AssetMeta/AssetMetaIO for JSON-based metadata and settings.
Refactored AssetRegistry to integrate these components and support auto-import via file system watcher.
Updated IImportableAssetHandler for handler-specific settings and polymorphic serialization.
Added comprehensive unit tests for all new systems.
Removed obsolete code and legacy integration tests.

BREAKING CHANGE: Asset system APIs and storage format have changed; migration required for existing projects.
2026-04-14 20:18:38 +09:00
d9bfa43663 feat(rendering): add GPU scene updates and optimizations
Added a new `code-executor` agent with strict TDD and performance focus. Refactored `TextureProcessor` and `TextureAssetHandler` to use `Magick.NET` for image processing. Enhanced `GPUScene` with `InstanceCounterBuffer` and improved instance management. Introduced a compute shader for GPU scene updates. Updated `GhostRenderPipeline` to handle add/remove instance buffers.

BREAKING CHANGE: Removed `x86` platform support and replaced `CachesFolderPath` with `LibraryFolderPath`. Updated project dependencies and removed unused utility classes.
2026-04-14 17:56:23 +09:00
817b32b8d9 feat(graphics): refactor pipeline keying and allocators
Major refactor of graphics pipeline keying, shader cache, and resource allocation.
Replaced most Allocator usage with AllocationHandle, modernized logger usage,
and unified pipeline state keys. Updated MeshUtility to use AllocationHandle.FreeList.
Added new shader pipeline architecture docs and improved error handling throughout.

BREAKING CHANGE: Pipeline keying and resource allocation APIs have changed.
2026-04-13 23:07:52 +09:00
c66fda5332 feat(shader): refactor and enhance shader pipeline
Refactored the shader compilation pipeline to introduce modularity, improve performance, and enhance maintainability. Key changes include:

- Added `ShaderCompilationConfig`, `CompilerOptimizeLevel`, and `ShaderStage` enums.
- Replaced `SM` property with `ShaderModel` in shader models.
- Introduced `ShaderLibrary` for in-memory and disk-based shader caching.
- Refactored `DSLShaderCompiler` and `AntlrShaderCompiler` for better hashing and error handling.
- Centralized shader compilation logic in `ShaderCompilerUtility`.
- Removed legacy shader compilation logic from `IShaderCompiler`.
- Updated `RenderGraph`, `ResourceManager`, and `Material` to integrate with the new caching system.
- Improved memory management with `NativeMemoryManager<T>`.

BREAKING CHANGE: Removed legacy shader compilation methods and replaced them with a new caching and compilation system.
2026-04-11 23:10:39 +09:00
f9a6e9cbbe feat(shader): refactor and enhance shader compilation
Refactored shader compilation and resource management systems:
- Introduced `DXCShaderCompiler` for HLSL compilation and reflection.
- Added `BuildFinalShaderCode` method for robust shader code generation.
- Replaced raw strings with `ShaderEntryPoint` struct for shader paths.
- Updated `RenderContext` and `RenderGraphContext` for new pipeline methods.
- Added thread-safe resource management methods in `ResourceManager`.
- Introduced `DXCShaderReflectionData` for shader reflection handling.
- Removed redundant code and simplified `ShaderPropertiesRegistry`.

BREAKING CHANGE: Updated shader and resource APIs to use new structures and methods.
2026-04-11 00:45:46 +09:00
4ed5572ce7 feat(shader): add compute shader support and refactor pipeline
Refactored shader system to support both graphics and compute shaders.
- Updated ANTLR grammars and parser logic for explicit shader model and compute shader entry points.
- Split shader models and descriptors for graphics and compute.
- Refactored pipeline key generation and D3D12 pipeline library for compute support.
- Updated push constant layouts and HLSL includes for both shader types.
- Improved error handling and test coverage with new example files.

BREAKING CHANGE: Shader model, descriptor, and pipeline APIs have changed. Existing shader and pipeline code must be updated to use the new types and conventions.
2026-04-10 02:53:40 +09:00
68fda03aa9 feat(render): refactor pipeline & shader system for DX12 WG
Major refactor of render pipeline and shader system:
- Replaced legacy shader properties with source generator and attribute-based HLSL struct generation.
- Introduced ShaderPropertiesRegistry for runtime property layout/code registration.
- Added modular IRenderPipeline, IRenderPipelineSettings, and IRenderPayload interfaces.
- Implemented GhostRenderPipeline and ECS-driven GPUScene management.
- Added experimental DirectX 12 Work Graph support.
- Refactored shader compilation, variant hashing, and caching.
- Updated APIs for consistency and improved codegen for registration.

These changes modernize the rendering infrastructure for advanced features like work graphs and dynamic pipelines.

BREAKING CHANGE: Shader DSL, pipeline, and property APIs have changed. Existing shaders and pipeline integrations must be updated.
2026-04-08 23:08:02 +09:00
0fc449bc78 feat(ufbx): switch to native ufbx_vec/quat/matrix types
Replaces all Misaki.HighPerformance.Mathematics vector, quaternion, and matrix types in Ghost.Ufbx bindings with new native ufbx_vec2, ufbx_vec3, ufbx_vec4, ufbx_quat, and ufbx_matrix structs. Updates all interop code, struct fields, and API signatures accordingly. Adds struct definitions for the new types and provides matrix operations as struct methods. Removes unnecessary math package reference. Also includes minor fixes to system attributes, meshlet LOD logic, and mesh utility.

BREAKING CHANGE: All Ufbx-related APIs now use ufbx_* types instead of Misaki.HighPerformance.Mathematics types. Existing code using the old types will require updates.
2026-04-07 23:50:55 +09:00
a5c10cfe5a feat(render): support per-frame render payloads
Refactored the render pipeline system to introduce per-frame IRenderPayload management.
IRenderPipelineSettings now requires CreatePipeline and CreatePayload methods.
Updated RenderSystem and test pipeline to use the new payload model.
Removed legacy GhostRenderPipeline and test code.
Added RenderPipelineSystemAttribute for pipeline system registration.
Includes minor fixes such as version field type corrections and typo fixes.

BREAKING CHANGE: Render pipeline and payload creation APIs have changed; implementers must update to the new interface methods.
2026-04-07 17:12:01 +09:00
6c96d4cf50 feat(core,rendering)!: add cleanup component support, refactor render pipeline
Introduce ICleanupComponent and cleanup archetype logic in ECS. Refactor component versioning to uint. Update IResourceDatabase to use map/unmap pattern. Decouple per-frame render requests from RenderSystem via IRenderPayload. Update render pipeline and extraction system to new API.

BREAKING CHANGE: Entity destruction and render pipeline APIs have changed. IResourceDatabase.MapResource signature is updated; all callers must use map/memcpy/unmap. RenderSystem no longer manages per-frame render requests directly.
2026-04-06 22:05:48 +09:00
c6bdbe0710 feat(d3d12): add indirect command execution support
Added ICommandSignature and D3D12CommandSignature for indirect command execution in the D3D12 backend, with supporting types. Updated ICommandBuffer and IGraphicsEngine interfaces to support indirect execution and command signature creation. Refactored command buffer pooling in D3D12GraphicsEngine for more flexible reuse. Changed BeginFrame and EndFrame to void and clarified parameter names. Updated resource and frame data structures to use direct buffer indices. Added RenderingUtility for buffer and texture uploads. Removed IRenderOutput interface. Updated RenderSystem render loop and HLSL/C# code to match new buffer usage patterns.

BREAKING CHANGE: ICommandBuffer, IGraphicsEngine, and related APIs have changed signatures and behaviors. Indirect command execution is now supported and required for some advanced features.
2026-04-05 23:11:08 +09:00
effd33b285 feat(rendergraph): async queue, pool refactor, barrier cleanup
Refactor resource pool to use UnsafeQueue/UnsafeList for transient resources, improving memory management and performance.
Add async GPU wait support to ICommandQueue and D3D12.
Refactor render graph barrier system, streamline CompiledBarrier, and remove ResourceBarrier.
RenderGraphCompiler now returns Result<float2, Error> for dynamic resolution scaling.
Replace custom memory pools with Allocator.FreeList for temp allocations.
Add ResourceUploadBatch for async/sync resource uploads.
Fix D3D12 disposal and fence tracking bugs.
Update NuGet dependencies.
Numerous minor cleanups and code improvements.
2026-04-05 17:54:23 +09:00
92970f85ef feat(render): improve frame sync and CPU write tracking
Refactored frame submission and synchronization logic for more accurate GPU/CPU coordination. Introduced CpuWriteOpen property to enforce correct CPU frame access patterns. Updated ResourceManager to track _submittedFrame and improved event waiting logic. Added debug assertions and enhanced logging for frame start events. Documented known command submission issue in GraphicsTestWindow.
2026-04-03 22:17:54 +09:00
2dc97f3149 feat(resource-manager): add reusable page pool for efficiency
Refactored ResourceManager to introduce a _freePages queue for reusable pages, reducing unnecessary allocations. Added TryRentReusablePage and IsHeapFlagsCompatible helpers to efficiently rent compatible pages. Updated page creation and retirement logic to use the free pool, and ensured all page pools are properly released during cleanup.
2026-04-03 18:19:00 +09:00
ba9e24c46c feat(rhi): refactor resource & barrier management for D3D12
Modernizes resource and barrier management for the D3D12 backend. Key changes:
- Simplifies BarrierDesc by removing nullable "before" states; now inferred from resource database.
- Adds IsAliasing flag to BarrierDesc for aliasing transitions.
- Replaces ResourceMemoryType with HeapType in BufferDesc and related APIs.
- Enhances ResourceViewGroup with usage inference methods.
- Adds D3D12 utility helpers for heap/flag conversions and resource description extraction.
- Optimizes command buffer barrier emission, skipping redundant barriers.
- Refactors Material and RenderContext to use new APIs and state tracking.
- Updates ResourceManager pooling to use HeapType and standard Queue.
- Simplifies RenderGraphExecutor barrier logic and aliasing handling.
- Improves RenderSystem frame synchronization and resource retirement.
- Cleans up obsolete code and improves debug output.

BREAKING CHANGE: Updates to resource and barrier APIs require changes to all code interfacing with resource creation, barriers, and memory types.
2026-04-03 17:03:41 +09:00
6321b36ef5 feat(resource): refactor heap management & suballocation
Major overhaul of GPU resource/heap management:
- Replace resource pooling and upload buffer logic with transient heap/page-based suballocation in ResourceManager.
- Add support for suballocation and heap flags/types, with D3D12 helpers.
- Remove ICommandBuffer.UploadBuffer/UploadTexture; add UpdateSubResources and CopyBuffer, move upload logic to RenderingContext.
- Refactor D3D12ResourceAllocator/Database for suballocation, heap flags, and mapping.
- Standardize on Handle<GPUBuffer> usage.
- Update meshlet/mesh utilities for new allocation handles and memory pools.
- Refactor RenderGraph and docs to use "heap" terminology.
- Use cpuFrame/gpuFrame consistently for frame sync.
- Add s2h.hlsl, s2h_3d.hlsl, s2h_scatter.hlsl shader debug libs.
- Miscellaneous fixes, cleanup, and dependency updates.

BREAKING CHANGE: Resource pooling and upload APIs replaced with new heap/page-based suballocation system. Update all buffer/texture creation and upload code to use new ResourceManager and ICommandBuffer methods.
2026-04-03 01:48:49 +09:00
d03eb659fa feat(meshlet): refactor meshlet pipeline & add benchmark
Refactor meshlet build pipeline for robustness and performance.
Rename DxcShaderCompiler to DXCShaderCompiler. Enhance meshlet
data structures with bounds and LOD info. Add fallback mesh
simplification. Remove obsolete MeshRenderPass. Add
MeshoptBenchmark for meshlet build performance. Update mesh
import utilities for correct handedness. Minor bug fixes and
code cleanups.
2026-04-02 17:50:44 +09:00
e32a24739d feat(rhi): add NoAccess ops, axis conversion, meshlet color
Added NoAccess to attachment ops for depth/stencil, updated D3D12 and RenderGraph to handle new ops, and improved axis/handedness conversion in mesh loading. Enabled meshlet color hashing in test shader. Changed default rasterizer winding, added format helpers, and updated camera transform for correct mesh orientation. JobScheduler usage commented out for now.
2026-04-01 19:57:27 +09:00
eb41f23582 feat(rendergraph): skip redundant SRV barriers, add usage param
Add logic to skip generic SRV barriers for resources explicitly handled as color, depth, or random access in raster passes, preventing redundant transitions. Update RGTextureDesc to accept a TextureUsage parameter for more flexible texture descriptor creation.
2026-04-01 17:20:18 +09:00
3157596b5d Fix D3D12 depth format and stencil barrier issues in Render Graph 2026-04-01 15:38:37 +09:00
a00cb27529 feat(wrapper): span-based interop, resource API refactor
Refactored native wrappers to use ReadOnlySpan<T> for pointer parameters, improving .NET safety and interop. Enhanced wrapper generator with $TYPE and prefix/suffix-based parameter remapping. Added platform-specific native library loading for meshoptimizer, nvtt, and ufbx. Updated D3D12GraphicsEngineFactory for native DLL resolution and removed redundant logic from UnitTestApp. Changed RenderGraphBuilder's resource extraction API to use QueryTextureExtraction/QueryBufferExtraction with explicit handles and flags. Removed IRenderer and D3D12Renderer, moving RenderContext to RenderPipeline. Improved mesh loading, resource management, and updated test shader conventions. Updated project references, build settings, and added launchSettings.json for tooling.

BREAKING CHANGE: Native wrapper APIs now use ReadOnlySpan<T> instead of pointers. RenderGraphBuilder resource extraction API has changed. IRenderer and D3D12Renderer have been removed.
2026-04-01 14:50:20 +09:00
0b6e5b8501 feat(mesh): update Vertex layout, add mesh loader
Refactored Vertex to use float3 position/normal, float2 uv, float4 tangent, and Color128 color, updating all mesh generation and HLSL code accordingly. Added MeshUtility for loading .obj/.fbx meshes with deduplication and normal/tangent computation. Updated GraphicsTestWindow to use the new loader and improved resource management. Fixed D3D12ResourceAllocator resource creation logic, improved camera projection math, and simplified RenderingLayerMask. Updated package references and app display name.

BREAKING CHANGE: Vertex struct layout changed; all mesh code and shaders must use the new format.
2026-04-01 00:06:31 +09:00
89e6c68f2a feat(rhi)!: refactor resource handles to GPUTexture/GPUBuffer
Refactored all graphics resource handles to use Handle<GPUTexture> and Handle<GPUBuffer> instead of Handle<Texture> and Handle<GraphicsBuffer>. Updated all APIs, interfaces, and implementations to use the new types, including ICommandBuffer, IResourceAllocator, ISwapChain, IRenderOutput, IRenderGraphBuilder, and related classes. Introduced TempJobAllocator for frame-latency-aware allocations. Updated ResourceHandleExtensions for new conversions. Performed minor code cleanups and removed the empty ClusterLod.cs file.

BREAKING CHANGE: All usages of Handle<Texture> and Handle<GraphicsBuffer> are replaced with Handle<GPUTexture> and Handle<GPUBuffer>. This affects all APIs and resource management code. Callers must update their code to use the new handle types.
2026-03-30 21:27:16 +09:00
b28b32f502 feat(ui): migrate ProjectBrowser to GridView, improve cleanup
Refactored ProjectBrowser to use GridView instead of ItemsView for file display, updated selection logic, and set a minimum grid height. FloatingWindow now manages DockingLayout cleanup on close to prevent resource leaks. Simplified DockDocument and DockGroup instantiations in EngineEditorWindow. Updated GetDirectoryNameConverter to use Path.GetDirectoryName directly. App shutdown now calls EditorApplication.Shutdown(). Added Ghost.Engine reference in ActivationHandler.
2026-03-29 19:55:05 +09:00
fa617accc3 fix(docking): restore incremental layout updates to prevent visual tree rebuilds 2026-03-29 19:32:44 +09:00
ff22b89ba3 feat(docking): implement proportional sizing for docking layout
- Add DockLength property to DockModule to track star weights
- Add SyncLengths() to DockPanel to capture current Grid weights
- Update DockPanel.UpdateLayoutStructure to use DockLength
- Update DockingLayout.SplitGroup to distribute weights when splitting
2026-03-29 19:02:21 +09:00
2e6e705558 fix: update DockGroup to use DockTabItem and fix tab selection 2026-03-29 18:51:52 +09:00
e6e38f5eea fix(docking): defer container cleanup to avoid visual tree modification during layout 2026-03-29 18:47:38 +09:00
d15bd22743 feat(docking): improve tab management and error handling
Refactored DockGroup to only remove obsolete TabViewItems and restore tab selection more reliably. Updated DockGroup.xaml to enable tab reordering and add-tab button. Switched to CommunityToolkit.WinUI.Controls for GridSplitter and added a style for it. Made DockPanel, DockRegionHighlight, and DockingLayout partial classes. In App.xaml.cs, wrapped initialization in a try-catch to exit on error, and ensured process exit on window close. Improved ProjectBrowser scrollbar behavior and layout settings.
2026-03-29 16:07:18 +09:00
15870ffe89 refactor: adopt WinUI.Dock approach for layout updates and tab content hosting 2026-03-29 15:16:08 +09:00
70b7e56eb7 feat(editor): implement scroll state saving for ProjectBrowser ItemsView 2026-03-29 14:54:26 +09:00
257838b33e refactor: make DockPanel grid definitions incremental to preserve virtualization state 2026-03-29 14:18:49 +09:00
8ff98c56be refactor: remove SizeChanged hack from ProjectBrowser 2026-03-29 14:17:19 +09:00
2c84696994 fix: make DockPanel layout updates incremental to preserve visual tree state 2026-03-29 14:03:38 +09:00
a33a150d06 fix: resolve root module null reference and itemsview scroll bug 2026-03-29 12:30:07 +09:00
60ef684d80 Fixed package version issue. 2026-03-29 01:24:21 +09:00
486 changed files with 40119 additions and 13474 deletions

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@
*.sln.docstates *.sln.docstates
AGENTS.md AGENTS.md
.opencode/
.code-review-graph/
.github/instructions/
ref/ ref/
docfx/ docfx/
NUL NUL

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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
# GhostEngine Asset Registry — Design Analysis & Recommendations
## 1. Your Current Design at a Glance
Your current approach is **Unreal-style packed binary** (`.gasset`):
```
┌──────────────────────────────────────────────┐
│ AssetMetadata (128 bytes, fixed) │
│ FormatVersion ─ ID ─ TypeID ─ │
│ HandlerVersion ─ DependencyCount ─ │
│ DependenciesOffset ─ SettingsOffset/Size ─ │
│ ContentOffset/Size │
├──────────────────────────────────────────────┤
│ Settings blob (struct → raw bytes) │
├──────────────────────────────────────────────┤
│ Content blob (e.g. ImageContentHeader + raw) │
├──────────────────────────────────────────────┤
│ Dependencies (Guid[]) │
└──────────────────────────────────────────────┘
```
The AssetRegistry maintains an in-memory GUID↔path index by reading the first 20 bytes of every `.gasset` on startup, with a `FileSystemWatcher` for live updates. A planned SQLite backend (`AssetRegistry.Backend.cs`) would persist this catalog.
---
## 2. Unreal vs Unity — The Trade-Off Matrix
| Dimension | Unreal (Packed Binary `.uasset`) | Unity (Raw File + `.meta` sidecar) |
|---|---|---|
| **Source control** | Opaque blobs — merges impossible, diffs useless | Raw files are human-readable; `.meta` is text YAML — mergeable |
| **Import speed** | One file to open per asset | Two opens per asset (source + meta), but meta is tiny |
| **Runtime loading** | One `seek+read` → done (no re-import step) | Must "import" (cook) before runtime loading; raw files are editor-only |
| **Artist iteration** | Must re-import through editor | Can drop a PNG in Explorer & it auto-imports |
| **Dependency tracking** | Embedded in the binary — self-contained | External DB (`.meta` GUIDs + Library/) — can desync |
| **Asset settings versioning** | Binary struct layout is fragile | YAML/JSON → easy to add fields with defaults |
| **Corruption resilience** | One corrupted byte → whole asset lost | Source file is unaffected; re-import fixes derived data |
| **Build pipeline** | Already cooked (or close to it) | Separate cook step needed for builds |
| **Team discoverability** | "What is this .gasset?" → need editor to inspect | "It's a PNG, I can open it anywhere" |
### Key Insight
> Unreal doesn't actually store source data inside `.uasset` for most asset types. Unreal stores the **cooked/processed** representation. The source data (FBX, PSD, etc.) lives outside the engine's asset system — artists use a separate "source art" folder. The `.uasset` is a **derived artifact**, not the source of truth.
Unity's insight was: **leave source files alone, store metadata beside them, and derive everything else into a Library/ cache.** The `.meta` sidecar is tiny (GUID + import settings in YAML), version-control-friendly, and the actual imported data lives in `Library/` (a local, regenerable cache).
---
## 3. Current Design — Issues Found
### 3.1 Binary Settings Are a Versioning Nightmare
```csharp
// TextureAssetHandler — writes settings as raw struct bytes
Unsafe.WriteUnaligned(ref address, settings.Basic);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, ...), settings.Advanced);
```
**Problem:** Adding a single field to `BasicSettings`, `AdvancedSettings`, or `SamplerSettings` changes the struct layout. Every existing `.gasset` file becomes unreadable because the byte offsets shift. You have `HandlerVersion` in the metadata, but no migration logic — and you'd need one per handler per version.
> [!CAUTION]
> This is the #1 pain point of the Unreal approach in practice. Epic has dedicated teams managing asset versioning with `FArchive` custom serialization + version tags. For a small team, this is a massive maintenance burden.
### 3.2 Source File Is Destroyed on Import
```csharp
// OnFileSystemOp — line 224
File.Delete(assetPath); // ← deletes the original source file!
```
After import, the source `.png` is deleted and only the `.gasset` remains. If the user wants to change import settings (e.g. switch from BC7 to BC5 for a normal map), they need to find the original source file elsewhere and re-import.
### 3.3 Handler Discovery Is O(N × M) per Call
```csharp
// GetAssetHandlerForExtension — line 326-338
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) ...))
```
This scans **every type in every loaded assembly** on each call. It's called from `OnFileSystemOp` (FileSystemWatcher callback — frequent!) and `ImportAssetAsync`. The `_cachedHandler` dictionary helps for repeat loads, but the initial scan is expensive and runs every time a new extension is encountered.
### 3.4 `async void` in FileSystemWatcher Callback
```csharp
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
```
If `ImportAsync` throws, the exception is swallowed silently (unobserved). `FileSystemWatcher` callbacks should be synchronous (queue work to a channel/queue), or at minimum wrap the body in `try/catch`.
### 3.5 Race Conditions in Path Mapping
```csharp
// ConcurrentDictionary + lock(_pathLock)
_pathToGuid = new ConcurrentDictionary<...>(); // concurrent dict
lock (_pathLock) { _pathToGuid[relativePath] = guid; } // but manually locked
```
You're using `ConcurrentDictionary` but also taking a `Lock` for every access. These two strategies conflict — either use a plain `Dictionary<>` + lock, or use `ConcurrentDictionary` lock-free. Mixing them gives the worst of both: allocation overhead of `ConcurrentDictionary` with the contention of a lock.
### 3.6 Missing Content Hash for Cache Invalidation
The `TextureProcessor` hashes **settings** to build a cache key (`guid_settingsHash.dds`), but doesn't hash the **source content**. If you replace a PNG with a different image of the same name, the stale cache is served because only the settings hash changed (it didn't).
### 3.7 No Version Migration Path
The 128-byte `AssetMetadata` header reserves space for expansion — good! But there's no mechanism to detect "this `.gasset` was written by handler v1 and we're now at v3" and upgrade in place. Currently `HandlerVersion` is written but never read.
---
## 4. Recommendation: Hybrid Architecture
I recommend a **Unity-inspired hybrid** — keep source files untouched, use lightweight sidecar metadata, and produce a separate cooked cache. Here's the concrete design:
### 4.1 Three-Layer Architecture
```
ProjectRoot/
├── Assets/ ← Source files (PNG, FBX, HLSL, ...)
│ ├── Textures/
│ │ ├── hero_diffuse.png ← Source of truth (never modified)
│ │ └── hero_diffuse.png.gmeta ← Sidecar: GUID + import settings (YAML/JSON)
│ └── Models/
│ ├── character.fbx
│ └── character.fbx.gmeta
├── Library/ ← Derived data cache (local, .gitignore'd)
│ ├── AssetDB.sqlite ← Fast GUID↔path + dependency index
│ ├── Imports/ ← Cooked assets (DDS, compiled meshes, etc.)
│ │ ├── <guid>.imported ← Binary cooked data (current .gasset content section)
│ │ └── ...
│ └── Thumbnails/
│ └── <guid>.thumb
└── .ghostignore ← Patterns to exclude from asset scanning
```
### 4.2 `.gmeta` Sidecar File
```yaml
# hero_diffuse.png.gmeta
guid: 0906f4eb-c3f0-431b-bcea-132c88ab0c3f
handler: TextureAssetHandler
handlerVersion: 1
settings:
textureType: Default
textureShape: Texture2D
isSRGB: true
maxSize: 2048
filterMode: Anisotropic
wrapMode: Repeat
generateMipmaps: true
compressionLevel: Normal
# ... full settings tree
dependencies: []
labels: [environment, hero] # optional user tags
```
**Why this is better:**
| Concern | Current `.gasset` | Proposed `.gmeta` |
|---|---|---|
| Add a field | Binary layout breaks | YAML: missing keys → default values |
| Merge conflict | Impossible (binary) | Text merge, trivial |
| Inspect settings | Need editor | Open in any text editor |
| Source file recovery | Destroyed | Untouched, always available |
| Re-import | Need original file | `Library/` rebuild from source + `.gmeta` |
| `git diff` | `Binary files differ` | Readable YAML diff |
### 4.3 SQLite Catalog (`Library/AssetDB.sqlite`)
Replace the in-memory `ConcurrentDictionary<string, Guid>` mapping with an SQLite database (you already planned this in `AssetRegistry.Backend.cs`):
```sql
-- Core asset table
CREATE TABLE assets (
guid BLOB PRIMARY KEY, -- 16 bytes, exactly sizeof(Guid)
path TEXT NOT NULL, -- relative path to .gmeta
handler TEXT NOT NULL, -- handler type name
content_hash TEXT, -- xxHash64 of source file bytes
settings_hash TEXT, -- xxHash64 of import settings
imported_at INTEGER, -- unix timestamp of last successful import
UNIQUE(path)
);
-- Dependency edges (forward: asset → dependency)
CREATE TABLE dependencies (
from_guid BLOB NOT NULL REFERENCES assets(guid),
to_guid BLOB NOT NULL REFERENCES assets(guid),
PRIMARY KEY (from_guid, to_guid)
);
-- Reverse index for "what depends on me?" queries
CREATE INDEX idx_dep_reverse ON dependencies(to_guid);
-- Full-text search on asset paths and labels
CREATE VIRTUAL TABLE assets_fts USING fts5(path, labels);
```
**Startup becomes:**
1. Open SQLite DB → instant GUID↔path from indexed table
2. Diff `Assets/` tree vs DB → find stale/new/deleted `.gmeta` files
3. Queue incremental re-imports only for changed assets
This is **dramatically faster** than scanning every `.gasset` header on disk (your current `LoadExistingAssets`).
### 4.4 Import Pipeline
```
Source File Changed
FileSystemWatcher
├─── No .gmeta exists? → Generate one (new GUID, default settings)
Hash source + settings
├─── Hash matches DB? → Skip (already imported)
Queue ImportJob to background channel
ImportWorker (background thread pool)
├── Read source file
├── Run handler pipeline (e.g. NVTT compress)
├── Write Library/Imports/<guid>.imported
├── Update SQLite (content_hash, settings_hash, imported_at)
└── Fire AssetChanged event on main thread
```
### 4.5 Handler Registration — Build Once, Cache Forever
Replace the per-call assembly scan with a startup-once TypeCache approach (you already have this pattern in the engine):
```csharp
// Startup: build lookup tables once
Dictionary<string, Type> _extensionToHandler; // ".png" → typeof(TextureAssetHandler)
Dictionary<Guid, Type> _typeIdToHandler; // TypeGuid → handler type
// Populated once via TypeCache / assembly attribute scan at editor startup
foreach (var type in TypeCache.GetTypesWithAttribute<CustomAssetHandlerAttribute>())
{
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>();
_typeIdToHandler[new Guid(attr.ID)] = type;
foreach (var ext in attr.SupportedExtensions)
_extensionToHandler[ext] = type;
}
```
---
## 5. What to Keep from Your Current Design
Your design has several things done well:
| Element | Verdict |
|---|---|
| `AssetMetadata` fixed-size header with offsets | ✅ Keep for the cooked `.imported` files — great for O(1) seeks |
| `Handle<GPUTexture>` on `TextureAsset` | ✅ Clean separation of asset data vs GPU resource handle |
| `WeakReference<Asset>` cache in registry | ✅ Elegant — auto-evicts when nothing holds the asset |
| `IAssetHandler` / `IImportableAssetHandler` split | ✅ Good separation (some assets are import-only, e.g. shaders compiled differently) |
| `AssetReference` with internal/external encoding | ✅ Clever — keeps sub-asset refs compact |
| `TextureProcessor` cache with settings hash | ✅ Great idea, just needs content hash too |
| `Result<T>` return pattern | ✅ Consistent with the rest of GhostEngine |
---
## 6. Summary Recommendation
```
┌────────────────────────────────────────────────────────────┐
│ RECOMMENDED APPROACH │
│ │
│ Source files → untouched, checked into git │
│ .gmeta sidecars → GUID + settings (YAML), in git │
│ Library/ → derived cache, .gitignored │
│ AssetDB.sqlite → fast GUID↔path index │
│ Imports/*.imported → cooked binary (your AssetMetadata │
│ header + content, no settings) │
│ │
│ Binary format → for cooked data only, not settings │
│ Settings format → YAML/JSON in .gmeta (human + VCS) │
│ Handler discovery → one-time TypeCache at startup │
│ Watcher callbacks → queue to Channel<T>, no async void │
└────────────────────────────────────────────────────────────┘
```
This gives you:
- **Unreal's runtime performance** (cooked binary in Library/ → single seek+read)
- **Unity's artist workflow** (drop files in Assets/, settings are readable text)
- **Clean version control** (text `.gmeta` files merge cleanly)
- **Resilient re-import** (source is never touched; Library/ is regenerable)
- **Zero startup cost** (SQLite index instead of scanning thousands of file headers)
---
## 7. Open Questions for You
1. **Do you want `.gmeta` in YAML, JSON, or a custom text format?** YAML is more compact and human-friendly, but adds a parser dependency. JSON is built into .NET but more verbose. A custom format is more work.
2. **Should the cooked `.imported` files keep the 128-byte `AssetMetadata` header?** It's useful for validation on load, but since SQLite already knows the GUID and handler, you could simplify the binary format.
3. **Do you want hot-reload of import settings?** (Changing `.gmeta` → auto re-import and refresh live asset in editor.) Your current `WeakReference<Asset>` + `RefreshAsync` already supports this.
4. **How do you want to handle the `Library/` on first clone?** Options: (a) full re-import from source, (b) share a pre-built Library via LFS, (c) asset server that caches imports.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,457 @@
# Shader Pipeline Architecture — Proposed Design
> Presented as a design walkthrough. Take what's useful, ignore what doesn't fit your vision.
---
## 1. System Topology
The first decision: **where does each responsibility live?**
```mermaid
graph TB
subgraph EditorProcess["Ghost.Editor Process"]
FW["FileWatcher<br/>(monitors .ghost DSL files)"]
AR["AssetRegistry<br/>(GUID ↔ file path mapping)"]
EP["Editor UI<br/>(status bar, material inspector)"]
end
subgraph CompilerProcess["GhostShaderServer Process"]
DSL["DSL Compiler<br/>(Ghost DSL → HLSL)"]
DXC["DXC Compiler<br/>(HLSL → DXIL bytecode)"]
MW["Manifest Writer<br/>(updates variant → hash mapping)"]
end
subgraph RuntimeGraphics["Ghost.Graphics (Runtime)"]
SL["ShaderLibrary<br/>(reads bytecode from cache)"]
PL["PipelineLibrary<br/>(PSO creation + double-buffer)"]
RGC["RenderGraphContext<br/>(binds PSO per draw call)"]
BR["IShaderCompilationBridge<br/>(interface, 2 methods)"]
end
subgraph SharedDisk["Shared Disk (ShaderCache/)"]
MF["ShaderManifest.bin<br/>(GUID+variant → content hash)"]
BC["Bytecode Files<br/>(content-addressed .bin blobs)"]
end
FW -- "file changed event" --> AR
AR -- "GUID + file path<br/>(named pipe)" --> CompilerProcess
DSL --> DXC
DXC -- "bytecode bytes" --> MW
MW -- "write blob" --> BC
MW -- "update entry" --> MF
SL -- "read blob" --> BC
SL -- "read mapping" --> MF
BR -- "status query<br/>(named pipe)" --> CompilerProcess
EP -- "poll status" --> BR
style CompilerProcess fill:#1a1a2e,stroke:#e94560,color:#eee
style EditorProcess fill:#1a1a2e,stroke:#0f3460,color:#eee
style RuntimeGraphics fill:#1a1a2e,stroke:#16213e,color:#eee
style SharedDisk fill:#0f3460,stroke:#533483,color:#eee
```
### Why a Separate Process?
| Concern | In-process compiler | Separate process |
|---------|-------------------|------------------|
| DXC crash | Editor dies | Server restarts, editor lives |
| DXC memory leak | Editor bloats over time | Kill & restart server periodically |
| Parallelism | Threads compete with editor UI | Fully independent CPU budget |
| Build pipeline reuse | Need separate build-time path | Same server binary, different mode |
| Complexity | Lower (one process) | Higher (IPC needed) |
> [!TIP]
> If the separate process feels like overkill for your current stage, **start with in-process behind the `IShaderCompilationBridge` interface**, then swap the implementation to out-of-process later. The interface is the same either way.
---
## 2. Data Model — The Manifest
This is the most important data structure in the entire system. It decouples **identity** from **content**.
```mermaid
graph LR
subgraph ShaderAsset["Shader Asset (on disk)"]
GUID["Asset GUID<br/><i>e.g. 7f3a-...-c82b</i><br/>stable forever"]
SRC["Source Code<br/><i>.ghost DSL file</i><br/>changes on edit"]
end
subgraph Manifest["ShaderManifest"]
E1["Entry:<br/>GUID=7f3a | Pass=0 | Variant=0x00<br/>→ ContentHash=0xABCD"]
E2["Entry:<br/>GUID=7f3a | Pass=0 | Variant=0x01<br/>→ ContentHash=0x1234"]
E3["Entry:<br/>GUID=7f3a | Pass=1 | Variant=0x00<br/>→ ContentHash=0x5678"]
end
subgraph Cache["ShaderCache/ (content addressed)"]
B1["AB/shader_cache_ABCD...bin"]
B2["12/shader_cache_1234...bin"]
B3["56/shader_cache_5678...bin"]
end
GUID --> E1
GUID --> E2
GUID --> E3
E1 --> B1
E2 --> B2
E3 --> B3
style ShaderAsset fill:#16213e,stroke:#0f3460,color:#eee
style Manifest fill:#1a1a2e,stroke:#e94560,color:#eee
style Cache fill:#0f3460,stroke:#533483,color:#eee
```
### Manifest Entry Structure
```
ManifestKey = Hash(AssetGUID + PassIndex + VariantKeywordMask)
ManifestValue = ContentHash (= Hash of compiled bytecode)
```
- **ManifestKey** is *structurally* derived — same shader, same pass, same keywords = same key, regardless of source changes.
- **ContentHash** is *content-derived* — changes every time the source code changes.
- When source changes: the ManifestKey stays the same, but the ContentHash it points to gets updated.
> [!IMPORTANT]
> The `Shader` struct in runtime only needs to know the **AssetGUID**. It never stores or cares about content hashes. The `ShaderLibrary` uses the manifest to translate `(GUID, Pass, Variant) → ContentHash → File`.
---
## 3. Compilation Flow — What Happens When You Save a Shader
```mermaid
sequenceDiagram
participant User
participant FileSystem
participant Editor as Ghost.Editor
participant Server as ShaderServer
participant Cache as ShaderCache/
User->>FileSystem: Save "water.ghost"
FileSystem-->>Editor: FileWatcher event
Editor->>Editor: Lookup GUID for "water.ghost"<br/>via AssetRegistry
Editor->>Server: CompileRequest {<br/> guid: 7f3a-...,<br/> filePath: "water.ghost",<br/> defines: [...],<br/> platform: D3D12<br/>}
Note over Server: Mark status = Compiling<br/>for this GUID
Server->>Server: Read .ghost DSL file
Server->>Server: DSL Compiler: DSL → HLSL
alt DSL has syntax errors
Server->>Server: Mark status = Error
Server-->>Editor: CompileResult {<br/> status: Error,<br/> errors: [...]<br/>}
Editor->>Editor: Show errors in<br/>console/inspector
else DSL is valid
Server->>Server: For each (pass, variant):<br/>DXC Compile HLSL → DXIL
alt Any DXC error
Server->>Server: Mark status = Error
Server-->>Editor: CompileResult {<br/> status: Error,<br/> errors: [...]<br/>}
else All variants compiled
Server->>Cache: Write bytecode blobs<br/>(content-addressed)
Server->>Cache: Update manifest entries:<br/>(GUID+pass+variant) → new hash
Server->>Server: Mark status = Ready
Server-->>Editor: CompileResult {<br/> status: Ready,<br/> variantCount: N<br/>}
Editor->>Editor: Show ✓ in status bar
end
end
```
### Key Design Decision: Compile All Variants Upfront?
**No.** Only compile variants that are *currently referenced* by materials in the scene. The editor knows which materials reference which shader (via AssetRegistry), and which keyword combinations those materials use. Ship only what's needed.
For the edit-time hot-reload, you really only need the specific variants the viewport is currently rendering. The full permutation set is a build-time concern.
---
## 4. Runtime PSO Resolution — The Frame-by-Frame Flow
This is where most of the complexity lives. Here's what `SetActiveMaterial` does every frame:
```mermaid
flowchart TD
A["SetActiveMaterial(material)"] --> B["Compute ManifestKey<br/>= f(shader.GUID, passIndex, variantMask)"]
B --> C{"PipelineLibrary<br/>has PSO for<br/>ManifestKey?"}
C -- "Yes (cache hit)" --> D["Bind existing PSO<br/>to command buffer"]
D --> Z["Done ✓"]
C -- "No (cache miss)" --> E{"ShaderLibrary<br/>has bytecode for<br/>ManifestKey?"}
E -- "Yes (manifest hit)" --> F["Read bytecode<br/>from cache file"]
F --> G["Create PSO from bytecode"]
G --> H["Store in PipelineLibrary"]
H --> D
E -- "No (manifest miss)" --> I{"Is this Editor<br/>or Runtime?"}
I -- "Runtime<br/>(shipped game)" --> J["Bind Fallback<br/>ERROR PSO ⚠️"]
J --> K["Log error:<br/>missing shader"]
K --> Z
I -- "Editor" --> L{"Query Bridge:<br/>IsCompiling?"}
L -- "Status = Compiling" --> M["Bind OLD PSO<br/>(keep previous frame's shader)"]
M --> Z
L -- "Status = Error" --> N["Bind ERROR PSO<br/>(magenta)"]
N --> Z
L -- "Status = Ready" --> O["The manifest was just updated.<br/>Re-read manifest entry."]
O --> F
L -- "Status = NotAvailable" --> J
style A fill:#533483,stroke:#e94560,color:#eee
style D fill:#16213e,stroke:#0f3460,color:#eee
style J fill:#e94560,stroke:#1a1a2e,color:#eee
style M fill:#0f3460,stroke:#533483,color:#eee
style N fill:#e94560,stroke:#1a1a2e,color:#eee
style Z fill:#16213e,stroke:#16213e,color:#eee
```
### The "Keep Old PSO" Strategy — How It Works Mechanically
This is the part that makes the UX feel seamless. The trick:
```mermaid
graph LR
subgraph PipelineLibrary
direction TB
K["ManifestKey 0xAABB"]
K --> CURRENT["current: PSO_v2 ✓<br/>(what we render with)"]
K --> PENDING["pending: null<br/>(set during recompilation)"]
end
style CURRENT fill:#16213e,stroke:#0f3460,color:#eee
style PENDING fill:#1a1a2e,stroke:#e94560,color:#eee
```
When shader source changes and recompilation starts:
```mermaid
graph LR
subgraph PipelineLibrary_During["During Recompilation"]
direction TB
K2["ManifestKey 0xAABB"]
K2 --> CURRENT2["current: PSO_v2 ✓<br/>(still rendering with this)"]
K2 --> PENDING2["pending: COMPILING<br/>(server is working...)"]
end
style CURRENT2 fill:#16213e,stroke:#0f3460,color:#eee
style PENDING2 fill:#e94560,stroke:#1a1a2e,color:#eee
```
When recompilation finishes successfully:
```mermaid
graph LR
subgraph PipelineLibrary_After["After Swap"]
direction TB
K3["ManifestKey 0xAABB"]
K3 --> CURRENT3["current: PSO_v3 ✓<br/>(new shader, rendering now)"]
K3 --> PENDING3["pending: null<br/>(swap complete)"]
end
style CURRENT3 fill:#16213e,stroke:#0f3460,color:#eee
style PENDING3 fill:#1a1a2e,stroke:#533483,color:#eee
```
> [!NOTE]
> The old `PSO_v2` is **not immediately destroyed**. It stays alive until the GPU is done with any in-flight frames referencing it (tracked by fence value). This prevents use-after-free on the GPU timeline.
---
## 5. Hot-Reload Sequence — The Complete Picture
Everything combined into one timeline:
```mermaid
sequenceDiagram
participant User
participant Editor
participant Server as ShaderServer
participant Cache as Disk Cache
participant Runtime as RenderGraphContext
participant GPU
Note over Runtime,GPU: Frame N: Rendering with PSO_v2
User->>Editor: Edit & save "water.ghost"
Editor->>Server: CompileRequest(guid=7f3a)
Server->>Server: status[7f3a] = Compiling
Note over Runtime,GPU: Frame N+1
Runtime->>Runtime: SetActiveMaterial()
Runtime->>Runtime: ManifestKey lookup → old hash still there
Runtime->>Runtime: PipelineLibrary has PSO → use it
Note over Runtime: Still rendering with PSO_v2<br/>(user sees no flicker)
Note over Server: Background: DSL→HLSL→DXC...
Note over Runtime,GPU: Frame N+2, N+3, ...
Runtime->>Runtime: Same as N+1, no visible change
Server->>Cache: Write new bytecode files
Server->>Cache: Update manifest:<br/>key(7f3a,0,0) → new_hash
Server->>Server: status[7f3a] = Ready
Note over Runtime,GPU: Frame N+K (compilation done)
Runtime->>Runtime: SetActiveMaterial()
Runtime->>Runtime: Manifest read → NEW content hash
Runtime->>Runtime: PipelineLibrary miss for new hash
Runtime->>Cache: Read new bytecode
Runtime->>GPU: Create PSO_v3
Runtime->>Runtime: PipelineLibrary: current=PSO_v3
Runtime->>Runtime: Bind PSO_v3
Note over Runtime,GPU: Frame N+K+1: Rendering with PSO_v3 ✓
Runtime->>Runtime: Defer release PSO_v2<br/>(after GPU fence)
```
### What the User Sees
| Frame | Viewport | Status Bar |
|-------|----------|------------|
| N | Water renders normally | — |
| N+1 | Water renders normally (old shader) | 🔄 Compiling water.ghost... |
| N+2 | Water renders normally (old shader) | 🔄 Compiling water.ghost... |
| N+K | Water renders with new shader | ✅ water.ghost compiled (2 variants) |
**Zero flicker. Zero blocking. Zero pink frames.**
---
## 6. How the Manifest Key Replaces Your Current Hash Problem
Here's a before/after of your `Shader` struct:
### Current Design (problematic)
```mermaid
graph TD
subgraph Current["Current: Hash = f(source code)"]
S1["Shader struct"] --> P1["Pass[0].Key = 0xABCD<br/><i>derived from source hash</i>"]
P1 --> V1["ShaderVariantKey = f(0xABCD, keywords)"]
V1 --> PK1["PipelineKey = f(variant, rtv, dsv)"]
PK1 --> PSO1["PSO lookup in PipelineLibrary"]
EDIT["User edits source"] -.-> STALE["Pass[0].Key is now STALE ❌<br/>Still 0xABCD, but source changed"]
STALE -.-> WRONG["Looks up OLD bytecode<br/>or worse, the old PSO"]
end
style STALE fill:#e94560,stroke:#1a1a2e,color:#eee
style WRONG fill:#e94560,stroke:#1a1a2e,color:#eee
```
### Proposed Design (stable)
```mermaid
graph TD
subgraph Proposed["Proposed: Key = f(GUID, pass index)"]
S2["Shader struct<br/>assetGUID = 7f3a-..."] --> P2["Pass[0]: index=0<br/><i>no source hash stored</i>"]
P2 --> MK["ManifestKey = f(7f3a, 0, keywords)"]
MK --> MANIFEST["Manifest Lookup<br/>→ ContentHash = 0x9999"]
MANIFEST --> SL2["ShaderLibrary<br/>→ read 99/shader_cache_9999.bin"]
SL2 --> PSO2["Create or get PSO"]
EDIT2["User edits source"] -.-> RECOMP["Server recompiles<br/>→ new ContentHash = 0xBBBB"]
RECOMP -.-> MUPD["Manifest updated:<br/>same key → 0xBBBB"]
MUPD -.-> NEXT["Next frame: manifest read<br/>picks up 0xBBBB automatically"]
end
style RECOMP fill:#0f3460,stroke:#533483,color:#eee
style MUPD fill:#0f3460,stroke:#533483,color:#eee
style NEXT fill:#16213e,stroke:#0f3460,color:#eee
```
> [!IMPORTANT]
> **The `Shader` struct never changes.** No unload, no recreate, no generation counter bump. The manifest is the *only* mutable state, and it lives on disk, outside the runtime's object graph. The runtime just reads it.
---
## 7. The Two Interfaces That Make This Work
Only two abstractions are needed in `Ghost.Graphics` to support the full pipeline:
```mermaid
classDiagram
class IShaderCompilationBridge {
<<interface>>
+TryGetBytecode(manifestKey: ulong, out bytecode: ReadOnlyMemory~byte~) bool
+IsCompiling(manifestKey: ulong) bool
}
class RuntimeStub {
+TryGetBytecode() → always from ShaderLibrary cache
+IsCompiling() → always false
}
class EditorImplementation {
-NamedPipeClient _serverConnection
+TryGetBytecode() → check manifest, read cache
+IsCompiling() → query server status
}
IShaderCompilationBridge <|.. RuntimeStub : "Shipped game"
IShaderCompilationBridge <|.. EditorImplementation : "Editor mode"
class ShaderLibrary {
-string _cacheDirectory
+GetCache(contentHash: ulong) Result~bytes~
+GetFromManifest(manifestKey: ulong) Result~bytes~
}
EditorImplementation --> ShaderLibrary : reads cache
RuntimeStub --> ShaderLibrary : reads cache
```
> [!TIP]
> `RenderGraphContext` doesn't talk to the bridge directly. It talks to `ShaderLibrary`, which internally consults the bridge on cache miss. This keeps the rendering code clean — it never sees compilation status. It just gets bytecode or it doesn't.
---
## 8. Build Pipeline — How Shipped Games Work
For completeness, here's how the same architecture handles builds:
```mermaid
flowchart LR
subgraph BuildTime["Build Pipeline"]
SCAN["Scan all materials<br/>in scenes/assets"] --> COLLECT["Collect all referenced<br/>(GUID, pass, variant) tuples"]
COLLECT --> COMPILE["Compile all variants<br/>via ShaderServer"]
COMPILE --> PACK["Package manifest +<br/>bytecode blobs into<br/>game data archive"]
end
subgraph ShippedGame["Runtime (shipped game)"]
LOAD["Load manifest +<br/>bytecode from archive"] --> LIB["ShaderLibrary<br/>(read-only, all variants pre-cached)"]
LIB --> MISS{"Cache miss?"}
MISS -- "Never<br/>(if build is correct)" --> OK["Create PSO normally"]
MISS -- "Somehow yes<br/>(bug or modding)" --> ERR["Error PSO<br/>+ log warning"]
end
BuildTime --> ShippedGame
style BuildTime fill:#1a1a2e,stroke:#0f3460,color:#eee
style ShippedGame fill:#16213e,stroke:#533483,color:#eee
```
The beauty: **the same `ShaderLibrary` and `PipelineLibrary` code runs in both editor and shipped game**. The only difference is whether `IShaderCompilationBridge` is the editor implementation or the runtime stub.
---
## Summary of Key Design Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Stable GUID identity, not content hash | Shader struct never needs recreation on edit |
| 2 | Content-addressed cache | Deduplication, easy invalidation, git-friendly |
| 3 | Manifest as the bridge | Decouples identity from compiled output cleanly |
| 4 | Keep old PSO during recompile | Zero flicker, seamless UX |
| 5 | Separate compiler process | Crash isolation, independent resource budget |
| 6 | Two-method interface in runtime | Minimal coupling, easy to stub for shipped game |
| 7 | Deferred PSO release via fence | Prevents GPU use-after-free |
| 8 | Same code path for editor + shipped | Fewer bugs, one pipeline to maintain |

View File

@@ -1,669 +0,0 @@
# DockLayout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create a fully-featured, dynamically splittable, multi-window docking layout system using WinUI 3.
**Architecture:** A C# data model (node tree) drives the recursive generation of XAML `Grid` and `NavigationTabView` controls. Drag and drop events mutate the node tree, and the UI automatically reflects the changes.
**Tech Stack:** C#, WinUI 3, CommunityToolkit.Mvvm (for ObservableObject).
---
### Task 1: Create Core Data Models
**Files:**
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs`
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs`
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs`
- [ ] **Step 1: Write `DockNode` base class**
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
namespace Ghost.Editor.Core.Controls.Internal.Docking;
public abstract partial class DockNode : ObservableObject
{
[ObservableProperty]
private DockGroupNode? _parent;
}
```
- [ ] **Step 2: Write `DockGroupNode` class**
```csharp
using System.Collections.ObjectModel;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Controls.Internal.Docking;
public partial class DockGroupNode : DockNode
{
[ObservableProperty]
private Orientation _orientation = Orientation.Horizontal;
public ObservableCollection<DockNode> Children { get; } = new();
public void AddChild(DockNode node)
{
node.Parent = this;
Children.Add(node);
}
public void RemoveChild(DockNode node)
{
if (Children.Remove(node))
{
node.Parent = null;
}
}
}
```
- [ ] **Step 3: Write `DockPanelNode` class**
```csharp
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Ghost.Editor.Core.Controls.Internal.Docking;
public partial class DockPanelNode : DockNode
{
public ObservableCollection<object> Items { get; } = new();
[ObservableProperty]
private int _selectedIndex = -1;
[ObservableProperty]
private object? _selectedItem;
}
```
- [ ] **Step 4: Commit**
```bash
git add src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/*
git commit -m "feat(dock): add core data models for docking system"
```
---
### Task 2: Implement Tree Renderer in XAML
**Files:**
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.xaml`
- [ ] **Step 1: Add DependencyProperty for Root in DockLayout.cs**
```csharp
using Ghost.Editor.Core.Controls.Internal.Docking;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls;
public sealed partial class DockLayout : Control
{
public DockLayout()
{
DefaultStyleKey = typeof(DockLayout);
}
public DockGroupNode? Root
{
get => (DockGroupNode?)GetValue(RootProperty);
set => SetValue(RootProperty, value);
}
public static readonly DependencyProperty RootProperty =
DependencyProperty.Register("Root", typeof(DockGroupNode), typeof(DockLayout), new PropertyMetadata(null, OnRootChanged));
private static void OnRootChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockLayout layout)
{
layout.RenderTree();
}
}
private void RenderTree()
{
if (GetTemplateChild("PART_RootGrid") is Grid rootGrid)
{
rootGrid.Children.Clear();
if (Root != null)
{
var ui = CreateUIForNode(Root);
rootGrid.Children.Add(ui);
}
}
}
private UIElement CreateUIForNode(DockNode node)
{
if (node is DockGroupNode groupNode)
{
// Simple visualizer for now, full grid logic in next step
var grid = new Grid();
foreach (var child in groupNode.Children)
{
grid.Children.Add(CreateUIForNode(child));
}
return grid;
}
else if (node is DockPanelNode panelNode)
{
return new Ghost.Editor.Controls.NavigationTabView
{
ItemsSource = panelNode.Items,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
}
return new Grid(); // Fallback
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
RenderTree();
}
}
```
- [ ] **Step 2: Define ControlTemplate in DockLayout.xaml**
```xml
<?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.View.Controls">
<Style TargetType="local:DockLayout">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockLayout">
<Grid x:Name="PART_RootGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.*
git commit -m "feat(dock): implement basic recursive tree renderer"
```
---
### Task 3: Implement DockGroupNode Grid Builder
**Files:**
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
- [ ] **Step 1: Replace `CreateUIForNode` Group logic to generate Columns/Rows and GridSplitters**
```csharp
private UIElement CreateUIForNode(DockNode node)
{
if (node is DockGroupNode groupNode)
{
var grid = new Grid();
bool isHorizontal = groupNode.Orientation == Orientation.Horizontal;
int childCount = groupNode.Children.Count;
for (int i = 0; i < childCount; i++)
{
var childNode = groupNode.Children[i];
var childUI = CreateUIForNode(childNode);
if (isHorizontal)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
Grid.SetColumn((FrameworkElement)childUI, i * 2);
}
else
{
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
Grid.SetRow((FrameworkElement)childUI, i * 2);
}
grid.Children.Add(childUI);
// Add GridSplitter between children
if (i < childCount - 1)
{
if (isHorizontal)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { Width = 4, HorizontalAlignment = HorizontalAlignment.Center, ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Columns };
Grid.SetColumn(splitter, (i * 2) + 1);
grid.Children.Add(splitter);
}
else
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { Height = 4, VerticalAlignment = VerticalAlignment.Center, ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Rows };
Grid.SetRow(splitter, (i * 2) + 1);
grid.Children.Add(splitter);
}
}
}
// Listen to CollectionChanged to trigger re-render
groupNode.Children.CollectionChanged -= GroupNode_Children_CollectionChanged;
groupNode.Children.CollectionChanged += GroupNode_Children_CollectionChanged;
return grid;
}
else if (node is DockPanelNode panelNode)
{
var tabView = new Ghost.Editor.Controls.NavigationTabView
{
TabItemsSource = panelNode.Items,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
CanDragTabs = true,
AllowDrop = true,
Tag = panelNode // Store reference to data node
};
return tabView;
}
return new Grid();
}
private void GroupNode_Children_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
// For MVP, just re-render the whole tree when a group changes structure
RenderTree();
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
git commit -m "feat(dock): implement grid and gridsplitter generation for groups"
```
---
### Task 4: Setup Visual Drop Target Overlay
**Files:**
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.xaml`
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
- [ ] **Step 1: Add Drop Overlay to ControlTemplate**
```xml
<ControlTemplate TargetType="local:DockLayout">
<Grid>
<Grid x:Name="PART_RootGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" />
<Border x:Name="PART_DropTargetOverlay"
Background="#660078D4"
BorderBrush="#FF0078D4"
BorderThickness="2"
Visibility="Collapsed"
IsHitTestVisible="False" />
</Grid>
</ControlTemplate>
```
- [ ] **Step 2: Add Fields and ApplyTemplate logic in DockLayout.cs**
```csharp
private Border? _dropTargetOverlay;
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_dropTargetOverlay = GetTemplateChild("PART_DropTargetOverlay") as Border;
RenderTree();
}
// Helper enum for later
public enum DockPosition { Center, Top, Bottom, Left, Right, None }
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.*
git commit -m "feat(dock): add visual drop target overlay"
```
---
### Task 5: Implement Drag and Drop Calculations (Highlighting)
**Files:**
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
- [ ] **Step 1: Attach TabView Drag Events in `CreateUIForNode`**
```csharp
else if (node is DockPanelNode panelNode)
{
var tabView = new Ghost.Editor.Controls.NavigationTabView
{
TabItemsSource = panelNode.Items,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
CanDragTabs = true,
AllowDrop = true,
Tag = panelNode // Store reference to data node
};
tabView.DragOver += TabView_DragOver;
tabView.DragLeave += TabView_DragLeave;
tabView.Drop += TabView_Drop;
tabView.TabDragStarting += TabView_TabDragStarting;
return tabView;
}
```
- [ ] **Step 2: Implement Drag Handling Logic**
```csharp
private object? _draggedItem;
private DockPanelNode? _sourceNode;
private DockPosition _currentDropPosition = DockPosition.None;
private void TabView_TabDragStarting(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDragStartingEventArgs args)
{
_draggedItem = args.Item;
_sourceNode = sender.Tag as DockPanelNode;
args.Data.Properties.Add("DockTab", _draggedItem); // Identify our drag
}
private void TabView_DragOver(object sender, DragEventArgs e)
{
if (e.DataView.Properties.ContainsKey("DockTab") && sender is FrameworkElement targetElement)
{
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
var position = e.GetPosition(targetElement);
double width = targetElement.ActualWidth;
double height = targetElement.ActualHeight;
double edgeThreshold = 0.25; // 25% of edge triggers split
if (position.X < width * edgeThreshold) _currentDropPosition = DockPosition.Left;
else if (position.X > width * (1 - edgeThreshold)) _currentDropPosition = DockPosition.Right;
else if (position.Y < height * edgeThreshold) _currentDropPosition = DockPosition.Top;
else if (position.Y > height * (1 - edgeThreshold)) _currentDropPosition = DockPosition.Bottom;
else _currentDropPosition = DockPosition.Center;
UpdateDropOverlay(targetElement, _currentDropPosition);
}
}
private void TabView_DragLeave(object sender, DragEventArgs e)
{
if (_dropTargetOverlay != null)
{
_dropTargetOverlay.Visibility = Visibility.Collapsed;
_currentDropPosition = DockPosition.None;
}
}
private void UpdateDropOverlay(FrameworkElement targetElement, DockPosition position)
{
if (_dropTargetOverlay == null) return;
if (position == DockPosition.None)
{
_dropTargetOverlay.Visibility = Visibility.Collapsed;
return;
}
var transform = targetElement.TransformToVisual(this);
var bounds = transform.TransformBounds(new Windows.Foundation.Rect(0, 0, targetElement.ActualWidth, targetElement.ActualHeight));
_dropTargetOverlay.Visibility = Visibility.Visible;
_dropTargetOverlay.Width = double.NaN;
_dropTargetOverlay.Height = double.NaN;
switch (position)
{
case DockPosition.Center:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
case DockPosition.Left:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - (bounds.Left + bounds.Width / 2), ActualHeight - bounds.Bottom);
break;
case DockPosition.Right:
_dropTargetOverlay.Margin = new Thickness(bounds.Left + bounds.Width / 2, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
case DockPosition.Top:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - (bounds.Top + bounds.Height / 2));
break;
case DockPosition.Bottom:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top + bounds.Height / 2, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
git commit -m "feat(dock): implement drop highlight calculations"
```
---
### Task 6: Implement Dropping (Data Tree Mutation)
**Files:**
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
- [ ] **Step 1: Implement `TabView_Drop` logic**
```csharp
private void TabView_Drop(object sender, DragEventArgs e)
{
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
if (_draggedItem == null || _sourceNode == null || !(sender is FrameworkElement targetElement) || !(targetElement.Tag is DockPanelNode targetNode))
return;
if (_sourceNode == targetNode && _currentDropPosition == DockPosition.Center)
return; // Reordering within same tab is handled natively by TabView
// 1. Remove from source
_sourceNode.Items.Remove(_draggedItem);
CleanupEmptyNodes(_sourceNode);
// 2. Add to target
if (_currentDropPosition == DockPosition.Center)
{
targetNode.Items.Add(_draggedItem);
}
else
{
// Split scenario
var parentGroup = targetNode.Parent;
if (parentGroup != null)
{
int index = parentGroup.Children.IndexOf(targetNode);
parentGroup.Children.RemoveAt(index);
var newGroup = new DockGroupNode
{
Orientation = (_currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Right) ? Orientation.Horizontal : Orientation.Vertical
};
var newPanel = new DockPanelNode();
newPanel.Items.Add(_draggedItem);
if (_currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Top)
{
newGroup.AddChild(newPanel);
newGroup.AddChild(targetNode);
}
else
{
newGroup.AddChild(targetNode);
newGroup.AddChild(newPanel);
}
parentGroup.Children.Insert(index, newGroup);
}
}
_draggedItem = null;
_sourceNode = null;
_currentDropPosition = DockPosition.None;
}
private void CleanupEmptyNodes(DockPanelNode panelNode)
{
if (panelNode.Items.Count > 0) return;
var parentGroup = panelNode.Parent;
if (parentGroup != null)
{
parentGroup.RemoveChild(panelNode);
// If group only has 1 child left, collapse it
if (parentGroup.Children.Count == 1)
{
var onlyChild = parentGroup.Children[0];
var grandParent = parentGroup.Parent;
if (grandParent != null)
{
int index = grandParent.Children.IndexOf(parentGroup);
parentGroup.RemoveChild(onlyChild);
grandParent.Children.RemoveAt(index);
grandParent.Children.Insert(index, onlyChild);
}
else if (parentGroup == Root)
{
// If root is collapsing, the only child becomes the new root
parentGroup.RemoveChild(onlyChild);
if (onlyChild is DockGroupNode newRootGroup)
{
Root = newRootGroup;
}
else
{
// Wrap panel in a new group to keep Root as a GroupNode
var wrapperGroup = new DockGroupNode();
wrapperGroup.AddChild(onlyChild);
Root = wrapperGroup;
}
}
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
git commit -m "feat(dock): implement tree mutation on drop and empty node cleanup"
```
---
### Task 7: Implement Window Tear-Off (TabDroppedOutside)
**Files:**
- Create: `src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml`
- Create: `src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs`
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
- [ ] **Step 1: Create `DockWindow` wrapper**
`DockWindow.xaml`:
```xml
<?xml version="1.0" encoding="utf-8" ?>
<winex:WindowEx
x:Class="Ghost.Editor.View.Windows.DockWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Ghost.Editor.View.Controls"
xmlns:winex="using:WinUIEx">
<Grid>
<controls:DockLayout x:Name="PART_DockLayout" />
</Grid>
</winex:WindowEx>
```
`DockWindow.xaml.cs`:
```csharp
using Ghost.Editor.Core.Controls.Internal.Docking;
using WinUIEx;
namespace Ghost.Editor.View.Windows;
public sealed partial class DockWindow : WindowEx
{
public DockWindow(object initialTabContent)
{
InitializeComponent();
// Setup initial single panel layout
var rootGroup = new DockGroupNode();
var panel = new DockPanelNode();
panel.Items.Add(initialTabContent);
rootGroup.AddChild(panel);
PART_DockLayout.Root = rootGroup;
// Optional: Titlebar setup etc.
}
}
```
- [ ] **Step 2: Handle `TabDroppedOutside` in `DockLayout.cs`**
Modify `CreateUIForNode` in `DockLayout.cs`:
```csharp
// ... inside CreateUIForNode for DockPanelNode ...
tabView.TabDragStarting += TabView_TabDragStarting;
tabView.TabDroppedOutside += TabView_TabDroppedOutside; // NEW
return tabView;
```
Add event handler:
```csharp
private void TabView_TabDroppedOutside(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDroppedOutsideEventArgs args)
{
if (_sourceNode != null && _draggedItem != null)
{
// Remove from current tree
_sourceNode.Items.Remove(_draggedItem);
CleanupEmptyNodes(_sourceNode);
// Create new window
var newWindow = new Ghost.Editor.View.Windows.DockWindow(_draggedItem);
newWindow.Activate();
_draggedItem = null;
_sourceNode = null;
_currentDropPosition = DockPosition.None;
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Windows/DockWindow.* src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
git commit -m "feat(dock): implement tab tear-off to new window"
```

View File

@@ -1,724 +0,0 @@
# Docking Layout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a custom WinUI 3 docking layout system for GhostEngine's editor with Unity/Blender-style region highlighting and dynamic tab creation.
**Architecture:** A UI-driven approach where custom controls (`DockingLayout`, `DockPanel`, `DockGroup`, `DockDocument`) manage their own state and visual tree. Drag-and-drop manipulates the visual tree directly.
**Tech Stack:** C#, WinUI 3, Windows App SDK
---
### Task 1: Core Enums and Base Classes
**Files:**
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs`
- [ ] **Step 1: Create Enums**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs`:
```csharp
namespace Ghost.Editor.View.Controls.Docking;
public enum DockTarget
{
Center,
Left,
Right,
Top,
Bottom
}
```
- [ ] **Step 2: Create DockModule base class**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs`:
```csharp
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
public abstract class DockModule : Control
{
public DockContainer? Owner { get; internal set; }
public DockingLayout? Root { get; internal set; }
public void Detach()
{
Owner?.Children.Remove(this);
Owner = null;
}
}
```
- [ ] **Step 3: Create DockContainer base class**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs`:
```csharp
using System.Collections.ObjectModel;
namespace Ghost.Editor.View.Controls.Docking;
public abstract class DockContainer : DockModule
{
public ObservableCollection<DockModule> Children { get; } = new();
protected DockContainer()
{
Children.CollectionChanged += OnChildrenChanged;
}
private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.OldItems != null)
{
foreach (DockModule module in e.OldItems)
{
module.Owner = null;
}
}
if (e.NewItems != null)
{
foreach (DockModule module in e.NewItems)
{
module.Owner = this;
module.Root = Root;
}
}
OnChildrenUpdated();
}
protected virtual void OnChildrenUpdated() { }
}
```
- [ ] **Step 4: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs
git commit -m "feat(docking): add core enums and base classes"
```
---
### Task 2: DockDocument and DockGroup
**Files:**
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml`
- [ ] **Step 1: Create DockDocument**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs`:
```csharp
using Microsoft.UI.Xaml;
namespace Ghost.Editor.View.Controls.Docking;
public class DockDocument : DockModule
{
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
nameof(Title), typeof(string), typeof(DockDocument), new PropertyMetadata(string.Empty));
public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(
nameof(Content), typeof(object), typeof(DockDocument), new PropertyMetadata(null));
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public object Content
{
get => GetValue(ContentProperty);
set => SetValue(ContentProperty, value);
}
public DockDocument()
{
DefaultStyleKey = typeof(DockDocument);
}
}
```
- [ ] **Step 2: Create DockGroup XAML**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml`:
```xml
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockGroup">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockGroup">
<Grid>
<TabView x:Name="PART_TabView"
IsAddTabButtonVisible="False"
CanDragTabs="True"
CanReorderTabs="True"
AllowDrop="True">
</TabView>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
- [ ] **Step 3: Create DockGroup Code-Behind**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`:
```csharp
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
[TemplatePart(Name = "PART_TabView", Type = typeof(TabView))]
public class DockGroup : DockContainer
{
private TabView? _tabView;
public DockGroup()
{
DefaultStyleKey = typeof(DockGroup);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_tabView = GetTemplateChild("PART_TabView") as TabView;
UpdateTabs();
}
protected override void OnChildrenUpdated()
{
UpdateTabs();
}
private void UpdateTabs()
{
if (_tabView == null) return;
_tabView.TabItems.Clear();
foreach (var child in Children)
{
if (child is DockDocument doc)
{
var tabItem = new TabViewItem
{
Header = doc.Title,
Content = doc.Content,
Tag = doc
};
_tabView.TabItems.Add(tabItem);
}
}
}
}
```
- [ ] **Step 4: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml
git commit -m "feat(docking): add DockDocument and DockGroup"
```
---
### Task 3: DockPanel
**Files:**
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml`
- [ ] **Step 1: Create DockPanel XAML**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml`:
```xml
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockPanel">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockPanel">
<Grid x:Name="PART_Grid" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
- [ ] **Step 2: Create DockPanel Code-Behind**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs`:
```csharp
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using CommunityToolkit.WinUI.Controls;
namespace Ghost.Editor.View.Controls.Docking;
[TemplatePart(Name = "PART_Grid", Type = typeof(Grid))]
public class DockPanel : DockContainer
{
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
nameof(Orientation), typeof(Orientation), typeof(DockPanel), new PropertyMetadata(Orientation.Horizontal, OnOrientationChanged));
public Orientation Orientation
{
get => (Orientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
private Grid? _grid;
public DockPanel()
{
DefaultStyleKey = typeof(DockPanel);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_grid = GetTemplateChild("PART_Grid") as Grid;
UpdateLayoutStructure();
}
protected override void OnChildrenUpdated()
{
UpdateLayoutStructure();
}
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((DockPanel)d).UpdateLayoutStructure();
}
private void UpdateLayoutStructure()
{
if (_grid == null) return;
_grid.Children.Clear();
_grid.RowDefinitions.Clear();
_grid.ColumnDefinitions.Clear();
if (Children.Count == 0) return;
if (Orientation == Orientation.Horizontal)
{
for (int i = 0; i < Children.Count; i++)
{
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var child = Children[i];
Grid.SetColumn(child, i * 2);
_grid.Children.Add(child);
if (i < Children.Count - 1)
{
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Columns, Width = 4 };
Grid.SetColumn(splitter, i * 2 + 1);
_grid.Children.Add(splitter);
}
}
}
else
{
for (int i = 0; i < Children.Count; i++)
{
_grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
var child = Children[i];
Grid.SetRow(child, i * 2);
_grid.Children.Add(child);
if (i < Children.Count - 1)
{
_grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Rows, Height = 4 };
Grid.SetRow(splitter, i * 2 + 1);
_grid.Children.Add(splitter);
}
}
}
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml
git commit -m "feat(docking): add DockPanel"
```
---
### Task 4: DockRegionHighlight and DockingLayout
**Files:**
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml`
- [ ] **Step 1: Create DockRegionHighlight XAML**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml`:
```xml
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockRegionHighlight">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockRegionHighlight">
<Border Background="#400078D7" BorderBrush="#800078D7" BorderThickness="2" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
- [ ] **Step 2: Create DockRegionHighlight Code-Behind**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs`:
```csharp
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
public class DockRegionHighlight : Control
{
public DockRegionHighlight()
{
DefaultStyleKey = typeof(DockRegionHighlight);
IsHitTestVisible = false;
}
}
```
- [ ] **Step 3: Create DockingLayout XAML**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml`:
```xml
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockingLayout">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockingLayout">
<Grid>
<ContentPresenter x:Name="PART_Content" Content="{TemplateBinding RootPanel}" />
<Canvas x:Name="PART_OverlayCanvas" IsHitTestVisible="False">
<local:DockRegionHighlight x:Name="PART_Highlight" Visibility="Collapsed" />
</Canvas>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
- [ ] **Step 4: Create DockingLayout Code-Behind**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`:
```csharp
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
[TemplatePart(Name = "PART_Content", Type = typeof(ContentPresenter))]
[TemplatePart(Name = "PART_OverlayCanvas", Type = typeof(Canvas))]
[TemplatePart(Name = "PART_Highlight", Type = typeof(DockRegionHighlight))]
public class DockingLayout : Control
{
public static readonly DependencyProperty RootPanelProperty = DependencyProperty.Register(
nameof(RootPanel), typeof(DockPanel), typeof(DockingLayout), new PropertyMetadata(null, OnRootPanelChanged));
public DockPanel? RootPanel
{
get => (DockPanel?)GetValue(RootPanelProperty);
set => SetValue(RootPanelProperty, value);
}
private Canvas? _overlayCanvas;
private DockRegionHighlight? _highlight;
public DockingLayout()
{
DefaultStyleKey = typeof(DockingLayout);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_overlayCanvas = GetTemplateChild("PART_OverlayCanvas") as Canvas;
_highlight = GetTemplateChild("PART_Highlight") as DockRegionHighlight;
}
private static void OnRootPanelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockingLayout layout && e.NewValue is DockPanel panel)
{
panel.Root = layout;
}
}
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null)
{
if (RootPanel == null)
{
RootPanel = new DockPanel();
}
if (targetGroup == null)
{
if (RootPanel.Children.Count == 0)
{
var group = new DockGroup();
group.Children.Add(document);
RootPanel.Children.Add(group);
return;
}
targetGroup = RootPanel.Children[0] as DockGroup;
}
if (targetGroup != null)
{
targetGroup.Children.Add(document);
}
}
}
```
- [ ] **Step 5: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml
git commit -m "feat(docking): add DockRegionHighlight and DockingLayout"
```
---
### Task 5: Drag and Drop Logic
**Files:**
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
- [ ] **Step 1: Implement Drag and Drop in DockGroup**
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs` to handle drag events on the TabView:
```csharp
// Add to OnApplyTemplate:
if (_tabView != null)
{
_tabView.TabDragStarting += OnTabDragStarting;
_tabView.TabDroppedOutside += OnTabDroppedOutside;
_tabView.DragOver += OnDragOver;
_tabView.Drop += OnDrop;
_tabView.DragLeave += OnDragLeave;
}
// Add methods:
private void OnTabDragStarting(TabView sender, TabViewTabDragStartingEventArgs args)
{
if (args.Tab.Tag is DockDocument doc)
{
args.Data.Properties.Add("DockDocument", doc);
doc.Detach();
}
}
private void OnTabDroppedOutside(TabView sender, TabViewTabDroppedOutsideEventArgs args)
{
if (args.Tab.Tag is DockDocument doc)
{
Root?.CreateFloatingWindow(doc);
}
}
private void OnDragOver(object sender, DragEventArgs e)
{
if (e.DataView.Properties.ContainsKey("DockDocument"))
{
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
Root?.ShowHighlight(this, e.GetPosition(this));
}
}
private void OnDrop(object sender, DragEventArgs e)
{
if (e.DataView.Properties.TryGetValue("DockDocument", out var obj) && obj is DockDocument doc)
{
Root?.HandleDrop(doc, this, e.GetPosition(this));
}
}
private void OnDragLeave(object sender, DragEventArgs e)
{
Root?.HideHighlight();
}
```
- [ ] **Step 2: Implement Highlight and Drop in DockingLayout**
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs` to add `ShowHighlight`, `HideHighlight`, `HandleDrop`, and `CreateFloatingWindow`:
```csharp
// Add methods:
internal void ShowHighlight(DockGroup targetGroup, Windows.Foundation.Point position)
{
if (_highlight == null || _overlayCanvas == null) return;
_highlight.Visibility = Visibility.Visible;
var target = CalculateDockTarget(targetGroup, position);
// Calculate rect based on target (simplified for brevity, needs actual math based on targetGroup's ActualWidth/Height)
double width = targetGroup.ActualWidth;
double height = targetGroup.ActualHeight;
double x = 0, y = 0;
switch (target)
{
case DockTarget.Left: width /= 2; break;
case DockTarget.Right: width /= 2; x = width; break;
case DockTarget.Top: height /= 2; break;
case DockTarget.Bottom: height /= 2; y = height; break;
case DockTarget.Center: break;
}
var transform = targetGroup.TransformToVisual(_overlayCanvas);
var point = transform.TransformPoint(new Windows.Foundation.Point(x, y));
Canvas.SetLeft(_highlight, point.X);
Canvas.SetTop(_highlight, point.Y);
_highlight.Width = width;
_highlight.Height = height;
}
internal void HideHighlight()
{
if (_highlight != null) _highlight.Visibility = Visibility.Collapsed;
}
internal void HandleDrop(DockDocument doc, DockGroup targetGroup, Windows.Foundation.Point position)
{
HideHighlight();
var target = CalculateDockTarget(targetGroup, position);
if (target == DockTarget.Center)
{
targetGroup.Children.Add(doc);
}
else
{
// Split logic: create new DockPanel, move targetGroup and doc into it
var parentPanel = targetGroup.Owner as DockPanel;
if (parentPanel != null)
{
int index = parentPanel.Children.IndexOf(targetGroup);
targetGroup.Detach();
var newPanel = new DockPanel { Orientation = (target == DockTarget.Left || target == DockTarget.Right) ? Orientation.Horizontal : Orientation.Vertical };
var newGroup = new DockGroup();
newGroup.Children.Add(doc);
if (target == DockTarget.Left || target == DockTarget.Top)
{
newPanel.Children.Add(newGroup);
newPanel.Children.Add(targetGroup);
}
else
{
newPanel.Children.Add(targetGroup);
newPanel.Children.Add(newGroup);
}
parentPanel.Children.Insert(index, newPanel);
}
}
}
private DockTarget CalculateDockTarget(DockGroup group, Windows.Foundation.Point position)
{
double w = group.ActualWidth;
double h = group.ActualHeight;
double x = position.X;
double y = position.Y;
if (x < w * 0.25) return DockTarget.Left;
if (x > w * 0.75) return DockTarget.Right;
if (y < h * 0.25) return DockTarget.Top;
if (y > h * 0.75) return DockTarget.Bottom;
return DockTarget.Center;
}
internal void CreateFloatingWindow(DockDocument doc)
{
// To be implemented in Task 6
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs
git commit -m "feat(docking): implement drag and drop logic"
```
---
### Task 6: Floating Window
**Files:**
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs`
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
- [ ] **Step 1: Create FloatingWindow**
Create `src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs`:
```csharp
using Microsoft.UI.Xaml;
namespace Ghost.Editor.View.Controls.Docking;
public class FloatingWindow : Window
{
public FloatingWindow(DockDocument document)
{
var layout = new DockingLayout();
var group = new DockGroup();
group.Children.Add(document);
var panel = new DockPanel();
panel.Children.Add(group);
layout.RootPanel = panel;
Content = layout;
// Basic window setup
AppWindow.Resize(new Windows.Graphics.SizeInt32(800, 600));
}
}
```
- [ ] **Step 2: Update DockingLayout**
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs` to implement `CreateFloatingWindow`:
```csharp
internal void CreateFloatingWindow(DockDocument doc)
{
var window = new FloatingWindow(doc);
window.Activate();
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs
git commit -m "feat(docking): add floating window support"
```

View File

@@ -1,75 +0,0 @@
# DockLayout System Design
## Purpose
To create a fully-featured docking layout system for the Ghost Engine Editor using WinUI 3, supporting tab tearing, window popping, and dynamic splitting of regions in a style similar to Unity or Blender.
## Architecture
The DockLayout will be entirely driven by a C# data model that represents a tree of nodes. The UI (`DockLayout` control) will observe this tree and recursively generate the corresponding XAML `Grid` and `TabView` elements.
### Core Data Model (The Node Tree)
```csharp
public abstract class DockNode : INotifyPropertyChanged { }
// Represents a split region (Grid)
public class DockGroupNode : DockNode
{
public Orientation Orientation { get; set; } // Horizontal or Vertical
public ObservableCollection<DockNode> Children { get; }
public ObservableCollection<GridLength> Sizes { get; } // Replaces Ratios for better WinUI 3 Grid binding
}
// Represents a leaf node containing a TabView
public class DockPanelNode : DockNode
{
// The items shown in the TabView
public ObservableCollection<object> Items { get; }
public int SelectedIndex { get; set; }
}
```
### Visual Components
1. **`DockLayout` (Control)**
* The root control.
* Takes a `DockNode` (usually a `DockGroupNode`) as its `Root`.
* Listens to Drag/Drop events to render the transparent drop target overlay over the layout.
2. **Node Renderers**
* A recursive template selector or code-behind builder that converts `DockGroupNode` into a `Grid` with `GridSplitter`s.
* Converts `DockPanelNode` into a `NavigationTabView` (or standard `TabView` with customized drag behaviors).
3. **`DockDropTarget` (Visual Overlay)**
* A simple XAML structure (e.g., a colored `Border` with opacity) that highlights a portion of a `DockPanelNode` based on mouse position during a drag operation (Left/Right/Top/Bottom 25% for splitting, Center 50% for merging).
## Interactions & Data Flow
### 1. Internal Dragging (Within the same window)
* User starts dragging a tab.
* The `DockLayout` tracks the mouse pointer `DragOver` events.
* It determines which `DockPanelNode` the mouse is currently hovering over.
* It calculates relative coordinates to show the Unity-style drop highlight.
* On **Drop**:
* If dropped in the **center**: The tab object is moved from its source `DockPanelNode.Items` to the target `DockPanelNode.Items`.
* If dropped on an **edge** (e.g., Right): The target `DockPanelNode` is removed from its parent `DockGroupNode`. A new `DockGroupNode` (Horizontal) is created to replace it. The target node and a *new* `DockPanelNode` (containing the dragged tab) are added as children to this new group.
### 2. Window Tear-Off (Full Docking)
* User drags a tab completely outside the main window.
* `TabView.TabDroppedOutside` is triggered.
* The system creates a new WinUI 3 `Window`.
* A new `DockLayout` instance is placed in this window.
* The dragged tab object is removed from its original tree and added to a new `DockPanelNode` inside the new window's tree.
* *Note: Because WinUI 3 supports multiple windows on the same UI thread, we don't have to worry about cross-thread marshaling of UI elements, making this much simpler than UWP.*
### 3. Empty Node Cleanup
* When a `DockPanelNode`'s `Items` collection reaches 0 (the last tab is dragged away), it is removed from the tree.
* If its parent `DockGroupNode` now only has 1 child remaining, that `DockGroupNode` is removed and replaced by its single child, collapsing the tree.
## Implementation Phases
1. Define the Data Model (`DockNode` structure).
2. Implement the recursive UI generation (binding the tree to nested Grids and TabViews).
3. Implement basic tab moving (Merge) between existing `DockPanelNode`s.
4. Implement edge dropping (Split) and the drop target highlight overlay.
5. Implement empty node cleanup logic.
6. Implement multi-window tear-off via `TabDroppedOutside`.

View File

@@ -1,38 +0,0 @@
# Docking Layout Design
## Overview
A custom WinUI 3 docking layout system for GhostEngine's editor, inspired by `WinUI.Dock` but tailored to support Unity/Blender-style region highlighting and dynamic tab creation.
## Architecture & Components
The system uses a UI-driven approach where custom controls manage their own state and visual tree.
- **`DockingLayout`**: The root control. Manages the overall state, drag-and-drop coordination, and floating windows.
- **`DockPanel`**: A container that splits its area horizontally or vertically (using a `Grid` with `GridSplitter`s).
- **`DockGroup`**: A container that holds multiple documents, rendered as a WinUI 3 `TabView`.
- **`DockDocument`**: The actual content item, representing a single tab and its payload.
- **`FloatingWindow`**: A separate WinUI `Window` that hosts a minimal `DockingLayout` internally for torn-off tabs.
- **`DockRegionHighlight`**: A visual overlay control (e.g., a semi-transparent blue rectangle) used during drag-and-drop to show exactly where the drop will occur (Left, Right, Top, Bottom, or Center).
## Drag and Drop Flow
1. **Start**: Dragging a `TabViewItem` initiates a drag operation. We store a reference to the dragged `DockDocument`.
2. **Over**: As the pointer moves over a `DockGroup` or `DockPanel`, we calculate the relative pointer position to determine the target region (Left 25%, Right 25%, Top 25%, Bottom 25%, or Center 50%). We display the `DockRegionHighlight` over that specific area.
3. **Drop**:
- If dropped on an edge region (Left/Right/Top/Bottom), we split the target container: we create a new `DockPanel`, move the existing content to one side, and place the dragged document on the other.
- If dropped in the Center of a `DockGroup`, we add the document as a new tab to that group.
- If dropped outside the main window bounds, we create a new `FloatingWindow` and place the document inside it.
## Dynamic Creation API
The `DockingLayout` will expose methods to allow programmatically adding new panels or tabs at runtime (e.g., for a "plus icon" scenario).
```csharp
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null);
```
## Implementation Details
- The controls will be created under `src\Editor\Ghost.Editor\View\Controls\Docking\`.
- We will use WinUI 3's built-in drag and drop APIs (`CanDrag`, `DragStarting`, `AllowDrop`, `DragOver`, `Drop`).
- The `DockRegionHighlight` will be a `Border` or `Rectangle` added to the `DockingLayout`'s visual tree (e.g., in a `Popup` or an overlay `Grid`) and positioned absolutely based on the current drag target.

View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup Condition="'$(Configuration)' == 'Debug_Editor'">
<DefineConstants>$(DefineConstants);DEBUG;GHOST_EDITOR;MHP_ENABLE_SAFETY_CHECKS;</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release_Editor'">
<DefineConstants>$(DefineConstants);GHOST_EDITOR;MHP_ENABLE_SAFETY_CHECKS;</DefineConstants>
</PropertyGroup>
</Project>

View File

@@ -1,3 +1,4 @@
using Ghost.Core.Graphics;
using Misaki.HighPerformance.Mathematics; using Misaki.HighPerformance.Mathematics;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
@@ -7,25 +8,6 @@ using System.Text.RegularExpressions;
namespace Ghost.DSL.Generator; namespace Ghost.DSL.Generator;
public enum PackingRules
{
Exact,
Aligned,
}
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Enum)]
public class GenerateHLSLAttribute : Attribute
{
private readonly PackingRules _packingRules;
private readonly string? _outputSource;
public GenerateHLSLAttribute(PackingRules packingRules, string? outputSource)
{
_packingRules = packingRules;
_outputSource = outputSource;
}
}
internal static partial class ShaderStructGenerator internal static partial class ShaderStructGenerator
{ {
private struct ShaderFieldInfo private struct ShaderFieldInfo

View File

@@ -4,11 +4,12 @@
<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>
<PackageReference Include="Antlr4.Runtime.Standard" Version="4.13.1" /> <PackageReference Include="Antlr4.Runtime.Standard" Version="4.13.1" />
<PackageReference Include="Antlr4BuildTasks" Version="12.11.0" /> <PackageReference Include="Antlr4BuildTasks" Version="12.14.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -17,6 +18,11 @@
<Listener>false</Listener> <Listener>false</Listener>
<Visitor>true</Visitor> <Visitor>true</Visitor>
</Antlr4> </Antlr4>
<Antlr4 Include="Grammar\GhostComputeShaderParser.g4">
<Visitor>true</Visitor>
<Generator>MSBuild:Compile</Generator>
<Listener>false</Listener>
</Antlr4>
<Antlr4 Include="Grammar\GhostShaderParser.g4"> <Antlr4 Include="Grammar\GhostShaderParser.g4">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
<Listener>false</Listener> <Listener>false</Listener>

View File

@@ -0,0 +1,71 @@
parser grammar GhostComputeShaderParser;
options {
tokenVocab = GhostShaderLexer;
}
// Top-level rule
computeFile: compute+ EOF;
compute:
COMPUTE STRING_LITERAL LBRACE
computeBody
RBRACE;
computeBody:
shaderModel | (definesBlock | includesBlock | keywordsBlock | hlslBlock | computeEntry)*;
shaderModel:
SM IDENTIFIER SEMICOLON;
scope:
GLOBAL | LOCAL;
definesBlock:
DEFINES LBRACE
defineStatement*
RBRACE;
defineStatement:
IDENTIFIER SEMICOLON;
includesBlock:
INCLUDES LBRACE
includeStatement*
RBRACE;
includeStatement:
STRING_LITERAL SEMICOLON;
keywordsBlock:
KEYWORDS LBRACE
keywordStatement*
RBRACE;
keywordStatement:
scope? IDENTIFIER (COMMA IDENTIFIER)* SEMICOLON;
hlslBlock:
HLSL LBRACE
hlslBody
RBRACE;
// Recursively matches content, ensuring braces are balanced.
hlslBody:
(
~(LBRACE | RBRACE) // Match ANY token except open/close braces
|
LBRACE hlslBody RBRACE // Or match a nested block recursively
)*;
computeEntry:
IDENTIFIER STRING_LITERAL COLON STRING_LITERAL SEMICOLON;
functionCall:
IDENTIFIER LPAREN functionArguments? RPAREN SEMICOLON;
functionArguments:
functionArgument (COMMA functionArgument)*;
functionArgument:
STRING_LITERAL | NUMBER | IDENTIFIER;

View File

@@ -2,7 +2,7 @@ lexer grammar GhostShaderLexer;
// Keywords // Keywords
SHADER: 'shader'; SHADER: 'shader';
PROPERTIES: 'properties'; COMPUTE: 'compute';
PIPELINE: 'pipeline'; PIPELINE: 'pipeline';
PASS: 'pass'; PASS: 'pass';
DEFINES: 'defines'; DEFINES: 'defines';
@@ -11,6 +11,7 @@ INCLUDES: 'includes';
GLOBAL: 'global'; GLOBAL: 'global';
LOCAL: 'local'; LOCAL: 'local';
HLSL: 'hlsl'; HLSL: 'hlsl';
SM: 'sm';
// Punctuation // Punctuation
LBRACE: '{'; LBRACE: '{';

View File

@@ -13,23 +13,14 @@ shader:
RBRACE; RBRACE;
shaderBody: shaderBody:
(propertiesBlock | pipelineBlock | passBlock | functionCall)*; shaderModel | (pipelineBlock | passBlock | functionCall)*;
// Properties block shaderModel:
propertiesBlock: SM IDENTIFIER SEMICOLON;
PROPERTIES LBRACE
propertyDeclaration*
RBRACE;
propertyDeclaration:
scope? IDENTIFIER IDENTIFIER (EQUALS LBRACE propertyInitializer RBRACE)? SEMICOLON;
scope: scope:
GLOBAL | LOCAL; GLOBAL | LOCAL;
propertyInitializer:
(NUMBER | IDENTIFIER) (COMMA (NUMBER | IDENTIFIER))*;
// Pipeline block // Pipeline block
pipelineBlock: pipelineBlock:
PIPELINE LBRACE PIPELINE LBRACE

View File

@@ -1,6 +1,7 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Core.Graphics; using Ghost.Core.Graphics;
using Ghost.DSL.ShaderParser; using Ghost.DSL.ShaderParser;
using Misaki.HighPerformance.Utilities;
using System.Text; using System.Text;
namespace Ghost.DSL.ShaderCompiler; namespace Ghost.DSL.ShaderCompiler;
@@ -17,16 +18,8 @@ public struct DSLShaderError
} }
} }
internal static class DSLShaderCompiler public static class DSLShaderCompiler
{ {
private const string _GLOBAL_PROPERTY_FILE_NAME = "GlobalData.g.hlsl";
private const string _GENERATED_FILE_HEADER = "// Auto-generated shader file. Please do not edit this file directly.";
private static string GetPassUniqueId(DSLShaderSemantics shader, PassSemantic pass)
{
return $"{shader.name}_{pass.name}";
}
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent) private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
{ {
if (semantic == null) if (semantic == null)
@@ -44,103 +37,149 @@ internal static class DSLShaderCompiler
}; };
} }
private static int LayoutCBufferProperties(Span<PropertyDescriptor> properties) private static Result<string> BuildFinalShaderCode(string shaderPath, ReadOnlySpan<string> includes, string? injectedCode, string? properties)
{ {
if (properties.IsEmpty) string shaderCode;
if (shaderPath == "hlsl_block")
{ {
return 0; if (string.IsNullOrEmpty(injectedCode))
}
var currentOffset = 0;
foreach (ref var prop in properties)
{
var size = prop.type.GetSize();
if ((currentOffset % 16) + size > 16)
{ {
currentOffset = (currentOffset + 15) & ~15; return Result.Failure("Shader code is empty. Either provide a valid shader path or inject shader code directly.");
} }
prop.offset = currentOffset; shaderCode = string.Empty;
prop.size = size; }
else
{
if (!File.Exists(shaderPath))
{
return Result.Failure("Shader file not found: " + shaderPath);
}
currentOffset += size; shaderCode = File.ReadAllText(shaderPath);
} }
return (currentOffset + 15) & ~15; var sb = new StringBuilder();
foreach (var includePath in includes)
{
sb.AppendLine($"#include \"{includePath}\"");
}
if (!string.IsNullOrEmpty(properties))
{
sb.AppendLine($"#line 0 \"properties\"");
sb.AppendLine(properties);
}
if (!string.IsNullOrEmpty(injectedCode))
{
sb.AppendLine($"#line 0 \"injected_code\"");
sb.AppendLine(injectedCode);
}
if (!string.IsNullOrEmpty(shaderCode))
{
sb.AppendLine($"#line 0 \"{shaderPath}\"");
sb.AppendLine(shaderCode);
}
return sb.ToString();
} }
// 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 ShaderDescriptor ResolveShader(DSLShaderSemantics semantics) public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
{ {
var descriptor = new ShaderDescriptor if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
{ {
name = semantics.name, propertyInfo = default;
hlsl = semantics.hlsl }
var passes = semantics.passes == null ? Array.Empty<PassDescriptor>() : new PassDescriptor[semantics.passes.Count];
for (var i = 0; i < passes.Length; i++)
{
var pass = semantics.passes![i];
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
var result = BuildFinalShaderCode(pass.amplificationShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
if (result.IsFailure)
{
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
}
var amplificationShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.amplificationShader.entry };
result = BuildFinalShaderCode(pass.meshShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
if (result.IsFailure)
{
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
}
var meshShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.meshShader.entry };
result = BuildFinalShaderCode(pass.pixelShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
if (result.IsFailure)
{
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
}
var pixelShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.pixelShader.entry };
passes[i] = new PassDescriptor
{
name = pass.name,
amplificationShaderCode = amplificationShaderCode,
meshShaderCode = meshShaderCode,
pixelShaderCode = pixelShaderCode,
localPipeline = localPipeline,
defines = pass.defines?.ToArray() ?? Array.Empty<string>(),
keywords = pass.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
};
}
var descriptor = new GraphicsShaderDescriptor
{
Name = semantics.name,
PropertyBufferSize = propertyInfo.Size,
ShaderModel = semantics.shaderModel,
Passes = passes
}; };
var shaderGlobalProperties = semantics.properties? for (var i = 0; i < descriptor.Passes.Length; i++)
.Where(p => p.scope == PropertyScope.Global)
.Select(p => new PropertyDescriptor
{
name = p.name,
type = p.type,
defaultValue = p.defaultValue
}).ToArray();
var shaderLocalProperties = semantics.properties?
.Where(p => p.scope == PropertyScope.Local)
.Select(p => new PropertyDescriptor
{
name = p.name,
type = p.type,
defaultValue = p.defaultValue
}).ToArray();
descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty<PropertyDescriptor>();
descriptor.properties = shaderLocalProperties ?? Array.Empty<PropertyDescriptor>();
descriptor.cbufferSize = LayoutCBufferProperties(descriptor.properties);
if (semantics.passes != null)
{ {
descriptor.passes = new PassDescriptor[semantics.passes.Count]; descriptor.Passes[i].shader = descriptor;
for (var i = 0; i < semantics.passes.Count; i++)
{
var pass = semantics.passes[i];
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
descriptor.passes[i] = new PassDescriptor
{
identifier = GetPassUniqueId(semantics, pass),
name = pass.name,
taskShader = pass.taskShader,
meshShader = pass.meshShader,
pixelShader = pass.pixelShader,
localPipeline = localPipeline,
defines = pass.defines?.ToArray() ?? Array.Empty<string>(),
includes = pass.includes?.ToArray() ?? Array.Empty<string>(),
keywords = pass.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>(),
hlsl = pass.hlsl
};
}
}
else
{
descriptor.passes = Array.Empty<PassDescriptor>();
} }
return descriptor; return descriptor;
} }
public static Result<ShaderDescriptor> CompileShader(string shaderPath, string generatedOutputDirectory) public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
{
using var reader = new StreamReader(stream);
return CompileGraphicsShaderCode(reader.ReadToEnd());
}
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(string shaderPath)
{
if (!File.Exists(shaderPath))
{
return Result.Failure("Shader file not found: " + shaderPath);
}
var code = File.ReadAllText(shaderPath);
return CompileGraphicsShaderCode(code);
}
public static Result<GraphicsShaderDescriptor> CompileGraphicsShaderCode(string shaderCode)
{ {
try try
{ {
var source = File.ReadAllText(shaderPath);
// Use ANTLR4 parser // Use ANTLR4 parser
var shaderModels = AntlrShaderCompiler.ParseShaders(source, out var parseErrors); var parseErrors = new List<DSLShaderError>();
var shaderModels = AntlrShaderCompiler.ParseShaders(shaderCode, parseErrors);
if (parseErrors.Count != 0) if (parseErrors.Count != 0)
{ {
@@ -172,37 +211,13 @@ internal static class DSLShaderCompiler
return Result.Failure("Failed to compile shader due to errors:\n" + errorMessages.ToString()); return Result.Failure("Failed to compile shader due to errors:\n" + errorMessages.ToString());
} }
var desc = ResolveShader(model); var result = ResolveShader(model);
var globalPropResult = GenerateGlobalProperties(desc.globalProperties, generatedOutputDirectory); if (result.IsFailure)
if (globalPropResult.IsFailure)
{ {
return Result.Failure("Failed to generate global properties: " + globalPropResult.Message); return result;
} }
var generatedResult = GenerateShaderCode(desc, generatedOutputDirectory); return result.Value;
if (generatedResult.IsFailure)
{
return Result.Failure("Failed to generate pass files: " + generatedResult.Message);
}
foreach (ref var pass in desc.passes.AsSpan())
{
if (pass.includes == null)
{
pass.includes = new string[2];
}
else
{
Array.Resize(ref pass.includes, pass.includes.Length + 2);
// Shift existing includes to make room for the two new includes at the front.
pass.includes.AsSpan(0, pass.includes.Length - 2).CopyTo(pass.includes.AsSpan(2));
}
pass.includes[0] = globalPropResult.Value;
pass.includes[1] = generatedResult.Value;
}
return desc;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -210,117 +225,100 @@ internal static class DSLShaderCompiler
} }
} }
private static string ShaderPropertyTypeToHLSLType(ShaderPropertyType type) public static Result<ComputeShaderDescriptor> CompileComputeShader(Stream stream)
{ {
return type switch using var reader = new StreamReader(stream);
return CompileComputeShaderCode(reader.ReadToEnd());
}
public static Result<ComputeShaderDescriptor> CompileComputeShader(string shaderPath)
{
if (!File.Exists(shaderPath))
{ {
ShaderPropertyType.Float => "float", return Result.Failure("Shader file not found: " + shaderPath);
ShaderPropertyType.Float2 => "float2", }
ShaderPropertyType.Float3 => "float3",
ShaderPropertyType.Float4 => "float4", var code = File.ReadAllText(shaderPath);
ShaderPropertyType.Int => "int", return CompileComputeShaderCode(code);
ShaderPropertyType.Int2 => "int2", }
ShaderPropertyType.Int3 => "int3",
ShaderPropertyType.Int4 => "int4", public static Result<ComputeShaderDescriptor> CompileComputeShaderCode(string shaderCode)
ShaderPropertyType.UInt => "uint", {
ShaderPropertyType.UInt2 => "uint2", try
ShaderPropertyType.UInt3 => "uint3", {
ShaderPropertyType.UInt4 => "uint4", var parseErrors = new List<DSLShaderError>();
ShaderPropertyType.Bool => "bool", var shaderModels = AntlrShaderCompiler.ParseComputeShaders(shaderCode, parseErrors);
ShaderPropertyType.Bool2 => "bool2",
ShaderPropertyType.Bool3 => "bool3", if (parseErrors.Count != 0)
ShaderPropertyType.Bool4 => "bool4", {
// NOTE: Textures here are bindless, represented as uint (descriptor index). var errorMessages = new StringBuilder();
ShaderPropertyType.Texture2D => "TEXTURE2D", foreach (var error in parseErrors)
ShaderPropertyType.Texture3D => "TEXTURE3D", {
ShaderPropertyType.TextureCube => "TEXTURECUBE", errorMessages.AppendLine(error.ToString());
ShaderPropertyType.Texture2DArray => "TEXTURE2D_ARRAY", }
ShaderPropertyType.TextureCubeArray => "TEXTURECUBE_ARRAY",
ShaderPropertyType.Sampler => "SAMPLER", return Result.Failure("Failed to parse compute shader due to errors:\n" + errorMessages.ToString());
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported shader property type: {type}") }
if (shaderModels.Count == 0)
{
return Result.Failure("No compute shader found in the provided file.");
}
var model = AntlrShaderCompiler.ConvertToComputeSemantics(shaderModels[0], out var errors);
if (errors.Count != 0 || model == null)
{
var errorMessages = new StringBuilder();
foreach (var error in errors)
{
errorMessages.AppendLine(error.ToString());
}
return Result.Failure("Failed to compile compute shader due to errors:\n" + errorMessages.ToString());
}
var result = ResolveComputeShader(model);
if (result.IsFailure)
{
return result;
}
return result.Value;
}
catch (Exception ex)
{
return Result.Failure("Failed to compile compute shader: " + ex.Message);
}
}
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
{
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
{
propertyInfo = default;
}
var shaderCodes = new ShaderCode[semantics.entryPoints.Count];
for (var i = 0; i < shaderCodes.Length; i++)
{
var result = BuildFinalShaderCode(semantics.entryPoints[i].shaderPath, semantics.includes.AsSpan(), semantics.hlsl, propertyInfo.Code);
if (result.IsFailure)
{
return Result.Failure($"Failed to build shader code for entry point '{semantics.entryPoints[i].entry}': {result.Message}");
}
shaderCodes[i] = new ShaderCode { code = result.Value, entryPoint = semantics.entryPoints[i].entry };
}
return new ComputeShaderDescriptor
{
Name = semantics.name,
PropertyBufferSize = propertyInfo.Size,
ShaderModel = semantics.shaderModel,
ShaderCodes = shaderCodes,
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
}; };
} }
public static Result<string> GenerateShaderCode(ShaderDescriptor descriptor, string targetDirectory)
{
if (!Directory.Exists(targetDirectory))
{
return Result.Failure("Target directory does not exist.");
}
var outputFileName = descriptor.name.Replace('/', '_');
var outputFilePath = Path.Combine(targetDirectory, outputFileName + ".g.hlsl");
var outputDirectory = Path.GetDirectoryName(outputFilePath);
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory!);
}
using var fileStream = File.CreateText(outputFilePath);
var fileDefine = outputFileName.Replace('/', '_').ToUpperInvariant() + "_G_HLSL";
var sb = new StringBuilder();
sb.AppendLine(_GENERATED_FILE_HEADER);
sb.AppendLine(@$"
#ifndef {fileDefine}
#define {fileDefine}
#include ""F:/csharp/GhostEngine/src/Runtime//Ghost.Graphics/Shaders/Includes/Common.hlsl""");
sb.Append(@"
struct PerMaterialData
{");
foreach (var prop in descriptor.properties)
{
sb.Append($@"
{ShaderPropertyTypeToHLSLType(prop.type)} {prop.name};");
}
sb.Append(@"
};");
sb.AppendLine();
sb.AppendLine(@$"
#endif // {fileDefine}");
fileStream.Write(sb.ToString());
return outputFilePath;
}
public static Result<string> GenerateGlobalProperties(ReadOnlySpan<PropertyDescriptor> globalProperties, string targetDirectory)
{
if (!Directory.Exists(targetDirectory))
{
return Result.Failure("Target directory does not exist.");
}
var globalFilePath = Path.Combine(targetDirectory, _GLOBAL_PROPERTY_FILE_NAME);
using var globalFileStream = File.CreateText(globalFilePath);
var sb = new StringBuilder();
sb.AppendLine(_GENERATED_FILE_HEADER);
sb.Append(@"
#ifndef GLOBALDATA_G_HLSL
#define GLOBALDATA_G_HLSL
#include ""F:/csharp/GhostEngine/src/Runtime//Ghost.Graphics/Shaders/Includes/Common.hlsl""
struct GlobalData
{");
foreach (var prop in globalProperties)
{
sb.Append($@"
{ShaderPropertyTypeToHLSLType(prop.type)} {prop.name};");
}
sb.AppendLine(@"
};
#endif // GLOBALDATA_G_HLSL");
globalFileStream.Write(sb.ToString());
return globalFilePath;
}
} }

View File

@@ -8,12 +8,12 @@ public enum PropertyScope
Local, Local,
} }
public class PropertySemantic public struct ShaderEntryPoint
{ {
public PropertyScope scope; public string entry;
public ShaderPropertyType type; public string shaderPath;
public string name = string.Empty;
public object? defaultValue; public readonly bool IsCreated => !string.IsNullOrEmpty(entry) && !string.IsNullOrEmpty(shaderPath);
} }
public class PipelineSemantic public class PipelineSemantic
@@ -28,7 +28,7 @@ public class PipelineSemantic
public class PassSemantic public class PassSemantic
{ {
public string name = string.Empty; public string name = string.Empty;
public ShaderEntryPoint taskShader; public ShaderEntryPoint amplificationShader;
public ShaderEntryPoint meshShader; public ShaderEntryPoint meshShader;
public ShaderEntryPoint pixelShader; public ShaderEntryPoint pixelShader;
public string? hlsl; public string? hlsl;
@@ -41,8 +41,18 @@ public class PassSemantic
public class DSLShaderSemantics public class DSLShaderSemantics
{ {
public string name = string.Empty; public string name = string.Empty;
public string? hlsl; public ShaderModel shaderModel;
public List<PropertySemantic>? properties;
public PipelineSemantic? pipeline; public PipelineSemantic? pipeline;
public List<PassSemantic>? passes; public List<PassSemantic>? passes;
}
public class DSLComputeShaderSemantics
{
public string name = string.Empty;
public string? hlsl;
public ShaderModel shaderModel;
public List<string>? defines;
public List<string>? includes;
public List<KeywordsGroup>? keywords;
public List<ShaderEntryPoint> entryPoints = null!;
} }

View File

@@ -7,10 +7,8 @@ namespace Ghost.DSL.ShaderParser;
public class AntlrShaderCompiler public class AntlrShaderCompiler
{ {
public static List<ShaderModel> ParseShaders(string source, out List<DSLShaderError> errors) public static List<GraphicsShaderModel> ParseShaders(string source, List<DSLShaderError> errors)
{ {
errors = new List<DSLShaderError>();
try try
{ {
var inputStream = new AntlrInputStream(source); var inputStream = new AntlrInputStream(source);
@@ -33,7 +31,7 @@ public class AntlrShaderCompiler
if (errors.Count > 0) if (errors.Count > 0)
{ {
return new List<ShaderModel>(); return new List<GraphicsShaderModel>();
} }
var visitor = new ShaderVisitor(); var visitor = new ShaderVisitor();
@@ -49,11 +47,91 @@ public class AntlrShaderCompiler
line = -1, line = -1,
column = -1 column = -1
}); });
return new List<ShaderModel>(); return new List<GraphicsShaderModel>();
} }
} }
public static DSLShaderSemantics? ConvertToSemantics(ShaderModel model, out List<DSLShaderError> errors) public static List<ComputeShaderModel> ParseComputeShaders(string source, List<DSLShaderError> errors)
{
errors = new List<DSLShaderError>();
try
{
var inputStream = new AntlrInputStream(source);
var lexer = new GhostShaderLexer(inputStream);
// Capture lexer errors
lexer.RemoveErrorListeners();
var lexerErrorListener = new ErrorListener(errors);
lexer.AddErrorListener(lexerErrorListener);
var tokenStream = new CommonTokenStream(lexer);
var parser = new GhostComputeShaderParser(tokenStream);
// Capture parser errors
parser.RemoveErrorListeners();
var parserErrorListener = new ErrorListener(errors);
parser.AddErrorListener(parserErrorListener);
var tree = parser.computeFile();
if (errors.Count > 0)
{
return new List<ComputeShaderModel>();
}
var visitor = new ComputeShaderVisitor();
visitor.Visit(tree);
return visitor.ComputeShaders;
}
catch (Exception ex)
{
errors.Add(new DSLShaderError
{
message = $"Unexpected error during parsing: {ex.Message}",
line = -1,
column = -1
});
return new List<ComputeShaderModel>();
}
}
private static bool TryGetShaderModel(string model, List<DSLShaderError> errors, out ShaderModel shaderModel)
{
if (string.IsNullOrEmpty(model))
{
shaderModel = ShaderModel.SM_6_6; // Default to lowest supported shader model for compute shaders
}
else
{
switch (model)
{
case "6_6":
shaderModel = ShaderModel.SM_6_6;
break;
case "6_7":
shaderModel = ShaderModel.SM_6_7;
break;
case "6_8":
shaderModel = ShaderModel.SM_6_8;
break;
default:
shaderModel = default;
errors.Add(new DSLShaderError
{
message = $"Unknown shader model '{model}'.",
line = 0,
column = 0
});
return false;
}
}
return true;
}
public static DSLComputeShaderSemantics? ConvertToComputeSemantics(ComputeShaderModel model, out List<DSLShaderError> errors)
{ {
errors = new List<DSLShaderError>(); errors = new List<DSLShaderError>();
@@ -61,166 +139,84 @@ public class AntlrShaderCompiler
{ {
errors.Add(new DSLShaderError errors.Add(new DSLShaderError
{ {
message = "Shader name cannot be empty.", message = "Compute shader name cannot be empty.",
line = 0, line = 0,
column = 0 column = 0
}); });
return null; return null;
} }
var semantics = new DSLShaderSemantics var semantics = new DSLComputeShaderSemantics
{ {
name = model.Name, name = model.Name,
properties = ConvertProperties(model.Properties, errors), defines = model.Defines?.Defines,
pipeline = ConvertPipeline(model.Pipeline, errors) includes = model.Includes?.Includes,
hlsl = model.Hlsl?.Code
}; };
foreach (var pass in model.Passes) if (TryGetShaderModel(model.ShaderModel, errors, out var shaderModel))
{ {
var passSemantic = ConvertPass(pass, errors); semantics.shaderModel = shaderModel;
if (passSemantic != null) }
if (model.Keywords != null)
{
semantics.keywords = new List<KeywordsGroup>();
foreach (var group in model.Keywords.Groups)
{ {
semantics.passes ??= new List<PassSemantic>(); var keywordGroup = new KeywordsGroup
semantics.passes.Add(passSemantic); {
space = group.Scope?.ToLower() == "global" ? KeywordSpace.Global : KeywordSpace.Local,
keywords = group.Keywords
};
semantics.keywords.Add(keywordGroup);
} }
} }
return semantics; foreach (var entry in model.ShaderEntries)
}
private static List<PropertySemantic>? ConvertProperties(PropertiesBlockModel? properties, List<DSLShaderError> errors)
{
if (properties == null || properties.Properties.Count == 0)
{ {
return null; var entryType = entry.EntryType.ToLower();
} if (entryType == "cs")
{
var result = new List<PropertySemantic>(); semantics.entryPoints ??= new List<ShaderEntryPoint>();
var usedNames = new HashSet<string>(); semantics.entryPoints.Add(new ShaderEntryPoint
{
foreach (var prop in properties.Properties) shaderPath = entry.ShaderPath,
{ entry = entry.EntryPoint
if (usedNames.Contains(prop.Name)) });
}
else
{ {
errors.Add(new DSLShaderError errors.Add(new DSLShaderError
{ {
message = $"Duplicate property name '{prop.Name}'.", message = $"Unknown compute shader entry type '{entry.EntryType}'. Expected 'compute' or 'cs'.",
line = 0, line = 0,
column = 0 column = 0
}); });
continue;
} }
var semantic = new PropertySemantic
{
name = prop.Name,
scope = prop.Scope?.ToLower() == "global" ? PropertyScope.Global : PropertyScope.Local,
type = ParsePropertyType(prop.Type, errors)
};
if (prop.Initializer.Count > 0)
{
semantic.defaultValue = ParsePropertyValue(semantic.type, prop.Initializer, errors);
}
usedNames.Add(prop.Name);
result.Add(semantic);
} }
return result; if (semantics.entryPoints == null)
}
private static ShaderPropertyType ParsePropertyType(string type, List<DSLShaderError> errors)
{
return type.ToLower() switch
{
"float" => ShaderPropertyType.Float,
"float2" => ShaderPropertyType.Float2,
"float3" => ShaderPropertyType.Float3,
"float4" => ShaderPropertyType.Float4,
"float4x4" => ShaderPropertyType.Float4x4,
"int" => ShaderPropertyType.Int,
"int2" => ShaderPropertyType.Int2,
"int3" => ShaderPropertyType.Int3,
"int4" => ShaderPropertyType.Int4,
"uint" => ShaderPropertyType.UInt,
"uint2" => ShaderPropertyType.UInt2,
"uint3" => ShaderPropertyType.UInt3,
"uint4" => ShaderPropertyType.UInt4,
"bool" => ShaderPropertyType.Bool,
"bool2" => ShaderPropertyType.Bool2,
"bool3" => ShaderPropertyType.Bool3,
"bool4" => ShaderPropertyType.Bool4,
"tex2d" => ShaderPropertyType.Texture2D,
"tex3d" => ShaderPropertyType.Texture3D,
"texcube" => ShaderPropertyType.TextureCube,
"texcube_arr" => ShaderPropertyType.TextureCubeArray,
"tex2d_arr" => ShaderPropertyType.Texture2DArray,
"sampler" => ShaderPropertyType.Sampler,
_ => ShaderPropertyType.None
};
}
private static object? ParsePropertyValue(ShaderPropertyType type, List<string> values, List<DSLShaderError> errors)
{
// For textures, the value is an identifier (e.g., "white", "black")
if (type is ShaderPropertyType.Texture2D or ShaderPropertyType.Texture3D or ShaderPropertyType.TextureCube)
{
return values.Count > 0 ? values[0] : null;
}
// For samplers, no default value
if (type == ShaderPropertyType.Sampler)
{
return null;
}
// For numeric types, parse the values
try
{
return type switch
{
ShaderPropertyType.Float => values.Count > 0 ? float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0f,
ShaderPropertyType.Float2 => values.Count >= 2 ? new Misaki.HighPerformance.Mathematics.float2(
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture)) : default,
ShaderPropertyType.Float3 => values.Count >= 3 ? new Misaki.HighPerformance.Mathematics.float3(
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture)) : default,
ShaderPropertyType.Float4 => values.Count >= 4 ? new Misaki.HighPerformance.Mathematics.float4(
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(values[3], System.Globalization.CultureInfo.InvariantCulture)) : default,
ShaderPropertyType.Int => values.Count > 0 ? int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0,
ShaderPropertyType.Int2 => values.Count >= 2 ? new Misaki.HighPerformance.Mathematics.int2(
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture)) : default,
ShaderPropertyType.Int3 => values.Count >= 3 ? new Misaki.HighPerformance.Mathematics.int3(
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
int.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture)) : default,
ShaderPropertyType.Int4 => values.Count >= 4 ? new Misaki.HighPerformance.Mathematics.int4(
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
int.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture),
int.Parse(values[3], System.Globalization.CultureInfo.InvariantCulture)) : default,
ShaderPropertyType.UInt => values.Count > 0 ? uint.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0u,
ShaderPropertyType.Bool => values.Count > 0 && (values[0] == "1" || values[0].ToLower() == "true"),
_ => null
};
}
catch (Exception ex)
{ {
errors.Add(new DSLShaderError errors.Add(new DSLShaderError
{ {
message = $"Failed to parse property value: {ex.Message}", message = $"Compute shader '{model.Name}' must contain a compute/cs entry declaration.",
line = 0, line = 0,
column = 0 column = 0
}); });
return null;
} }
if (semantics.entryPoints != null && semantics.entryPoints.Count > 8)
{
errors.Add(new DSLShaderError
{
message = $"Compute shader '{model.Name}' cannot have more than 8 entry points.",
line = 0,
column = 0
});
}
return semantics;
} }
private static PipelineSemantic? ConvertPipeline(PipelineBlockModel? pipeline, List<DSLShaderError> errors) private static PipelineSemantic? ConvertPipeline(PipelineBlockModel? pipeline, List<DSLShaderError> errors)
@@ -241,11 +237,11 @@ public class AntlrShaderCompiler
{ {
"disabled" => ZTest.Disabled, "disabled" => ZTest.Disabled,
"less" => ZTest.Less, "less" => ZTest.Less,
"lessequal" => ZTest.LessEqual, "less_equal" => ZTest.LessEqual,
"equal" => ZTest.Equal, "equal" => ZTest.Equal,
"greaterequal" => ZTest.GreaterEqual, "greater_equal" => ZTest.GreaterEqual,
"greater" => ZTest.Greater, "greater" => ZTest.Greater,
"notequal" => ZTest.NotEqual, "not_equal" => ZTest.NotEqual,
"always" => ZTest.Always, "always" => ZTest.Always,
_ => ZTest.Disabled _ => ZTest.Disabled
}; };
@@ -269,7 +265,7 @@ public class AntlrShaderCompiler
"alpha" => Blend.Alpha, "alpha" => Blend.Alpha,
"additive" => Blend.Additive, "additive" => Blend.Additive,
"multiply" => Blend.Multiply, "multiply" => Blend.Multiply,
"premultipliedalpha" => Blend.PremultipliedAlpha, "premultiplied_alpha" => Blend.PremultipliedAlpha,
_ => Blend.Opaque _ => Blend.Opaque
}; };
break; break;
@@ -312,20 +308,20 @@ public class AntlrShaderCompiler
var entryType = entry.EntryType.ToLower(); var entryType = entry.EntryType.ToLower();
var shaderEntry = new ShaderEntryPoint var shaderEntry = new ShaderEntryPoint
{ {
shader = entry.ShaderPath, shaderPath = entry.ShaderPath,
entry = entry.EntryPoint entry = entry.EntryPoint
}; };
switch (entryType) switch (entryType)
{ {
case "mesh" or "ms": case "ms":
semantic.meshShader = shaderEntry; semantic.meshShader = shaderEntry;
break; break;
case "pixel" or "ps": case "ps":
semantic.pixelShader = shaderEntry; semantic.pixelShader = shaderEntry;
break; break;
case "task" or "ts": case "as":
semantic.taskShader = shaderEntry; semantic.amplificationShader = shaderEntry;
break; break;
default: default:
errors.Add(new DSLShaderError errors.Add(new DSLShaderError
@@ -338,7 +334,7 @@ public class AntlrShaderCompiler
} }
} }
if (semantic.meshShader.shader == null || semantic.pixelShader.shader == null) if (semantic.meshShader.shaderPath == null || semantic.pixelShader.shaderPath == null)
{ {
errors.Add(new DSLShaderError errors.Add(new DSLShaderError
{ {
@@ -351,6 +347,45 @@ public class AntlrShaderCompiler
return semantic; return semantic;
} }
public static DSLShaderSemantics? ConvertToSemantics(GraphicsShaderModel model, out List<DSLShaderError> errors)
{
errors = new List<DSLShaderError>();
if (string.IsNullOrWhiteSpace(model.Name))
{
errors.Add(new DSLShaderError
{
message = "Shader name cannot be empty.",
line = 0,
column = 0
});
return null;
}
var semantics = new DSLShaderSemantics
{
name = model.Name,
pipeline = ConvertPipeline(model.Pipeline, errors)
};
if (TryGetShaderModel(model.ShaderModel, errors, out var shaderModel))
{
semantics.shaderModel = shaderModel;
}
foreach (var pass in model.Passes)
{
var passSemantic = ConvertPass(pass, errors);
if (passSemantic != null)
{
semantics.passes ??= new List<PassSemantic>();
semantics.passes.Add(passSemantic);
}
}
return semantics;
}
private class ErrorListener : BaseErrorListener, IAntlrErrorListener<int>, IAntlrErrorListener<IToken> private class ErrorListener : BaseErrorListener, IAntlrErrorListener<int>, IAntlrErrorListener<IToken>
{ {
private readonly List<DSLShaderError> _errors; private readonly List<DSLShaderError> _errors;

View File

@@ -0,0 +1,149 @@
using Antlr4.Runtime.Misc;
using Ghost.DSL.ShaderParser.Model;
using TerraFX.Interop.Windows;
namespace Ghost.DSL.ShaderParser;
internal class ComputeShaderVisitor : GhostComputeShaderParserBaseVisitor<object>
{
public List<ComputeShaderModel> ComputeShaders { get; } = new();
public override object VisitComputeFile([NotNull] GhostComputeShaderParser.ComputeFileContext context)
{
foreach (var shaderContext in context.compute())
{
var shader = (ComputeShaderModel)VisitCompute(shaderContext);
ComputeShaders.Add(shader);
}
return ComputeShaders;
}
private static string StripQuotes(string text)
{
if (text.Length >= 2 && text.StartsWith('"') && text.EndsWith('"'))
{
return text.Substring(1, text.Length - 2);
}
return text;
}
public override object VisitCompute([NotNull] GhostComputeShaderParser.ComputeContext context)
{
var compute = new ComputeShaderModel
{
Name = StripQuotes(context.STRING_LITERAL().GetText())
};
var computeBody = context.computeBody();
if (computeBody != null)
{
compute.ShaderModel = computeBody.shaderModel()?.GetText() ?? string.Empty;
foreach (var definesBlock in computeBody.definesBlock())
{
compute.Defines = (DefinesBlockModel)VisitDefinesBlock(definesBlock);
}
foreach (var includesBlock in computeBody.includesBlock())
{
compute.Includes = (IncludesBlockModel)VisitIncludesBlock(includesBlock);
}
foreach (var keywordsBlock in computeBody.keywordsBlock())
{
compute.Keywords = (KeywordsBlockModel)VisitKeywordsBlock(keywordsBlock);
}
var hlslBlock = computeBody.hlslBlock().FirstOrDefault();
if (hlslBlock != null)
{
compute.Hlsl = (HlslBlockModel)VisitHlslBlock(hlslBlock);
}
foreach (var computeEntry in computeBody.computeEntry())
{
compute.ShaderEntries.Add((ShaderEntryModel)VisitComputeEntry(computeEntry));
}
}
return compute;
}
public override object VisitDefinesBlock([NotNull] GhostComputeShaderParser.DefinesBlockContext context)
{
var defines = new DefinesBlockModel();
foreach (var defineStmt in context.defineStatement())
{
defines.Defines.Add(defineStmt.IDENTIFIER().GetText());
}
return defines;
}
public override object VisitIncludesBlock([NotNull] GhostComputeShaderParser.IncludesBlockContext context)
{
var includes = new IncludesBlockModel();
foreach (var includeStmt in context.includeStatement())
{
includes.Includes.Add(StripQuotes(includeStmt.STRING_LITERAL().GetText()));
}
return includes;
}
public override object VisitKeywordsBlock([NotNull] GhostComputeShaderParser.KeywordsBlockContext context)
{
var keywords = new KeywordsBlockModel();
foreach (var keywordStmt in context.keywordStatement())
{
var group = new KeywordGroupModel();
if (keywordStmt.scope() != null)
{
group.Scope = keywordStmt.scope().GetText();
}
foreach (var identifier in keywordStmt.IDENTIFIER())
{
group.Keywords.Add(identifier.GetText());
}
keywords.Groups.Add(group);
}
return keywords;
}
public override object VisitHlslBlock([NotNull] GhostComputeShaderParser.HlslBlockContext context)
{
var hlsl = new HlslBlockModel();
// Get the text between the braces
var start = context.LBRACE().Symbol.StopIndex + 1;
var stop = context.RBRACE().Symbol.StartIndex - 1;
if (stop >= start)
{
var input = context.Start.InputStream;
hlsl.Code = input.GetText(new Interval(start, stop));
}
return hlsl;
}
public override object VisitComputeEntry([NotNull] GhostComputeShaderParser.ComputeEntryContext context)
{
var entry = new ShaderEntryModel
{
EntryType = context.IDENTIFIER().GetText(),
ShaderPath = StripQuotes(context.STRING_LITERAL(0).GetText()),
EntryPoint = StripQuotes(context.STRING_LITERAL(1).GetText())
};
return entry;
}
}

View File

@@ -1,25 +1,24 @@
namespace Ghost.DSL.ShaderParser.Model; namespace Ghost.DSL.ShaderParser.Model;
public class ShaderModel public class GraphicsShaderModel
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public PropertiesBlockModel? Properties { get; set; } public string ShaderModel { get; set; } = string.Empty;
public PipelineBlockModel? Pipeline { get; set; } public PipelineBlockModel? Pipeline { get; set; }
public List<PassBlockModel> Passes { get; set; } = new(); public List<PassBlockModel> Passes { get; set; } = new();
public List<FunctionCallModel> FunctionCalls { get; set; } = new(); public List<FunctionCallModel> FunctionCalls { get; set; } = new();
} }
public class PropertiesBlockModel public class ComputeShaderModel
{ {
public List<PropertyDeclarationModel> Properties { get; set; } = new();
}
public class PropertyDeclarationModel
{
public string? Scope { get; set; }
public string Type { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public List<string> Initializer { get; set; } = new(); public string ShaderModel { get; set; } = string.Empty;
public DefinesBlockModel? Defines { get; set; }
public IncludesBlockModel? Includes { get; set; }
public KeywordsBlockModel? Keywords { get; set; }
public HlslBlockModel? Hlsl { get; set; }
public List<FunctionCallModel> FunctionCalls { get; set; } = new();
public List<ShaderEntryModel> ShaderEntries { get; set; } = new();
} }
public class PipelineBlockModel public class PipelineBlockModel

View File

@@ -5,13 +5,13 @@ namespace Ghost.DSL.ShaderParser;
public class ShaderVisitor : GhostShaderParserBaseVisitor<object> public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
{ {
public List<ShaderModel> Shaders { get; } = new(); public List<GraphicsShaderModel> Shaders { get; } = new();
public override object VisitShaderFile([NotNull] GhostShaderParser.ShaderFileContext context) public override object VisitShaderFile([NotNull] GhostShaderParser.ShaderFileContext context)
{ {
foreach (var shaderContext in context.shader()) foreach (var shaderContext in context.shader())
{ {
var shader = (ShaderModel)VisitShader(shaderContext); var shader = (GraphicsShaderModel)VisitShader(shaderContext);
Shaders.Add(shader); Shaders.Add(shader);
} }
return Shaders; return Shaders;
@@ -19,7 +19,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
public override object VisitShader([NotNull] GhostShaderParser.ShaderContext context) public override object VisitShader([NotNull] GhostShaderParser.ShaderContext context)
{ {
var shader = new ShaderModel var shader = new GraphicsShaderModel
{ {
Name = StripQuotes(context.STRING_LITERAL().GetText()) Name = StripQuotes(context.STRING_LITERAL().GetText())
}; };
@@ -27,10 +27,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
var shaderBody = context.shaderBody(); var shaderBody = context.shaderBody();
if (shaderBody != null) if (shaderBody != null)
{ {
foreach (var propBlock in shaderBody.propertiesBlock()) shader.ShaderModel = shaderBody.shaderModel()?.GetText() ?? string.Empty;
{
shader.Properties = (PropertiesBlockModel)VisitPropertiesBlock(propBlock);
}
foreach (var pipelineBlock in shaderBody.pipelineBlock()) foreach (var pipelineBlock in shaderBody.pipelineBlock())
{ {
@@ -51,47 +48,6 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
return shader; return shader;
} }
public override object VisitPropertiesBlock([NotNull] GhostShaderParser.PropertiesBlockContext context)
{
var properties = new PropertiesBlockModel();
foreach (var propDecl in context.propertyDeclaration())
{
properties.Properties.Add((PropertyDeclarationModel)VisitPropertyDeclaration(propDecl));
}
return properties;
}
public override object VisitPropertyDeclaration([NotNull] GhostShaderParser.PropertyDeclarationContext context)
{
var property = new PropertyDeclarationModel
{
Type = context.IDENTIFIER(0).GetText(),
Name = context.IDENTIFIER(1).GetText()
};
if (context.scope() != null)
{
property.Scope = context.scope().GetText();
}
if (context.propertyInitializer() != null)
{
var init = context.propertyInitializer();
foreach (var number in init.NUMBER())
{
property.Initializer.Add(number.GetText());
}
foreach (var identifier in init.IDENTIFIER())
{
property.Initializer.Add(identifier.GetText());
}
}
return property;
}
public override object VisitPipelineBlock([NotNull] GhostShaderParser.PipelineBlockContext context) public override object VisitPipelineBlock([NotNull] GhostShaderParser.PipelineBlockContext context)
{ {
var pipeline = new PipelineBlockModel(); var pipeline = new PipelineBlockModel();
@@ -209,7 +165,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
if (stop >= start) if (stop >= start)
{ {
var input = context.Start.InputStream; var input = context.Start.InputStream;
hlsl.Code = input.GetText(new Antlr4.Runtime.Misc.Interval(start, stop)); hlsl.Code = input.GetText(new Interval(start, stop));
} }
return hlsl; return hlsl;

View File

@@ -1,179 +0,0 @@
using Ghost.Editor.Core.Contracts;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.AssetHandler;
public abstract class Asset
{
public Guid ID
{
get;
}
public abstract Guid TypeID
{
get;
}
public Guid[] Dependencies
{
get;
}
public IAssetSettings? Settings
{
get;
}
protected Asset(Guid id, Guid[] dependencies, IAssetSettings? settings)
{
ID = id;
Dependencies = dependencies;
Settings = settings;
}
public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default)
{
return ValueTask.CompletedTask;
}
}
// Do not change the order of the fields in this struct, as it is used for binary serialization/deserialization.
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
internal struct AssetMetadata
{
public const int CURRENT_FORMAT_VERSION = 1;
public const int SIZE = 128; // Fixed size for metadata header. We choose 128 bytes to allow future expansion without breaking compatibility.
public AssetMetadata(Guid id, Guid typeID)
{
FormatVersion = CURRENT_FORMAT_VERSION;
ID = id;
TypeID = typeID;
}
public int FormatVersion
{
get;
}
public Guid ID
{
get;
}
public Guid TypeID
{
get;
}
public int HandlerVersion
{
get; set;
}
public int DependencyCount
{
get; set;
}
public long DependenciesOffset
{
get; set;
}
public long SettingsOffset
{
get; set;
}
public long SettingsSize
{
get; set;
}
public long ContentOffset
{
get; set;
}
public long ContentSize
{
get; set;
}
public static void WriteToStream(Stream stream, scoped ref readonly AssetMetadata metadata)
{
var buffer = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in metadata, 1));
stream.Write(buffer);
}
public static AssetMetadata ReadFromStream(Stream stream)
{
Span<byte> buffer = stackalloc byte[SIZE];
stream.ReadExactly(buffer);
return Unsafe.ReadUnaligned<AssetMetadata>(ref MemoryMarshal.GetReference(buffer));
}
}
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
public readonly struct DependencyInfo
{
public const int SIZE = 16;
public Guid ID
{
get; init;
}
public readonly ReadOnlySpan<byte> AsBytes()
{
return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1));
}
}
public readonly struct AssetReference : IEquatable<AssetReference>
{
private readonly int _value;
/// <summary>
/// The index of the asset in the dependency list.
/// </summary>
public int Index
{
get => Math.Abs(_value) - 1;
}
public static AssetReference Null => default;
public readonly bool IsInternal => _value >= 0;
public readonly bool IsExternal => _value < 0;
public bool Equals(AssetReference other)
{
return _value == other._value;
}
public override int GetHashCode()
{
return _value.GetHashCode();
}
public override bool Equals(object? obj)
{
return obj is AssetReference reference && Equals(reference);
}
public static bool operator ==(AssetReference left, AssetReference right)
{
return left.Equals(right);
}
public static bool operator !=(AssetReference left, AssetReference right)
{
return !(left == right);
}
}
public interface IAssetSettings;

View File

@@ -1,60 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandler;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetHandlerAttribute : Attribute
{
public required string ID
{
get; init;
}
public required string[] SupportedExtensions
{
get; init;
}
public bool AllowCaching
{
get; init;
} = true;
}
public interface IAssetExportOptions;
public interface IAssetHandler
{
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default);
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default);
}
public interface IImportableAssetHandler : IAssetHandler
{
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default);
ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default);
}
public static class AssetHandlerExtensions
{
public static async ValueTask<Result> ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default)
{
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
return await handler.ImportAsync(sourceStream, targetStream, id, token);
}
public static async ValueTask<Result> ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default)
{
await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
return await handler.ExportAsync(assetStream, targetStream, options, token);
}
public static async ValueTask<Result<Asset>> LoadAsync(this IAssetHandler handler, string assetFilePath, IAssetRegistry assetDatabase, CancellationToken token = default)
{
await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await handler.LoadAsync(sourceStream, assetDatabase, token);
}
}

View File

@@ -1,37 +0,0 @@
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandler;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetProcesserAttribute<T> : Attribute
{
public Type Type => typeof(T);
}
public readonly struct AssetProcesserContext
{
public IAssetRegistry Registry
{
get; init;
}
public string AssetPath
{
get; init;
}
public Asset Asset
{
get; init;
}
public IAssetHandler Handler
{
get; init;
}
}
public interface IAssetProcesser
{
ValueTask ProcessAsync(AssetProcesserContext ctx);
}

View File

@@ -1,397 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.Image;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using static Ghost.Editor.Core.AssetHandler.TextureAssetSettings;
namespace Ghost.Editor.Core.AssetHandler;
public enum TextureType : uint
{
Default,
Normal,
Lightmap,
SingleChannel
}
public enum TextureShape : uint
{
Texture2D,
Texture3D,
TextureCube
}
public enum TextureSize : uint
{
Size256 = 256,
Size512 = 512,
Size1024 = 1024,
Size2048 = 2048,
Size4096 = 4096,
Size8192 = 8192
}
public enum TextureCompressionLevel : uint
{
Low,
Normal,
High
}
public enum MipmapFilter : uint
{
Box,
Triangle,
Kaiser,
MitchellNetravali
}
public class TextureAsset : Asset
{
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
private readonly Handle<Texture> _texture;
public override Guid TypeID => s_typeGuid;
public Handle<Texture> Texture => _texture;
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings, Handle<Texture> texture)
: base(id, dependencies, settings)
{
_texture = texture;
}
}
public class TextureAssetSettings : IAssetSettings
{
public struct BasicSettings()
{
public TextureType TextureType
{
get; set;
} = TextureType.Default;
public TextureShape TextureShape
{
get; set;
} = TextureShape.Texture2D;
public int Columns
{
get; set;
} = 1;
public int Rows
{
get; set;
} = 1;
public bool IsSRGB
{
get; set;
} = true;
}
public struct AdvancedSettings()
{
public bool StretchToPowerOfTwo
{
get; set;
} = true;
public bool VirtualTexture
{
get; set;
} = false;
public bool GenerateMipmaps
{
get; set;
} = true;
public uint MipmapLevelCount
{
get; set;
} = 0; // 0 means generate full mipmap levels.
public bool GammaCorrection
{
get; set;
} = true;
public bool PremultiplyAlpha
{
get; set;
} = false;
public MipmapFilter MipmapFilter
{
get; set;
} = MipmapFilter.Kaiser;
public TextureCompressionLevel CompressionLevel
{
get; set;
} = TextureCompressionLevel.Normal;
public bool UseBorderColor
{
get; set;
} = false;
public Color128 BorderColor
{
get; set;
} = new Color128(0, 0, 0, 0);
public bool ZeroAlphaBorder
{
get; set;
} = false;
public bool CutoutAlpha
{
get; set;
} = false;
public byte CutoutAlphaThreshold
{
get; set;
} = 127;
public bool ScaleAlphaForMipCoverage
{
get; set;
} = false;
public byte ScaleAlphaForMipCoverageThreshold
{
get; set;
} = 127;
public bool MipmapStreaming
{
get; set;
} = false;
}
public struct SamplerSettings()
{
public TextureSize MaxSize
{
get; set;
} = TextureSize.Size2048;
public TextureFilterMode FilterMode
{
get; set;
} = TextureFilterMode.Anisotropic;
public TextureAddressMode WrapMode
{
get; set;
} = TextureAddressMode.Repeat;
}
public BasicSettings Basic
{
get; set;
} = new BasicSettings();
public AdvancedSettings Advanced
{
get; set;
} = new AdvancedSettings();
public SamplerSettings Sampler
{
get; set;
} = new SamplerSettings();
}
[CustomAssetHandler(ID = TextureAsset._TYPE_ID, SupportedExtensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
internal class TextureAssetHandler : IImportableAssetHandler
{
private const int _CURRENT_VERSION = 1;
private static async ValueTask<Result<long>> WriteSettingsToStreamAsync(TextureAssetSettings settings, Stream stream, CancellationToken token = default)
{
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
var tempArray = ArrayPool<byte>.Shared.Rent(size);
try
{
ref var address = ref MemoryMarshal.GetReference(tempArray);
Unsafe.WriteUnaligned(ref address, settings.Basic);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()), settings.Advanced);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()), settings.Sampler);
await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
return Result.Success<long>(size);
}
catch (Exception ex)
{
return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
private static async ValueTask<Result<IAssetSettings>> ReadSettingsFromStreamAsync(Stream stream, CancellationToken token = default)
{
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
var tempArray = ArrayPool<byte>.Shared.Rent(size);
try
{
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
// Use index-based reads after the await to avoid 'ref across await' errors.
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref tempArray[0]);
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>()]);
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()]);
var settings = new TextureAssetSettings
{
Basic = basic,
Advanced = advanced,
Sampler = sampler
};
return Result.Success<IAssetSettings>(settings);
}
catch (Exception ex)
{
return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
public ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default)
{
throw new NotImplementedException();
}
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
{
var info = ImageInfo.FromStream(sourceStream);
if (info.BitsPerChannel <= 0)
{
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
}
var isFloat = info.BitsPerChannel > 8;
var width = info.Width;
var height = info.Height;
var colorComponents = info.ColorComponents;
byte[] pixelBytes;
if (isFloat)
{
using var image = ImageResultFloat.FromStream(sourceStream, colorComponents);
var span = MemoryMarshal.AsBytes(image.AsSpan());
pixelBytes = ArrayPool<byte>.Shared.Rent(span.Length);
span.CopyTo(pixelBytes);
}
else
{
using var image = ImageResult.FromStream(sourceStream, colorComponents);
var span = image.AsSpan();
pixelBytes = ArrayPool<byte>.Shared.Rent(span.Length);
span.CopyTo(pixelBytes);
}
try
{
var settings = new TextureAssetSettings();
await Task.Run(() =>
TextureProcessor.CompressToCache(
EditorApplication.CachesFolderPath,
id,
pixelBytes,
width,
height,
isFloat,
colorComponents,
settings),
token).ConfigureAwait(false);
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = _CURRENT_VERSION,
SettingsOffset = AssetMetadata.SIZE,
};
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
var sizeResult = await WriteSettingsToStreamAsync(settings, targetStream, token).ConfigureAwait(false);
if (sizeResult.IsFailure)
{
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
}
// Content layout (all little-endian):
// int32 width
// int32 height
// byte isFloat (0 = byte, 1 = float)
// int32 colorComponents (cast of ColorComponents enum)
// byte[] pixelBytes
const int _CONTENT_HEADER_SIZE = 4 + 4 + 1 + 4; // 13 bytes
header.SettingsSize = sizeResult.Value;
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
header.ContentSize = _CONTENT_HEADER_SIZE + pixelBytes.Length;
// Write raw image content
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
var contentHeader = ArrayPool<byte>.Shared.Rent(_CONTENT_HEADER_SIZE);
try
{
BitConverter.TryWriteBytes(contentHeader.AsSpan(0, 4), width);
BitConverter.TryWriteBytes(contentHeader.AsSpan(4, 4), height);
contentHeader[8] = isFloat ? (byte)1 : (byte)0;
BitConverter.TryWriteBytes(contentHeader.AsSpan(9, 4), (int)colorComponents);
await targetStream.WriteAsync(contentHeader.AsMemory(0, _CONTENT_HEADER_SIZE), token).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(contentHeader);
}
await targetStream.WriteAsync(pixelBytes, token).ConfigureAwait(false);
await targetStream.FlushAsync(token).ConfigureAwait(false);
// Patch header now that all sizes are known
targetStream.Seek(0, SeekOrigin.Begin);
AssetMetadata.WriteToStream(targetStream, ref header);
return Result.Success();
}
finally
{
ArrayPool<byte>.Shared.Return(pixelBytes);
}
}
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default)
{
throw new NotImplementedException();
}
}

View File

@@ -1,228 +0,0 @@
using Ghost.Nvtt;
using Misaki.HighPerformance.Image;
using Misaki.HighPerformance.LowLevel;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
namespace Ghost.Editor.Core.AssetHandler;
/// <summary>
/// Drives the NVTT compression + mipmap pipeline for a single texture asset.
///
/// Responsibilities:
/// 1. Accept raw decoded pixel bytes + settings.
/// 2. Determine the cache file path (<c>CachesFolderPath/TextureCache/&lt;guid&gt;_&lt;hash&gt;.dds</c>).
/// 3. If the cache is already valid (hash matches), skip compression.
/// 4. Otherwise run the full NVTT pipeline and write the DDS to the cache file.
///
/// The caller owns opening/closing all streams; this class only takes spans and paths.
/// </summary>
internal static unsafe class TextureProcessor
{
private const string _TEXTURE_CACHE_SUBFOLDER = "TextureCache";
/// <summary>
/// Compresses <paramref name="pixelData"/> according to <paramref name="settings"/>
/// and writes the result to the texture cache.
///
/// Returns the absolute path of the cache file on success.
/// The cache file is skipped if it already exists with a matching content hash.
/// </summary>
public static string CompressToCache(
string cachesFolderPath,
Guid assetId,
ReadOnlySpan<byte> pixelData,
int width,
int height,
bool isFloat,
ColorComponents colorComponents,
TextureAssetSettings settings)
{
var cacheDir = Path.Combine(cachesFolderPath, _TEXTURE_CACHE_SUBFOLDER);
Directory.CreateDirectory(cacheDir);
var settingsHash = ComputeSettingsHash(settings);
var cacheFileName = $"{assetId:N}_{settingsHash:X16}.dds";
var cachePath = Path.Combine(cacheDir, cacheFileName);
if (File.Exists(cachePath))
{
return cachePath;
}
foreach (var stale in Directory.EnumerateFiles(cacheDir, $"{assetId:N}_*.dds"))
{
File.Delete(stale);
}
RunNvttPipeline(cachePath, pixelData, width, height, isFloat, colorComponents, settings);
return cachePath;
}
private static void RunNvttPipeline(
string outputPath,
ReadOnlySpan<byte> pixelData,
int width,
int height,
bool isFloat,
ColorComponents colorComponents,
TextureAssetSettings settings)
{
using var pSurface = new DisposablePtr<NvttSurface>(NvttSurface.Create());
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
var inputFormat = isFloat
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
fixed (void* pData = pixelData)
{
pSurface.Get()->SetImageData(inputFormat, width, height, 1, pData, NvttBoolean.NVTT_True, null);
}
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
if (!isFloat)
{
pSurface.Get()->Swizzle(2, 1, 0, 3, null);
}
var maxExtent = (int)settings.Sampler.MaxSize;
if (settings.Advanced.StretchToPowerOfTwo)
{
pSurface.Get()->ResizeMakeSquare(maxExtent,
NvttRoundMode.NVTT_RoundMode_ToNearestPowerOfTwo,
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
}
else if (pSurface.Get()->Width() > maxExtent || pSurface.Get()->Height() > maxExtent)
{
pSurface.Get()->ResizeMax(maxExtent,
NvttRoundMode.NVTT_RoundMode_None,
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
}
if (settings.Advanced.UseBorderColor)
{
var c = settings.Advanced.BorderColor;
pSurface.Get()->SetBorder(c.r, c.g, c.b, c.a, null);
}
else if (settings.Advanced.ZeroAlphaBorder)
{
pSurface.Get()->SetBorder(0f, 0f, 0f, 0f, null);
}
if (settings.Basic.IsSRGB && settings.Advanced.GammaCorrection)
{
pSurface.Get()->ToLinearFromSrgb(null);
}
if (settings.Advanced.PremultiplyAlpha)
{
pSurface.Get()->PremultiplyAlpha(null);
}
pCompOpts.Get()->SetFormat(SelectFormat(settings));
pCompOpts.Get()->SetQuality(SelectQuality(settings.Advanced.CompressionLevel));
if (settings.Advanced.CutoutAlpha)
{
pCompOpts.Get()->SetQuantization(false, false, true,
settings.Advanced.CutoutAlphaThreshold);
}
pOutOpts.Get()->SetOutputHeader(true);
pOutOpts.Get()->SetSrgbFlag(settings.Basic.IsSRGB);
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(outputPath));
var nvttFilter = SelectMipmapFilter(settings.Advanced.MipmapFilter);
int mipmapCount;
if (!settings.Advanced.GenerateMipmaps)
{
mipmapCount = 1;
}
else if (settings.Advanced.MipmapLevelCount == 0)
{
mipmapCount = pSurface.Get()->CountMipmaps(1);
}
else
{
mipmapCount = (int)settings.Advanced.MipmapLevelCount;
}
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
pCtx.Get()->OutputHeader(pSurface.Get(), mipmapCount, pCompOpts.Get(), pOutOpts.Get());
using var pMip = new DisposablePtr<NvttSurface>(pSurface.Get()->Clone());
for (var level = 0; level < mipmapCount; level++)
{
// Scale alpha for coverage on each pMip (if requested)
if (settings.Advanced.ScaleAlphaForMipCoverage && level > 0)
{
var refCoverage = pMip.Get()->AlphaTestCoverage(
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3);
pMip.Get()->ScaleAlphaToCoverage(refCoverage,
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3, null);
}
pCtx.Get()->Compress(pMip.Get(), 0, level, pCompOpts.Get(), pOutOpts.Get());
if (level + 1 < mipmapCount)
{
pMip.Get()->BuildNextMipmapDefaults(nvttFilter, 1, null);
}
}
}
private static NvttFormat SelectFormat(TextureAssetSettings settings)
=> settings.Basic.TextureType switch
{
TextureType.Normal => NvttFormat.NVTT_Format_BC5, // RG normal map
TextureType.SingleChannel => NvttFormat.NVTT_Format_BC4, // single channel
TextureType.Lightmap => NvttFormat.NVTT_Format_BC6U, // HDR lightmap (unsigned)
_ => NvttFormat.NVTT_Format_BC7, // default color
};
private static NvttQuality SelectQuality(TextureCompressionLevel level)
=> level switch
{
TextureCompressionLevel.Low => NvttQuality.NVTT_Quality_Fastest,
TextureCompressionLevel.High => NvttQuality.NVTT_Quality_Production,
_ => NvttQuality.NVTT_Quality_Normal,
};
private static NvttMipmapFilter SelectMipmapFilter(MipmapFilter filter)
=> filter switch
{
MipmapFilter.Box => NvttMipmapFilter.NVTT_MipmapFilter_Box,
MipmapFilter.Triangle => NvttMipmapFilter.NVTT_MipmapFilter_Triangle,
MipmapFilter.MitchellNetravali => NvttMipmapFilter.NVTT_MipmapFilter_Mitchell,
_ => NvttMipmapFilter.NVTT_MipmapFilter_Kaiser,
};
private static ulong ComputeSettingsHash(TextureAssetSettings s)
{
var basicSize = Unsafe.SizeOf<TextureAssetSettings.BasicSettings>();
var advancedSize = Unsafe.SizeOf<TextureAssetSettings.AdvancedSettings>();
var samplerSize = Unsafe.SizeOf<TextureAssetSettings.SamplerSettings>();
var total = basicSize + advancedSize + samplerSize;
Span<byte> buf = stackalloc byte[total];
var basic = s.Basic;
var advanced = s.Advanced;
var sampler = s.Sampler;
MemoryMarshal.Write(buf, in basic);
MemoryMarshal.Write(buf.Slice(basicSize), in advanced);
MemoryMarshal.Write(buf.Slice(basicSize + advancedSize), in sampler);
return XxHash64.HashToUInt64(buf);
}
}

View File

@@ -0,0 +1,73 @@
using Ghost.Core;
using Ghost.Engine;
namespace Ghost.Editor.Core.Assets;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetHandlerAttribute : Attribute
{
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 Guid ID
{
get;
}
public Guid TypeID
{
get;
}
public IAssetSettings? Settings
{
get;
}
}
public interface IAssetExportOptions;
public interface IAssetHandler
{
IAssetSettings? CreateDefaultSettings(string ext);
ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default);
}
public interface IImportableAssetHandler : IAssetHandler
{
ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
}
public readonly record struct ImportedSubAsset(Guid Guid, string Kind, string DisplayName, string StablePath, string VirtualSourcePath, Guid AssetTypeId);
public interface IPackableAssetHandler : IAssetHandler
{
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);
}

View File

@@ -0,0 +1,127 @@
using Ghost.Engine;
using System.Collections.Concurrent;
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
{
private static readonly Dictionary<string, AssetHandlerInfo> s_byExtension;
private static readonly Dictionary<Guid, AssetHandlerInfo> s_byTypeId;
private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes;
private static readonly ConcurrentDictionary<Type, IAssetHandler?> s_handlerCache;
static AssetHandlerRegistry()
{
s_byExtension = new Dictionary<string, AssetHandlerInfo>(StringComparer.OrdinalIgnoreCase);
s_byTypeId = new Dictionary<Guid, AssetHandlerInfo>();
s_iAssetSettingsTypes = new List<(Type Type, string Name)>();
s_handlerCache = new ConcurrentDictionary<Type, IAssetHandler?>();
}
public static void RegisterHandler(Type handlerType, Guid assetTypeId, AssetType runtimeAssetType, int version, bool allowCaching, params ReadOnlySpan<string> extensions)
{
var info = new AssetHandlerInfo
{
HandlerType = handlerType,
RuntimeAssetType = runtimeAssetType,
EditorAssetTypeID = assetTypeId,
Version = version
};
s_byTypeId[assetTypeId] = info;
foreach (var ext in extensions)
{
var normalizedExt = ext.StartsWith('.') ? ext : "." + ext;
s_byExtension[normalizedExt] = info;
}
if (allowCaching)
{
s_handlerCache[handlerType] = null;
}
}
public static void RegisterIAssetSettingsType(Type type, string name)
{
s_iAssetSettingsTypes.Add((type, name));
}
public static IAssetHandler? GetByExtension(string extension)
{
if (string.IsNullOrEmpty(extension))
{
return null;
}
var normalized = extension.StartsWith('.') ? extension : "." + extension;
if (!s_byExtension.TryGetValue(normalized, out var info))
{
return null;
}
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
{
try
{
return (IAssetHandler?)Activator.CreateInstance(t);
}
catch
{
return null;
}
});
}
public static IAssetHandler? GetByAssetTypeId(Guid typeId)
{
if (!s_byTypeId.TryGetValue(typeId, out var info))
{
return null;
}
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
{
try
{
return (IAssetHandler?)Activator.CreateInstance(t);
}
catch
{
return null;
}
});
}
public static bool TryGetHandlerInfoByAssetTypeId(Guid typeId, out AssetHandlerInfo info)
{
return s_byTypeId.TryGetValue(typeId, out info);
}
public static bool TryGetHandlerInfoByExtension(string extension, out AssetHandlerInfo info)
{
if (string.IsNullOrEmpty(extension))
{
info = default;
return false;
}
var normalized = extension.StartsWith('.') ? extension : "." + extension;
return s_byExtension.TryGetValue(normalized, out info);
}
public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes()
{
return s_iAssetSettingsTypes;
}
}

View File

@@ -0,0 +1,151 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Ghost.Editor.Core.Assets;
/// <summary>
/// Mark IAssetSettings for polymorphic serialization.
/// Each handler type will register its own derived type.
/// </summary>
public interface IAssetSettings;
internal sealed class DefaultAssetSettings : IAssetSettings;
/// <summary>
/// Persisted as a JSON sidecar (.gmeta) next to every source asset.
/// This is the single source of truth for asset identity and import settings.
/// </summary>
public sealed class AssetMeta
{
/// <summary>
/// Globally unique identifier for this asset. Generated once, never changes.
/// </summary>
public required Guid Guid { get; init; }
/// <summary>
/// The Guid that identifies type id of asset.
/// </summary>
public Guid? AssetTypeId { get; set; }
/// <summary>
/// Version of the handler that last imported this asset.
/// </summary>
public int HandlerVersion { get; set; }
/// <summary>
/// xxHash64 of the source file content at last successful import.
/// </summary>
public string? ContentHash { get; set; }
/// <summary>
/// xxHash64 of the serialized import settings at last successful import.
/// </summary>
public string? SettingsHash { get; set; }
/// <summary>
/// UTC timestamp of last successful import.
/// </summary>
public DateTime? LastImportedUtc { get; set; }
/// <summary>
/// GUIDs of other assets this asset depends on.
/// </summary>
public Guid[] Dependencies { get; set; } = [];
/// <summary>
/// Optional user-facing labels for search/filtering in the editor.
/// </summary>
public string[] Labels { get; set; } = [];
/// <summary>
/// Handler-specific import settings.
/// </summary>
public IAssetSettings? Settings { get; set; }
}
internal static class AssetMetaIO
{
public const string META_EXTENSION_NAME = "gmeta";
public const string META_EXTENSION = ".gmeta";
internal static readonly JsonSerializerOptions s_options = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { ConfigureAssetSettingsPolymorphism }
},
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
}
};
private static void ConfigureAssetSettingsPolymorphism(JsonTypeInfo typeInfo)
{
if (typeInfo.Type != typeof(IAssetSettings))
{
return;
}
typeInfo.PolymorphismOptions = new JsonPolymorphismOptions
{
TypeDiscriminatorPropertyName = "$type",
IgnoreUnrecognizedTypeDiscriminators = true,
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor
};
foreach (var setting in AssetHandlerRegistry.GetIAssetSettingsTypes())
{
typeInfo.PolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(setting.Type, setting.Name));
}
}
public static async ValueTask<AssetMeta?> ReadAsync(string metaPath, CancellationToken token = default)
{
if (!File.Exists(metaPath))
{
return null;
}
try
{
await using var stream = new FileStream(metaPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<AssetMeta>(stream, s_options, token).ConfigureAwait(false);
}
catch
{
return null;
}
}
public static async ValueTask WriteAsync(string metaPath, AssetMeta meta, CancellationToken token = default)
{
var tempPath = metaPath + ".tmp";
await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(stream, meta, s_options, token).ConfigureAwait(false);
}
if (File.Exists(metaPath))
{
File.Delete(metaPath);
}
File.Move(tempPath, metaPath);
}
public static string GetMetaPath(string sourceFilePath)
{
return sourceFilePath + META_EXTENSION;
}
public static string GetSourcePath(string metaPath)
{
return metaPath[..^META_EXTENSION.Length];
}
}

View File

@@ -0,0 +1,505 @@
using Ghost.Core;
using Ghost.Core.Utilities;
using Ghost.Editor.Core.Services;
using Ghost.Engine;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.Jobs;
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;
using TerraFX.Interop.Mimalloc;
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 Guid ID
{
get;
}
public Guid TypeID => typeof(MeshAsset).GUID;
public IAssetSettings? Settings
{
get;
}
public ModelManifest Manifest
{
get;
}
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
{
ID = id;
Settings = settings;
Manifest = manifest;
}
public void Dispose()
{
}
}
[Guid(GUID)]
public abstract class MeshAsset : IAsset
{
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
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();
}
}
public enum CoordinateAxis
{
PositiveX,
PositiveY,
PositiveZ,
NegativeX,
NegativeY,
NegativeZ
}
public enum VertexDataSource
{
Imported,
Computed,
ComputedIfMissing
}
public class MeshAssetSettings : IAssetSettings
{
public VertexDataSource NormalDataSource
{
get; set;
} = VertexDataSource.ComputedIfMissing;
public VertexDataSource TangentDataSource
{
get; set;
} = VertexDataSource.ComputedIfMissing;
}
internal class ObjAssetSettings : MeshAssetSettings
{
public CoordinateAxis ObjectUpAxis
{
get; set;
} = CoordinateAxis.PositiveY;
public CoordinateAxis ObjectForwardAxis
{
get; set;
} = CoordinateAxis.NegativeZ;
public CoordinateAxis ObjectRightAxis
{
get; set;
} = CoordinateAxis.PositiveX;
public float UnitMeterScale
{
get; set;
} = 1.0f;
}
internal class FbxAssetSettings : MeshAssetSettings
{
}
[CustomAssetHandler(AssetTypeId = MeshAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
{
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly JobScheduler _jobScheduler = EditorApplication.GetService<EngineCore>().JobScheduler;
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 parseJob = new MeshParsingJob(root, sourcePath, AllocationHandle.Persistent, meshSettings);
var handle = _jobScheduler.Schedule(in parseJob);
await _jobScheduler.WaitAsync(handle, token);
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 MeshAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
{
if (settings is MeshAssetSettings 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(MeshAsset).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(_jobScheduler, geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
await MeshProcessor.BuildClusterLodHierarchyAsync(_jobScheduler, meshletData.Share(), token).ConfigureAwait(false);
var bounds = ComputeBounds(geometry.Vertices);
var header = new MeshContentHeader
{
magic = MeshContentHeader.MAGIC,
version = MeshContentHeader.VERSION,
vertexCount = (uint)geometry.Vertices.Count,
indexCount = (uint)geometry.Indices.Count,
materialPartCount = (uint)geometry.MaterialParts.Length,
meshletCount = (uint)meshletData.GetRef().meshlets.Count,
meshletGroupCount = (uint)meshletData.GetRef().groups.Count,
meshletHierarchyNodeCount = (uint)meshletData.GetRef().hierarchyNodes.Count,
meshletVertexCount = (uint)meshletData.GetRef().meshletVertices.Count,
meshletTriangleCount = (uint)meshletData.GetRef().meshletTriangles.Count,
materialSlotCount = (uint)meshletData.GetRef().materialSlotCount,
lodLevelCount = (uint)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 = (ulong)stream.Position;
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
header.indexOffset = (ulong)stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
header.materialPartOffset = (ulong)stream.Position;
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
header.meshletOffset = (ulong)stream.Position;
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
header.meshletGroupOffset = (ulong)stream.Position;
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
header.meshletHierarchyNodeOffset = (ulong)stream.Position;
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
header.meshletVertexOffset = (ulong)stream.Position;
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
header.meshletTriangleOffset = (ulong)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,184 @@
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics;
using System;
using System.Collections.Generic;
using System.Text;
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

@@ -0,0 +1,368 @@
using Ghost.Core;
using Ghost.Engine.Utilities;
using Ghost.Graphics.RHI;
using Ghost.Graphics.Utilities;
using Ghost.MeshOptimizer;
using Ghost.Ufbx;
using Misaki.HighPerformance.Jobs;
using Misaki.HighPerformance.LowLevel;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.LowLevel.Utilities;
using Misaki.HighPerformance.Mathematics;
using System.Runtime.CompilerServices;
using System.Text;
using TLSFPool = Misaki.HighPerformance.LowLevel.Buffer.MemoryPool<Misaki.HighPerformance.LowLevel.Buffer.TLSF, Misaki.HighPerformance.LowLevel.Buffer.TLSF.CreationOptions>;
namespace Ghost.Editor.Core.Assets;
internal readonly unsafe struct MeshParsingJob : IJob
{
private struct GeometryPart : IDisposable
{
public UnsafeList<Vertex> vertices;
public UnsafeList<uint> indices;
public int materialIndex;
public bool missingNormals;
public bool missingTangents;
public void Dispose()
{
vertices.Dispose();
indices.Dispose();
}
}
private readonly MeshNode _rootNode;
private readonly string _filePath;
private readonly AllocationHandle _allocationHandle;
private readonly MeshAssetSettings _settings;
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, MeshAssetSettings settings)
{
_rootNode = rootNode;
_filePath = filePath;
_allocationHandle = allocationHandle;
_settings = settings;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float4 ComputeTangent(float3 t, float3 n, float3 b)
{
var proj = n * math.dot(n, t);
t = math.normalize(t - proj);
var w = math.dot(math.cross(n, t), b) < 0.0f ? -1.0f : 1.0f;
return new float4(t.xyz, w);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ufbx_coordinate_axis ToUfbxCoordinateAxis(CoordinateAxis axis)
{
return axis switch
{
CoordinateAxis.PositiveX => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_POSITIVE_X,
CoordinateAxis.PositiveY => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_POSITIVE_Y,
CoordinateAxis.PositiveZ => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_POSITIVE_Z,
CoordinateAxis.NegativeX => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_NEGATIVE_X,
CoordinateAxis.NegativeY => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_NEGATIVE_Y,
CoordinateAxis.NegativeZ => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_NEGATIVE_Z,
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float4x4 ToFloat4x4(ufbx_vec3 t, ufbx_quat q, ufbx_vec3 s)
{
return float4x4.TRS(
new float3(t.x, t.y, t.z),
new quaternion(q.x, q.y, q.z, q.w),
new float3(s.x, s.y, s.z)
);
}
private void ParseHierarchy(ufbx_node* node, MeshNode self, AllocationHandle allocationHandle)
{
var children = new List<MeshNode>();
self.Name = node->name.ToString();
self.LocalTransform = ToFloat4x4(node->local_transform.translation, node->local_transform.rotation, node->local_transform.scale);
self.Children = children;
if (node->mesh != null)
{
var geoNode = ParseGeometry(node->mesh, allocationHandle);
if (geoNode != null)
{
children.Add(geoNode);
}
}
// TODO: Handle lights, cameras, and other node types.
for (var i = 0u; i < node->children.count; i++)
{
var childNode = new MeshNode();
ParseHierarchy(node->children.data[i], childNode, allocationHandle);
childNode.Parent = self;
children.Add(childNode);
}
}
private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh, AllocationHandle allocationHandle)
{
if (pMesh->num_faces == 0)
{
return null;
}
var numMaterials = pMesh->materials.count > 0 ? (int)pMesh->materials.count : 1;
// Bucket faces by material
using var materialBuckets = new UnsafeArray<UnsafeList<Vertex>>(numMaterials, allocationHandle);
using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
for (var i = 0; i < numMaterials; i++)
{
materialBuckets[i] = new UnsafeList<Vertex>(10240, allocationHandle);
}
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, allocationHandle);
for (var j = 0u; j < pMesh->num_faces; j++)
{
var face = pMesh->faces.data[j];
var materialIdx = pMesh->face_material.count > j ? pMesh->face_material.data[j] : 0;
var numTris = UfbxApi.TriangulateFace(triIndicesArray.AsSpan(0, maxScratchIndices), pMesh, face);
var totalIndices = numTris * 3;
for (var k = 0; k < totalIndices; k++)
{
var ufbxTopologyIndex = triIndicesArray[k];
var posIdx = pMesh->vertex_position.indices.data[ufbxTopologyIndex];
var normIdx = pMesh->vertex_normal.exists ? pMesh->vertex_normal.indices.data[ufbxTopologyIndex] : uint.MaxValue;
var tanIdx = pMesh->vertex_tangent.exists ? pMesh->vertex_tangent.indices.data[ufbxTopologyIndex] : uint.MaxValue;
var uvIdx = pMesh->vertex_uv.exists ? pMesh->vertex_uv.indices.data[ufbxTopologyIndex] : uint.MaxValue;
var colIdx = pMesh->vertex_color.exists ? pMesh->vertex_color.indices.data[ufbxTopologyIndex] : uint.MaxValue;
var btanIdx = pMesh->vertex_bitangent.exists ? pMesh->vertex_bitangent.indices.data[ufbxTopologyIndex] : uint.MaxValue;
var position = pMesh->vertex_position.values.data[posIdx];
var normal = normIdx != uint.MaxValue ? pMesh->vertex_normal.values.data[normIdx] : default;
var uv = uvIdx != uint.MaxValue ? pMesh->vertex_uv.values.data[uvIdx] : default;
var color = colIdx != uint.MaxValue ? pMesh->vertex_color.values.data[colIdx] : default;
var vertex = new Vertex
{
position = new float3(position.x, position.y, position.z),
normal = new float3(normal.x, normal.y, normal.z),
uv = new float2(uv.x, uv.y),
color = new Color128(color.x, color.y, color.z, color.w)
};
if (tanIdx != uint.MaxValue)
{
var mt = pMesh->vertex_tangent.values.data[tanIdx];
var mb = btanIdx != uint.MaxValue ? pMesh->vertex_bitangent.values.data[btanIdx] : default;
var t = new float3(mt.x, mt.y, mt.z);
var n = vertex.normal;
var b = btanIdx != uint.MaxValue ? new float3(mb.x, mb.y, mb.z) : math.cross(n, t);
vertex.tangent = ComputeTangent(t, n, b);
}
materialBuckets[materialIdx].Add(vertex);
if (!missingNormalsBucket[materialIdx])
{
missingNormalsBucket[materialIdx] = normIdx == uint.MaxValue;
}
if (!missingTangentsBucket[materialIdx])
{
missingTangentsBucket[materialIdx] = tanIdx == uint.MaxValue || btanIdx == uint.MaxValue;
}
}
}
// Per-material weld + optimize, collect intermediate results
using var partResults = new UnsafeList<GeometryPart>(numMaterials, allocationHandle);
for (var m = 0; m < numMaterials; m++)
{
ref var flatVertices = ref materialBuckets[m];
if (flatVertices.Count == 0)
{
flatVertices.Dispose();
continue;
}
var numIndices = (uint)flatVertices.Count;
using var weldedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
var stream = new ufbx_vertex_stream
{
data = flatVertices.GetUnsafePtr(),
vertex_count = numIndices,
vertex_size = (nuint)sizeof(Vertex)
};
var error = new ufbx_error();
var numUniqueVertices = UfbxApi.GenerateIndices([stream], weldedIndices, null, &error);
if (numUniqueVertices == 0 && error.type != ufbx_error_type.UFBX_ERROR_NONE)
{
flatVertices.Dispose();
continue;
}
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
// Allocate temporary per-part buffers (will be merged then disposed)
var partVertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocationHandle);
var partIndices = new UnsafeList<uint>((int)numIndices, allocationHandle);
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(partVertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
partVertices.UnsafeSetCount((int)finalVertexCount);
MemoryUtility.MemCpy(partIndices.GetUnsafePtr(), cachedIndices.GetUnsafePtr(), numIndices * sizeof(uint));
partIndices.UnsafeSetCount((int)numIndices);
var part = new GeometryPart
{
vertices = partVertices,
indices = partIndices,
materialIndex = m,
missingNormals = missingNormalsBucket[m],
missingTangents = missingTangentsBucket[m]
};
partResults.Add(part);
flatVertices.Dispose();
}
if (partResults.Count == 0)
{
return null;
}
// Merge all material parts into one unified vertex/index buffer
var totalVertexCount = 0;
var totalIndexCount = 0;
for (var i = 0; i < partResults.Count; i++)
{
totalVertexCount += partResults[i].vertices.Count;
totalIndexCount += partResults[i].indices.Count;
}
var mergedVertices = new UnsafeList<Vertex>(totalVertexCount, _allocationHandle);
var mergedIndices = new UnsafeList<uint>(totalIndexCount, _allocationHandle);
var materialParts = new UnsafeArray<MaterialPartInfo>(partResults.Count, _allocationHandle);
var vertexOffset = 0;
var indexOffset = 0;
for (var i = 0; i < partResults.Count; i++)
{
ref var part = ref partResults[i];
// Compute normals/tangents per-part before merge (requires local indices)
if (_settings.NormalDataSource == VertexDataSource.Computed || (_settings.NormalDataSource == VertexDataSource.ComputedIfMissing && part.missingNormals))
{
MeshBuilder.ComputeNormal(part.vertices, part.indices);
}
if (_settings.TangentDataSource == VertexDataSource.Computed || (_settings.TangentDataSource == VertexDataSource.ComputedIfMissing && part.missingTangents))
{
MeshBuilder.ComputeTangents(part.vertices, part.indices);
}
materialParts[i] = new MaterialPartInfo
{
materialIndex = part.materialIndex,
vertexStart = vertexOffset,
vertexCount = part.vertices.Count,
indexStart = indexOffset,
indexCount = part.indices.Count,
};
mergedVertices.AddRange(part.vertices.AsSpan());
// Rebase indices to global vertex space
for (var j = 0; j < part.indices.Count; j++)
{
mergedIndices.Add(part.indices[j] + (uint)vertexOffset);
}
vertexOffset += part.vertices.Count;
indexOffset += part.indices.Count;
part.Dispose();
}
return new GeometryMeshNode
{
Name = pMesh->name.ToString(),
LocalTransform = float4x4.identity,
Vertices = mergedVertices,
Indices = mergedIndices,
MaterialParts = materialParts,
};
}
public void Execute(ref readonly JobExecutionContext context)
{
var error = new ufbx_error();
var load_Opts = new ufbx_load_opts
{
target_unit_meters = 1.0f,
target_axes = ufbx_coordinate_axes.left_handed_y_up,
// Force z-axis mirroring to correctly convert handedness to Left-Handed,
// while preserving correct left/right orientation when viewed from the front.
handedness_conversion_axis = ufbx_mirror_axis.UFBX_MIRROR_AXIS_Z,
space_conversion = ufbx_space_conversion.UFBX_SPACE_CONVERSION_MODIFY_GEOMETRY,
};
if (_settings is ObjAssetSettings objSettings)
{
load_Opts.obj_axes = new ufbx_coordinate_axes
{
right = ToUfbxCoordinateAxis(objSettings.ObjectRightAxis),
up = ToUfbxCoordinateAxis(objSettings.ObjectUpAxis),
front = ToUfbxCoordinateAxis(objSettings.ObjectForwardAxis)
};
load_Opts.obj_unit_meters = objSettings.UnitMeterScale;
load_Opts.obj_search_mtl_by_filename = true;
}
using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(_filePath) + 1, AllocationHandle.TLSF);
var count = Encoding.UTF8.GetBytes(_filePath, str.AsSpan());
str[count] = 0;
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
if (scene.Get() == null)
{
Logger.Error(error.description.ToString());
return;
}
ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
}
}
internal partial class MeshProcessor
{
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.DSL.ShaderCompiler;
using Ghost.Engine;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Assets;
[Guid(GUID)]
public sealed partial class GraphicsShaderAsset : IAsset
{
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
{
get;
}
internal GraphicsShaderAsset(GraphicsShaderDescriptor descriptor, Guid id)
{
ID = id;
Descriptor = descriptor;
}
public void Dispose()
{
}
}
[Guid(GUID)]
public sealed partial class ComputeShaderAsset : IAsset
{
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
{
get;
}
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
{
ID = id;
Descriptor = descriptor;
}
public void Dispose()
{
}
}
// Shader does not handle import/export via asset registry, it will handled by the hot reload system.
[CustomAssetHandler(AssetTypeId = GraphicsShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gshdr" })]
internal class GraphicsShaderAssetHandler : IPackableAssetHandler
{
public IAssetSettings? CreateDefaultSettings(string ext)
{
return null;
}
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
try
{
var result = DSLShaderCompiler.CompileGraphicsShader(assetPath);
if (result.IsFailure)
{
return Result.Failure(result.Message);
}
return new GraphicsShaderAsset(result.Value, id);
}
catch (Exception ex)
{
return Result.Failure($"Failed to load shader asset: {ex.Message}");
}
}
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
{
return new ValueTask<Result>(Result.Failure("Saving shader assets is not supported yet as it's read-only. Please edit the shader source file directly if you need to modify it."));
}
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{
return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
}
}
[CustomAssetHandler(AssetTypeId = ComputeShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gcomp" })]
internal class ComputeShaderAssetHandler : IPackableAssetHandler
{
public IAssetSettings? CreateDefaultSettings(string ext)
{
return null;
}
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
try
{
var result = DSLShaderCompiler.CompileComputeShaderCode(assetPath);
if (result.IsFailure)
{
return Result.Failure(result.Message);
}
return new ComputeShaderAsset(result.Value, id);
}
catch (Exception ex)
{
return Result.Failure($"Failed to load shader asset: {ex.Message}");
}
}
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
{
return new ValueTask<Result>(Result.Failure("Saving shader assets is not supported yet as it's read-only. Please edit the shader source file directly if you need to modify it."));
}
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{
return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
}
}

View File

@@ -0,0 +1,497 @@
using Ghost.Core;
using Ghost.Engine;
using Ghost.Graphics.RHI;
using Ghost.StbI;
using Misaki.HighPerformance.LowLevel;
using System.IO.MemoryMappedFiles;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Assets;
public enum TextureType : uint
{
Default,
Normal,
Lightmap,
SingleChannel
}
public enum TextureShape : uint
{
Texture2D,
Texture3D,
TextureCube
}
public enum TextureSize : uint
{
Size256 = 256,
Size512 = 512,
Size1024 = 1024,
Size2048 = 2048,
Size4096 = 4096,
Size8192 = 8192
}
public enum TextureCompressionLevel : uint
{
Low,
Normal,
High
}
public enum MipmapFilter : uint
{
Box,
Triangle,
Kaiser,
MitchellNetravali
}
[Guid(GUID)]
public unsafe class TextureAsset : IAsset
{
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 uint _width;
private readonly uint _height;
private readonly uint _depth;
private readonly uint _colorComponents;
private readonly uint _dimension;
public Guid ID => _id;
public Guid TypeID => typeof(TextureAsset).GUID;
public IAssetSettings Settings => _settings;
public IntPtr TextureData => _textureData;
public uint Width => _width;
public uint Height => _height;
public uint Depth => _depth;
public uint Dimension => _dimension;
public uint ColorComponents => _colorComponents;
internal TextureAsset([OwnershipTransfer] IntPtr data, TextureContentHeader header, Guid id, IAssetSettings settings)
{
_id = id;
_settings = settings;
_textureData = data;
_width = header.width;
_height = header.height;
_depth = header.bpc;
_dimension = header.dimension;
_colorComponents = header.colorComponents;
}
~TextureAsset()
{
Dispose();
}
public void Dispose()
{
StbIApi.ImageFree((void*)_textureData);
GC.SuppressFinalize(this);
}
}
public class TextureAssetSettings : IAssetSettings
{
public struct BasicSettings()
{
public TextureType TextureType
{
get; set;
} = TextureType.Default;
public TextureShape TextureShape
{
get; set;
} = TextureShape.Texture2D;
public int Columns
{
get; set;
} = 1;
public int Rows
{
get; set;
} = 1;
public int Depth
{
get; set;
} = 1;
public bool IsSRGB
{
get; set;
} = true;
}
public struct AdvancedSettings()
{
public bool StretchToPowerOfTwo
{
get; set;
} = true;
public bool VirtualTexture
{
get; set;
} = false;
public bool GenerateMipmaps
{
get; set;
} = true;
public uint MipmapLevelCount
{
get; set;
} = 0; // 0 means generate full mipmap levels.
public bool PremultiplyAlpha
{
get; set;
} = false;
public MipmapFilter MipmapFilter
{
get; set;
} = MipmapFilter.Kaiser;
public TextureCompressionLevel CompressionLevel
{
get; set;
} = TextureCompressionLevel.Normal;
public bool UseBorderColor
{
get; set;
} = false;
public Color128 BorderColor
{
get; set;
} = new Color128(0, 0, 0, 0);
public bool ZeroAlphaBorder
{
get; set;
} = false;
public bool CutoutAlpha
{
get; set;
} = false;
public byte CutoutAlphaThreshold
{
get; set;
} = 127;
public bool ScaleAlphaForMipCoverage
{
get; set;
} = false;
public byte ScaleAlphaForMipCoverageThreshold
{
get; set;
} = 127;
public bool MipmapStreaming
{
get; set;
} = false;
}
public struct SamplerSettings()
{
public TextureSize MaxSize
{
get; set;
} = TextureSize.Size2048;
public TextureFilterMode FilterMode
{
get; set;
} = TextureFilterMode.Anisotropic;
public TextureAddressMode WrapMode
{
get; set;
} = TextureAddressMode.Repeat;
}
public BasicSettings Basic
{
get; set;
} = new BasicSettings();
public AdvancedSettings Advanced
{
get; set;
} = new AdvancedSettings();
public SamplerSettings Sampler
{
get; set;
} = new SamplerSettings();
}
[CustomAssetHandler(AssetTypeId = TextureAsset.GUID, RuntimeAssetType = AssetType.Texture, Extensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHandler
{
internal struct TextureInfo
{
public IntPtr pixelData;
public int width;
public int height;
public int depth;
public int bitsPerChannel;
public int colorComponents;
public bool isHDR;
}
public IAssetSettings? CreateDefaultSettings(string ext)
{
return new TextureAssetSettings();
}
private static TextureDimension GetTextureDimension(TextureAssetSettings settings)
{
if (settings.Basic.Columns > 1 && settings.Basic.Rows > 1)
{
if (settings.Basic.Depth > 1)
{
return TextureDimension.Texture3D;
}
return TextureDimension.Texture2DArray;
}
if (settings.Basic.Columns == 1 && settings.Basic.Rows == 1)
{
if (settings.Basic.Depth == 6)
{
return TextureDimension.TextureCube;
}
else if (settings.Basic.Depth > 6 && settings.Basic.Depth % 6 == 0)
{
return TextureDimension.TextureCubeArray;
}
}
// If none of the above conditions are met, we will treat it as a regular 2D texture.
return TextureDimension.Texture2D;
}
private static unsafe Result<TextureInfo> GetImageInfo(string sourcePath, TextureAssetSettings settings)
{
using var mmf = MemoryMappedFile.CreateFromFile(sourcePath, FileMode.Open);
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
byte* ptr = null;
try
{
var ext = Path.GetExtension(sourcePath);
var isHDR = ext.Equals(".hdr", StringComparison.OrdinalIgnoreCase) || settings.Basic.TextureShape == TextureShape.TextureCube;
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
int imageWidth, imageHeight, bitsPerChannel, colorComponents;
var bufferSpan = new ReadOnlySpan<byte>(ptr, (int)accessor.Capacity);
bitsPerChannel = StbIApi.Is16BitFromMemory(bufferSpan) > 0 ? 16 : 8;
void* pPixels;
if (isHDR || bitsPerChannel > 8)
{
pPixels = StbIApi.LoadfFromMemory(bufferSpan, &imageWidth, &imageHeight, &colorComponents, 4);
}
else
{
pPixels = StbIApi.LoadFromMemory(bufferSpan, &imageWidth, &imageHeight, &colorComponents, 4);
}
return new TextureInfo
{
pixelData = (IntPtr)pPixels,
width = imageWidth,
height = imageHeight,
depth = 1,
bitsPerChannel = bitsPerChannel,
colorComponents = 4, // We forced req_comp to 4
isHDR = isHDR,
};
}
catch (Exception ex)
{
return Result<TextureInfo>.Failure($"Failed to get image info: {ex.Message}");
}
finally
{
if (ptr != null)
{
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
}
}
}
public ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
try
{
var textureSettings = settings as TextureAssetSettings ?? new TextureAssetSettings();
var infoResult = GetImageInfo(assetPath, textureSettings);
if (infoResult.IsFailure)
{
return ValueTask.FromResult(Result<IAsset>.Failure(infoResult.Message));
}
var info = infoResult.Value;
var contentHeader = new TextureContentHeader
{
width = (uint)info.width,
height = (uint)info.height,
bpc = (uint)info.bitsPerChannel,
colorComponents = (uint)info.colorComponents,
dimension = (uint)GetTextureDimension(textureSettings),
};
return ValueTask.FromResult(Result.Success<IAsset>(new TextureAsset(info.pixelData, contentHeader, id, textureSettings)));
}
catch (Exception ex)
{
return ValueTask.FromResult(Result<IAsset>.Failure(ex.Message));
}
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
private static unsafe void WriteCallback(void* context, void* data, int size)
{
var stream = (Stream)GCHandle.FromIntPtr((IntPtr)context).Target!;
var buffer = new ReadOnlySpan<byte>(data, size);
stream.Write(buffer);
}
public async ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
{
if (asset is not TextureAsset textureAsset)
{
return Result.Failure("Asset type is not TextureAsset");
}
await using var targetStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
return await Task.Run(() =>
{
// It will be safe here to pass the gc handle to c because c will not use it, c will only pass it back to c# in the callback, and we will free the handle after the write operation is done.
var gcHandle = GCHandle.Alloc(targetStream, GCHandleType.Normal);
try
{
var ext = Path.GetExtension(targetStream.Name);
var result = 0;
unsafe
{
switch (ext)
{
case ".png":
result = StbIApi.WritePngToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 0);
break;
case ".jpg":
result = StbIApi.WriteJpgToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 90);
break;
// TODO: Add support for other image formats
default:
return Result.Failure($"Unsupported image format: {ext}");
}
}
return result != 0 ? Result.Success() : Result.Failure("Failed to write image data.");
}
catch (Exception ex)
{
return Result.Failure(ex.Message);
}
finally
{
gcHandle.Free();
}
}, token).ConfigureAwait(false);
}
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
if (!File.Exists(sourcePath))
{
return Result.Failure("Source file does not exist.");
}
try
{
var textureSettings = settings as TextureAssetSettings ?? new TextureAssetSettings();
var infoResult = GetImageInfo(sourcePath, textureSettings);
if (!infoResult.IsSuccess)
{
return Result.Failure(infoResult.Message);
}
var info = infoResult.Value;
var result = await TextureProcessor.GenerateMipAndCompressAsync(EditorApplication.CacheFolderPath, id,
info,
textureSettings, token)
.ConfigureAwait(false);
if (result.IsFailure)
{
return Result.Failure(result.Message);
}
var (cachePath, mip) = result.Value;
await using var targetStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
var header = new TextureContentHeader
{
width = (uint)info.width,
height = (uint)info.height,
bpc = (uint)info.bitsPerChannel,
colorComponents = (uint)info.colorComponents,
mipLevels = (uint)mip,
dimension = (uint)GetTextureDimension(textureSettings)
};
targetStream.Write(MemoryMarshal.AsBytes(new Span<TextureContentHeader>(ref header)));
await using var ddsStream = new FileStream(cachePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await ddsStream.CopyToAsync(targetStream, token).ConfigureAwait(false);
await targetStream.FlushAsync(token).ConfigureAwait(false);
return Result.Success(Array.Empty<ImportedSubAsset>());
}
catch (Exception ex)
{
return Result.Failure($"Failed to import texture asset: {ex.Message}");
}
}
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{
return ValueTask.FromResult(Result.Failure("Packing texture assets is not supported yet."));
}
}

View File

@@ -0,0 +1,434 @@
using Ghost.Core;
using Ghost.Engine;
using Ghost.Nvtt;
using Misaki.HighPerformance.Jobs;
using Misaki.HighPerformance.LowLevel;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
namespace Ghost.Editor.Core.Assets;
internal static partial class TextureProcessor
{
private struct NvttPipelineJob : IJob
{
private readonly Wrapper<Result<int>> _result;
private readonly string _outputPath;
private readonly TextureAssetHandler.TextureInfo _textureInfo;
private readonly TextureAssetSettings _settings;
private UnsafeArray<MipLevel> _mipLevels;
public NvttPipelineJob(Wrapper<Result<int>> result, string outputPath, TextureAssetHandler.TextureInfo textureInfo, TextureAssetSettings settings, UnsafeArray<MipLevel> mipLevels)
{
_result = result;
_outputPath = outputPath;
_textureInfo = textureInfo;
_settings = settings;
_mipLevels = mipLevels;
}
private unsafe Result<int> RunMipGenCompressionPipeline()
{
using var pSurface = new DisposablePtr<NvttSurface>(NvttSurface.Create());
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
var inputFormat = _textureInfo.colorComponents == 1
? NvttInputFormat.NVTT_InputFormat_R_32F
: _textureInfo.bitsPerChannel > 8
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
var isNormal = _settings.Basic.TextureType == TextureType.Normal;
if (!pSurface.Get()->SetImageData(inputFormat, _textureInfo.width, _textureInfo.height, _textureInfo.depth, (void*)_textureInfo.pixelData, isNormal, null))
{
return Result.Failure<int>("Failed to set image data for NVTT compression.");
}
if (isNormal)
{
pSurface.Get()->SetNormalMap(true);
}
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
if (_textureInfo.colorComponents > 1 && _textureInfo.bitsPerChannel <= 8)
{
pSurface.Get()->Swizzle(2, 1, 0, 3, null);
}
var maxExtent = (int)_settings.Sampler.MaxSize;
if (_settings.Advanced.StretchToPowerOfTwo)
{
pSurface.Get()->ResizeMakeSquare(maxExtent,
NvttRoundMode.NVTT_RoundMode_ToNearestPowerOfTwo,
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
}
else if (pSurface.Get()->Width() > maxExtent || pSurface.Get()->Height() > maxExtent)
{
pSurface.Get()->ResizeMax(maxExtent,
NvttRoundMode.NVTT_RoundMode_None,
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
}
if (_settings.Advanced.UseBorderColor)
{
var c = _settings.Advanced.BorderColor;
pSurface.Get()->SetBorder(c.r, c.g, c.b, c.a, null);
}
else if (_settings.Advanced.ZeroAlphaBorder)
{
pSurface.Get()->SetBorder(0f, 0f, 0f, 0f, null);
}
if (_settings.Basic.IsSRGB)
{
pSurface.Get()->ToLinearFromSrgb(null);
}
if (_settings.Advanced.PremultiplyAlpha)
{
pSurface.Get()->PremultiplyAlpha(null);
}
pCompOpts.Get()->SetFormat(SelectFormat(_settings, _textureInfo.isHDR));
pCompOpts.Get()->SetQuality(SelectQuality(_settings.Advanced.CompressionLevel));
if (_settings.Advanced.CutoutAlpha)
{
pCompOpts.Get()->SetQuantization(false, false, true,
_settings.Advanced.CutoutAlphaThreshold);
}
pOutOpts.Get()->SetOutputHeader(true);
pOutOpts.Get()->SetSrgbFlag(_settings.Basic.IsSRGB);
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(_outputPath));
var nvttFilter = SelectMipmapFilter(_settings.Advanced.MipmapFilter);
int mipmapCount;
if (!_settings.Advanced.GenerateMipmaps)
{
mipmapCount = 1;
}
else if (_settings.Advanced.MipmapLevelCount == 0)
{
mipmapCount = pSurface.Get()->CountMipmaps(1);
}
else
{
mipmapCount = (int)_settings.Advanced.MipmapLevelCount;
}
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
pCtx.Get()->OutputHeader(pSurface.Get(), mipmapCount, pCompOpts.Get(), pOutOpts.Get());
using var pMip = new DisposablePtr<NvttSurface>(pSurface.Get()->Clone());
if (pMip.Get() == null)
{
return Result.Failure("Failed to clone surface for mipmap generation.");
}
for (var level = 0; level < mipmapCount; level++)
{
// Scale alpha for coverage on each pMip (if requested)
if (_settings.Advanced.ScaleAlphaForMipCoverage && level > 0)
{
var refCoverage = pMip.Get()->AlphaTestCoverage(
_settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3);
pMip.Get()->ScaleAlphaToCoverage(refCoverage,
_settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3, null);
}
using var compressMip = new DisposablePtr<NvttSurface>(pMip.Get()->Clone());
if (_settings.Basic.IsSRGB)
{
compressMip.Get()->ToSrgb(null);
}
if (!pCtx.Get()->Compress(compressMip.Get(), 0, level, pCompOpts.Get(), pOutOpts.Get()))
{
return Result.Failure("Failed to compress mipmap.");
}
if (level + 1 < mipmapCount)
{
if (!pMip.Get()->BuildNextMipmapDefaults(nvttFilter, 1, null))
{
return Result.Failure("Failed to build next mipmap.");
}
}
}
return Result.Success(mipmapCount);
}
private unsafe Result<int> RunCubeMapCompressionPipeline()
{
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
pCompOpts.Get()->SetFormat(SelectFormat(_settings, _textureInfo.isHDR));
pCompOpts.Get()->SetQuality(SelectQuality(_settings.Advanced.CompressionLevel));
pOutOpts.Get()->SetOutputHeader(true);
pOutOpts.Get()->SetSrgbFlag(_settings.Basic.IsSRGB);
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(_outputPath));
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
var maxCubeMips = _mipLevels.Length;
var w0 = _mipLevels[0].width;
if (!pCtx.Get()->OutputHeaderData(NvttTextureType.NVTT_TextureType_Cube, w0, w0, 1, maxCubeMips, false, pCompOpts.Get(), pOutOpts.Get()))
{
return Result.Failure("Failed to output header for cube map.");
}
for (var face = 0; face < 6; face++)
{
for (var level = 0; level < maxCubeMips; level++)
{
using var faceSurf = new DisposablePtr<NvttSurface>(NvttSurface.Create());
var w = _mipLevels[level].width;
var faceSize = w * w * _textureInfo.colorComponents;
var pSrcData = (float*)_mipLevels[level].data.GetUnsafePtr() + face * faceSize;
if (!faceSurf.Get()->SetImageData(NvttInputFormat.NVTT_InputFormat_RGBA_32F, w, w, 1, pSrcData, false, null))
{
return Result.Failure("Failed to set image data for NVTT compression.");
}
if (_settings.Basic.IsSRGB)
{
faceSurf.Get()->ToSrgb(null);
}
if (!pCtx.Get()->Compress(faceSurf.Get(), face, level, pCompOpts.Get(), pOutOpts.Get()))
{
return Result.Failure("Failed to compress cube map face.");
}
}
}
return Result.Success(maxCubeMips);
}
public void Execute(ref readonly JobExecutionContext context)
{
try
{
Result<int> finalResult;
if (_settings.Basic.TextureShape == TextureShape.TextureCube)
{
finalResult = RunCubeMapCompressionPipeline();
}
else
{
finalResult = RunMipGenCompressionPipeline();
}
_result.Value = finalResult;
}
catch (Exception ex)
{
Logger.Error($"Exception during NVTT compression: {ex}");
}
}
}
public static async ValueTask<Result<(string cachePath, int mipmapCount)>> GenerateMipAndCompressAsync(string cachesFolderPath, Guid assetId,
TextureAssetHandler.TextureInfo textureInfo,
TextureAssetSettings settings, CancellationToken cancellationToken)
{
var settingsHash = ComputeSettingsHash(settings);
var cacheFileName = $"texturecache_{assetId:N}_{settingsHash:X16}.dds";
var textureCachePath = Path.Combine(cachesFolderPath, "TextureCache");
var cachePath = Path.Combine(textureCachePath, cacheFileName);
Directory.CreateDirectory(textureCachePath);
if (File.Exists(cachePath))
{
var isValid = false;
var mipMapCount = 1u;
var hasMipMapFlag = false;
try
{
using var fs = new FileStream(cachePath, FileMode.Open, FileAccess.Read);
using var reader = new BinaryReader(fs);
if (reader.ReadUInt32() == 0x20534444)
{
reader.BaseStream.Seek(4, SeekOrigin.Current);
var flags = reader.ReadUInt32();
hasMipMapFlag = (flags & 0x00020000) != 0;
reader.BaseStream.Seek(28, SeekOrigin.Begin);
mipMapCount = reader.ReadUInt32();
isValid = true;
}
}
catch
{
// Ignore read errors and regenerate
}
if (isValid)
{
return (cachePath, (!hasMipMapFlag || mipMapCount == 0) ? 1 : (int)mipMapCount);
}
try
{
File.Delete(cachePath);
}
catch
{
// Ignore deletion errors, maybe file is still locked or we have no permission.
// The pipeline will overwrite it.
}
}
UnsafeArray<MipLevel> mipLevels = default;
var scheduler = EditorApplication.GetService<EngineCore>().JobScheduler;
try
{
if (settings.Basic.TextureShape == TextureShape.TextureCube)
{
int maxCubeMips;
int edge;
UnsafeArray<float> baseCubeData;
unsafe
{
using var cubeSurface0 = new DisposablePtr<NvttCubeSurface>(NvttCubeSurface.Create());
using var mip0Surf = new DisposablePtr<NvttSurface>(NvttSurface.Create());
if (!mip0Surf.Get()->SetImageData(NvttInputFormat.NVTT_InputFormat_RGBA_32F, textureInfo.width, textureInfo.height, 1, (void*)textureInfo.pixelData, false, null))
{
return Result.Failure("Failed to set image data for cube map.");
}
cubeSurface0.Get()->Fold(mip0Surf.Get(), NvttCubeLayout.NVTT_CubeLayout_LatitudeLongitude);
edge = cubeSurface0.Get()->EdgeLength();
maxCubeMips = (int)Math.Floor(Math.Log2(edge)) + 1;
var pixelsPerFace = edge * edge;
var faceSize = pixelsPerFace * textureInfo.colorComponents;
baseCubeData = new UnsafeArray<float>(faceSize * 6, AllocationHandle.FreeList);
var channels = textureInfo.colorComponents;
var channelPtrs = stackalloc float*[channels];
for (var face = 0; face < 6; face++)
{
using var faceSurf = new DisposablePtr<NvttSurface>(cubeSurface0.Get()->Face(face));
// NVTT stores data in planar format: [RRRR...][GGGG...][BBBB...][AAAA...]
// We need to interleave into RGBARGBA... for our sampling code.
var pDst = (float*)baseCubeData.GetUnsafePtr() + face * faceSize;
for (var ch = 0; ch < channels; ch++)
{
channelPtrs[ch] = faceSurf.Get()->Channel(ch);
}
for (var p = 0; p < pixelsPerFace; p++)
{
for (var ch = 0; ch < channels; ch++)
{
pDst[p * channels + ch] = channelPtrs[ch][p];
}
}
}
}
var handle = GenerateMipHDRI(scheduler, textureInfo, baseCubeData, edge, maxCubeMips, out mipLevels);
await scheduler.WaitAsync(handle, cancellationToken);
baseCubeData.Dispose();
}
var result = new Wrapper<Result<int>>();
var nvttJob = new NvttPipelineJob(result, cachePath, textureInfo, settings, mipLevels);
var nvttJobHandle = scheduler.Schedule(in nvttJob);
await scheduler.WaitAsync(nvttJobHandle, cancellationToken);
if (result.Value.IsFailure)
{
return Result.Failure(result.Value.Message);
}
return (cachePath, result.Value.Value);
}
finally
{
if (mipLevels.IsCreated)
{
var mipDisposeJob = new MipLevelDisposeJob
{
mipLevels = mipLevels,
};
scheduler.Schedule(in mipDisposeJob);
}
}
}
private static NvttFormat SelectFormat(TextureAssetSettings settings, bool isHDR)
=> isHDR
? NvttFormat.NVTT_Format_BC6U
: settings.Basic.TextureType switch
{
TextureType.Normal => NvttFormat.NVTT_Format_BC5, // RG normal map
TextureType.SingleChannel => NvttFormat.NVTT_Format_BC4, // single channel
TextureType.Lightmap => NvttFormat.NVTT_Format_BC6U, // HDR lightmap (unsigned)
_ => NvttFormat.NVTT_Format_BC7, // default color
};
private static NvttQuality SelectQuality(TextureCompressionLevel level)
=> level switch
{
TextureCompressionLevel.Low => NvttQuality.NVTT_Quality_Fastest,
TextureCompressionLevel.High => NvttQuality.NVTT_Quality_Production,
_ => NvttQuality.NVTT_Quality_Normal,
};
private static NvttMipmapFilter SelectMipmapFilter(MipmapFilter filter)
=> filter switch
{
MipmapFilter.Box => NvttMipmapFilter.NVTT_MipmapFilter_Box,
MipmapFilter.Triangle => NvttMipmapFilter.NVTT_MipmapFilter_Triangle,
MipmapFilter.MitchellNetravali => NvttMipmapFilter.NVTT_MipmapFilter_Mitchell,
_ => NvttMipmapFilter.NVTT_MipmapFilter_Kaiser,
};
private static ulong ComputeSettingsHash(TextureAssetSettings settings)
{
var basicSize = Unsafe.SizeOf<TextureAssetSettings.BasicSettings>();
var advancedSize = Unsafe.SizeOf<TextureAssetSettings.AdvancedSettings>();
var samplerSize = Unsafe.SizeOf<TextureAssetSettings.SamplerSettings>();
var total = basicSize + advancedSize + samplerSize;
Span<byte> buf = stackalloc byte[total];
var basic = settings.Basic;
var advanced = settings.Advanced;
var sampler = settings.Sampler;
MemoryMarshal.Write(buf, in basic);
MemoryMarshal.Write(buf.Slice(basicSize), in advanced);
MemoryMarshal.Write(buf.Slice(basicSize + advancedSize), in sampler);
return XxHash64.HashToUInt64(buf);
}
}

View File

@@ -0,0 +1,371 @@
using Ghost.Core;
using Misaki.HighPerformance.Jobs;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.Mathematics;
using Misaki.HighPerformance.Mathematics.SPMD;
using System.Runtime.CompilerServices;
using static Misaki.HighPerformance.Mathematics.math;
namespace Ghost.Editor.Core.Assets;
internal static partial class TextureProcessor
{
private const int _SAMPLE_COUNT = 1024;
private struct MipLevel
{
public UnsafeArray<float> data;
public int width;
public int height;
public int offset;
public float roughness;
}
private unsafe struct GGXMipGenerationJobSPMD<TFloat, TInt> : IJobParallelFor
where TFloat : unmanaged, ISPMDLane<TFloat, float>
where TInt : unmanaged, ISPMDLane<TInt, int>
{
public float* pImage;
public MipLevel* pMipLevels;
public float* pRadicalInverse_VdCLut;
public int imageWidth;
public int imageHeight;
public int numMipLevels;
public int channelCount;
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut)
{
var x = i / N;
var y = TFloat.Load(lut + (int)i[0]);
return MathV.Create<TFloat, float>(x, y);
}
// GGX Importance Sampling
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
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 phi = 2.0f * PI * Xi.x;
var cosTheta = TFloat.Sqrt((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y));
var sinTheta = TFloat.Sqrt(1.0f - cosTheta * cosTheta);
// Spherical to Cartesian coordinates (Halfway vector)
TFloat.SinCos(phi, out var sinPhi, out var cosPhi);
var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta);
// Tangent space to World space
var mask = TFloat.Abs(N.z) < 0.999f;
var up = MathV.Select(mask, MathV.Create<TFloat, float>(0.0f, 0.0f, 1.0f), MathV.Create<TFloat, float>(1.0f, 0.0f, 0.0f));
var tangent = MathV.Normalize(MathV.Cross(up, N));
var bitangent = MathV.Cross(N, tangent);
var sampleVec = (tangent * H.x) + (bitangent * H.y) + (N * H.z);
return MathV.Normalize(sampleVec);
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static float3 CubemapUVToDir(int face, float u, float v)
{
var sc = 2.0f * u - 1.0f;
var tc = 1.0f - 2.0f * v;
float x = 0.0f, y = 0.0f, z = 0.0f;
switch (face)
{
case 0: x = 1.0f; y = tc; z = -sc; break;
case 1: x = -1.0f; y = tc; z = sc; break;
case 2: x = sc; y = 1.0f; z = -tc; break;
case 3: x = sc; y = -1.0f; z = tc; break;
case 4: x = sc; y = tc; z = 1.0f; break;
case 5: x = -sc; y = tc; z = -1.0f; break;
}
return normalize(float3(x, y, z));
}
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir)
{
var absX = TFloat.Abs(dir.x);
var absY = TFloat.Abs(dir.y);
var absZ = TFloat.Abs(dir.z);
var isXPos = dir.x >= TFloat.Zero;
var isYPos = dir.y >= TFloat.Zero;
var isZPos = dir.z >= TFloat.Zero;
var maxAxis = TFloat.Max(TFloat.Max(absX, absY), absZ);
var faceIndexF = TFloat.Select(maxAxis == absX,
TFloat.Select(isXPos, 0.0f, 1.0f),
TFloat.Select(maxAxis == absY,
TFloat.Select(isYPos, 2.0f, 3.0f),
TFloat.Select(isZPos, 4.0f, 5.0f)));
var faceIndex = faceIndexF.Cast<TInt, int>();
var sc = TFloat.Select(maxAxis == absX,
TFloat.Select(isXPos, -dir.z, dir.z),
TFloat.Select(maxAxis == absY,
dir.x,
TFloat.Select(isZPos, dir.x, -dir.x)));
var tc = TFloat.Select(maxAxis == absX,
dir.y,
TFloat.Select(maxAxis == absY,
TFloat.Select(isYPos, -dir.z, dir.z),
dir.y));
var u = 0.5f * (sc / maxAxis + 1.0f);
var v = 0.5f * (1.0f - tc / maxAxis);
var px = (u * (edge - 1.0f)).Cast<TInt, int>();
var py = (v * (edge - 1.0f)).Cast<TInt, int>();
px = TInt.Clamp(px, TInt.Zero, edge - 1);
py = TInt.Clamp(py, TInt.Zero, edge - 1);
var faceOffset = faceIndex * (edge * edge);
var idx = (faceOffset + py * edge + px) * c;
return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1);
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public void Execute(int loopIndex, ref readonly JobExecutionContext ctx)
{
var m = 0;
while (m < numMipLevels - 1 && loopIndex >= pMipLevels[m + 1].offset)
{
m++;
}
var span = new ReadOnlySpan<MipLevel>(pMipLevels, numMipLevels);
var pLevel = &pMipLevels[m];
var w = pLevel->width;
var data = pLevel->data;
var local_i = loopIndex - pLevel->offset;
var faceArea = w * w;
var face = local_i / faceArea;
var face_local_i = local_i % faceArea;
var x = face_local_i % w;
var y = face_local_i / w;
var u = (x + 0.5f) / w;
var v = (y + 0.5f) / w;
var N = CubemapUVToDir(face, u, v);
// For split-sum, we assume View and Reflection directions equal the Normal
var V = N;
var R = N;
var vN = MathV.Create<TFloat, float>(
TFloat.Create(N.x),
TFloat.Create(N.y),
TFloat.Create(N.z)
);
var vV = MathV.Create<TFloat, float>(
TFloat.Create(V.x),
TFloat.Create(V.y),
TFloat.Create(V.z)
);
var vPrefilteredColor = Vector3<TFloat, float>.Zero;
var vTotalWeight = TFloat.Zero;
// Monte Carlo Integration Loop
var vLuma = MathV.Create<TFloat, float>(0.2126f, 0.7152f, 0.0722f);
var dynamicSampleCount = (int)max(1.0f, _SAMPLE_COUNT * pLevel->roughness);
var dsc = TFloat.Create(dynamicSampleCount);
for (var i = 0; i < dynamicSampleCount; i += TFloat.LaneWidth)
{
var laneIndices = TFloat.Sequence(i, 1.0f);
var validLaneMask = laneIndices < dsc;
// Generate a Hammersley random sequence point
var Xi = Hammersley(laneIndices, dynamicSampleCount, pRadicalInverse_VdCLut);
// Get the halfway vector based on GGX NDF
var H = ImportanceSampleGGX(Xi, vN, pLevel->roughness);
// Calculate Light direction
var L = MathV.Reflect(-vV, H);
L = MathV.Normalize(L);
var NdotL = TFloat.Max(MathV.Dot(vN, L), TFloat.Zero);
var sampleColor = SampleCubemap(pImage, imageWidth, channelCount, L);
NdotL &= validLaneMask;
// The Karis Average Weight: 1 / (1 + luma)
// A normal sky pixel (luma 1.0) gets a weight of 0.5.
// A sun pixel (luma 1000.0) gets a tiny weight of ~0.001, naturally suppressing it.
// This introduce bias, but significantly reduces fireflies without needing solid angle sampling or cdf inversion.
// And since this is a mip generation step, a little bias is acceptable for much better performance and stability.
var luma = MathV.Dot(sampleColor, vLuma);
var fireflyWeight = TFloat.One / (TFloat.One + luma);
var finalWeight = NdotL * fireflyWeight;
vPrefilteredColor += sampleColor * finalWeight;
vTotalWeight += finalWeight;
}
var totalWeight = 0.0f;
var prefilteredColor = float3(0.0f, 0.0f, 0.0f);
for (var i = 0; i < TFloat.LaneWidth; i++)
{
prefilteredColor.x += vPrefilteredColor.x[i];
prefilteredColor.y += vPrefilteredColor.y[i];
prefilteredColor.z += vPrefilteredColor.z[i];
totalWeight += vTotalWeight[i];
}
// Average the result
if (totalWeight > 0.0f)
{
prefilteredColor *= 1.0f / totalWeight;
}
// Write to output mip array
var out_idx = (face * (w * w) + y * w + x) * channelCount;
data[out_idx] = prefilteredColor.x;
data[out_idx + 1] = prefilteredColor.y;
data[out_idx + 2] = prefilteredColor.z;
if (channelCount == 4)
{
data[out_idx + 3] = 1.0f;
}
}
}
private struct VdCLutDisposeJob : IJob
{
public UnsafeArray<float> radicalInverse_VdCLut;
public void Execute(ref readonly JobExecutionContext ctx)
{
radicalInverse_VdCLut.Dispose();
}
}
private struct MipLevelDisposeJob : IJob
{
public UnsafeArray<MipLevel> mipLevels;
public void Execute(ref readonly JobExecutionContext ctx)
{
for (var i = 0; i < mipLevels.Length; i++)
{
mipLevels[i].data.Dispose();
}
mipLevels.Dispose();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float RadicalInverse_VdC(uint bits)
{
bits = (bits << 16) | (bits >> 16);
bits = ((bits & 0x55555555u) << 1) | ((bits & 0xAAAAAAAAu) >> 1);
bits = ((bits & 0x33333333u) << 2) | ((bits & 0xCCCCCCCCu) >> 2);
bits = ((bits & 0x0F0F0F0Fu) << 4) | ((bits & 0xF0F0F0F0u) >> 4);
bits = ((bits & 0x00FF00FFu) << 8) | ((bits & 0xFF00FF00u) >> 8);
return bits * 2.3283064365386963e-10f; // bits / 0x100000000
}
private static JobHandle GenerateMipHDRI(JobScheduler scheduler, TextureAssetHandler.TextureInfo textureInfo, UnsafeArray<float> baseCubeData, int edge, int totalMipLevels, out UnsafeArray<MipLevel> mipLevels)
{
Logger.DebugAssert(textureInfo.isHDR, "GenerateMipHDRI should only be called for HDR textures.");
Logger.DebugAssert(textureInfo.colorComponents >= 3, "Texture must have at least 3 color components for RGB.");
mipLevels = new UnsafeArray<MipLevel>(totalMipLevels, AllocationHandle.FreeList);
var radicalInverse_VdCLut = new UnsafeArray<float>(_SAMPLE_COUNT, AllocationHandle.FreeList);
for (var i = 0u; i < _SAMPLE_COUNT; i++)
{
radicalInverse_VdCLut[i] = RadicalInverse_VdC(i);
}
int w;
var totalPixel = 0;
for (var i = 0; i < totalMipLevels; i++)
{
w = Math.Max(1, edge >> i);
mipLevels[i] = new MipLevel
{
data = new UnsafeArray<float>(w * w * 6 * textureInfo.colorComponents, AllocationHandle.FreeList),
width = w,
height = w,
offset = totalPixel,
roughness = (float)i / (totalMipLevels - 1) // Linear roughness from 0 to 1 across mip levels
};
totalPixel += w * w * 6;
}
JobHandle handle;
unsafe
{
if (WideLane.IsSupported)
{
var job = new GGXMipGenerationJobSPMD<WideLane<float>, WideLane<int>>
{
pImage = (float*)baseCubeData.GetUnsafePtr(),
pMipLevels = (MipLevel*)mipLevels.GetUnsafePtr(),
pRadicalInverse_VdCLut = (float*)radicalInverse_VdCLut.GetUnsafePtr(),
imageWidth = edge,
imageHeight = edge,
numMipLevels = totalMipLevels,
channelCount = textureInfo.colorComponents,
};
handle = scheduler.ScheduleParallelFor(in job, totalPixel, 64);
}
else
{
var job = new GGXMipGenerationJobSPMD<ScalarLane<float>, ScalarLane<int>>
{
pImage = (float*)baseCubeData.GetUnsafePtr(),
pMipLevels = (MipLevel*)mipLevels.GetUnsafePtr(),
pRadicalInverse_VdCLut = (float*)radicalInverse_VdCLut.GetUnsafePtr(),
imageWidth = edge,
imageHeight = edge,
numMipLevels = totalMipLevels,
channelCount = textureInfo.colorComponents,
};
handle = scheduler.ScheduleParallelFor(in job, totalPixel, 64);
}
}
if (!handle.IsValid)
{
return JobHandle.Invalid;
}
var disposeJob = new VdCLutDisposeJob
{
radicalInverse_VdCLut = radicalInverse_VdCLut
};
var disposeHandle = scheduler.Schedule(in disposeJob, handle);
Logger.DebugAssert(disposeHandle.IsValid, "Dispose job handle is invalid.");
return disposeHandle;
}
}

View File

@@ -55,7 +55,6 @@ public class EditorInjectionAttribute : DiscoverableAttributeBase
{ {
Singleton, Singleton,
Transient, Transient,
Scoped
} }
public ServiceLifetime Lifetime public ServiceLifetime Lifetime
@@ -63,12 +62,12 @@ public class EditorInjectionAttribute : DiscoverableAttributeBase
get; get;
} }
public Type? ImplementationType public Type ImplementationType
{ {
get; get;
} }
public EditorInjectionAttribute(ServiceLifetime lifetime, Type? implementationType = null) public EditorInjectionAttribute(ServiceLifetime lifetime, Type implementationType)
{ {
Lifetime = lifetime; Lifetime = lifetime;
ImplementationType = implementationType; ImplementationType = implementationType;
@@ -99,4 +98,4 @@ public sealed class ContextMenuItemAttribute : DiscoverableAttributeBase
Name = name; Name = name;
Group = group; Group = group;
} }
} }

View File

@@ -1,5 +1,7 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Editor.Core.AssetHandler; using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Services;
using Ghost.Engine.AssetLoader;
namespace Ghost.Editor.Core.Contracts; namespace Ghost.Editor.Core.Contracts;
@@ -39,11 +41,22 @@ public sealed class AssetChangedEventArgs : EventArgs
public interface IAssetRegistry : IDisposable public interface IAssetRegistry : IDisposable
{ {
event EventHandler<AssetChangedEventArgs>? OnAssetChanged;
event EventHandler<Guid>? OnAssetImported;
AssetCatalog GetAssetCatalog();
string? GetAssetPath(Guid id); string? GetAssetPath(Guid id);
Guid GetAssetGuid(string assetPath); Guid GetAssetGuid(string assetPath);
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default); ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default); ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default); ValueTask<Result<IAsset>> LoadAssetAsync(Guid id, CancellationToken token = default);
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default); ValueTask<Result> SaveAssetAsync(IAsset asset, CancellationToken token = default);
ValueTask<Result> SaveAssetAsync(Guid id, CancellationToken token = default);
void SetAssetDirty(Guid id);
ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default);
ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default);
ValueTask<Result[]> SaveDirtyAssetsAsync();
} }

View File

@@ -0,0 +1,66 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.Core.Utilities;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Editor.Core.Contracts;
public unsafe struct ComputeCompileResult
{
public fixed ulong resultHash[8];
public readonly int count;
public ulong HashCode
{
get
{
var a = Hash.Combine64(resultHash[0], resultHash[1], resultHash[2], resultHash[3]);
var b = Hash.Combine64(resultHash[4], resultHash[5], resultHash[6], resultHash[7]);
return Hash.Combine64(a, b);
}
}
}
public ref struct ShaderCompilationConfig
{
public ReadOnlySpan<string> defines;
public string shaderCode;
public string entryPoint;
public ShaderStage stage;
public ShaderModel model;
public CompilerOptimizeLevel optimizeLevel;
public CompilerOption options;
}
public enum CompilerOptimizeLevel
{
O0,
O1,
O2,
O3
}
[Flags]
public enum CompilerOption
{
None = 0,
KeepDebugInfo = 1 << 0,
KeepReflections = 1 << 1,
WarnAsError = 1 << 2,
SpirvCrossCompile = 1 << 3
}
public enum ShaderStage
{
TaskShader,
MeshShader,
PixelShader,
ComputeShader,
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
}
public interface IShaderCompiler : IDisposable
{
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
}

View File

@@ -48,7 +48,7 @@ public sealed partial class ContextFlyout : MenuFlyout
Opening += ContextFlyout_Opening; Opening += ContextFlyout_Opening;
} }
// Recursively sorts nodes and calculates folder groups // Recursively sorts nodes and calculates folder pGroups
private static void PrepareNodes(List<MenuNode> nodes) private static void PrepareNodes(List<MenuNode> nodes)
{ {
if (nodes.Count == 0) if (nodes.Count == 0)

View File

@@ -1,3 +1,4 @@
using Ghost.Core.Utilities;
using Microsoft.UI.Dispatching; using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
@@ -6,15 +7,26 @@ namespace Ghost.Editor.Core;
public static class EditorApplication public static class EditorApplication
{ {
public const string ASSETS_FOLDER_NAME = "Assets"; public const string ASSETS_FOLDER_NAME = "Assets";
public const string SOURCES_FOLDER_NAME = "Sources";
public const string PACKAGES_FOLDER_NAME = "Packages"; public const string PACKAGES_FOLDER_NAME = "Packages";
public const string CACHES_FOLDER_NAME = "Caches"; public const string LIBRARY_FOLDER_NAME = "Library";
public const string CACHE_FOLDER_NAME = "Cache";
public const string CONFIG_FOLDER_NAME = "Config"; public const string CONFIG_FOLDER_NAME = "Config";
public const string IMPORTS_FOLDER_NAME = "Imports";
private static IServiceProvider? s_serviceProvider; private static IServiceProvider? s_serviceProvider;
private static string s_currentProjectPath = string.Empty; private static string s_currentProjectPath = string.Empty;
private static string s_currentProjectName = string.Empty; private static string s_currentProjectName = string.Empty;
private static string s_assetsFolderPath = string.Empty;
private static string s_packagesFolderPath = string.Empty;
private static string s_libraryFolderPath = string.Empty;
private static string s_cacheFolderPath = string.Empty;
private static string s_configFolderPath = string.Empty;
private static string s_libraryImportsFolderPath = string.Empty;
private static DispatcherQueue? s_dispatcherQueue; private static DispatcherQueue? s_dispatcherQueue;
internal static Application CurrentApplication => Application.Current; internal static Application CurrentApplication => Application.Current;
@@ -22,11 +34,12 @@ public static class EditorApplication
public static string ProjectPath => s_currentProjectPath; public static string ProjectPath => s_currentProjectPath;
public static string ProjectName => s_currentProjectName; public static string ProjectName => s_currentProjectName;
public static string AssetsFolderPath => Path.Combine(ProjectPath, ASSETS_FOLDER_NAME); public static string AssetsFolderPath => s_assetsFolderPath;
public static string SourcesFolderPath => Path.Combine(ProjectPath, SOURCES_FOLDER_NAME); public static string PackagesFolderPath => s_packagesFolderPath;
public static string PackagesFolderPath => Path.Combine(ProjectPath, PACKAGES_FOLDER_NAME); public static string LibraryFolderPath => s_libraryFolderPath;
public static string CachesFolderPath => Path.Combine(ProjectPath, CACHES_FOLDER_NAME); public static string ConfigFolderPath => s_configFolderPath;
public static string ConfigFolderPath => Path.Combine(ProjectPath, CONFIG_FOLDER_NAME); public static string CacheFolderPath => s_cacheFolderPath;
public static string LibraryImportsFolderPath => s_libraryImportsFolderPath;
public static DispatcherQueue DispatcherQueue public static DispatcherQueue DispatcherQueue
{ {
@@ -43,9 +56,29 @@ public static class EditorApplication
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;
s_serviceProvider = serviceProvider; s_serviceProvider = serviceProvider;
s_currentProjectPath = projectPath; s_currentProjectPath = projectPath;
s_currentProjectName = projectName; s_currentProjectName = projectName;
s_assetsFolderPath = Path.Combine(projectPath, ASSETS_FOLDER_NAME);
s_packagesFolderPath = Path.Combine(projectPath, PACKAGES_FOLDER_NAME);
s_libraryFolderPath = Path.Combine(projectPath, LIBRARY_FOLDER_NAME);
s_configFolderPath = Path.Combine(projectPath, CONFIG_FOLDER_NAME);
s_cacheFolderPath = Path.Combine(projectPath, CACHE_FOLDER_NAME);
s_libraryImportsFolderPath = Path.Combine(s_libraryFolderPath, IMPORTS_FOLDER_NAME);
Directory.CreateDirectory(s_assetsFolderPath);
Directory.CreateDirectory(s_packagesFolderPath);
Directory.CreateDirectory(s_libraryFolderPath);
Directory.CreateDirectory(s_configFolderPath);
Directory.CreateDirectory(s_cacheFolderPath);
Directory.CreateDirectory(s_libraryImportsFolderPath);
} }
internal static void SetDispatcherQueue(DispatcherQueue dispatcherQueue) internal static void SetDispatcherQueue(DispatcherQueue dispatcherQueue)
@@ -67,4 +100,4 @@ public static class EditorApplication
internal static void Shutdown() internal static void Shutdown()
{ {
} }
} }

View File

@@ -3,18 +3,22 @@
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework> <TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion> <TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>Ghost.Editor.Core</RootNamespace> <RootNamespace>Ghost.Editor.Core</RootNamespace>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers> <Platforms>x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI> <UseWinUI>true</UseWinUI>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<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 --> <Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
<langversion>preview</langversion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" /> <Content Remove="Assets\MeshNode.cs" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" /> </ItemGroup>
<ItemGroup>
<PackageReference Include="FluentIcons.WinUI" Version="2.1.324" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" /> <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<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" />
@@ -23,7 +27,12 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" /> <ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
<ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" /> <ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
<ProjectReference Include="..\..\Runtime\Ghost.Generator\Ghost.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\ThridParty\Ghost.DXC\Ghost.DXC.csproj" />
<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.StbI\Ghost.StbI.csproj" />
<ProjectReference Include="..\Ghost.DSL\Ghost.DSL.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -37,4 +46,4 @@
<SubType>Designer</SubType> <SubType>Designer</SubType>
</Page> </Page>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@@ -0,0 +1,363 @@
using Ghost.Editor.Core.Assets;
using Microsoft.Data.Sqlite;
namespace Ghost.Editor.Core.Services;
/// <summary>
/// Thread-safe SQLite-backed asset catalog.
/// Uses connection pooling and local command creation for safe multi-threaded access.
/// </summary>
public sealed partial class AssetCatalog
{
public readonly record struct SubAssetInfo(Guid Guid, Guid ParentGuid, string Kind, string DisplayName, string StablePath, string SourcePath, Guid AssetTypeId);
private readonly string _connectionString;
private const string SqlGetGuid = "SELECT guid FROM assets WHERE source_path = @path";
private const string SqlGetPath = "SELECT source_path FROM assets WHERE guid = @guid";
private const string SqlGetAssetTypeId = "SELECT asset_type_id FROM assets WHERE guid = @guid";
private const string SqlGetImportedAt = "SELECT imported_at_ms FROM assets WHERE guid = @guid";
private const string SqlUpsert = @"
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)
VALUES (@guid, @path, @asset_type_id, @version, @content_hash, @settings_hash, @imported_at_ms, @parent_guid, @subasset_kind, @display_name, @stable_path)
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)
{
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var builder = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
ForeignKeys = true,
Pooling = true,
};
_connectionString = builder.ToString();
// Initial setup
using var connection = OpenConnection();
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "PRAGMA journal_mode = WAL;";
cmd.ExecuteNonQuery();
}
CreateSchemaInternal(connection);
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection(_connectionString);
connection.Open();
return connection;
}
private static void CreateSchemaInternal(SqliteConnection connection)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS assets (
guid BLOB (16) PRIMARY KEY NOT NULL,
source_path TEXT NOT NULL,
asset_type_id BLOB (16),
handler_version INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
settings_hash TEXT,
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 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 (
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
to_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
PRIMARY KEY (from_guid, to_guid)
);
CREATE INDEX IF NOT EXISTS idx_dep_reverse ON dependencies(to_guid);
CREATE TABLE IF NOT EXISTS labels (
guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
label TEXT NOT NULL,
PRIMARY KEY (guid, label)
);
CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);";
cmd.ExecuteNonQuery();
}
private static string ToUniversalPath(string path)
{
if (OperatingSystem.IsWindows())
{
return Path.GetFullPath(path).Replace('\\', '/');
}
return path;
}
public Guid GetGuid(string sourcePath)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlGetGuid;
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
var result = cmd.ExecuteScalar();
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
}
public string? GetSourcePath(Guid guid)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlGetPath;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
return cmd.ExecuteScalar() as string;
}
private void UpsertInternal(AssetMeta meta, string sourcePath, Guid? parentGuid, string? kind, string? displayName, string? stablePath)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlUpsert;
cmd.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray());
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
cmd.Parameters.AddWithValue("@asset_type_id", meta.AssetTypeId?.ToByteArray() ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@version", meta.HandlerVersion);
cmd.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value);
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)
{
var subAssets = GetSubAssets(guid);
foreach (var sub in subAssets)
{
Remove(sub.Guid);
}
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlDelete;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
return cmd.ExecuteNonQuery() > 0;
}
public Guid GetAssetTypeId(Guid guid)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlGetAssetTypeId;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
var result = cmd.ExecuteScalar();
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
}
public DateTime? GetImportedAt(Guid guid)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlGetImportedAt;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
var result = cmd.ExecuteScalar();
return result is long ticks ? new DateTime(ticks, DateTimeKind.Utc) : null;
}
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
{
using var connection = OpenConnection();
using var tx = connection.BeginTransaction();
using (var clearCmd = connection.CreateCommand())
{
clearCmd.Transaction = tx;
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();
foreach (var dep in dependencies)
{
toParam.Value = dep.ToByteArray();
insertCmd.ExecuteNonQuery();
}
}
tx.Commit();
}
public List<Guid> GetReferencers(Guid guid)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlGetReferencers;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
using var reader = cmd.ExecuteReader();
var list = new List<Guid>();
while (reader.Read())
{
list.Add(new Guid((byte[])reader[0]));
}
return list;
}
public List<Guid> GetDependencies(Guid guid)
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlGetDependencies;
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
using var reader = cmd.ExecuteReader();
var list = new List<Guid>();
while (reader.Read())
{
list.Add(new Guid((byte[])reader[0]));
}
return list;
}
public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll()
{
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = SqlEnumerate;
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
yield return (new Guid((byte[])reader[0]), reader.GetString(1));
}
}
public IEnumerable<Guid> EnumerateByTypes(params Guid[] assetTypeIds)
{
if (assetTypeIds.Length == 0)
{
yield break;
}
using var connection = OpenConnection();
using var cmd = connection.CreateCommand();
var parameterNames = new List<string>(assetTypeIds.Length);
for (int i = 0; i < assetTypeIds.Length; i++)
{
string 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

@@ -1,6 +0,0 @@
namespace TestProject.AssetDB;
internal partial class AssetRegistry
{
// TODO: Sqlite backend implementation
}

View File

@@ -1,510 +1,430 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Editor.Core.AssetHandler; using Ghost.Core.Utilities;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Contracts; using Ghost.Editor.Core.Contracts;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace TestProject.AssetDB; namespace Ghost.Editor.Core.Services;
internal class PathComparer : IEqualityComparer<string> /// <summary>
/// Central asset registry for the GhostEngine editor.
/// </summary>
internal sealed class AssetRegistry : IAssetRegistry, IDisposable
{ {
private static string ToCanonicalPath(string? path) private readonly AssetCatalog _catalog;
{ private readonly ImportCoordinator _importCoordinator;
return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty;
}
public bool Equals(string? x, string? y)
{
return string.Equals(
ToCanonicalPath(x),
ToCanonicalPath(y),
StringComparison.Ordinal);
}
public int GetHashCode(string str)
{
return ToCanonicalPath(str).GetHashCode(StringComparison.Ordinal);
}
}
// TODO: Path based locking for multi-threaded access?
// Is it actually necessary since this is mostly used in editor environment where single-threaded access is common (99.999%)?
internal partial class AssetRegistry : IAssetRegistry
{
public const string ASSET_EXTENSION = ".gasset";
public const string TEMP_EXTENSION = ".gtemp";
private readonly string _rootDirectory;
private readonly FileSystemWatcher _watcher; private readonly FileSystemWatcher _watcher;
private readonly ConcurrentDictionary<string, Guid> _pathToGuid; private readonly ConcurrentDictionary<Guid, WeakReference<IAsset>> _loadedAssets;
private readonly ConcurrentDictionary<Guid, string> _guidToPath; private readonly SemaphoreSlim _loadLock;
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander; private readonly ConcurrentDictionary<string, bool> _ignoreMetaWrites;
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets; private readonly ConcurrentHashSet<Guid> _dirtyAssets;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _eventDebouncers;
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph; public event EventHandler<AssetChangedEventArgs>? OnAssetChanged;
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache; public event EventHandler<Guid>? OnAssetImported
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
private readonly SemaphoreSlim _cacheSlim;
private readonly Lock _pathLock;
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
public AssetRegistry(string rootDirectory)
{ {
if (!Directory.Exists(rootDirectory)) add => _importCoordinator.OnImportCompleted += value;
{ remove => _importCoordinator.OnImportCompleted -= value;
throw new DirectoryNotFoundException("The specified root directory does not exist."); }
}
if (!Path.IsPathFullyQualified(rootDirectory)) public AssetRegistry()
{ {
throw new InvalidOperationException("The specified root directory must be an absolute path."); var dbPath = Path.Combine(EditorApplication.LibraryFolderPath, "AssetDB.sqlite");
}
_rootDirectory = rootDirectory; _catalog = new AssetCatalog(dbPath);
_watcher = new FileSystemWatcher(rootDirectory) _importCoordinator = new ImportCoordinator(_catalog);
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<IAsset>>();
_loadLock = new SemaphoreSlim(1, 1);
_ignoreMetaWrites = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
_dirtyAssets = new ConcurrentHashSet<Guid>();
_eventDebouncers = new ConcurrentDictionary<string, CancellationTokenSource>();
SyncCatalogWithDisk();
_watcher = new FileSystemWatcher(EditorApplication.AssetsFolderPath)
{ {
IncludeSubdirectories = true, IncludeSubdirectories = true,
EnableRaisingEvents = true, EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
}; };
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer()); _watcher.Created += OnFileSystemEvent;
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512); _watcher.Deleted += OnFileSystemEvent;
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16); _watcher.Changed += OnFileSystemEvent;
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512); _watcher.Renamed += OnFileSystemRenameEvent;
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
_cacheSlim = new SemaphoreSlim(1, 1);
_pathLock = new Lock();
LoadExistingAssets();
_watcher.Created += OnFileSystemOp;
_watcher.Deleted += OnFileSystemOp;
_watcher.Changed += OnFileSystemOp;
_watcher.Renamed += OnFileSystemRenameOp;
} }
// TODO: DB Cache private void SyncCatalogWithDisk()
private unsafe void LoadExistingAssets()
{ {
Span<byte> guidBuffer = stackalloc byte[sizeof(Guid)]; if (!Directory.Exists(EditorApplication.AssetsFolderPath))
foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories))
{ {
var relativePath = Path.GetRelativePath(_rootDirectory, filePath); return;
}
try var metaFiles = Directory.EnumerateFiles(EditorApplication.AssetsFolderPath, "*.gmeta", SearchOption.AllDirectories);
var foundGuids = new HashSet<Guid>();
foreach (var metaPath in metaFiles)
{
var meta = AssetMetaIO.ReadAsync(metaPath).AsTask().Result;
if (meta != null)
{ {
var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); var sourceRelative = AssetMetaIO.GetSourcePath(Path.GetRelativePath(EditorApplication.ProjectPath, metaPath));
try _catalog.Upsert(meta, sourceRelative);
{ foundGuids.Add(meta.Guid);
fs.Seek(4, SeekOrigin.Begin); // Skip format version
fs.ReadExactly(guidBuffer);
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(guidBuffer));
UpdatePathMapping(relativePath, guid);
}
finally
{
fs.Dispose();
}
} }
catch (Exception }
#if DEBUG
ex foreach (var (guid, path) in _catalog.EnumerateAll())
#endif {
) if (path.Contains('#', StringComparison.Ordinal))
{ {
#if DEBUG
System.Diagnostics.Debugger.BreakForUserUnhandledException(ex);
#endif
continue; continue;
} }
}
}
private void UpdateGraph(Guid assetId, IEnumerable<Guid> newDependencies) if (!foundGuids.Contains(guid))
{
// 1. Clean up old references (reverse)
if (_dependencyCache.TryGetValue(assetId, out var oldDeps))
{
foreach (var dep in oldDeps)
{ {
if (_referencerGraph.TryGetValue(dep, out var refs)) _catalog.Remove(guid);
{
refs.Remove(assetId);
}
} }
} }
// 2. Set new forward dependencies
var newDepSet = new HashSet<Guid>(newDependencies);
_dependencyCache[assetId] = newDepSet;
// 3. Add new references (reverse)
foreach (var dep in newDepSet)
{
ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists);
if (!exists || referencers is null)
{
referencers = new HashSet<Guid>();
}
referencers.Add(assetId);
}
} }
private void UpdatePathMapping(string relativePath, Guid guid) private async void OnFileSystemEvent(object sender, FileSystemEventArgs e)
{
lock (_pathLock)
{
_pathToGuid[relativePath] = guid;
_guidToPath[guid] = relativePath;
}
}
private bool RemovePathMappingByPath(string relativePath)
{
lock (_pathLock)
{
if (_pathToGuid.Remove(relativePath, out var guid))
{
return _guidToPath.TryRemove(guid, out _);
}
}
return false;
}
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
{
if (_ignoreFileChanges.TryRemove(e.FullPath, out _))
{
return;
}
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
var ext = Path.GetExtension(relativePath);
var changeType = AssetChangeType.None;
var fireEvent = false;
var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal);
var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal);
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
changeType = AssetChangeType.Created;
if (!isAsset && !isTemp)
{
var handler = GetAssetHandlerForExtension(ext);
if (handler is IImportableAssetHandler importableHandler)
{
var assetPath = string.Create(e.FullPath.Length - ext.Length + ASSET_EXTENSION.Length, e.FullPath, (destSpan, source) =>
{
source.AsSpan(0, source.Length - ext.Length).CopyTo(destSpan);
ASSET_EXTENSION.AsSpan().CopyTo(destSpan.Slice(source.Length - ext.Length));
});
var newGuid = Guid.NewGuid();
await using var sourceStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read);
await using var targetStream = new FileStream(assetPath, FileMode.Create, FileAccess.Write);
await importableHandler.ImportAsync(sourceStream, targetStream, newGuid);
File.Delete(assetPath);
UpdatePathMapping(relativePath, newGuid);
fireEvent = true;
}
}
break;
case WatcherChangeTypes.Deleted:
changeType = AssetChangeType.Deleted;
if (isAsset)
{
fireEvent = RemovePathMappingByPath(relativePath);
}
break;
case WatcherChangeTypes.Changed:
changeType = AssetChangeType.Modified;
fireEvent = isAsset;
break;
case WatcherChangeTypes.All:
// Can this even happen?
break;
default:
break;
}
if (fireEvent)
{
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
}
}
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
{ {
var ext = Path.GetExtension(e.FullPath); var ext = Path.GetExtension(e.FullPath);
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
if (ext is ".tmp" or ".gtemp")
{ {
return; return;
} }
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath); if (_eventDebouncers.TryGetValue(e.FullPath, out var existingCts))
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
if (_pathToGuid.Remove(oldRelativePath, out var guid))
{ {
UpdatePathMapping(newRelativePath, guid); existingCts.Cancel();
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed)); existingCts.Dispose();
} }
var cts = new CancellationTokenSource();
_eventDebouncers[e.FullPath] = cts;
try
{
// Add a small delay to group rapid sequential triggers together (250ms is usually sufficient)
await Task.Delay(250, cts.Token);
}
catch (TaskCanceledException)
{
// A newer event for this file interrupted us; abort this duplicate handling
return;
}
finally
{
if (_eventDebouncers.TryGetValue(e.FullPath, out var currentCts) && currentCts == cts)
{
_eventDebouncers.TryRemove(e.FullPath, out _);
cts.Dispose();
}
}
if (_ignoreMetaWrites.TryRemove(e.FullPath, out _))
{
return;
}
try
{
var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
var fileExists = File.Exists(e.FullPath);
if (ext == AssetMetaIO.META_EXTENSION)
{
if (fileExists)
{
var meta = await AssetMetaIO.ReadAsync(e.FullPath);
if (meta != null)
{
_catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath));
await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), relativePath, ImportReason.SettingsChanged));
}
}
return;
}
var changeType = AssetChangeType.None;
var guid = _catalog.GetGuid(relativePath);
if (!fileExists)
{
// The file is no longer on disk. Wait safely completed.
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));
}
}
catch (Exception ex)
{
Logger.Error(ex);
}
}
private void OnFileSystemRenameEvent(object sender, RenamedEventArgs e)
{
var oldRelative = Path.GetRelativePath(EditorApplication.ProjectPath, e.OldFullPath);
var newRelative = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
var guid = _catalog.GetGuid(oldRelative);
if (guid != Guid.Empty)
{
_catalog.Remove(guid);
var metaFile = AssetMetaIO.GetMetaPath(newRelative);
if (File.Exists(metaFile))
{
var meta = AssetMetaIO.ReadAsync(metaFile).AsTask().Result;
if (meta != null)
{
_catalog.Upsert(meta, newRelative);
}
}
}
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelative, oldRelative, AssetChangeType.Renamed));
}
private async Task HandleNewSourceFileAsync(string relativePath)
{
var ext = Path.GetExtension(relativePath);
var handler = AssetHandlerRegistry.GetByExtension(ext);
var metaPath = AssetMetaIO.GetMetaPath(relativePath);
if (File.Exists(metaPath))
{
return;
}
var assetTypeId = Guid.Empty;
if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo))
{
assetTypeId = handlerInfo.EditorAssetTypeID;
}
var meta = new AssetMeta
{
Guid = Guid.NewGuid(),
AssetTypeId = assetTypeId,
HandlerVersion = 1,
Settings = handler?.CreateDefaultSettings(ext)
};
_ignoreMetaWrites[metaPath] = true;
await AssetMetaIO.WriteAsync(metaPath, meta);
_catalog.Upsert(meta, relativePath);
await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, relativePath, metaPath, ImportReason.NewAsset));
}
public AssetCatalog GetAssetCatalog()
{
return _catalog;
} }
public string? GetAssetPath(Guid id) public string? GetAssetPath(Guid id)
{ {
lock (_pathLock) return _catalog.GetSourcePath(id);
{
if (_guidToPath.TryGetValue(id, out var path))
{
return path;
}
}
return null;
} }
public Guid GetAssetGuid(string path) public Guid GetAssetGuid(string path)
{ {
lock (_pathLock) return _catalog.GetGuid(path);
{
if (_pathToGuid.TryGetValue(path, out var guid))
{
return guid;
}
}
return Guid.Empty;
}
private IAssetHandler GetAssetHandler(Type type)
{
var typeHandle = type.TypeHandle.Value;
if (_cachedHander.TryGetValue(typeHandle, out var handler))
{
return handler;
}
var obj = Activator.CreateInstance(type);
if (obj is not IAssetHandler newHandler)
{
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
}
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is null || attr.AllowCaching)
{
_cachedHander[typeHandle] = newHandler;
}
return newHandler;
}
private IAssetHandler? GetAssetHandlerForExtension(string extension)
{
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
{
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
return GetAssetHandler(handlerType);
}
}
return null;
}
private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId)
{
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
{
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is not null && new Guid(attr.ID) == typeId)
{
return GetAssetHandler(handlerType);
}
}
return null;
} }
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default) public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
{ {
if (!File.Exists(sourceFilePath)) // Simple copy + wait for FSW or manually trigger?
{ // Current requirement: "returns the new GUID immediately (import happens in background)"
return Result.Failure("Source file not found.");
}
var ext = Path.GetExtension(sourceFilePath); Directory.CreateDirectory(Path.GetDirectoryName(targetAssetPath)!);
var handler = GetAssetHandlerForExtension(ext); File.Copy(sourceFilePath, targetAssetPath, true);
if (handler is not IImportableAssetHandler importableHandler)
{
return Result.Failure("No importable asset handler found for the given file extension.");
}
var guid = Guid.NewGuid(); // FSW will trigger but we can speed it up
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory); await HandleNewSourceFileAsync(targetAssetPath);
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
{
return Result.Failure("Asset import failed.");
}
UpdatePathMapping(targetAssetPath, guid); var guid = _catalog.GetGuid(targetAssetPath);
return guid; return Result.Success(guid);
} }
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default) public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
{ {
var assetPath = GetAssetPath(assetId); var path = _catalog.GetSourcePath(assetId);
if (string.IsNullOrEmpty(assetPath)) if (path == null)
{ {
return Result.Failure("Asset not found in DB"); return Result.Failure("Asset not found");
} }
var fullAssetPath = Path.GetFullPath(assetPath, _rootDirectory); var metaPath = AssetMetaIO.GetMetaPath(path);
// 2. Identify the Handler await _importCoordinator.EnqueueAsync(new ImportJob(assetId, path, metaPath, ImportReason.ManualReimport), token);
// (You might want to store SourcePath in metadata later so you don't need to pass it here) return Result.Success();
var ext = Path.GetExtension(sourceFilePath); }
var handler = GetAssetHandlerForExtension(ext);
if (handler is not IImportableAssetHandler importableHandler) public async ValueTask<Result<IAsset>> LoadAssetAsync(Guid id, CancellationToken token = default)
{
if (_loadedAssets.TryGetValue(id, out var weakRef) && weakRef.TryGetTarget(out var asset))
{ {
return Result.Failure("No importable asset handler found for the given file extension."); return Result.Success(asset);
} }
_ignoreFileChanges[fullAssetPath] = true; await _loadLock.WaitAsync(token);
try
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read);
await using var targetStream = new FileStream(fullAssetPath, FileMode.Create, FileAccess.Write);
await importableHandler.ImportAsync(sourceStream, targetStream, assetId, token);
if (_loadedAssets.TryGetValue(assetId, out var weakRef) && weakRef.TryGetTarget(out var liveAsset))
{ {
await liveAsset.RefreshAsync(this, token); if (_loadedAssets.TryGetValue(id, out weakRef) && weakRef.TryGetTarget(out asset))
{
return Result.Success(asset);
}
var path = GetAssetPath(id);
if (!File.Exists(path))
{
return Result.Failure("Asset does not exist.");
}
var handler = AssetHandlerRegistry.GetByExtension(Path.GetExtension(path));
if (handler is null)
{
return Result.Failure("No Available handler type.");
}
var meta = await AssetMetaIO.ReadAsync(AssetMetaIO.GetMetaPath(path), token);
if (meta is null)
{
return Result.Failure("Meta file does not exist.");
}
return await handler.LoadAssetAsync(path, id, meta.Settings, token);
}
catch (Exception ex)
{
return Result.Failure(ex.Message);
}
finally
{
_loadLock.Release();
}
}
public async ValueTask<Result> SaveAssetAsync(IAsset asset, CancellationToken token = default)
{
try
{
var path = GetAssetPath(asset.ID);
if (!File.Exists(path))
{
return Result.Failure("Asset does not exist.");
}
var handler = AssetHandlerRegistry.GetByAssetTypeId(asset.TypeID);
if (handler is null)
{
return Result.Failure("No Avaliable handler type.");
}
// This will trigger the fsw and reimport automatically.
return await handler.SaveAssetAsync(path, asset, token);
}
catch (Exception ex)
{
return Result.Failure(ex.Message);
}
}
public async ValueTask<Result> SaveAssetAsync(Guid id, CancellationToken token = default)
{
var result = await LoadAssetAsync(id, token);
if (result.IsFailure)
{
return result;
}
return await SaveAssetAsync(result.Value, token);
}
public void SetAssetDirty(Guid id)
{
_dirtyAssets.Add(id);
}
public async ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default)
{
if (_dirtyAssets.Contains(asset.ID))
{
var result = await SaveAssetAsync(asset, token);
_dirtyAssets.Remove(asset.ID);
return result;
} }
return Result.Success(); return Result.Success();
} }
public async ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default) public async ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default)
{ {
// TODO: weakRef based locking instead of global lock for better concurrency. var result = await LoadAssetAsync(id, token);
// We should use GetOrAdd here. if (result.IsFailure)
if (_loadedAssets.TryGetValue(id, out var weakRef)
&& weakRef.TryGetTarget(out var existingAsset))
{ {
return existingAsset; return result;
} }
await _cacheSlim.WaitAsync(token); return await SaveAssetIfDirtyAsync(result.Value, token);
// Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting.
if (_loadedAssets.TryGetValue(id, out weakRef)
&& weakRef.TryGetTarget(out existingAsset))
{
return existingAsset;
}
try
{
var path = GetAssetPath(id);
if (string.IsNullOrEmpty(path))
{
return null;
}
var assetPath = Path.GetFullPath(path, _rootDirectory);
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
int sizeofGuid;
unsafe
{
sizeofGuid = sizeof(Guid);
}
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
fs.ReadExactly(typeIdBuffer);
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
var handler = GetAssetHandlerForTypeId(guid);
if (handler == null)
{
return null;
}
var assetResult = await handler.LoadAsync(fs, this, token);
if (assetResult.IsFailure)
{
return assetResult;
}
var asset = assetResult.Value;
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(asset), (key, oldRef) =>
{
// If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected.
// If the assetResult haven't been loaded before, we are in the addValue path, not here.
// If the previous reference has been collected, we can just replace it with the new one.
// Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time.
oldRef.SetTarget(asset);
return oldRef;
});
return assetResult;
}
finally
{
_cacheSlim.Release();
}
} }
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default) public async ValueTask<Result[]> SaveDirtyAssetsAsync()
{ {
var path = GetAssetPath(asset.ID); if (_dirtyAssets.IsEmpty)
if (path == null)
{ {
return Result.Failure("Asset not found."); return Array.Empty<Result>();
} }
var handler = GetAssetHandlerForTypeId(asset.TypeID); var tasks = new Task<Result>[_dirtyAssets.Count];
if (handler == null)
var i = 0;
foreach (var id in _dirtyAssets)
{ {
return Result.Failure("No asset handler found for the given asset type."); tasks[i++] = SaveAssetIfDirtyAsync(id).AsTask();
} }
var fullPath = Path.GetFullPath(path, _rootDirectory); _dirtyAssets.Clear();
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write); return await Task.WhenAll(tasks);
return await handler.SaveAsync(asset, fs, this, token);
} }
public void Dispose() public void Dispose()
{ {
_cacheSlim.Dispose();
_watcher.Dispose(); _watcher.Dispose();
_importCoordinator.Dispose();
_loadLock.Dispose();
} }
} }

View File

@@ -0,0 +1,274 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.DXC;
using Ghost.Editor.Core.Contracts;
using Ghost.Graphics.D3D12.Utilities;
using Misaki.HighPerformance.LowLevel;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using static Ghost.DXC.UUID;
namespace Ghost.Graphics.Core;
internal sealed partial class DXCShaderCompiler
{
private static string GetProfileString(ShaderStage stage, ShaderModel version)
{
return (stage, version) switch
{
(ShaderStage.TaskShader, ShaderModel.SM_6_6) => "as_6_6",
(ShaderStage.PixelShader, ShaderModel.SM_6_6) => "ps_6_6",
(ShaderStage.MeshShader, ShaderModel.SM_6_6) => "ms_6_6",
(ShaderStage.ComputeShader, ShaderModel.SM_6_6) => "cs_6_6",
(ShaderStage.Library, ShaderModel.SM_6_6) => "lib_6_6",
(ShaderStage.TaskShader, ShaderModel.SM_6_7) => "as_6_7",
(ShaderStage.PixelShader, ShaderModel.SM_6_7) => "ps_6_7",
(ShaderStage.MeshShader, ShaderModel.SM_6_7) => "ms_6_7",
(ShaderStage.ComputeShader, ShaderModel.SM_6_7) => "cs_6_7",
(ShaderStage.Library, ShaderModel.SM_6_7) => "lib_6_7",
(ShaderStage.TaskShader, ShaderModel.SM_6_8) => "as_6_8",
(ShaderStage.PixelShader, ShaderModel.SM_6_8) => "ps_6_8",
(ShaderStage.MeshShader, ShaderModel.SM_6_8) => "ms_6_8",
(ShaderStage.ComputeShader, ShaderModel.SM_6_8) => "cs_6_8",
(ShaderStage.Library, ShaderModel.SM_6_8) => "lib_6_8",
_ => throw new ArgumentOutOfRangeException(nameof(stage), "Unsupported shader stage or compiler version")
};
}
private static string GetOptimizeLevelString(CompilerOptimizeLevel level)
{
return level switch
{
CompilerOptimizeLevel.O0 => "-O0",
CompilerOptimizeLevel.O1 => "-O1",
CompilerOptimizeLevel.O2 => "-O2",
CompilerOptimizeLevel.O3 => "-O3",
_ => throw new ArgumentOutOfRangeException(nameof(level), "Unsupported optimization level")
};
}
private static List<string> GetCompilerArguments(ref readonly ShaderCompilationConfig config)
{
var argsArray = new List<string>
{
"-T", GetProfileString(config.stage, config.model), // Target profile (ms_6_6, ps_6_6)
"-E", config.entryPoint, // Entry point
"-HV", "2021", // HLSL version 2021
"-enable-16bit-types", // Enable 16-bit types
GetOptimizeLevelString(config.optimizeLevel), // Optimization level
};
foreach (var define in config.defines)
{
argsArray.Add("-D");
argsArray.Add(define);
}
if (config.stage == ShaderStage.TaskShader
|| config.stage == ShaderStage.MeshShader
|| config.stage == ShaderStage.PixelShader)
{
argsArray.Add("-D");
argsArray.Add("__GRAPHICS__");
}
else if (config.stage == ShaderStage.ComputeShader)
{
argsArray.Add("-D");
argsArray.Add("__COMPUTE__");
}
if (!config.options.HasFlag(CompilerOption.KeepDebugInfo))
{
argsArray.Add("-Qstrip_debug");
}
if (!config.options.HasFlag(CompilerOption.KeepReflections))
{
argsArray.Add("-Qstrip_reflect");
}
if (config.options.HasFlag(CompilerOption.WarnAsError))
{
argsArray.Add("-WX");
}
if (config.options.HasFlag(CompilerOption.SpirvCrossCompile))
{
argsArray.Add("-spirv");
}
argsArray.Add("-rootsig-define");
argsArray.Add("GLOBAL_BINDLESS_SIG");
return argsArray;
}
}
internal sealed unsafe partial class DXCShaderCompiler : IShaderCompiler
{
private UniquePtr<IDxcCompiler3> _compiler;
private UniquePtr<IDxcUtils> _utils;
private bool _disposed;
public DXCShaderCompiler()
{
IDxcCompiler3* pCompiler = default;
IDxcUtils* pUtils = default;
var hr = Api.DxcCreateInstance((Guid*)Unsafe.AsPointer(in Api.CLSID_DxcCompiler), __uuidof(pCompiler), (void**)&pCompiler);
if (hr < 0)
{
throw new InvalidOperationException($"Failed to create DXC compiler instance. HRESULT: 0x{hr:X8}");
}
hr = Api.DxcCreateInstance((Guid*)Unsafe.AsPointer(in Api.CLSID_DxcUtils), __uuidof(pUtils), (void**)&pUtils);
if (hr < 0)
{
pCompiler->Release();
throw new InvalidOperationException($"Failed to create DXC utils instance. HRESULT: 0x{hr:X8}");
}
_compiler.Attach(pCompiler);
_utils.Attach(pUtils);
}
~DXCShaderCompiler()
{
Dispose();
}
public Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle allocationHandle)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IDxcIncludeHandler* includeHandler = default;
IDxcBlobEncoding* sourceBlob = default;
try
{
var hr = _utils.Get()->CreateDefaultIncludeHandler(&includeHandler);
if (hr < 0)
{
return Result.Failure($"Failed to create default include handler. HRESULT: 0x{hr:X8}");
}
fixed (byte* pCode = Encoding.UTF8.GetBytes(config.shaderCode))
{
var sizeInBytes = Encoding.UTF8.GetByteCount(config.shaderCode);
hr = _utils.Get()->CreateBlobFromPinned(pCode, (uint)sizeInBytes, Api.DXC_CP_UTF8, &sourceBlob);
if (hr < 0)
{
return Result.Failure($"Failed to create blob from shader code. HRESULT: 0x{hr:X8}");
}
}
var argsArray = GetCompilerArguments(in config);
var argPtrs = stackalloc char*[argsArray.Count];
for (var i = 0; i < argsArray.Count; i++)
{
argPtrs[i] = (char*)Marshal.StringToHGlobalUni(argsArray[i]);
}
IDxcResult* result = default;
IDxcBlob* bytecodeBlob = default;
try
{
// Compile shader
var buffer = new DxcBuffer
{
Ptr = sourceBlob->GetBufferPointer(),
Size = sourceBlob->GetBufferSize(),
Encoding = Api.DXC_CP_UTF8
};
hr = _compiler.Get()->Compile(&buffer, argPtrs, (uint)argsArray.Count, includeHandler, __uuidof(result), (void**)&result);
if (hr < 0)
{
return Result.Failure($"Failed to compile shader. HRESULT: 0x{hr:X8}");
}
// Check compilation result
int hrStatus;
result->GetStatus(&hrStatus);
if (hrStatus < 0)
{
// Get error messages
IDxcBlobEncoding* pErrorBlob = default;
result->GetErrorBuffer(&pErrorBlob);
if (pErrorBlob != null)
{
var errorMessage = Marshal.PtrToStringUTF8((IntPtr)pErrorBlob->GetBufferPointer());
pErrorBlob->Release();
return Result.Failure($"DXC shader compilation failed:\n{errorMessage}");
}
else
{
return Result.Failure("DXC shader compilation failed with unknown error.");
}
}
// Get compiled bytecode
hr = result->GetResult(&bytecodeBlob);
if (hr < 0)
{
return Result.Failure($"Failed to get compiled shader bytecode. HRESULT: 0x{hr:X8}");
}
var bytecodeSize = bytecodeBlob->GetBufferSize();
var bytecode = new UnsafeArray<byte>((int)bytecodeSize, allocationHandle);
NativeMemory.Copy(bytecodeBlob->GetBufferPointer(), bytecode.GetUnsafePtr(), (nuint)bytecodeSize);
return bytecode;
}
finally
{
if (result != null)
{
result->Release();
}
if (bytecodeBlob != null)
{
bytecodeBlob->Release();
}
for (var i = 0; i < argsArray.Count; i++)
{
Marshal.FreeHGlobal((nint)argPtrs[i]);
}
}
}
finally
{
if (includeHandler != null)
{
includeHandler->Release();
}
if (sourceBlob != null)
{
sourceBlob->Release();
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_compiler.Get()->Release();
_utils.Get()->Release();
_disposed = true;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,48 @@
using Ghost.Core;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Contracts;
using Ghost.Engine;
namespace Ghost.Editor.Core.Services;
internal class EditorContentProvider : IContentProvider
{
private readonly AssetCatalog _catalog;
public EditorContentProvider(IAssetRegistry assetRegistry)
{
_catalog = assetRegistry.GetAssetCatalog();
}
public bool HasAsset(Guid guid)
{
return _catalog.GetSourcePath(guid) != null;
}
public Result<Stream> OpenRead(Guid guid, CancellationToken token = default)
{
var importedPath = ImportCoordinator.GetImportedAssetPath(guid);
if (!File.Exists(importedPath))
{
return Result.Failure($"Imported asset not found for GUID: {guid}");
}
return new FileStream(importedPath, FileMode.Open, FileAccess.Read, FileShare.Read);
}
public Guid[] GetDependencies(Guid guid)
{
return _catalog.GetDependencies(guid).ToArray();
}
public AssetType GetAssetType(Guid guid)
{
var assetTypeID = _catalog.GetAssetTypeId(guid);
if (AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(assetTypeID, out var info))
{
return info.RuntimeAssetType;
}
return AssetType.Unknown;
}
}

View File

@@ -0,0 +1,361 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Utilities;
using Ghost.Engine;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Ghost.Graphics.Services;
using Microsoft.Extensions.DependencyInjection;
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 Action<Key64<ShaderVariant>, ulong>? OnShaderVariantCompiled;
public EditorShaderCompilerBridge(IAssetRegistry assetRegistry, IServiceProvider serviceProvider)
{
_assetRegistry = assetRegistry;
_serviceProvider = serviceProvider;
_compiler = new DXCShaderCompiler();
_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);
var engineCore = _serviceProvider.GetService<EngineCore>();
if (engineCore != null)
{
var shaderLibrary = engineCore.RenderSystem.ShaderLibrary;
var pipelineLibrary = engineCore.RenderSystem.GraphicsEngine.PipelineLibrary;
shaderLibrary.InvalidateShaderCache(nameHash, pipelineLibrary);
}
}
}
}
}
[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)
{
Ghost.Core.Logger.Error($"Failed to compile graphics shader {shaderId}: {compileResult.Message}");
return Task.CompletedTask;
}
var engineCore = _serviceProvider.GetService<EngineCore>();
if (engineCore == null)
{
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 };
}
var shaderLibrary = engineCore.RenderSystem.ShaderLibrary;
shaderLibrary.CacheCompiledResult(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(byteCodes, stageCount));
var (compiledHash, _) = shaderLibrary.GetCompiledHash(shaderId, passIndex, variantKey);
OnShaderVariantCompiled?.Invoke(variantKey, compiledHash);
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)
{
Ghost.Core.Logger.Error($"Failed to compile compute shader {shaderId}: {compileResult.Message}");
return Task.CompletedTask;
}
var engineCore = _serviceProvider.GetService<EngineCore>();
if (engineCore == null)
{
return Task.CompletedTask;
}
using var bytecodeArray = compileResult.Value;
var byteCode = new ShaderByteCode
{
pCode = (byte*)bytecodeArray.GetUnsafePtr(),
size = (ulong)bytecodeArray.Length
};
var shaderLibrary = engineCore.RenderSystem.ShaderLibrary;
shaderLibrary.CacheCompiledResult(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(ref byteCode));
var (compiledHash, _) = shaderLibrary.GetCompiledHash(shaderId, passIndex, variantKey);
OnShaderVariantCompiled?.Invoke(variantKey, compiledHash);
return Task.CompletedTask;
}
public void Dispose()
{
_assetRegistry.OnAssetImported -= OnAssetImported;
}
}

View File

@@ -0,0 +1,214 @@
using Ghost.Core;
using Ghost.Editor.Core.Assets;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading.Channels;
namespace Ghost.Editor.Core.Services;
internal enum ImportReason
{
NewAsset,
SourceChanged,
SettingsChanged,
HandlerUpgraded,
ManualReimport,
Startup,
}
internal readonly record struct ImportJob(
Guid AssetGuid,
string SourcePath,
string MetaPath,
ImportReason Reason
);
internal sealed partial class ImportCoordinator : IDisposable
{
public const string IMPORTED_EXTENSION_NAME = "Imported";
public const string IMPORTED_EXTENSION = ".imported";
private readonly Channel<ImportJob> _importChannel;
private readonly AssetCatalog _catalog;
private readonly CancellationTokenSource _cts;
private readonly Task[] _workers;
public event EventHandler<Guid>? OnImportCompleted;
public ImportCoordinator(AssetCatalog catalog, int workerCount = 2)
{
_catalog = catalog;
_cts = new CancellationTokenSource();
_importChannel = Channel.CreateBounded<ImportJob>(new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = false,
});
_workers = new Task[workerCount];
for (var i = 0; i < workerCount; i++)
{
_workers[i] = Task.Run(() => WorkerLoop(_cts.Token));
}
}
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
{
return _importChannel.Writer.WriteAsync(job, token);
}
private async Task WorkerLoop(CancellationToken token)
{
await foreach (var job in _importChannel.Reader.ReadAllAsync(token))
{
try
{
await ProcessImportAsync(job, token);
}
catch (Exception ex)
{
Logger.Error(ex);
}
}
}
public static string GetImportedAssetPath(Guid assetGuid)
{
var fileName = $"{assetGuid:N}{IMPORTED_EXTENSION}";
var folderName = fileName.Substring(0, 2);
var importsFolder = Path.Combine(EditorApplication.LibraryImportsFolderPath, folderName);
var finalPath = Path.Combine(importsFolder, fileName);
Directory.CreateDirectory(importsFolder);
return finalPath;
}
private async ValueTask ProcessImportAsync(ImportJob job, CancellationToken token)
{
var meta = await AssetMetaIO.ReadAsync(job.MetaPath, token);
if (meta is null)
{
Logger.Error("Missing .gmeta file");
return;
}
var handler = meta.AssetTypeId.HasValue
? AssetHandlerRegistry.GetByAssetTypeId(meta.AssetTypeId.Value)
: AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath));
var contentHash = await ComputeFileHashAsync(job.SourcePath, token);
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)
if (job.Reason != ImportReason.ManualReimport &&
meta.ContentHash == contentHash &&
meta.SettingsHash == settingsHash &&
meta.HandlerVersion == handlerVersion)
{
return;
}
var importResult = Result.Success();
var subAssets = Array.Empty<ImportedSubAsset>();
if (handler is IImportableAssetHandler importable)
{
var targetPath = GetImportedAssetPath(job.AssetGuid);
var subAssetResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token);
importResult = subAssetResult;
if (subAssetResult.IsSuccess)
{
subAssets = subAssetResult.Value;
}
}
if (importResult.IsSuccess)
{
meta.ContentHash = contentHash;
meta.SettingsHash = settingsHash;
meta.HandlerVersion = handlerVersion;
meta.LastImportedUtc = DateTime.UtcNow;
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);
}
else
{
Logger.Error(importResult.Message ?? "Unknown import error");
}
}
private static async ValueTask<string> ComputeFileHashAsync(string filePath, CancellationToken token)
{
if (!File.Exists(filePath))
{
return string.Empty;
}
var hasher = new XxHash128();
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await hasher.AppendAsync(stream, token);
Span<byte> hash = stackalloc byte[16];
hasher.GetCurrentHash(hash);
return Convert.ToHexString(hash);
}
private static string ComputeSettingsHash(IAssetSettings? settings)
{
if (settings is null)
{
return string.Empty;
}
var hash = XxHash128.HashToUInt128(JsonSerializer.SerializeToUtf8Bytes(settings, settings.GetType()));
Span<byte> bytes = stackalloc byte[16];
Unsafe.WriteUnaligned(ref bytes[0], hash);
return Convert.ToHexString(bytes);
}
public void Dispose()
{
_importChannel.Writer.TryComplete();
_cts.Cancel();
_cts.Dispose();
}
}

View File

@@ -1,53 +0,0 @@
using Ghost.Editor.Core.AssetHandler;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Utilities;
public static class AssetHandlerUtility
{
public static async ValueTask SerializeAssetAsync<TSetting>(Stream stream, Guid id, Guid typeID, int handlerVersion, ReadOnlyMemory<Guid> dependencies, IAssetSettings? settings, ReadOnlyMemory<byte> contents, CancellationToken token = default)
where TSetting : IAssetSettings
{
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = handlerVersion,
DependenciesOffset = AssetMetadata.SIZE,
DependencyCount = dependencies.Length,
};
var tempArray = ArrayPool<byte>.Shared.Rent(4096);
if (dependencies.Length > 0)
{
stream.Seek(header.DependenciesOffset, SeekOrigin.Begin);
for (var i = 0; i < dependencies.Length; i++)
{
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(tempArray.AsSpan(0, 16)), dependencies.Span[i]);
await stream.WriteAsync(tempArray.AsMemory(0, 16), token);
}
}
header.SettingsOffset = stream.Position;
// TODO: We can use source generator to generate optimized serializer for settings.
// For now, we just use reflection for simplicity.
if (settings is not null)
{
var properties = typeof(TSetting).GetProperties();
if (properties.Length > 0)
{
using var bw = new BinaryWriter(stream);
for (var i = 0; (i < properties.Length); i++)
{
var property = properties[i];
var value = property.GetValue(settings);
}
}
}
}
}

View File

@@ -0,0 +1,167 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.Editor.Core.Contracts;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Editor.Core.Utilities;
internal struct GraphicsCompiledResult : IDisposable
{
public UnsafeArray<byte> asResult;
public UnsafeArray<byte> msResult;
public UnsafeArray<byte> psResult;
public void Dispose()
{
asResult.Dispose();
msResult.Dispose();
psResult.Dispose();
}
}
internal static class ShaderCompilerUtility
{
private static ReadOnlySpan<string> CombineDefines(ReadOnlySpan<string> a, ReadOnlySpan<string> b)
{
ReadOnlySpan<string> combined;
if (b.Length == 0)
{
combined = a;
}
else if (a.Length == 0)
{
combined = b;
}
else
{
var combinedDefines = new string[a.Length + b.Length];
a.CopyTo(combinedDefines);
b.CopyTo(combinedDefines.AsSpan(a.Length));
combined = combinedDefines;
}
return combined;
}
public static Result<GraphicsCompiledResult> CompileShaderPass(this IShaderCompiler shaderCompiler, ref readonly PassDescriptor descriptor, ref readonly ShaderCompilationConfig additionalConfig, AllocationHandle allocationHandle)
{
var fullDefines = CombineDefines(descriptor.defines, additionalConfig.defines);
var config = new ShaderCompilationConfig
{
defines = fullDefines,
model = additionalConfig.model,
optimizeLevel = additionalConfig.optimizeLevel,
options = additionalConfig.options
};
UnsafeArray<byte> asResult = default;
if (descriptor.amplificationShaderCode.IsCreated)
{
config.shaderCode = descriptor.amplificationShaderCode.code;
config.entryPoint = descriptor.amplificationShaderCode.entryPoint;
config.stage = ShaderStage.TaskShader;
var result = shaderCompiler.Compile(ref config, allocationHandle);
if (result.IsFailure)
{
return Result.Failure(result.Message);
}
asResult = result.Value;
}
UnsafeArray<byte> msResult;
if (descriptor.meshShaderCode.IsCreated)
{
config.shaderCode = descriptor.meshShaderCode.code;
config.entryPoint = descriptor.meshShaderCode.entryPoint;
config.stage = ShaderStage.MeshShader;
var result = shaderCompiler.Compile(ref config, allocationHandle);
if (result.IsFailure)
{
asResult.Dispose();
return Result.Failure(result.Message);
}
msResult = result.Value;
}
else
{
asResult.Dispose();
return Result.Failure("Mesh shader expected.");
}
UnsafeArray<byte> psResult;
if (descriptor.pixelShaderCode.IsCreated)
{
config.shaderCode = descriptor.pixelShaderCode.code;
config.entryPoint = descriptor.pixelShaderCode.entryPoint;
config.stage = ShaderStage.PixelShader;
var result = shaderCompiler.Compile(ref config, allocationHandle);
if (result.IsFailure)
{
asResult.Dispose();
msResult.Dispose();
return Result.Failure(result.Message);
}
psResult = result.Value;
}
else
{
asResult.Dispose();
msResult.Dispose();
return Result.Failure("Pixel shader expected.");
}
var compiled = new GraphicsCompiledResult
{
asResult = asResult,
msResult = msResult,
psResult = psResult,
};
return compiled;
}
public static Result<UnsafeArray<UnsafeArray<byte>>> CompileComputeShader(this IShaderCompiler shaderCompiler, ComputeShaderDescriptor descriptor, ref readonly ShaderCompilationConfig additionalConfig, AllocationHandle allocationHandle)
{
var fullDefines = CombineDefines(descriptor.Defines, additionalConfig.defines);
var config = new ShaderCompilationConfig
{
defines = fullDefines,
model = additionalConfig.model,
optimizeLevel = additionalConfig.optimizeLevel,
options = additionalConfig.options,
stage = ShaderStage.ComputeShader,
};
var compiled = new UnsafeArray<UnsafeArray<byte>>(descriptor.ShaderCodes.Length, allocationHandle);
for (int i = 0; i < descriptor.ShaderCodes.Length; i++)
{
config.shaderCode = descriptor.ShaderCodes[i].code;
config.entryPoint = descriptor.ShaderCodes[i].entryPoint;
var result = shaderCompiler.Compile(ref config, allocationHandle);
if (result.IsFailure)
{
for (int j = 0; j < i; j++)
{
compiled[j].Dispose();
}
compiled.Dispose();
return Result.Failure(result.Message);
}
compiled[i] = result.Value;
}
return compiled;
}
}

View File

@@ -6,13 +6,13 @@ namespace Ghost.Editor.Core.Utilities;
public static class TypeCache public static class TypeCache
{ {
private static TypeInfo[] s_types; private static TypeInfo[] s_types = null!;
private static Dictionary<nint, List<MethodInfo>> s_attributeMethodCache; private static Dictionary<nint, List<MethodInfo>> s_attributeMethodCache = null!;
private static Dictionary<nint, List<int>> s_attributeTypeCache = null!;
static TypeCache() static TypeCache()
{ {
s_types = LoadTypes(); Reload();
s_attributeMethodCache = FindMethodWithAttribute();
} }
private static TypeInfo[] LoadTypes() private static TypeInfo[] LoadTypes()
@@ -62,6 +62,29 @@ public static class TypeCache
return dict; return dict;
} }
private static Dictionary<nint, List<int>> FindTypesWithAttribute()
{
var dict = new Dictionary<nint, List<int>>();
for (int i = 0; i < s_types.Length; i++)
{
TypeInfo? type = s_types[i];
var attrs = type.GetCustomAttributes<DiscoverableAttributeBase>(false);
foreach (var attr in attrs)
{
var key = attr.GetType().TypeHandle.Value;
ref var typeList = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var exist);
if (!exist)
{
typeList = new List<int>();
}
typeList!.Add(i);
}
}
return dict;
}
internal static void Initialize() internal static void Initialize()
{ {
// Intentionally left blank. // Intentionally left blank.
@@ -72,6 +95,7 @@ public static class TypeCache
{ {
s_types = LoadTypes(); s_types = LoadTypes();
s_attributeMethodCache = FindMethodWithAttribute(); s_attributeMethodCache = FindMethodWithAttribute();
s_attributeTypeCache = FindTypesWithAttribute();
} }
public static IReadOnlyCollection<TypeInfo> GetTypes() public static IReadOnlyCollection<TypeInfo> GetTypes()
@@ -79,7 +103,7 @@ public static class TypeCache
return s_types; return s_types;
} }
public static IReadOnlyCollection<MethodInfo>? GetMethodsWithAttribute<T>() public static IEnumerable<MethodInfo>? GetMethodsWithAttribute<T>()
where T : DiscoverableAttributeBase where T : DiscoverableAttributeBase
{ {
var key = typeof(T).TypeHandle.Value; var key = typeof(T).TypeHandle.Value;
@@ -90,4 +114,16 @@ public static class TypeCache
return null; return null;
} }
}
public static IEnumerable<TypeInfo>? GetTypesWithAttribute<T>()
where T : DiscoverableAttributeBase
{
var key = typeof(T).TypeHandle.Value;
if (s_attributeTypeCache.TryGetValue(key, out var typeIndices))
{
return typeIndices.Select(i => s_types[i]);
}
return null;
}
}

View File

@@ -1,5 +1,7 @@
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 Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Buffer;
using System.Reflection; using System.Reflection;
@@ -53,20 +55,25 @@ internal static class ActivationHandler
public static ValueTask HandleAsync(LaunchArguments args) public static ValueTask HandleAsync(LaunchArguments args)
{ {
var opts = new AllocationManagerInitOpts 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 = 1024 * 1024 * 64, // 64 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
FreeListConcurrencyLevel = Environment.ProcessorCount FreeListChunkSize = 64 * 1024,
FreeListDefaultAlignment = 8,
TLSFInitialChunkSize = 64 * 1024,
TLSFAlignment = 8,
}; };
AllocationManager.Initialize(opts); AllocationManager.Initialize(opts);
TypeCache.Initialize();
// await ((Core.AssetHandle.AssetService)App.GetService<IAssetService>()).Init(); var assetRegistry = App.GetService<IAssetRegistry>();
var engineCore = App.GetService<EngineCore>();
// TODO: Init other subsystems here. assetRegistry.OnAssetImported += (sender, e) =>
// await Task.Delay(10000); // Wait 10 seconds to simulate work. {
engineCore.AssetManager.ReimportAsset(e);
};
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }

View File

@@ -10,7 +10,6 @@
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<ResourceDictionary Source="/Themes/Generic.xaml" /> <ResourceDictionary Source="/Themes/Generic.xaml" />
<ResourceDictionary Source="/Themes/DockingDictionary.xaml" />
<core:ControlsDictionary /> <core:ControlsDictionary />
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -2,12 +2,12 @@ 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.View.Pages.EngineEditor; using Ghost.Editor.Core.Utilities;
using Ghost.Editor.View.Windows;
using Ghost.Editor.ViewModels.Controls; using Ghost.Editor.ViewModels.Controls;
using Ghost.Editor.ViewModels.Pages.EngineEditor;
using Ghost.Editor.ViewModels.Windows; using Ghost.Editor.ViewModels.Windows;
using Ghost.Editor.Views.Windows;
using Ghost.Engine; using Ghost.Engine;
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;
@@ -52,6 +52,8 @@ 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).
@@ -63,27 +65,41 @@ public partial class App : Application
services.AddSingleton<IProgressService, ProgressService>(); services.AddSingleton<IProgressService, ProgressService>();
services.AddSingleton<IInspectorService, InspectorService>(); services.AddSingleton<IInspectorService, InspectorService>();
services.AddSingleton<IPreviewService, PreviewService>(); services.AddSingleton<IPreviewService, PreviewService>();
// services.AddSingleton<IAssetService, AssetService>(); services.AddSingleton<IAssetRegistry, AssetRegistry>();
services.AddSingleton<IContentProvider, EditorContentProvider>();
services.AddSingleton<IShaderCompilationBridge, EditorShaderCompilerBridge>();
services.AddSingleton<EngineCore>();
services.AddSingleton<EngineEditorViewModel>(); services.AddSingleton<EngineEditorViewModel>();
services.AddTransient<ProjectBrowserViewModel>(); services.AddTransient<ContentBrowserViewModel>();
#region Should be deleted // TODO: Use source generators to generate this code at compile time instead of using reflection at runtime.
services.AddTransient<ScenePage>(); foreach (var type in TypeCache.GetTypes())
{
var data = type.GetCustomAttributesData().FirstOrDefault(a => a.AttributeType == typeof(EditorInjectionAttribute));
if (data is null)
{
continue;
}
services.AddTransient<HierarchyPage>(); var lifeTime = (EditorInjectionAttribute.ServiceLifetime)data.ConstructorArguments[0].Value!;
services.AddTransient<HierarchyViewModel>(); var implementationType = (Type)data.ConstructorArguments[1].Value!;
var serviceType = type.IsInterface ? type.AsType() : implementationType;
services.AddTransient<ProjectPage>(); switch (lifeTime)
services.AddTransient<ProjectViewModel>(); {
case EditorInjectionAttribute.ServiceLifetime.Singleton:
services.AddTransient<ConsolePage>(); services.AddSingleton(serviceType, implementationType);
services.AddTransient<ConsoleViewModel>(); break;
case EditorInjectionAttribute.ServiceLifetime.Transient:
services.AddTransient<InspectorPage>(); services.AddTransient(serviceType, implementationType);
services.AddTransient<InspectorViewModel>(); break;
#endregion default:
break;
}
}
}) })
.Build(); .Build();
@@ -116,25 +132,32 @@ public partial class App : Application
return; return;
} }
EditorApplication.Initialize(Host.Services, arguments.ProjectPath, arguments.ProjectName); try
{
EditorApplication.Initialize(Host.Services, arguments.ProjectPath, arguments.ProjectName);
// NOTE: We must call DispatcherQueue.GetForCurrentThread() on the UI thread before any await.
EditorApplication.SetDispatcherQueue(DispatcherQueue.GetForCurrentThread());
// NOTE: We must call DispatcherQueue.GetForCurrentThread() on the UI thread before any await. var splashWindow = new SplashWindow();
EditorApplication.SetDispatcherQueue(DispatcherQueue.GetForCurrentThread()); splashWindow.Activate();
Window = splashWindow;
var splashWindow = new SplashWindow(); await Host.StartAsync();
splashWindow.Activate(); await ActivationHandler.HandleAsync(arguments);
Window = splashWindow;
await Host.StartAsync(); splashWindow.Hide();
await ActivationHandler.HandleAsync(arguments);
splashWindow.Hide(); var editorWindow = new EngineEditorWindow();
editorWindow.Activate();
Window = editorWindow;
var editorWindow = new EngineEditorWindow(); splashWindow.Close();
editorWindow.Activate(); }
Window = editorWindow; catch (Exception ex)
{
splashWindow.Close(); Logger.Error(ex);
Environment.Exit(ex.HResult);
}
} }
private void OnClosed(object? sender, WindowEventArgs args) private void OnClosed(object? sender, WindowEventArgs args)
@@ -144,24 +167,24 @@ public partial class App : Application
Host.StopAsync().GetAwaiter().GetResult(); Host.StopAsync().GetAwaiter().GetResult();
Host.Dispose(); Host.Dispose();
//EditorApplication.Shutdown(); EditorApplication.Shutdown();
ActivationHandler.Shutdown(); ActivationHandler.Shutdown();
} }
catch (Exception ex) catch (Exception ex)
{ {
Debugger.BreakForUserUnhandledException(ex); Logger.Error(ex);
} }
finally finally
{ {
//Environment.Exit(0); Environment.Exit(0);
} }
} }
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{ {
Logger.LogError(e.Exception); Logger.Error(e.Exception);
#if DEBUG #if DEBUG
Debugger.BreakForUserUnhandledException(e.Exception); Debugger.BreakForUserUnhandledException(e.Exception);
#endif #endif
} }
} }

View File

@@ -3,17 +3,15 @@
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework> <TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion> <TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<Platforms>x86;x64;ARM64</Platforms> <Platforms>x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers> <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<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="View\Controls\Hierarchy.xaml" /> <None Remove="Views\Controls\Hierarchy.xaml" />
<None Remove="View\Windows\BlankWindow1.xaml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" /> <Content Include="Assets\SplashScreen.scale-200.png" />
@@ -39,9 +37,9 @@
<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.5" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
<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.26100.7705" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" /> <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="WinUIEx" Version="2.9.0" /> <PackageReference Include="WinUIEx" Version="2.9.0" />
</ItemGroup> </ItemGroup>
@@ -142,6 +140,12 @@
<None Update="Assets\icon.ico"> <None Update="Assets\icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<Page Update="Views\Controls\LogViewer.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\Windows\EngineEditorWindow.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="View\Windows\BlankWindow1.xaml"> <Page Update="View\Windows\BlankWindow1.xaml">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</Page> </Page>
@@ -196,6 +200,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="ContextMenu\" /> <Folder Include="ContextMenu\" />
<Folder Include="ViewModels\Pages\" />
<Folder Include="Views\Pages\" />
</ItemGroup> </ItemGroup>
<PropertyGroup Label="Globals" /> <PropertyGroup Label="Globals" />
@@ -210,15 +216,18 @@
<!-- 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>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'">
<Optimize>True</Optimize>
</PropertyGroup>
</Project> </Project>

View File

@@ -1,27 +1,72 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Core.Utilities;
using Ghost.Engine;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace Ghost.Editor.Models; namespace Ghost.Editor.Models;
internal class ExplorerItem(string name, string path, bool isDirectory) internal partial class ExplorerItem : ObservableObject
{ {
public string Name public string Name
{ {
get; get;
} = name; }
public string FullName public string Path
{ {
get; get;
} = path; }
public bool IsDirectory public bool IsDirectory
{ {
get; get;
} = isDirectory; }
public ObservableCollection<ExplorerItem>? Children
public AssetType AssetType
{ {
get; get;
set; }
[ObservableProperty]
public partial ObservableCollection<ExplorerItem>? Children
{
get; set;
}
[ObservableProperty]
public partial string IconGlyph
{
get; set;
}
public ExplorerItem(string name, string path, bool isDirectory, AssetType assetType = AssetType.Unknown)
{
Name = name;
Path = path;
IsDirectory = isDirectory;
AssetType = assetType;
if (IsDirectory)
{
IconGlyph = "\uE8B7"; // Folder icon
}
else
{
IconGlyph = GetIconGlyphForAssetType(assetType);
}
}
private string GetIconGlyphForAssetType(AssetType assetType)
{
return assetType switch
{
AssetType.Texture => "\uEB9F", // Image icon
AssetType.Material => "\uE943", // Document/Material
AssetType.Shader => "\uE9E9", // Code
AssetType.Mesh => "\uE8B3", // 3D icon
AssetType.Audio => "\uE8D6", // Audio
_ => "\uE7C3" // Default file icon
};
} }
} }

View File

@@ -13,7 +13,7 @@ namespace Ghost.Editor.Properties {
/// <summary> /// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc. /// A strongly-typed heap class, for looking up localized strings, etc.
/// </summary> /// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder // This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio. // class via a tool like ResGen or Visual Studio.
@@ -48,7 +48,7 @@ namespace Ghost.Editor.Properties {
/// <summary> /// <summary>
/// Overrides the current thread's CurrentUICulture property for all /// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class. /// heap lookups using this strongly typed heap class.
/// </summary> /// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture { internal static global::System.Globalization.CultureInfo Culture {

View File

@@ -1,12 +1,11 @@
<ResourceDictionary <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/View/Controls/Docking/DockingLayout.xaml" /> <ResourceDictionary Source="/View/Controls/Docking/DockingLayout.xaml" />
<ResourceDictionary Source="/View/Controls/Docking/DockGroup.xaml" /> <ResourceDictionary Source="/View/Controls/Docking/DockGroup.xaml" />
<ResourceDictionary Source="/View/Controls/Docking/DockPanel.xaml" /> <ResourceDictionary Source="/View/Controls/Docking/DockPanel.xaml" />
<ResourceDictionary Source="/View/Controls/Docking/DockRegionHighlight.xaml" /> <ResourceDictionary Source="/View/Controls/Docking/DockRegionHighlight.xaml" />
<ResourceDictionary Source="/View/Controls/Docking/DockDocument.xaml" />
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -1,7 +1,7 @@
<ResourceDictionary <ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.UI.Xaml.Controls" xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:ghost="using:Ghost.Editor.Controls" xmlns:ghost="using:Ghost.Editor.Controls"
xmlns:local="using:Ghost.Editor.Core"> xmlns:local="using:Ghost.Editor.Core">
@@ -22,6 +22,7 @@
</Style> </Style>
<x:Double x:Key="ControlContentThemeFontSize">12</x:Double> <x:Double x:Key="ControlContentThemeFontSize">12</x:Double>
<x:Double x:Key="ContentControlFontSize">12</x:Double> <x:Double x:Key="ContentControlFontSize">12</x:Double>
<x:Double x:Key="ToolbarFontIconFontSize">14</x:Double>
<x:Double x:Key="TextControlThemeMinHeight">24</x:Double> <x:Double x:Key="TextControlThemeMinHeight">24</x:Double>
<Thickness x:Key="TextControlThemePadding">2,2,6,1</Thickness> <Thickness x:Key="TextControlThemePadding">2,2,6,1</Thickness>
<x:Double x:Key="ListViewItemMinHeight">32</x:Double> <x:Double x:Key="ListViewItemMinHeight">32</x:Double>
@@ -42,12 +43,133 @@
<Setter Property="TabWidthMode" Value="Compact" /> <Setter Property="TabWidthMode" Value="Compact" />
</Style> </Style>
<Style TargetType="NumberBox" /> <Style TargetType="NumberBox" />
<Style TargetType="controls:GridSplitter">
<Setter Property="MinHeight" Value="2" />
<Setter Property="MinWidth" Value="2" />
<Setter Property="Background" Value="{ThemeResource AcrylicBackgroundFillColorBaseBrush}" />
</Style>
<!-- Named Style --> <!-- Named Style -->
<Style <Style x:Key="ToolbarButton" TargetType="Button">
x:Key="ToolbarButton" <Setter Property="Padding" Value="2" />
BasedOn="{StaticResource SubtleButtonStyle}" <Setter Property="Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
TargetType="Button" /> <Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid
x:Name="RootGrid"
Padding="10,5"
Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter
x:Name="ContentPresenter"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Content}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource SubtleFillColorSecondaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource SubtleFillColorTertiaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource SubtleFillColorDisabledBrush}" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource ControlStrongFillColorDisabledBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="AccentToolbarButton" TargetType="Button">
<Setter Property="Padding" Value="2" />
<Setter Property="Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
<Setter Property="Foreground" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid
x:Name="RootGrid"
Padding="10,5"
Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter
x:Name="ContentPresenter"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Content}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource SubtleFillColorSecondaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource SubtleFillColorTertiaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource SubtleFillColorDisabledBrush}" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource AccentFillColorDisabledBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="VerticalDivider" TargetType="Border">
<Setter Property="BorderBrush" Value="{ThemeResource DividerStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1,0,0,0" />
<Setter Property="Margin" Value="2,0" />
</Style>
<Style x:Key="HorizontalDivider" TargetType="Border">
<Setter Property="BorderBrush" Value="{ThemeResource DividerStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Margin" Value="0,2" />
</Style>
<Style x:Key="VerticalStrongDivider" TargetType="Border">
<Setter Property="BorderBrush" Value="{ThemeResource ControlElevationBorderBrush}" />
<Setter Property="BorderThickness" Value="1,0,0,0" />
<Setter Property="Margin" Value="2,0" />
</Style>
<Style x:Key="HorizontalStrongDivider" TargetType="Border">
<Setter Property="BorderBrush" Value="{ThemeResource ControlElevationBorderBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Margin" Value="0,2" />
</Style>
<!-- Named Resource --> <!-- Named Resource -->
<x:Double x:Key="ToolbarIconSize">12</x:Double> <x:Double x:Key="ToolbarIconSize">12</x:Double>

View File

@@ -18,7 +18,7 @@ public partial class AssetPathToGlyphConverter : IValueConverter
var extension = Path.GetExtension(path).ToLowerInvariant(); var extension = Path.GetExtension(path).ToLowerInvariant();
// TODO: Use resource dictionary for icons. // TODO: Use heap dictionary for icons.
return extension switch return extension switch
{ {
".ghostscene" => "\uF159", ".ghostscene" => "\uF159",

View File

@@ -12,7 +12,7 @@ public partial class ExplorerItemToIconUriConverter : IValueConverter
{ {
if (value is ExplorerItem item) if (value is ExplorerItem item)
{ {
var path = _previewService.GetIconPath(item.FullName, item.IsDirectory, IconSize.Small); var path = _previewService.GetIconPath(item.Path, item.IsDirectory, IconSize.Small);
return new Uri(path); return new Uri(path);
} }

View File

@@ -6,7 +6,7 @@ public partial class GetDirectoryNameConverter : IValueConverter
{ {
public object? Convert(object value, Type targetType, object parameter, string language) public object? Convert(object value, Type targetType, object parameter, string language)
{ {
return value is string path ? System.IO.Path.GetDirectoryName(path) : null; return value is string path ? Path.GetDirectoryName(path) : null;
} }
public object? ConvertBack(object value, Type targetType, object parameter, string language) public object? ConvertBack(object value, Type targetType, object parameter, string language)

View File

@@ -1,210 +0,0 @@
using System;
using System.Collections.ObjectModel;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// Base class for containers that can hold other dock modules.
/// </summary>
public abstract class DockContainer : DockModule
{
private readonly ObservableCollection<DockModule> _children = new();
private bool _isCleaningUp;
/// <summary>
/// Gets the collection of child modules.
/// </summary>
public ReadOnlyObservableCollection<DockModule> Children { get; }
protected DockContainer()
{
Children = new ReadOnlyObservableCollection<DockModule>(_children);
_children.CollectionChanged += OnChildrenChanged;
}
private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
OnChildrenUpdated();
}
/// <summary>
/// Adds a child module to the end of the container.
/// </summary>
/// <param name="module">The module to add.</param>
public virtual void AddChild(DockModule module)
{
InsertChild(_children.Count, module);
}
/// <summary>
/// Inserts a child module at the specified index.
/// </summary>
/// <remarks>
/// This method does not support reordering existing children within the same container.
/// Cross-layout moves are intentionally allowed and supported (e.g., for dragging tabs between floating windows and the main window).
/// </remarks>
/// <param name="index">The zero-based index at which the module should be inserted.</param>
/// <param name="module">The module to insert.</param>
public virtual void InsertChild(int index, DockModule module)
{
ValidateChild(module);
if (module.Owner == null && module.Root != null && module.Root != this.Root)
throw new InvalidOperationException("Cannot insert a module that is the root of another layout. Detach it first.");
if (index < 0 || index > _children.Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (_children.Contains(module))
return;
module.Owner?.RemoveChild(module);
module.Owner = this;
module.Root = Root;
_children.Insert(index, module);
}
/// <summary>
/// Removes a child module from the container.
/// </summary>
/// <param name="module">The module to remove.</param>
public virtual void RemoveChild(DockModule module)
{
RemoveChildInternal(module, true);
}
internal void RemoveChildInternal(DockModule module, bool triggerCleanup)
{
ArgumentNullException.ThrowIfNull(module);
if (_children.Remove(module))
{
module.Owner = null;
module.Root = null;
if (!_isCleaningUp && triggerCleanup)
{
CheckCleanup();
}
}
}
/// <summary>
/// Replaces an existing child module with a new one.
/// </summary>
/// <remarks>
/// Cross-layout moves are intentionally allowed and supported (e.g., for dragging tabs between floating windows and the main window).
/// </remarks>
/// <param name="oldChild">The child module to be replaced.</param>
/// <param name="newChild">The new child module to insert.</param>
public virtual void ReplaceChild(DockModule oldChild, DockModule newChild)
{
ArgumentNullException.ThrowIfNull(oldChild);
ValidateChild(newChild);
if (newChild.Owner == null && newChild.Root != null && newChild.Root != this.Root)
throw new InvalidOperationException("Cannot insert a module that is the root of another layout. Detach it first.");
if (oldChild == newChild) return;
int index = _children.IndexOf(oldChild);
if (index < 0) throw new ArgumentException("oldChild not found in this container", nameof(oldChild));
// Detach newChild from its current owner if any
if (newChild.Owner == this)
{
throw new ArgumentException("newChild is already in this container", nameof(newChild));
}
var oldOwner = newChild.Owner;
newChild.Owner?.RemoveChildInternal(newChild, false);
// Remove oldChild without triggering cleanup
_isCleaningUp = true;
try
{
_children.RemoveAt(index);
oldChild.Owner = null;
oldChild.Root = null;
newChild.Owner = this;
newChild.Root = Root;
_children.Insert(index, newChild);
}
finally
{
_isCleaningUp = false;
}
CheckCleanup();
oldOwner?.CheckCleanup();
}
/// <summary>
/// Checks if the container is empty and removes it from its owner if necessary.
/// </summary>
protected virtual void CheckCleanup()
{
if (Children.Count == 0)
{
if (Owner != null)
{
Owner.RemoveChildInternal(this, true);
}
else if (Root != null && Root.RootModule == this)
{
Root.RootModule = null;
Root.NotifyLayoutEmpty();
}
}
}
/// <summary>
/// Validates if a module can be added as a child to this container.
/// </summary>
/// <param name="module">The module to validate.</param>
protected virtual void ValidateChild(DockModule module)
{
ArgumentNullException.ThrowIfNull(module);
if (module == this)
throw new ArgumentException("Cannot add a container to itself.", nameof(module));
if (module is DockContainer container)
{
var current = Owner;
while (current != null)
{
if (current == container)
throw new ArgumentException("Cannot add a container that is an ancestor of this container.", nameof(module));
current = current.Owner;
}
}
}
/// <summary>
/// Removes all child modules from the container.
/// </summary>
public void Clear()
{
foreach (var child in _children)
{
child.Owner = null;
child.Root = null;
}
_children.Clear();
if (!_isCleaningUp)
{
CheckCleanup();
}
}
protected override void OnRootChanged()
{
foreach (var child in _children)
{
child.Root = Root;
}
}
protected virtual void OnChildrenUpdated() { }
}

View File

@@ -1,42 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// Represents a document module in the docking system.
/// </summary>
public partial class DockDocument : DockModule
{
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
nameof(Title), typeof(string), typeof(DockDocument), new PropertyMetadata(string.Empty));
public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(
nameof(Content), typeof(object), typeof(DockDocument), new PropertyMetadata(null));
/// <summary>
/// Gets or sets the title of the document.
/// </summary>
public string? Title
{
get => (string?)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
/// <summary>
/// Gets or sets the content of the document.
/// </summary>
public object? Content
{
get => GetValue(ContentProperty);
set => SetValue(ContentProperty, value);
}
/// <summary>
/// Initializes a new instance of the <see cref="DockDocument"/> class.
/// </summary>
public DockDocument()
{
DefaultStyleKey = typeof(DockDocument);
}
}

View File

@@ -1,195 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// A container that displays its children (documents) as tabs.
/// </summary>
[TemplatePart(Name = PART_TAB_VIEW, Type = typeof(TabView))]
public partial class DockGroup : DockContainer
{
private const string PART_TAB_VIEW = "PART_TabView";
private const string DRAG_DOCUMENT_KEY = "DockDocument";
private TabView? _tabView;
public DockGroup()
{
DefaultStyleKey = typeof(DockGroup);
}
protected override void ValidateChild(DockModule module)
{
base.ValidateChild(module);
if (module is not DockDocument)
{
throw new ArgumentException($"{nameof(DockGroup)} only accepts {nameof(DockDocument)} children.", nameof(module));
}
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (_tabView != null)
{
_tabView.TabDragStarting -= OnTabDragStarting;
_tabView.TabDroppedOutside -= OnTabDroppedOutside;
_tabView.DragOver -= OnDragOver;
_tabView.Drop -= OnDrop;
_tabView.DragLeave -= OnDragLeave;
_tabView.TabCloseRequested -= OnTabCloseRequested;
}
_tabView = GetTemplateChild(PART_TAB_VIEW) as TabView;
if (_tabView != null)
{
_tabView.TabDragStarting += OnTabDragStarting;
_tabView.TabDroppedOutside += OnTabDroppedOutside;
_tabView.DragOver += OnDragOver;
_tabView.Drop += OnDrop;
_tabView.DragLeave += OnDragLeave;
_tabView.TabCloseRequested += OnTabCloseRequested;
}
UpdateTabs();
}
private void OnTabDragStarting(TabView sender, TabViewTabDragStartingEventArgs args)
{
if (args.Tab.Tag is DockDocument doc)
{
args.Data.Properties.Add(DRAG_DOCUMENT_KEY, doc);
}
}
private void OnTabDroppedOutside(TabView sender, TabViewTabDroppedOutsideEventArgs args)
{
if (args.Tab.Tag is DockDocument doc)
{
Root?.CreateFloatingWindow(doc);
}
}
private void OnDragOver(object sender, DragEventArgs e)
{
if (e.DataView.Properties.ContainsKey(DRAG_DOCUMENT_KEY))
{
e.AcceptedOperation = global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
Root?.ShowHighlight(this, e.GetPosition(this));
}
}
private void OnDrop(object sender, DragEventArgs e)
{
if (e.DataView.Properties.TryGetValue(DRAG_DOCUMENT_KEY, out var obj) && obj is DockDocument doc)
{
Root?.HandleDrop(doc, this, e.GetPosition(this));
}
}
private void OnDragLeave(object sender, DragEventArgs e)
{
Root?.HideHighlight();
}
private void OnTabCloseRequested(TabView sender, TabViewTabCloseRequestedEventArgs args)
{
if (args.Tab.Tag is DockDocument doc)
{
RemoveChild(doc);
}
}
protected override void OnChildrenUpdated()
{
UpdateTabs();
}
private void UpdateTabs()
{
if (_tabView == null) return;
var selectedDoc = _tabView.SelectedItem is TabViewItem selectedItem ? selectedItem.Tag as DockDocument : null;
// Remove tabs that are no longer in Children
for (int i = _tabView.TabItems.Count - 1; i >= 0; i--)
{
if (_tabView.TabItems[i] is TabViewItem tabItem && tabItem.Tag is DockDocument doc)
{
if (!Children.Contains(doc))
{
tabItem.ClearValue(ContentControl.ContentProperty);
tabItem.Content = null;
_tabView.TabItems.RemoveAt(i);
}
}
}
TabViewItem? newSelectedItem = null;
// Add tabs that are in Children but not in TabItems, and ensure correct order
for (int i = 0; i < Children.Count; i++)
{
if (Children[i] is DockDocument doc)
{
TabViewItem? existingTab = null;
for (int j = 0; j < _tabView.TabItems.Count; j++)
{
if (_tabView.TabItems[j] is TabViewItem tabItem && tabItem.Tag == doc)
{
existingTab = tabItem;
// Fix order if necessary
if (j != i)
{
_tabView.TabItems.RemoveAt(j);
_tabView.TabItems.Insert(i, existingTab);
}
break;
}
}
if (existingTab == null)
{
existingTab = new TabViewItem
{
Tag = doc
};
existingTab.SetBinding(TabViewItem.HeaderProperty, new Binding
{
Source = doc,
Path = new PropertyPath(nameof(DockDocument.Title)),
Mode = BindingMode.OneWay
});
existingTab.SetBinding(ContentControl.ContentProperty, new Binding
{
Source = doc,
Path = new PropertyPath(nameof(DockDocument.Content)),
Mode = BindingMode.OneWay
});
_tabView.TabItems.Insert(i, existingTab);
}
if (doc == selectedDoc)
{
newSelectedItem = existingTab;
}
}
}
if (newSelectedItem != null)
{
_tabView.SelectedItem = newSelectedItem;
}
else if (_tabView.TabItems.Count > 0)
{
_tabView.SelectedItem = _tabView.TabItems[0];
}
}
}

View File

@@ -1,23 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockGroup">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockGroup">
<Grid>
<TabView
x:Name="PART_TabView"
VerticalAlignment="Stretch"
AllowDrop="True"
CanDragTabs="True"
CanReorderTabs="False"
IsAddTabButtonVisible="False" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -1,42 +0,0 @@
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// Base class for all dockable modules in the docking system.
/// </summary>
public abstract class DockModule : Control
{
/// <summary>
/// Gets the container that owns this module.
/// </summary>
public DockContainer? Owner { get; internal set; }
private DockingLayout? _root;
/// <summary>
/// Gets or sets the root docking layout this module belongs to.
/// </summary>
public virtual DockingLayout? Root
{
get => _root;
internal set
{
if (_root != value)
{
_root = value;
OnRootChanged();
}
}
}
protected virtual void OnRootChanged() { }
/// <summary>
/// Detaches this module from its current owner.
/// </summary>
public void Detach()
{
Owner?.RemoveChild(this);
}
}

View File

@@ -1,120 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using CommunityToolkit.WinUI.Controls;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// A container that can host multiple dock modules with splitters.
/// </summary>
[TemplatePart(Name = PART_GRID, Type = typeof(Grid))]
public class DockPanel : DockContainer
{
private const string PART_GRID = "PART_Grid";
private const double SPLITTER_THICKNESS = 4;
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
nameof(Orientation), typeof(Orientation), typeof(DockPanel), new PropertyMetadata(Orientation.Horizontal, OnOrientationChanged));
/// <summary>
/// Gets or sets the orientation of the panel.
/// </summary>
public Orientation Orientation
{
get => (Orientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
private Grid? _grid;
public DockPanel()
{
DefaultStyleKey = typeof(DockPanel);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_grid = GetTemplateChild(PART_GRID) as Grid;
UpdateLayoutStructure();
}
protected override void OnChildrenUpdated()
{
UpdateLayoutStructure();
}
protected override void CheckCleanup()
{
base.CheckCleanup();
if (Children.Count == 1)
{
var child = Children[0];
var owner = Owner;
if (owner != null)
{
owner.ReplaceChild(this, child);
}
else if (Root != null && Root.RootModule == this)
{
RemoveChildInternal(child, false);
Root.RootModule = child;
}
}
}
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((DockPanel)d).UpdateLayoutStructure();
}
private void UpdateLayoutStructure()
{
if (_grid == null) return;
_grid.Children.Clear();
_grid.RowDefinitions.Clear();
_grid.ColumnDefinitions.Clear();
if (Children.Count == 0) return;
if (Orientation == Orientation.Horizontal)
{
for (int i = 0; i < Children.Count; i++)
{
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var child = Children[i];
Grid.SetColumn(child, i * 2);
_grid.Children.Add(child);
if (i < Children.Count - 1)
{
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Columns, Width = SPLITTER_THICKNESS };
Grid.SetColumn(splitter, i * 2 + 1);
_grid.Children.Add(splitter);
}
}
}
else
{
for (int i = 0; i < Children.Count; i++)
{
_grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
var child = Children[i];
Grid.SetRow(child, i * 2);
_grid.Children.Add(child);
if (i < Children.Count - 1)
{
_grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Rows, Height = SPLITTER_THICKNESS };
Grid.SetRow(splitter, i * 2 + 1);
_grid.Children.Add(splitter);
}
}
}
}
}

View File

@@ -1,15 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockPanel">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockPanel">
<Grid x:Name="PART_Grid" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -1,15 +0,0 @@
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// Represents a visual highlight for a docking region.
/// </summary>
public class DockRegionHighlight : Control
{
public DockRegionHighlight()
{
DefaultStyleKey = typeof(DockRegionHighlight);
IsHitTestVisible = false;
}
}

View File

@@ -1,15 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockRegionHighlight">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockRegionHighlight">
<Border Background="{ThemeResource SystemControlHighlightAccentBrush}" Opacity="0.25" BorderBrush="{ThemeResource SystemControlHighlightAccentBrush}" BorderThickness="2" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -1,309 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// The root control for the docking system layout.
/// </summary>
[TemplatePart(Name = PART_OVERLAY_CANVAS, Type = typeof(Canvas))]
[TemplatePart(Name = PART_HIGHLIGHT, Type = typeof(DockRegionHighlight))]
public class DockingLayout : Control
{
private const string PART_OVERLAY_CANVAS = "PART_OverlayCanvas";
private const string PART_HIGHLIGHT = "PART_Highlight";
/// <summary>
/// Gets or sets the root module of the docking layout.
/// </summary>
public static readonly DependencyProperty RootModuleProperty = DependencyProperty.Register(
nameof(RootModule), typeof(DockModule), typeof(DockingLayout), new PropertyMetadata(null, OnRootModuleChanged));
/// <summary>
/// Gets or sets the root module of the docking layout.
/// </summary>
public DockModule? RootModule
{
get => (DockModule?)GetValue(RootModuleProperty);
set => SetValue(RootModuleProperty, value);
}
/// <summary>
/// Occurs when the layout becomes empty.
/// </summary>
public event EventHandler? LayoutEmpty;
internal void NotifyLayoutEmpty() => LayoutEmpty?.Invoke(this, EventArgs.Empty);
private Canvas? _overlayCanvas;
private DockRegionHighlight? _highlight;
private readonly List<FloatingWindow> _floatingWindows = new();
/// <summary>
/// Initializes a new instance of the <see cref="DockingLayout"/> class.
/// </summary>
public DockingLayout()
{
DefaultStyleKey = typeof(DockingLayout);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_overlayCanvas = GetTemplateChild(PART_OVERLAY_CANVAS) as Canvas;
_highlight = GetTemplateChild(PART_HIGHLIGHT) as DockRegionHighlight;
}
private static void OnRootModuleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockingLayout layout)
{
if (e.OldValue is DockModule oldModule)
{
oldModule.Root = null;
}
if (e.NewValue is DockModule newModule)
{
if (newModule.Root != null && newModule.Root != layout)
{
throw new InvalidOperationException("Module is already owned by another DockingLayout");
}
if (newModule.Owner != null)
{
newModule.Owner.RemoveChild(newModule);
}
newModule.Root = layout;
}
}
}
/// <summary>
/// Adds a document to the docking layout.
/// </summary>
/// <param name="document">The document to add.</param>
/// <param name="target">The docking target position.</param>
/// <param name="targetGroup">The target group to add the document to. If null, a suitable group will be found or created.</param>
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null)
{
ArgumentNullException.ThrowIfNull(document);
if (targetGroup != null && targetGroup.Root != this)
{
throw new ArgumentException("targetGroup does not belong to this DockingLayout", nameof(targetGroup));
}
if (targetGroup == null)
{
if (RootModule != null)
{
targetGroup = FindFirstDockGroup(RootModule as DockContainer);
if (targetGroup == null)
{
// Root is not a container, or contains no groups. Wrap it.
var newGroup = new DockGroup();
newGroup.AddChild(document);
if (RootModule is DockDocument existingDoc)
{
RootModule = null;
newGroup.AddChild(existingDoc);
RootModule = newGroup;
}
else
{
var oldRoot = RootModule;
RootModule = null;
var panel = new DockPanel();
panel.AddChild(oldRoot);
panel.AddChild(newGroup);
RootModule = panel;
}
targetGroup = newGroup;
}
}
else
{
targetGroup = new DockGroup();
RootModule = targetGroup;
}
}
if (target == DockTarget.Center || targetGroup.Children.Count == 0)
{
targetGroup.AddChild(document);
}
else
{
SplitGroup(targetGroup, document, target);
}
}
private void SplitGroup(DockGroup targetGroup, DockDocument doc, DockTarget target)
{
doc.Owner?.RemoveChild(doc);
var parentPanel = targetGroup.Owner as DockPanel;
var newGroup = new DockGroup();
newGroup.AddChild(doc);
var orientation = (target == DockTarget.Left || target == DockTarget.Right) ? Orientation.Horizontal : Orientation.Vertical;
if (parentPanel == null)
{
// targetGroup is the RootModule
var newPanel = new DockPanel { Orientation = orientation };
RootModule = newPanel;
if (target == DockTarget.Left || target == DockTarget.Top)
{
newPanel.AddChild(newGroup);
newPanel.AddChild(targetGroup);
}
else
{
newPanel.AddChild(targetGroup);
newPanel.AddChild(newGroup);
}
return;
}
int index = parentPanel.Children.IndexOf(targetGroup);
if (index < 0)
{
throw new InvalidOperationException("targetGroup not found in parentPanel");
}
if (parentPanel.Orientation == orientation)
{
// Same orientation, just insert
if (target == DockTarget.Left || target == DockTarget.Top)
{
parentPanel.InsertChild(index, newGroup);
}
else
{
parentPanel.InsertChild(index + 1, newGroup);
}
}
else
{
// Different orientation, need a new sub-panel
var newPanel = new DockPanel { Orientation = orientation };
parentPanel.ReplaceChild(targetGroup, newPanel);
if (target == DockTarget.Left || target == DockTarget.Top)
{
newPanel.AddChild(newGroup);
newPanel.AddChild(targetGroup);
}
else
{
newPanel.AddChild(targetGroup);
newPanel.AddChild(newGroup);
}
}
}
private static DockGroup? FindFirstDockGroup(DockContainer? container)
{
if (container == null) return null;
if (container is DockGroup group)
{
return group;
}
foreach (var child in container.Children)
{
if (child is DockContainer childContainer)
{
var result = FindFirstDockGroup(childContainer);
if (result != null)
{
return result;
}
}
}
return null;
}
internal void ShowHighlight(DockGroup targetGroup, global::Windows.Foundation.Point position)
{
if (_highlight == null || _overlayCanvas == null) return;
_highlight.Visibility = Visibility.Visible;
var target = CalculateDockTarget(targetGroup, position);
// Calculate rect based on target
double width = targetGroup.ActualWidth;
double height = targetGroup.ActualHeight;
double x = 0, y = 0;
switch (target)
{
case DockTarget.Left: width /= 2; break;
case DockTarget.Right: width /= 2; x = width; break;
case DockTarget.Top: height /= 2; break;
case DockTarget.Bottom: height /= 2; y = height; break;
case DockTarget.Center: break;
}
var transform = targetGroup.TransformToVisual(_overlayCanvas);
var point = transform.TransformPoint(new global::Windows.Foundation.Point(x, y));
Microsoft.UI.Xaml.Controls.Canvas.SetLeft(_highlight, point.X);
Microsoft.UI.Xaml.Controls.Canvas.SetTop(_highlight, point.Y);
_highlight.Width = width;
_highlight.Height = height;
}
internal void HideHighlight()
{
if (_highlight != null) _highlight.Visibility = Visibility.Collapsed;
}
internal void HandleDrop(DockDocument doc, DockGroup targetGroup, global::Windows.Foundation.Point position)
{
HideHighlight();
var target = CalculateDockTarget(targetGroup, position);
if (target == DockTarget.Center)
{
if (doc.Owner == targetGroup) return;
targetGroup.AddChild(doc);
}
else
{
if (doc.Owner == targetGroup && targetGroup.Children.Count == 1) return;
SplitGroup(targetGroup, doc, target);
}
}
private DockTarget CalculateDockTarget(DockGroup group, global::Windows.Foundation.Point position)
{
double w = group.ActualWidth;
double h = group.ActualHeight;
double x = position.X;
double y = position.Y;
if (x < w * 0.25) return DockTarget.Left;
if (x > w * 0.75) return DockTarget.Right;
if (y < h * 0.25) return DockTarget.Top;
if (y > h * 0.75) return DockTarget.Bottom;
return DockTarget.Center;
}
internal void CreateFloatingWindow(DockDocument doc)
{
ArgumentNullException.ThrowIfNull(doc);
var window = new FloatingWindow(doc);
_floatingWindows.Add(window);
window.Closed += (s, e) => _floatingWindows.Remove(window);
window.Activate();
}
}

View File

@@ -1,20 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
<Style TargetType="local:DockingLayout">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockingLayout">
<Grid>
<ContentPresenter x:Name="PART_Content" Content="{TemplateBinding RootModule}" />
<Canvas x:Name="PART_OverlayCanvas" IsHitTestVisible="False">
<local:DockRegionHighlight x:Name="PART_Highlight" Visibility="Collapsed" />
</Canvas>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -1,10 +0,0 @@
namespace Ghost.Editor.View.Controls.Docking;
public enum DockTarget
{
Center,
Left,
Right,
Top,
Bottom
}

View File

@@ -1,30 +0,0 @@
using Microsoft.UI.Xaml;
namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// A floating window that contains a docking layout.
/// </summary>
public class FloatingWindow : Window
{
/// <summary>
/// Initializes a new instance of the <see cref="FloatingWindow"/> class with the specified document.
/// </summary>
/// <param name="document">The document to display in the floating window.</param>
public FloatingWindow(DockDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var layout = new DockingLayout();
var group = new DockGroup();
group.AddChild(document);
layout.RootModule = group;
layout.LayoutEmpty += (s, e) => Close();
Content = layout;
// Basic window setup
AppWindow.Resize(new global::Windows.Graphics.SizeInt32(800, 600));
}
}

View File

@@ -1,81 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Ghost.Editor.View.Pages.EngineEditor.ConsolePage"
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:local="using:Ghost.Editor.View.Pages.EngineEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Background="{ThemeResource LayerFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid
Grid.Row="0"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultSolid}"
BorderThickness="0,0,0,1">
<CommandBar DefaultLabelPosition="Collapsed">
<CommandBar.PrimaryCommands>
<AppBarButton Command="{x:Bind ViewModel.ClearLogsCommand}" Content="Clear" />
<AppBarSeparator />
<AppBarToggleButton Width="45" IsChecked="{x:Bind ViewModel.ShowInfo, Mode=TwoWay}">
<AppBarToggleButton.Icon>
<FontIcon Glyph="&#xF167;" />
</AppBarToggleButton.Icon>
</AppBarToggleButton>
<AppBarToggleButton Width="45" IsChecked="{x:Bind ViewModel.ShowWarning, Mode=TwoWay}">
<AppBarToggleButton.Icon>
<FontIcon Glyph="&#xE814;" />
</AppBarToggleButton.Icon>
</AppBarToggleButton>
<AppBarToggleButton Width="45" IsChecked="{x:Bind ViewModel.ShowError, Mode=TwoWay}">
<AppBarToggleButton.Icon>
<FontIcon Glyph="&#xEB90;" />
</AppBarToggleButton.Icon>
</AppBarToggleButton>
</CommandBar.PrimaryCommands>
<CommandBar.SecondaryCommands>
<AppBarToggleButton BorderThickness="0" Label="Clear On Play" />
<AppBarToggleButton
BorderThickness="0"
IsChecked="{x:Bind ViewModel.ShowStackTrace, Mode=TwoWay}"
Label="Show Stack Trace" />
</CommandBar.SecondaryCommands>
</CommandBar>
</Grid>
<!-- Log Content -->
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="100" />
</Grid.RowDefinitions>
<ListView
x:Name="LogListView"
Grid.Row="0"
ItemsSource="{x:Bind ViewModel.Logs, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedLog, Mode=TwoWay}" />
<Grid
Grid.Row="1"
Padding="4"
BorderBrush="{ThemeResource CardStrokeColorDefaultSolid}"
BorderThickness="0,1,0,0">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<TextBlock
IsTextSelectionEnabled="True"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.SelectedLog.ToString(), Mode=OneWay}"
TextWrapping="Wrap" />
</ScrollViewer>
</Grid>
</Grid>
</Grid>
</Page>

View File

@@ -1,19 +0,0 @@
using Ghost.Editor.ViewModels.Pages.EngineEditor;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Pages.EngineEditor;
internal sealed partial class ConsolePage : Page
{
public ConsoleViewModel ViewModel
{
get;
}
public ConsolePage()
{
ViewModel = App.GetService<ConsoleViewModel>();
InitializeComponent();
}
}

View File

@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<internal:NavigationTabPage
x:Class="Ghost.Editor.View.Pages.EngineEditor.HierarchyPage"
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:internal="using:Ghost.Editor.Controls"
xmlns:local="using:Ghost.Editor.View.Pages.EngineEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:sg="using:Ghost.Editor.Core.SceneGraph"
mc:Ignorable="d">
<internal:NavigationTabPage.Resources>
<DataTemplate x:Key="SceneTemplate" 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="&#xF159;" />
<TextBlock Margin="10,0" Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</TreeViewItem>
</DataTemplate>
<DataTemplate 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>
</internal:NavigationTabPage.Resources>
<Grid Padding="4,6" Background="{ThemeResource LayerFillColorDefaultBrush}">
<!--<TreeView ItemsSource="{x:Bind ViewModel.SceneList}" SelectionChanged="TreeView_SelectionChanged">
<TreeView.ItemTemplateSelector>
<local:HierarchyTemplateSector />
</TreeView.ItemTemplateSelector>
</TreeView>-->
</Grid>
</internal:NavigationTabPage>

View File

@@ -1,61 +0,0 @@
using Ghost.Editor.Controls;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.SceneGraph;
using Ghost.Editor.ViewModels.Pages.EngineEditor;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Pages.EngineEditor;
internal sealed partial class HierarchyPage : NavigationTabPage
{
private readonly IInspectorService _inspectorService;
public HierarchyViewModel ViewModel
{
get;
}
public HierarchyPage()
{
_inspectorService = App.GetService<IInspectorService>();
ViewModel = App.GetService<HierarchyViewModel>();
InitializeComponent();
}
public override void OnNavigatedTo(object? parameter)
{
ViewModel.OnNavigatedTo(parameter);
}
public override void OnNavigatedFrom()
{
ViewModel.OnNavigatedFrom();
}
private void TreeView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
{
if (args.AddedItems.Count > 0 && args.AddedItems[0] is IInspectable inspectable)
{
_inspectorService.SetSelected(inspectable, ViewModel);
}
else
{
_inspectorService.SetSelected(null, ViewModel);
}
}
}
internal partial class HierarchyTemplateSector : DataTemplateSelector
{
protected override DataTemplate SelectTemplateCore(object item)
{
if (item is not SceneGraphNode node)
{
return base.SelectTemplateCore(item);
}
return node.GetSceneHierarchyTemplate();
}
}

View File

@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<internal:NavigationTabPage
x:Class="Ghost.Editor.View.Pages.EngineEditor.InspectorPage"
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:internal="using:Ghost.Editor.Controls"
xmlns:local="using:Ghost.Editor.View.Pages.EngineEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Background="{ThemeResource LayerFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="75" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header -->
<Grid
Grid.Row="0"
Padding="15,0,10,0"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultSolid}"
BorderThickness="0,0,0,1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!--<IconSourceElement
Grid.Column="0"
Margin="0,0,15,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
IconSource="{x:Bind ViewModel.Inspectable.Icon, Mode=OneWay}" />-->
<!--<ContentPresenter Grid.Column="1" Content="{x:Bind ViewModel.Inspectable.HeaderContent, Mode=OneWay}" />-->
</Grid>
<!-- Content -->
<Grid Grid.Row="1" Padding="0,0,0,0">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<!--<ContentPresenter Content="{x:Bind ViewModel.Inspectable.InspectorContent, Mode=OneWay}" />-->
</ScrollViewer>
</Grid>
</Grid>
</internal:NavigationTabPage>

View File

@@ -1,29 +0,0 @@
using Ghost.Editor.Controls;
using Ghost.Editor.ViewModels.Pages.EngineEditor;
namespace Ghost.Editor.View.Pages.EngineEditor;
internal sealed partial class InspectorPage : NavigationTabPage
{
public InspectorViewModel ViewModel
{
get;
}
public InspectorPage()
{
ViewModel = App.GetService<InspectorViewModel>();
InitializeComponent();
}
public override void OnNavigatedTo(object? parameter)
{
ViewModel.OnNavigatedTo(parameter);
}
public override void OnNavigatedFrom()
{
ViewModel.OnNavigatedFrom();
}
}

View File

@@ -1,140 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Ghost.Editor.View.Pages.EngineEditor.ProjectPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converter="using:Ghost.Editor.Utilities.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Ghost.Editor.View.Pages.EngineEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:model="using:Ghost.Editor.Models"
mc:Ignorable="d">
<Page.Resources>
<converter:AssetPathToGlyphConverter x:Key="AssetPathToGlyphConverter" />
</Page.Resources>
<Grid Background="{ThemeResource LayerFillColorDefaultBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Folder Tree View -->
<Grid
Grid.Column="0"
Padding="4"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultSolid}"
BorderThickness="0,0,1,0">
<TreeView
x:Name="DirectoryTreeView"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ItemsSource="{x:Bind ViewModel.SubDirectories}"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.VerticalScrollBarVisibility="Auto"
SelectedItem="{x:Bind ViewModel.SelectedDirectory, Mode=TwoWay}">
<TreeView.ItemTemplate>
<DataTemplate x:DataType="model:ExplorerItem">
<TreeViewItem ItemsSource="{x:Bind Children}">
<StackPanel Orientation="Horizontal">
<FontIcon
VerticalAlignment="Center"
FontSize="14"
Glyph="&#xE8B7;" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</TreeViewItem>
</DataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</Grid>
<!-- Files -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid
Grid.Row="0"
Padding="4"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultSolid}"
BorderThickness="0,0,0,1">
<BreadcrumbBar Height="15" />
</Grid>
<ScrollViewer
Grid.Row="1"
Padding="8"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<GridView
x:Name="AssetsGridView"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ItemsSource="{x:Bind ViewModel.DirectoryAssets, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedAsset, Mode=TwoWay}">
<GridView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultGridViewItemStyle}" TargetType="GridViewItem">
<Setter Property="Margin" Value="2" />
</Style>
</GridView.ItemContainerStyle>
<GridView.ItemTemplate>
<DataTemplate x:DataType="model:ExplorerItem">
<Grid
Width="100"
Height="100"
Padding="8"
DoubleTapped="GridViewItem_DoubleTapped"
IsDoubleTapEnabled="True">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="0.25*" />
</Grid.RowDefinitions>
<FontIcon FontSize="42" Glyph="{x:Bind FullName, Converter={StaticResource AssetPathToGlyphConverter}}" />
<TextBlock
Grid.Row="1"
Margin="8,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</GridView.ItemTemplate>
</GridView>
</ScrollViewer>
<Grid
Grid.Row="2"
Padding="4"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultSolid}"
BorderThickness="0,1,0,0">
<TextBlock
VerticalAlignment="Center"
HorizontalTextAlignment="Left"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.SelectedAsset.FullName, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
</Grid>
</Grid>
</Page>

View File

@@ -1,25 +0,0 @@
using Ghost.Editor.ViewModels.Pages.EngineEditor;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
namespace Ghost.Editor.View.Pages.EngineEditor;
internal sealed partial class ProjectPage : Page
{
public ProjectViewModel ViewModel
{
get;
}
public ProjectPage()
{
ViewModel = App.GetService<ProjectViewModel>();
InitializeComponent();
}
private void GridViewItem_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
ViewModel.OpenSelected();
}
}

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<internal:NavigationTabPage
x:Class="Ghost.Editor.View.Pages.EngineEditor.ScenePage"
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:internal="using:Ghost.Editor.Controls"
xmlns:local="using:Ghost.Editor.View.Pages.EngineEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<SwapChainPanel
x:Name="SwapChainPanel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
</Grid>
</internal:NavigationTabPage>

View File

@@ -1,45 +0,0 @@
using Ghost.Editor.Controls;
//using Ghost.Graphics.Contracts;
//using Microsoft.UI.Xaml;
//using Microsoft.UI.Xaml.Controls;
//using WinRT;
namespace Ghost.Editor.View.Pages.EngineEditor;
internal sealed partial class ScenePage : NavigationTabPage
{
//private Renderer? _renderView;
//private ISwapChainPanelNative _swapChainPanelNative;
public ScenePage()
{
InitializeComponent();
//SwapChainPanel.Loaded += SwapChainPanel_Loaded;
//SwapChainPanel.Unloaded += SwapChainPanel_Unloaded;
//SwapChainPanel.SizeChanged += SwapChainPanel_SizeChanged;
}
//private void SwapChainPanel_Loaded(object sender, RoutedEventArgs e)
//{
// var guid = typeof(ISwapChainPanelNative.Interface).GUID;
// ((IWinRTObject)SwapChainPanel).NativeObject.TryAs(guid, out var swapChainPanelNativeHandle);
// _swapChainPanelNative = new ISwapChainPanelNative(swapChainPanelNativeHandle);
// _renderView = GraphicsPipeline.GraphicsDevice.CreateRenderer(new(_swapChainPanelNative, (uint)SwapChainPanel.ActualWidth, (uint)SwapChainPanel.ActualHeight));
//}
//private void SwapChainPanel_Unloaded(object sender, RoutedEventArgs e)
//{
// _swapChainPanelNative.Dispose();
// _renderView?.Dispose();
//}
//private void SwapChainPanel_SizeChanged(object sender, SizeChangedEventArgs e)
//{
// if (e.NewSize.ActualWidth > 8.0 && e.NewSize.ActualHeight > 8.0)
// {
// _renderView?.RequestResize((uint)e.NewSize.ActualWidth, (uint)e.NewSize.ActualHeight);
// }
//}
}

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="Ghost.Editor.View.Windows.BlankWindow1"
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:local="using:Ghost.Editor.View.Windows"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="BlankWindow1"
mc:Ignorable="d">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid />
</Window>

View File

@@ -1,29 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Ghost.Editor.View.Windows;
/// <summary>
/// An empty window that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class BlankWindow1 : Window
{
public BlankWindow1()
{
InitializeComponent();
}
}

View File

@@ -1,246 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<winex:WindowEx
x:Class="Ghost.Editor.View.Windows.EngineEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="using:CommunityToolkit.WinUI.Behaviors"
xmlns:controls="using:Ghost.Editor.View.Controls"
xmlns:ctc="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ee="using:Ghost.Editor.View.Pages.EngineEditor"
xmlns:ghost="using:Ghost.Editor.Controls"
xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:local="using:Ghost.Editor.View.Windows"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:winex="using:WinUIEx"
mc:Ignorable="d">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid Loaded="MainGrid_Loaded" Unloaded="MainGrid_Unloaded">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Titlebar -->
<TitleBar
x:Name="PART_TitleBar"
Grid.Row="0"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
Subtitle="Ghost Engine">
<TitleBar.IconSource>
<ImageIconSource ImageSource="ms-appx:///Assets/icon.targetsize-48.png" />
</TitleBar.IconSource>
</TitleBar>
<!-- Toolbar -->
<Grid
Grid.Row="1"
Padding="4,0,4,4"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}">
<ctc:TabbedCommandBar>
<ctc:TabbedCommandBar.MenuItems>
<ctc:TabbedCommandBarItem Header="Home">
<AppBarButton Label="Undo" />
<AppBarButton Label="Redo" />
<AppBarButton Label="Paste" />
</ctc:TabbedCommandBarItem>
<ctc:TabbedCommandBarItem Header="Home">
<AppBarButton Label="Undo" />
<AppBarButton Label="Redo" />
<AppBarButton Label="Paste" />
</ctc:TabbedCommandBarItem>
<ctc:TabbedCommandBarItem Header="Home">
<AppBarButton Label="Undo" />
<AppBarButton Label="Redo" />
<AppBarButton Label="Paste" />
</ctc:TabbedCommandBarItem>
</ctc:TabbedCommandBar.MenuItems>
</ctc:TabbedCommandBar>
</Grid>
<Grid xmlns:dock="using:Ghost.Editor.View.Controls.Docking" Grid.Row="2">
<dock:DockingLayout x:Name="MainDockingLayout" />
</Grid>
<!-- Editor -->
<!--<Grid Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ghost:NavigationTabView
Grid.Column="0"
Width="350"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<ghost:NavigationTabView.TabItems>
<TabViewItem Header="Hierarchy">
<TabViewItem.IconSource>
<FontIconSource Glyph="&#xE8A4;" />
</TabViewItem.IconSource>
<controls:Hierarchy />
</TabViewItem>
</ghost:NavigationTabView.TabItems>
</ghost:NavigationTabView>
<ghost:NavigationTabView Grid.Column="1">
<ghost:NavigationTabView.TabItems>
<ee:ScenePage Header="Scene">
<ee:ScenePage.IconSource>
<FontIconSource Glyph="&#xF159;" />
</ee:ScenePage.IconSource>
</ee:ScenePage>
</ghost:NavigationTabView.TabItems>
</ghost:NavigationTabView>
<ghost:NavigationTabView
Grid.Column="2"
Width="350"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<ghost:NavigationTabView.TabItems>
<ee:InspectorPage Header="Inspector">
<ee:InspectorPage.IconSource>
<FontIconSource Glyph="&#xEC7A;" />
</ee:InspectorPage.IconSource>
</ee:InspectorPage>
</ghost:NavigationTabView.TabItems>
</ghost:NavigationTabView>
</Grid>
<ghost:NavigationTabView Grid.Row="1" Height="350">
<ghost:NavigationTabView.TabItems>
<TabViewItem Header="Project">
<TabViewItem.IconSource>
<FontIconSource Glyph="&#xEC50;" />
</TabViewItem.IconSource>
<controls:ProjectBrowser />
</TabViewItem>
<TabViewItem Header="Console">
<TabViewItem.IconSource>
<FontIconSource Glyph="&#xE756;" />
</TabViewItem.IconSource>
<ee:ConsolePage />
</TabViewItem>
</ghost:NavigationTabView.TabItems>
</ghost:NavigationTabView>
</Grid>-->
<!-- Status Bar -->
<Grid
Grid.Row="3"
Height="25"
Background="{ThemeResource SmokeFillColorDefaultBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<FontIcon
Margin="8,0,0,0"
FontSize="16"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Glyph="&#xE930;"
Visibility="Visible" />
<StackPanel Orientation="Horizontal" Visibility="Collapsed">
<FontIcon
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{ThemeResource SystemFillColorAttentionBrush}"
Glyph="&#xE946;" />
<TextBlock
Margin="4,0,0,0"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="0" />
<FontIcon
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Glyph="&#xE7BA;" />
<TextBlock
Margin="4,0,0,0"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="0" />
<FontIcon
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Glyph="&#xE783;" />
<TextBlock
Margin="4,0,0,0"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="0" />
</StackPanel>
</Grid>
</Grid>
<!-- Info and Progress -->
<Grid Grid.Row="0" Grid.RowSpan="4">
<InfoBar
x:Name="InfoBar"
Margin="16"
HorizontalAlignment="Right"
VerticalAlignment="Bottom">
<interactivity:Interaction.Behaviors>
<behaviors:StackedNotificationsBehavior x:Name="NotificationQueue" />
</interactivity:Interaction.Behaviors>
</InfoBar>
<Grid
x:Name="ProgressBarContainer"
Background="{ThemeResource SmokeFillColorDefaultBrush}"
Visibility="Collapsed">
<Grid
Height="100"
Padding="36,24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{ThemeResource SolidBackgroundFillColorBaseBrush}"
CornerRadius="{StaticResource OverlayCornerRadius}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
x:Name="ProgressMessage"
Grid.Row="0"
Margin="0,0,0,12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Style="{StaticResource TitleTextBlockStyle}"
Text="Loading..." />
<ProgressBar
x:Name="ProgressBar"
Grid.Row="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
IsIndeterminate="True" />
</Grid>
</Grid>
</Grid>
</Grid>
</winex:WindowEx>

View File

@@ -1,88 +0,0 @@
using Ghost.Editor.Core;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Services;
using Ghost.Editor.ViewModels.Windows;
using Windows.ApplicationModel;
using WinUIEx;
namespace Ghost.Editor.View.Windows;
/// <summary>
/// An empty window that can be used on its own or navigated to within a Frame.
/// </summary>
internal sealed partial class EngineEditorWindow : WindowEx
{
private readonly NotificationService _notificationService;
private readonly ProgressService _progressService;
public EngineEditorViewModel ViewModel
{
get;
}
public EngineEditorWindow()
{
ViewModel = App.GetService<EngineEditorViewModel>();
_notificationService = (NotificationService)App.GetService<INotificationService>();
_progressService = (ProgressService)App.GetService<IProgressService>();
InitializeComponent();
AppWindow.SetIcon(Path.Combine(AppContext.BaseDirectory, "Assets/icon.ico"));
Title = "Ghost Engine";
ExtendsContentIntoTitleBar = true;
SetTitleBar(PART_TitleBar);
this.CenterOnScreen();
}
private void MainGrid_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
PART_TitleBar.Title = EditorApplication.ProjectName;
PART_TitleBar.Subtitle = $"Ghost Engine {Package.Current.Id.Version.Major}.{Package.Current.Id.Version.Minor}.{Package.Current.Id.Version.Build}";
_notificationService.SetReference(InfoBar, NotificationQueue);
_progressService.SetReference(ProgressBarContainer);
InitializeDockingLayout();
}
private void InitializeDockingLayout()
{
var sceneDoc = new Ghost.Editor.View.Controls.Docking.DockDocument { Title = "Scene", Content = new Ghost.Editor.View.Pages.EngineEditor.ScenePage() };
var hierarchyDoc = new Ghost.Editor.View.Controls.Docking.DockDocument { Title = "Hierarchy", Content = new Ghost.Editor.View.Controls.Hierarchy() };
var inspectorDoc = new Ghost.Editor.View.Controls.Docking.DockDocument { Title = "Inspector", Content = new Ghost.Editor.View.Pages.EngineEditor.InspectorPage() };
var projectDoc = new Ghost.Editor.View.Controls.Docking.DockDocument { Title = "Project", Content = new Ghost.Editor.View.Controls.ProjectBrowser() };
var consoleDoc = new Ghost.Editor.View.Controls.Docking.DockDocument { Title = "Console", Content = new Ghost.Editor.View.Pages.EngineEditor.ConsolePage() };
var leftGroup = new Ghost.Editor.View.Controls.Docking.DockGroup();
leftGroup.AddChild(hierarchyDoc);
var centerGroup = new Ghost.Editor.View.Controls.Docking.DockGroup();
centerGroup.AddChild(sceneDoc);
var rightGroup = new Ghost.Editor.View.Controls.Docking.DockGroup();
rightGroup.AddChild(inspectorDoc);
var bottomGroup = new Ghost.Editor.View.Controls.Docking.DockGroup();
bottomGroup.AddChild(projectDoc);
bottomGroup.AddChild(consoleDoc);
var topPanel = new Ghost.Editor.View.Controls.Docking.DockPanel { Orientation = Microsoft.UI.Xaml.Controls.Orientation.Horizontal };
topPanel.AddChild(leftGroup);
topPanel.AddChild(centerGroup);
topPanel.AddChild(rightGroup);
var rootPanel = new Ghost.Editor.View.Controls.Docking.DockPanel { Orientation = Microsoft.UI.Xaml.Controls.Orientation.Vertical };
rootPanel.AddChild(topPanel);
rootPanel.AddChild(bottomGroup);
MainDockingLayout.RootModule = rootPanel;
}
private void MainGrid_Unloaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
_notificationService.ClearReference();
_progressService.ClearReference();
}
}

View File

@@ -0,0 +1,179 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Core.Utilities;
using Ghost.Editor.Core;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Utilities;
using Ghost.Editor.Models;
using Ghost.Engine;
using Microsoft.UI.Dispatching;
using System.Collections.ObjectModel;
namespace Ghost.Editor.ViewModels.Controls;
internal partial class ContentBrowserViewModel : ObservableObject
{
private readonly IInspectorService _inspectorService;
private readonly IAssetRegistry _assetRegistry;
private readonly DispatcherQueue _dispatcherQueue;
private readonly Dictionary<string, ExplorerItem> _pathToDirectoryItemMap = new();
private ExplorerItem? _selectedItem;
public ObservableCollection<ExplorerItem> Directories
{
get;
} = new();
public ObservableCollection<ExplorerItem> Files
{
get;
} = new();
public ExplorerItem? SelectedItem
{
get => _selectedItem;
set
{
// TODO: Resolve inspector by reading metadata from selected asset
_selectedItem = value;
}
}
public string CurrentDirectoryPath
{
get;
set => field = PathUtility.Normalize(value);
} = string.Empty;
public ContentBrowserViewModel(IInspectorService inspectorService, IAssetRegistry assetRegistry)
{
_inspectorService = inspectorService;
_assetRegistry = assetRegistry;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
var assetsRootItem = new ExplorerItem(EditorApplication.ASSETS_FOLDER_NAME, EditorApplication.AssetsFolderPath, true);
LoadSubFolderRecursive(assetsRootItem);
Directories.Add(assetsRootItem);
_assetRegistry.OnAssetChanged += OnAssetChanged;
}
private void OnAssetChanged(object? sender, AssetChangedEventArgs e)
{
if (e.AssetPath.EndsWith(FileExtensions.META_FILE_EXTENSION))
{
return;
}
var fullPath = PathUtility.Normalize(e.AssetPath);
var dirPath = Path.GetDirectoryName(fullPath);
if (string.Equals(dirPath, CurrentDirectoryPath, StringComparison.OrdinalIgnoreCase))
{
_dispatcherQueue.TryEnqueue(() =>
{
if (e.ChangeType == AssetChangeType.Created || e.ChangeType == AssetChangeType.Renamed)
{
if (e.ChangeType == AssetChangeType.Renamed && e.OldAssetPath != null)
{
var oldFullPath = PathUtility.Normalize(e.OldAssetPath);
var oldItem = Files.FirstOrDefault(f => string.Equals(f.Path, oldFullPath, StringComparison.OrdinalIgnoreCase));
if (oldItem != null) Files.Remove(oldItem);
}
if (!Files.Any(f => string.Equals(f.Path, fullPath, StringComparison.OrdinalIgnoreCase)))
{
var isDir = Directory.Exists(fullPath);
var assetType = AssetType.Unknown;
if (!isDir)
{
var ext = Path.GetExtension(fullPath);
if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var info))
{
assetType = info.RuntimeAssetType;
}
}
Files.Add(new ExplorerItem(Path.GetFileName(fullPath), fullPath, isDir, assetType));
}
}
else if (e.ChangeType == AssetChangeType.Deleted)
{
var item = Files.FirstOrDefault(f => string.Equals(f.Path, fullPath, StringComparison.OrdinalIgnoreCase));
if (item != null)
{
Files.Remove(item);
}
}
});
}
}
private void LoadSubFolderRecursive(ExplorerItem parentItem)
{
foreach (var directory in Directory.EnumerateDirectories(parentItem.Path))
{
var item = new ExplorerItem(Path.GetFileName(directory), directory, true);
LoadSubFolderRecursive(item);
_pathToDirectoryItemMap[directory] = item;
parentItem.Children ??= new();
parentItem.Children.Add(item);
}
}
internal void NavigateToDirectory(string? path)
{
Files.Clear();
if (!Directory.Exists(path))
{
return;
}
foreach (var directory in Directory.EnumerateDirectories(path))
{
var directoryItem = new ExplorerItem(Path.GetFileName(directory), directory, true);
Files.Add(directoryItem);
}
foreach (var file in Directory.EnumerateFiles(path))
{
if (file.EndsWith(FileExtensions.META_FILE_EXTENSION))
{
continue;
}
var ext = Path.GetExtension(file);
var assetType = AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo) ? handlerInfo.RuntimeAssetType : AssetType.Unknown;
var fileItem = new ExplorerItem(Path.GetFileName(file), file, false, assetType);
Files.Add(fileItem);
}
CurrentDirectoryPath = Path.GetFullPath(path);
}
internal (ExplorerItem?, int) OpenSelected()
{
if (SelectedItem == null)
{
return (null, 0);
}
if (SelectedItem.IsDirectory)
{
NavigateToDirectory(SelectedItem.Path);
SelectedItem = _pathToDirectoryItemMap[SelectedItem.Path];
return (SelectedItem, 0);
}
else
{
// _assetRegistry.OpenAsset(SelectedItem.FullName);
return (null, 1);
}
}
}

View File

@@ -1,116 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Editor.Core;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Utilities;
using Ghost.Editor.Models;
using System.Collections.ObjectModel;
namespace Ghost.Editor.ViewModels.Controls;
internal partial class ProjectBrowserViewModel : ObservableObject
{
private readonly IInspectorService _inspectorService;
// private readonly IAssetService _assetService;
private readonly Dictionary<string, ExplorerItem> _pathToDirectoryItemMap = new();
private ExplorerItem? _selectedItem;
public ObservableCollection<ExplorerItem> Directories
{
get;
} = new();
public ObservableCollection<ExplorerItem> Files
{
get;
} = new();
public ExplorerItem? SelectedItem
{
get => _selectedItem;
set
{
// TODO: Resolve inspector by reading metadata from selected asset
_selectedItem = value;
}
}
public string CurrentDirectoryPath
{
get; set;
} = string.Empty;
public ProjectBrowserViewModel(IInspectorService inspectorService) // , IAssetService assetService)
{
_inspectorService = inspectorService;
// _assetService = assetService;
var assetsRootItem = new ExplorerItem(EditorApplication.ASSETS_FOLDER_NAME, Path.Combine(EditorApplication.ProjectPath, EditorApplication.ASSETS_FOLDER_NAME), true);
LoadSubFolderRecursive(assetsRootItem);
Directories.Add(assetsRootItem);
}
private void LoadSubFolderRecursive(ExplorerItem parentItem)
{
foreach (var directory in Directory.EnumerateDirectories(parentItem.FullName))
{
var item = new ExplorerItem(Path.GetFileName(directory), directory, true);
LoadSubFolderRecursive(item);
_pathToDirectoryItemMap[directory] = item;
parentItem.Children ??= new();
parentItem.Children.Add(item);
}
}
internal void NavigateToDirectory(string? path)
{
Files.Clear();
if (!Directory.Exists(path))
{
return;
}
foreach (var directory in Directory.EnumerateDirectories(path))
{
var directoryItem = new ExplorerItem(Path.GetFileName(directory), directory, true);
Files.Add(directoryItem);
}
foreach (var file in Directory.EnumerateFiles(path))
{
if (Path.GetExtension(file) == FileExtensions.META_FILE_EXTENSION)
{
continue;
}
var fileItem = new ExplorerItem(Path.GetFileName(file), file, false);
Files.Add(fileItem);
}
CurrentDirectoryPath = path;
}
internal (ExplorerItem?, int) OpenSelected()
{
if (SelectedItem == null)
{
return (null, 0);
}
if (SelectedItem.IsDirectory)
{
NavigateToDirectory(SelectedItem.FullName);
SelectedItem = _pathToDirectoryItemMap[SelectedItem.FullName];
return (SelectedItem, 0);
}
else
{
// _assetService.OpenAsset(SelectedItem.FullName);
return (null, 1);
}
}
}

View File

@@ -1,53 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Ghost.Core;
using System.Collections.ObjectModel;
namespace Ghost.Editor.ViewModels.Pages.EngineEditor;
internal partial class ConsoleViewModel : ObservableObject
{
public ReadOnlyObservableCollection<LogMessage> Logs => Logger.Logs;
[ObservableProperty]
public partial bool ShowInfo
{
get; set;
} = true;
[ObservableProperty]
public partial bool ShowWarning
{
get; set;
} = true;
[ObservableProperty]
public partial bool ShowError
{
get; set;
} = true;
[ObservableProperty]
public partial bool ShowStackTrace
{
get; set;
} = false;
[ObservableProperty]
public partial LogMessage? SelectedLog
{
get; set;
}
partial void OnShowStackTraceChanged(bool value)
{
//Logger.HasStackTrace = value;
//Logger.LogInfo($"Stack trace visibility set to {value}.");
}
[RelayCommand]
private void ClearLogs()
{
//Logger.Clear();
}
}

View File

@@ -1,36 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.ViewModels.Pages.EngineEditor;
internal partial class HierarchyViewModel : ObservableObject, INavigationAware
{
//[ObservableProperty]
//public partial ObservableCollection<SceneNode> SceneList
//{
// get;
// private set;
//} = new(EditorSceneManager.LoadedWorlds);
//private void OnWorldLoaded(SceneNode node)
//{
// SceneList.Add(node);
//}
//private void OnWorldUnloaded(SceneNode node)
//{
// SceneList.Remove(node);
//}
public void OnNavigatedTo(object? parameter)
{
//EditorSceneManager.OnWorldLoaded += OnWorldLoaded;
//EditorSceneManager.OnWorldUnloaded += OnWorldUnloaded;
}
public void OnNavigatedFrom()
{
//EditorSceneManager.OnWorldLoaded -= OnWorldLoaded;
//EditorSceneManager.OnWorldUnloaded -= OnWorldUnloaded;
}
}

View File

@@ -1,31 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.ViewModels.Pages.EngineEditor;
internal partial class InspectorViewModel(IInspectorService inspectorService) : ObservableObject, INavigationAware
{
[ObservableProperty]
public partial IInspectable? Inspectable
{
get;
set;
}
public void OnNavigatedTo(object? parameter)
{
inspectorService.OnSelectionChanged += OnSelectionChanged;
Inspectable = inspectorService.Selected;
}
public void OnNavigatedFrom()
{
inspectorService.OnSelectionChanged -= OnSelectionChanged;
Inspectable = null;
}
private void OnSelectionChanged(object? sender, EventArgs e)
{
Inspectable = inspectorService.Selected;
}
}

View File

@@ -1,143 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Editor.Models;
using System.Collections.ObjectModel;
namespace Ghost.Editor.ViewModels.Pages.EngineEditor;
internal partial class ProjectViewModel : ObservableObject
{
// private readonly IAssetService _assetService;
public ObservableCollection<ExplorerItem> SubDirectories
{
get;
} = new();
[ObservableProperty]
public partial ObservableCollection<ExplorerItem> DirectoryAssets
{
get;
set;
} = new();
[ObservableProperty]
public partial ExplorerItem? SelectedDirectory
{
get;
set;
}
[ObservableProperty]
public partial ExplorerItem? SelectedAsset
{
get;
set;
}
// public ProjectViewModel(IAssetService assetService)
// {
// _assetService = assetService;
// var assetsRootItem = new ExplorerItem("Assets", Path.Combine(EditorApplication.ProjectPath, EditorApplication.ASSETS_FOLDER_NAME), true);
// LoadSubFolderRecursive(ref assetsRootItem);
// SubDirectories.Add(assetsRootItem);
// }
private static void LoadSubFolderRecursive(ref ExplorerItem parentItem)
{
foreach (var directory in Directory.EnumerateDirectories(parentItem.FullName))
{
var item = new ExplorerItem(Path.GetFileName(directory), directory, true);
LoadSubFolderRecursive(ref item);
parentItem.Children ??= new();
parentItem.Children.Add(item);
}
}
public static Task<ExplorerItem?> FindNodeIterative(ExplorerItem root, Func<ExplorerItem, bool> predicate)
{
var stack = new Stack<ExplorerItem>();
stack.Push(root);
return Task.Run(() =>
{
while (stack.Count > 0)
{
var node = stack.Pop();
if (predicate(node))
{
return node;
}
if (node.Children == null || node.Children.Count == 0)
{
continue;
}
for (var i = node.Children.Count - 1; i >= 0; i--)
{
stack.Push(node.Children[i]);
}
}
return null;
});
}
private void NavigateToDirectory(string? path)
{
App.Window?.DispatcherQueue.TryEnqueue(async () =>
{
DirectoryAssets.Clear();
if (!Directory.Exists(path))
{
return;
}
foreach (var directory in Directory.EnumerateDirectories(path))
{
var directoryItem = new ExplorerItem(Path.GetFileName(directory), directory, true);
DirectoryAssets.Add(directoryItem);
}
foreach (var file in Directory.EnumerateFiles(path))
{
var fileItem = new ExplorerItem(Path.GetFileName(file), file, false);
DirectoryAssets.Add(fileItem);
}
SelectedDirectory = await FindNodeIterative(SubDirectories[0], x => x.FullName == path);
});
}
public void OpenSelected()
{
if (SelectedAsset == null)
{
return;
}
if (SelectedAsset.IsDirectory)
{
NavigateToDirectory(SelectedAsset.FullName);
}
else
{
// _assetService.OpenAsset(SelectedAsset.FullName);
}
}
partial void OnSelectedDirectoryChanged(ExplorerItem? value)
{
if (value == null)
{
return;
}
DirectoryAssets.Clear();
NavigateToDirectory(value.FullName);
}
}

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