Compare commits
60 Commits
feature/do
...
b42398bbce
| Author | SHA1 | Date | |
|---|---|---|---|
| b42398bbce | |||
| d052ca848f | |||
| 744b058e6a | |||
| 5de480e231 | |||
| 8d3e1c91d7 | |||
| bffe05f0ef | |||
| 220db828a0 | |||
| d2bf2f12a2 | |||
| e7fedfd35a | |||
| e384a2f38c | |||
| 0eaf7cd51d | |||
| 631638f3fb | |||
| e3a02437c3 | |||
| 5903ddda2b | |||
| 1a91811621 | |||
| 4757c0c91a | |||
| 3533d3367f | |||
| 884611181a | |||
| cb4092179f | |||
| c249a389e3 | |||
| ed00f205b0 | |||
| 4f5556ee1b | |||
| abd5ad74d5 | |||
| 13bf1501e4 | |||
| 6615fe794e | |||
| d9bfa43663 | |||
| 817b32b8d9 | |||
| c66fda5332 | |||
| f9a6e9cbbe | |||
| 4ed5572ce7 | |||
| 68fda03aa9 | |||
| 0fc449bc78 | |||
| a5c10cfe5a | |||
| 6c96d4cf50 | |||
| c6bdbe0710 | |||
| effd33b285 | |||
| 92970f85ef | |||
| 2dc97f3149 | |||
| ba9e24c46c | |||
| 6321b36ef5 | |||
| d03eb659fa | |||
| e32a24739d | |||
| eb41f23582 | |||
| 3157596b5d | |||
| a00cb27529 | |||
| 0b6e5b8501 | |||
| 89e6c68f2a | |||
| b28b32f502 | |||
| fa617accc3 | |||
| ff22b89ba3 | |||
| 2e6e705558 | |||
| e6e38f5eea | |||
| d15bd22743 | |||
| 15870ffe89 | |||
| 70b7e56eb7 | |||
| 257838b33e | |||
| 8ff98c56be | |||
| 2c84696994 | |||
| a33a150d06 | |||
| 60ef684d80 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,6 +11,10 @@
|
||||
*.sln.docstates
|
||||
|
||||
AGENTS.md
|
||||
.opencode/
|
||||
.code-review-graph/
|
||||
.github/instructions/
|
||||
|
||||
ref/
|
||||
docfx/
|
||||
NUL
|
||||
|
||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright © 2026 Enjie Huang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1216
docs/specs/asset_loading_design.md
Normal file
1216
docs/specs/asset_loading_design.md
Normal file
File diff suppressed because it is too large
Load Diff
310
docs/specs/asset_registry_analysis.md
Normal file
310
docs/specs/asset_registry_analysis.md
Normal 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.
|
||||
1625
docs/specs/asset_registry_design.md
Normal file
1625
docs/specs/asset_registry_design.md
Normal file
File diff suppressed because it is too large
Load Diff
457
docs/specs/shader_pipeline_architecture.md
Normal file
457
docs/specs/shader_pipeline_architecture.md
Normal 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 |
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -1,3 +1,4 @@
|
||||
using Ghost.Core.Graphics;
|
||||
using Misaki.HighPerformance.Mathematics;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
@@ -7,25 +8,6 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ghost.DSL.Generator;
|
||||
|
||||
public enum PackingRules
|
||||
{
|
||||
Exact,
|
||||
Aligned,
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Enum)]
|
||||
public class GenerateHLSLAttribute : Attribute
|
||||
{
|
||||
private readonly PackingRules _packingRules;
|
||||
private readonly string? _outputSource;
|
||||
|
||||
public GenerateHLSLAttribute(PackingRules packingRules, string? outputSource)
|
||||
{
|
||||
_packingRules = packingRules;
|
||||
_outputSource = outputSource;
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class ShaderStructGenerator
|
||||
{
|
||||
private struct ShaderFieldInfo
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
@@ -17,6 +17,11 @@
|
||||
<Listener>false</Listener>
|
||||
<Visitor>true</Visitor>
|
||||
</Antlr4>
|
||||
<Antlr4 Include="Grammar\GhostComputeShaderParser.g4">
|
||||
<Visitor>true</Visitor>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<Listener>false</Listener>
|
||||
</Antlr4>
|
||||
<Antlr4 Include="Grammar\GhostShaderParser.g4">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<Listener>false</Listener>
|
||||
|
||||
71
src/Editor/Ghost.DSL/Grammar/GhostComputeShaderParser.g4
Normal file
71
src/Editor/Ghost.DSL/Grammar/GhostComputeShaderParser.g4
Normal 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;
|
||||
@@ -2,7 +2,7 @@ lexer grammar GhostShaderLexer;
|
||||
|
||||
// Keywords
|
||||
SHADER: 'shader';
|
||||
PROPERTIES: 'properties';
|
||||
COMPUTE: 'compute';
|
||||
PIPELINE: 'pipeline';
|
||||
PASS: 'pass';
|
||||
DEFINES: 'defines';
|
||||
@@ -11,6 +11,7 @@ INCLUDES: 'includes';
|
||||
GLOBAL: 'global';
|
||||
LOCAL: 'local';
|
||||
HLSL: 'hlsl';
|
||||
SM: 'sm';
|
||||
|
||||
// Punctuation
|
||||
LBRACE: '{';
|
||||
|
||||
@@ -13,23 +13,14 @@ shader:
|
||||
RBRACE;
|
||||
|
||||
shaderBody:
|
||||
(propertiesBlock | pipelineBlock | passBlock | functionCall)*;
|
||||
shaderModel | (pipelineBlock | passBlock | functionCall)*;
|
||||
|
||||
// Properties block
|
||||
propertiesBlock:
|
||||
PROPERTIES LBRACE
|
||||
propertyDeclaration*
|
||||
RBRACE;
|
||||
|
||||
propertyDeclaration:
|
||||
scope? IDENTIFIER IDENTIFIER (EQUALS LBRACE propertyInitializer RBRACE)? SEMICOLON;
|
||||
shaderModel:
|
||||
SM IDENTIFIER SEMICOLON;
|
||||
|
||||
scope:
|
||||
GLOBAL | LOCAL;
|
||||
|
||||
propertyInitializer:
|
||||
(NUMBER | IDENTIFIER) (COMMA (NUMBER | IDENTIFIER))*;
|
||||
|
||||
// Pipeline block
|
||||
pipelineBlock:
|
||||
PIPELINE LBRACE
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Core.Graphics;
|
||||
using Ghost.DSL.ShaderParser;
|
||||
using Misaki.HighPerformance.Utilities;
|
||||
using System.Text;
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
var currentOffset = 0;
|
||||
|
||||
foreach (ref var prop in properties)
|
||||
{
|
||||
var size = prop.type.GetSize();
|
||||
|
||||
if ((currentOffset % 16) + size > 16)
|
||||
if (string.IsNullOrEmpty(injectedCode))
|
||||
{
|
||||
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;
|
||||
prop.size = size;
|
||||
shaderCode = string.Empty;
|
||||
}
|
||||
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.
|
||||
// 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,
|
||||
hlsl = semantics.hlsl
|
||||
propertyInfo = default;
|
||||
}
|
||||
|
||||
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?
|
||||
.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)
|
||||
for (var i = 0; i < descriptor.Passes.Length; i++)
|
||||
{
|
||||
descriptor.passes = new PassDescriptor[semantics.passes.Count];
|
||||
for (var i = 0; i < semantics.passes.Count; i++)
|
||||
{
|
||||
var pass = semantics.passes[i];
|
||||
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
|
||||
descriptor.passes[i] = new PassDescriptor
|
||||
{
|
||||
identifier = GetPassUniqueId(semantics, pass),
|
||||
name = pass.name,
|
||||
taskShader = pass.taskShader,
|
||||
meshShader = pass.meshShader,
|
||||
pixelShader = pass.pixelShader,
|
||||
localPipeline = localPipeline,
|
||||
defines = pass.defines?.ToArray() ?? Array.Empty<string>(),
|
||||
includes = pass.includes?.ToArray() ?? Array.Empty<string>(),
|
||||
keywords = pass.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>(),
|
||||
hlsl = pass.hlsl
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor.passes = Array.Empty<PassDescriptor>();
|
||||
descriptor.Passes[i].shader = 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
|
||||
{
|
||||
var source = File.ReadAllText(shaderPath);
|
||||
|
||||
// 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)
|
||||
{
|
||||
@@ -172,37 +211,13 @@ internal static class DSLShaderCompiler
|
||||
return Result.Failure("Failed to compile shader due to errors:\n" + errorMessages.ToString());
|
||||
}
|
||||
|
||||
var desc = ResolveShader(model);
|
||||
var globalPropResult = GenerateGlobalProperties(desc.globalProperties, generatedOutputDirectory);
|
||||
if (globalPropResult.IsFailure)
|
||||
var result = ResolveShader(model);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return Result.Failure("Failed to generate global properties: " + globalPropResult.Message);
|
||||
return result;
|
||||
}
|
||||
|
||||
var generatedResult = GenerateShaderCode(desc, generatedOutputDirectory);
|
||||
if (generatedResult.IsFailure)
|
||||
{
|
||||
return Result.Failure("Failed to generate pass files: " + generatedResult.Message);
|
||||
}
|
||||
|
||||
foreach (ref var pass in desc.passes.AsSpan())
|
||||
{
|
||||
if (pass.includes == null)
|
||||
{
|
||||
pass.includes = new string[2];
|
||||
}
|
||||
else
|
||||
{
|
||||
Array.Resize(ref pass.includes, pass.includes.Length + 2);
|
||||
// Shift existing includes to make room for the two new includes at the front.
|
||||
pass.includes.AsSpan(0, pass.includes.Length - 2).CopyTo(pass.includes.AsSpan(2));
|
||||
}
|
||||
|
||||
pass.includes[0] = globalPropResult.Value;
|
||||
pass.includes[1] = generatedResult.Value;
|
||||
}
|
||||
|
||||
return desc;
|
||||
return result.Value;
|
||||
}
|
||||
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",
|
||||
ShaderPropertyType.Float2 => "float2",
|
||||
ShaderPropertyType.Float3 => "float3",
|
||||
ShaderPropertyType.Float4 => "float4",
|
||||
ShaderPropertyType.Int => "int",
|
||||
ShaderPropertyType.Int2 => "int2",
|
||||
ShaderPropertyType.Int3 => "int3",
|
||||
ShaderPropertyType.Int4 => "int4",
|
||||
ShaderPropertyType.UInt => "uint",
|
||||
ShaderPropertyType.UInt2 => "uint2",
|
||||
ShaderPropertyType.UInt3 => "uint3",
|
||||
ShaderPropertyType.UInt4 => "uint4",
|
||||
ShaderPropertyType.Bool => "bool",
|
||||
ShaderPropertyType.Bool2 => "bool2",
|
||||
ShaderPropertyType.Bool3 => "bool3",
|
||||
ShaderPropertyType.Bool4 => "bool4",
|
||||
// NOTE: Textures here are bindless, represented as uint (descriptor index).
|
||||
ShaderPropertyType.Texture2D => "TEXTURE2D",
|
||||
ShaderPropertyType.Texture3D => "TEXTURE3D",
|
||||
ShaderPropertyType.TextureCube => "TEXTURECUBE",
|
||||
ShaderPropertyType.Texture2DArray => "TEXTURE2D_ARRAY",
|
||||
ShaderPropertyType.TextureCubeArray => "TEXTURECUBE_ARRAY",
|
||||
ShaderPropertyType.Sampler => "SAMPLER",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported shader property type: {type}")
|
||||
return Result.Failure("Shader file not found: " + shaderPath);
|
||||
}
|
||||
|
||||
var code = File.ReadAllText(shaderPath);
|
||||
return CompileComputeShaderCode(code);
|
||||
}
|
||||
|
||||
public static Result<ComputeShaderDescriptor> CompileComputeShaderCode(string shaderCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parseErrors = new List<DSLShaderError>();
|
||||
var shaderModels = AntlrShaderCompiler.ParseComputeShaders(shaderCode, parseErrors);
|
||||
|
||||
if (parseErrors.Count != 0)
|
||||
{
|
||||
var errorMessages = new StringBuilder();
|
||||
foreach (var error in parseErrors)
|
||||
{
|
||||
errorMessages.AppendLine(error.ToString());
|
||||
}
|
||||
|
||||
return Result.Failure("Failed to parse compute shader due to errors:\n" + errorMessages.ToString());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ public enum PropertyScope
|
||||
Local,
|
||||
}
|
||||
|
||||
public class PropertySemantic
|
||||
public struct ShaderEntryPoint
|
||||
{
|
||||
public PropertyScope scope;
|
||||
public ShaderPropertyType type;
|
||||
public string name = string.Empty;
|
||||
public object? defaultValue;
|
||||
public string entry;
|
||||
public string shaderPath;
|
||||
|
||||
public readonly bool IsCreated => !string.IsNullOrEmpty(entry) && !string.IsNullOrEmpty(shaderPath);
|
||||
}
|
||||
|
||||
public class PipelineSemantic
|
||||
@@ -28,7 +28,7 @@ public class PipelineSemantic
|
||||
public class PassSemantic
|
||||
{
|
||||
public string name = string.Empty;
|
||||
public ShaderEntryPoint taskShader;
|
||||
public ShaderEntryPoint amplificationShader;
|
||||
public ShaderEntryPoint meshShader;
|
||||
public ShaderEntryPoint pixelShader;
|
||||
public string? hlsl;
|
||||
@@ -41,8 +41,18 @@ public class PassSemantic
|
||||
public class DSLShaderSemantics
|
||||
{
|
||||
public string name = string.Empty;
|
||||
public string? hlsl;
|
||||
public List<PropertySemantic>? properties;
|
||||
public ShaderModel shaderModel;
|
||||
public PipelineSemantic? pipeline;
|
||||
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!;
|
||||
}
|
||||
@@ -7,10 +7,8 @@ namespace Ghost.DSL.ShaderParser;
|
||||
|
||||
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
|
||||
{
|
||||
var inputStream = new AntlrInputStream(source);
|
||||
@@ -33,7 +31,7 @@ public class AntlrShaderCompiler
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new List<ShaderModel>();
|
||||
return new List<GraphicsShaderModel>();
|
||||
}
|
||||
|
||||
var visitor = new ShaderVisitor();
|
||||
@@ -49,11 +47,91 @@ public class AntlrShaderCompiler
|
||||
line = -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>();
|
||||
|
||||
@@ -61,166 +139,84 @@ public class AntlrShaderCompiler
|
||||
{
|
||||
errors.Add(new DSLShaderError
|
||||
{
|
||||
message = "Shader name cannot be empty.",
|
||||
message = "Compute shader name cannot be empty.",
|
||||
line = 0,
|
||||
column = 0
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
var semantics = new DSLShaderSemantics
|
||||
var semantics = new DSLComputeShaderSemantics
|
||||
{
|
||||
name = model.Name,
|
||||
properties = ConvertProperties(model.Properties, errors),
|
||||
pipeline = ConvertPipeline(model.Pipeline, errors)
|
||||
defines = model.Defines?.Defines,
|
||||
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);
|
||||
if (passSemantic != null)
|
||||
semantics.shaderModel = shaderModel;
|
||||
}
|
||||
|
||||
if (model.Keywords != null)
|
||||
{
|
||||
semantics.keywords = new List<KeywordsGroup>();
|
||||
foreach (var group in model.Keywords.Groups)
|
||||
{
|
||||
semantics.passes ??= new List<PassSemantic>();
|
||||
semantics.passes.Add(passSemantic);
|
||||
var keywordGroup = new KeywordsGroup
|
||||
{
|
||||
space = group.Scope?.ToLower() == "global" ? KeywordSpace.Global : KeywordSpace.Local,
|
||||
keywords = group.Keywords
|
||||
};
|
||||
semantics.keywords.Add(keywordGroup);
|
||||
}
|
||||
}
|
||||
|
||||
return semantics;
|
||||
}
|
||||
|
||||
private static List<PropertySemantic>? ConvertProperties(PropertiesBlockModel? properties, List<DSLShaderError> errors)
|
||||
{
|
||||
if (properties == null || properties.Properties.Count == 0)
|
||||
foreach (var entry in model.ShaderEntries)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<PropertySemantic>();
|
||||
var usedNames = new HashSet<string>();
|
||||
|
||||
foreach (var prop in properties.Properties)
|
||||
{
|
||||
if (usedNames.Contains(prop.Name))
|
||||
var entryType = entry.EntryType.ToLower();
|
||||
if (entryType == "cs")
|
||||
{
|
||||
semantics.entryPoints ??= new List<ShaderEntryPoint>();
|
||||
semantics.entryPoints.Add(new ShaderEntryPoint
|
||||
{
|
||||
shaderPath = entry.ShaderPath,
|
||||
entry = entry.EntryPoint
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new DSLShaderError
|
||||
{
|
||||
message = $"Duplicate property name '{prop.Name}'.",
|
||||
message = $"Unknown compute shader entry type '{entry.EntryType}'. Expected 'compute' or 'cs'.",
|
||||
line = 0,
|
||||
column = 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var semantic = new PropertySemantic
|
||||
{
|
||||
name = prop.Name,
|
||||
scope = prop.Scope?.ToLower() == "global" ? PropertyScope.Global : PropertyScope.Local,
|
||||
type = ParsePropertyType(prop.Type, errors)
|
||||
};
|
||||
|
||||
if (prop.Initializer.Count > 0)
|
||||
{
|
||||
semantic.defaultValue = ParsePropertyValue(semantic.type, prop.Initializer, errors);
|
||||
}
|
||||
|
||||
usedNames.Add(prop.Name);
|
||||
result.Add(semantic);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ShaderPropertyType ParsePropertyType(string type, List<DSLShaderError> errors)
|
||||
{
|
||||
return type.ToLower() switch
|
||||
{
|
||||
"float" => ShaderPropertyType.Float,
|
||||
"float2" => ShaderPropertyType.Float2,
|
||||
"float3" => ShaderPropertyType.Float3,
|
||||
"float4" => ShaderPropertyType.Float4,
|
||||
"float4x4" => ShaderPropertyType.Float4x4,
|
||||
"int" => ShaderPropertyType.Int,
|
||||
"int2" => ShaderPropertyType.Int2,
|
||||
"int3" => ShaderPropertyType.Int3,
|
||||
"int4" => ShaderPropertyType.Int4,
|
||||
"uint" => ShaderPropertyType.UInt,
|
||||
"uint2" => ShaderPropertyType.UInt2,
|
||||
"uint3" => ShaderPropertyType.UInt3,
|
||||
"uint4" => ShaderPropertyType.UInt4,
|
||||
"bool" => ShaderPropertyType.Bool,
|
||||
"bool2" => ShaderPropertyType.Bool2,
|
||||
"bool3" => ShaderPropertyType.Bool3,
|
||||
"bool4" => ShaderPropertyType.Bool4,
|
||||
"tex2d" => ShaderPropertyType.Texture2D,
|
||||
"tex3d" => ShaderPropertyType.Texture3D,
|
||||
"texcube" => ShaderPropertyType.TextureCube,
|
||||
"texcube_arr" => ShaderPropertyType.TextureCubeArray,
|
||||
"tex2d_arr" => ShaderPropertyType.Texture2DArray,
|
||||
"sampler" => ShaderPropertyType.Sampler,
|
||||
_ => ShaderPropertyType.None
|
||||
};
|
||||
}
|
||||
|
||||
private static object? ParsePropertyValue(ShaderPropertyType type, List<string> values, List<DSLShaderError> errors)
|
||||
{
|
||||
// For textures, the value is an identifier (e.g., "white", "black")
|
||||
if (type is ShaderPropertyType.Texture2D or ShaderPropertyType.Texture3D or ShaderPropertyType.TextureCube)
|
||||
{
|
||||
return values.Count > 0 ? values[0] : null;
|
||||
}
|
||||
|
||||
// For samplers, no default value
|
||||
if (type == ShaderPropertyType.Sampler)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// For numeric types, parse the values
|
||||
try
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
ShaderPropertyType.Float => values.Count > 0 ? float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0f,
|
||||
ShaderPropertyType.Float2 => values.Count >= 2 ? new Misaki.HighPerformance.Mathematics.float2(
|
||||
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||
ShaderPropertyType.Float3 => values.Count >= 3 ? new Misaki.HighPerformance.Mathematics.float3(
|
||||
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||
float.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||
ShaderPropertyType.Float4 => values.Count >= 4 ? new Misaki.HighPerformance.Mathematics.float4(
|
||||
float.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||
float.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||
float.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture),
|
||||
float.Parse(values[3], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||
ShaderPropertyType.Int => values.Count > 0 ? int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0,
|
||||
ShaderPropertyType.Int2 => values.Count >= 2 ? new Misaki.HighPerformance.Mathematics.int2(
|
||||
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||
ShaderPropertyType.Int3 => values.Count >= 3 ? new Misaki.HighPerformance.Mathematics.int3(
|
||||
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||
int.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||
ShaderPropertyType.Int4 => values.Count >= 4 ? new Misaki.HighPerformance.Mathematics.int4(
|
||||
int.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture),
|
||||
int.Parse(values[1], System.Globalization.CultureInfo.InvariantCulture),
|
||||
int.Parse(values[2], System.Globalization.CultureInfo.InvariantCulture),
|
||||
int.Parse(values[3], System.Globalization.CultureInfo.InvariantCulture)) : default,
|
||||
ShaderPropertyType.UInt => values.Count > 0 ? uint.Parse(values[0], System.Globalization.CultureInfo.InvariantCulture) : 0u,
|
||||
ShaderPropertyType.Bool => values.Count > 0 && (values[0] == "1" || values[0].ToLower() == "true"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
if (semantics.entryPoints == null)
|
||||
{
|
||||
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,
|
||||
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)
|
||||
@@ -241,11 +237,11 @@ public class AntlrShaderCompiler
|
||||
{
|
||||
"disabled" => ZTest.Disabled,
|
||||
"less" => ZTest.Less,
|
||||
"lessequal" => ZTest.LessEqual,
|
||||
"less_equal" => ZTest.LessEqual,
|
||||
"equal" => ZTest.Equal,
|
||||
"greaterequal" => ZTest.GreaterEqual,
|
||||
"greater_equal" => ZTest.GreaterEqual,
|
||||
"greater" => ZTest.Greater,
|
||||
"notequal" => ZTest.NotEqual,
|
||||
"not_equal" => ZTest.NotEqual,
|
||||
"always" => ZTest.Always,
|
||||
_ => ZTest.Disabled
|
||||
};
|
||||
@@ -269,7 +265,7 @@ public class AntlrShaderCompiler
|
||||
"alpha" => Blend.Alpha,
|
||||
"additive" => Blend.Additive,
|
||||
"multiply" => Blend.Multiply,
|
||||
"premultipliedalpha" => Blend.PremultipliedAlpha,
|
||||
"premultiplied_alpha" => Blend.PremultipliedAlpha,
|
||||
_ => Blend.Opaque
|
||||
};
|
||||
break;
|
||||
@@ -312,20 +308,20 @@ public class AntlrShaderCompiler
|
||||
var entryType = entry.EntryType.ToLower();
|
||||
var shaderEntry = new ShaderEntryPoint
|
||||
{
|
||||
shader = entry.ShaderPath,
|
||||
shaderPath = entry.ShaderPath,
|
||||
entry = entry.EntryPoint
|
||||
};
|
||||
|
||||
switch (entryType)
|
||||
{
|
||||
case "mesh" or "ms":
|
||||
case "ms":
|
||||
semantic.meshShader = shaderEntry;
|
||||
break;
|
||||
case "pixel" or "ps":
|
||||
case "ps":
|
||||
semantic.pixelShader = shaderEntry;
|
||||
break;
|
||||
case "task" or "ts":
|
||||
semantic.taskShader = shaderEntry;
|
||||
case "as":
|
||||
semantic.amplificationShader = shaderEntry;
|
||||
break;
|
||||
default:
|
||||
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
|
||||
{
|
||||
@@ -351,6 +347,45 @@ public class AntlrShaderCompiler
|
||||
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 readonly List<DSLShaderError> _errors;
|
||||
|
||||
149
src/Editor/Ghost.DSL/ShaderParser/ComputeShaderVisitor.cs
Normal file
149
src/Editor/Ghost.DSL/ShaderParser/ComputeShaderVisitor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
namespace Ghost.DSL.ShaderParser.Model;
|
||||
|
||||
public class ShaderModel
|
||||
public class GraphicsShaderModel
|
||||
{
|
||||
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 List<PassBlockModel> Passes { 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 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
|
||||
|
||||
@@ -5,13 +5,13 @@ namespace Ghost.DSL.ShaderParser;
|
||||
|
||||
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)
|
||||
{
|
||||
foreach (var shaderContext in context.shader())
|
||||
{
|
||||
var shader = (ShaderModel)VisitShader(shaderContext);
|
||||
var shader = (GraphicsShaderModel)VisitShader(shaderContext);
|
||||
Shaders.Add(shader);
|
||||
}
|
||||
return Shaders;
|
||||
@@ -19,7 +19,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
||||
|
||||
public override object VisitShader([NotNull] GhostShaderParser.ShaderContext context)
|
||||
{
|
||||
var shader = new ShaderModel
|
||||
var shader = new GraphicsShaderModel
|
||||
{
|
||||
Name = StripQuotes(context.STRING_LITERAL().GetText())
|
||||
};
|
||||
@@ -27,10 +27,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
||||
var shaderBody = context.shaderBody();
|
||||
if (shaderBody != null)
|
||||
{
|
||||
foreach (var propBlock in shaderBody.propertiesBlock())
|
||||
{
|
||||
shader.Properties = (PropertiesBlockModel)VisitPropertiesBlock(propBlock);
|
||||
}
|
||||
shader.ShaderModel = shaderBody.shaderModel()?.GetText() ?? string.Empty;
|
||||
|
||||
foreach (var pipelineBlock in shaderBody.pipelineBlock())
|
||||
{
|
||||
@@ -51,47 +48,6 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
||||
return shader;
|
||||
}
|
||||
|
||||
public override object VisitPropertiesBlock([NotNull] GhostShaderParser.PropertiesBlockContext context)
|
||||
{
|
||||
var properties = new PropertiesBlockModel();
|
||||
|
||||
foreach (var propDecl in context.propertyDeclaration())
|
||||
{
|
||||
properties.Properties.Add((PropertyDeclarationModel)VisitPropertyDeclaration(propDecl));
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
public override object VisitPropertyDeclaration([NotNull] GhostShaderParser.PropertyDeclarationContext context)
|
||||
{
|
||||
var property = new PropertyDeclarationModel
|
||||
{
|
||||
Type = context.IDENTIFIER(0).GetText(),
|
||||
Name = context.IDENTIFIER(1).GetText()
|
||||
};
|
||||
|
||||
if (context.scope() != null)
|
||||
{
|
||||
property.Scope = context.scope().GetText();
|
||||
}
|
||||
|
||||
if (context.propertyInitializer() != null)
|
||||
{
|
||||
var init = context.propertyInitializer();
|
||||
foreach (var number in init.NUMBER())
|
||||
{
|
||||
property.Initializer.Add(number.GetText());
|
||||
}
|
||||
foreach (var identifier in init.IDENTIFIER())
|
||||
{
|
||||
property.Initializer.Add(identifier.GetText());
|
||||
}
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
|
||||
public override object VisitPipelineBlock([NotNull] GhostShaderParser.PipelineBlockContext context)
|
||||
{
|
||||
var pipeline = new PipelineBlockModel();
|
||||
@@ -209,7 +165,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
||||
if (stop >= start)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/<guid>_<hash>.dds</c>).
|
||||
/// 3. If the cache is already valid (hash matches), skip compression.
|
||||
/// 4. Otherwise run the full NVTT pipeline and write the DDS to the cache file.
|
||||
///
|
||||
/// The caller owns opening/closing all streams; this class only takes spans and paths.
|
||||
/// </summary>
|
||||
internal static unsafe class TextureProcessor
|
||||
{
|
||||
private const string _TEXTURE_CACHE_SUBFOLDER = "TextureCache";
|
||||
|
||||
/// <summary>
|
||||
/// Compresses <paramref name="pixelData"/> according to <paramref name="settings"/>
|
||||
/// and writes the result to the texture cache.
|
||||
///
|
||||
/// Returns the absolute path of the cache file on success.
|
||||
/// The cache file is skipped if it already exists with a matching content hash.
|
||||
/// </summary>
|
||||
public static string CompressToCache(
|
||||
string cachesFolderPath,
|
||||
Guid assetId,
|
||||
ReadOnlySpan<byte> pixelData,
|
||||
int width,
|
||||
int height,
|
||||
bool isFloat,
|
||||
ColorComponents colorComponents,
|
||||
TextureAssetSettings settings)
|
||||
{
|
||||
var cacheDir = Path.Combine(cachesFolderPath, _TEXTURE_CACHE_SUBFOLDER);
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var settingsHash = ComputeSettingsHash(settings);
|
||||
var cacheFileName = $"{assetId:N}_{settingsHash:X16}.dds";
|
||||
var cachePath = Path.Combine(cacheDir, cacheFileName);
|
||||
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
foreach (var stale in Directory.EnumerateFiles(cacheDir, $"{assetId:N}_*.dds"))
|
||||
{
|
||||
File.Delete(stale);
|
||||
}
|
||||
|
||||
RunNvttPipeline(cachePath, pixelData, width, height, isFloat, colorComponents, settings);
|
||||
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
private static void RunNvttPipeline(
|
||||
string outputPath,
|
||||
ReadOnlySpan<byte> pixelData,
|
||||
int width,
|
||||
int height,
|
||||
bool isFloat,
|
||||
ColorComponents colorComponents,
|
||||
TextureAssetSettings settings)
|
||||
{
|
||||
using var pSurface = new DisposablePtr<NvttSurface>(NvttSurface.Create());
|
||||
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
|
||||
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
|
||||
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
|
||||
|
||||
var inputFormat = isFloat
|
||||
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
|
||||
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
|
||||
|
||||
fixed (void* pData = pixelData)
|
||||
{
|
||||
pSurface.Get()->SetImageData(inputFormat, width, height, 1, pData, NvttBoolean.NVTT_True, null);
|
||||
}
|
||||
|
||||
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
|
||||
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
|
||||
if (!isFloat)
|
||||
{
|
||||
pSurface.Get()->Swizzle(2, 1, 0, 3, null);
|
||||
}
|
||||
|
||||
var maxExtent = (int)settings.Sampler.MaxSize;
|
||||
if (settings.Advanced.StretchToPowerOfTwo)
|
||||
{
|
||||
pSurface.Get()->ResizeMakeSquare(maxExtent,
|
||||
NvttRoundMode.NVTT_RoundMode_ToNearestPowerOfTwo,
|
||||
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
||||
}
|
||||
else if (pSurface.Get()->Width() > maxExtent || pSurface.Get()->Height() > maxExtent)
|
||||
{
|
||||
pSurface.Get()->ResizeMax(maxExtent,
|
||||
NvttRoundMode.NVTT_RoundMode_None,
|
||||
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
||||
}
|
||||
|
||||
if (settings.Advanced.UseBorderColor)
|
||||
{
|
||||
var c = settings.Advanced.BorderColor;
|
||||
pSurface.Get()->SetBorder(c.r, c.g, c.b, c.a, null);
|
||||
}
|
||||
else if (settings.Advanced.ZeroAlphaBorder)
|
||||
{
|
||||
pSurface.Get()->SetBorder(0f, 0f, 0f, 0f, null);
|
||||
}
|
||||
|
||||
if (settings.Basic.IsSRGB && settings.Advanced.GammaCorrection)
|
||||
{
|
||||
pSurface.Get()->ToLinearFromSrgb(null);
|
||||
}
|
||||
|
||||
if (settings.Advanced.PremultiplyAlpha)
|
||||
{
|
||||
pSurface.Get()->PremultiplyAlpha(null);
|
||||
}
|
||||
|
||||
pCompOpts.Get()->SetFormat(SelectFormat(settings));
|
||||
pCompOpts.Get()->SetQuality(SelectQuality(settings.Advanced.CompressionLevel));
|
||||
|
||||
if (settings.Advanced.CutoutAlpha)
|
||||
{
|
||||
pCompOpts.Get()->SetQuantization(false, false, true,
|
||||
settings.Advanced.CutoutAlphaThreshold);
|
||||
}
|
||||
|
||||
pOutOpts.Get()->SetOutputHeader(true);
|
||||
pOutOpts.Get()->SetSrgbFlag(settings.Basic.IsSRGB);
|
||||
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
|
||||
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(outputPath));
|
||||
|
||||
var nvttFilter = SelectMipmapFilter(settings.Advanced.MipmapFilter);
|
||||
|
||||
int mipmapCount;
|
||||
if (!settings.Advanced.GenerateMipmaps)
|
||||
{
|
||||
mipmapCount = 1;
|
||||
}
|
||||
else if (settings.Advanced.MipmapLevelCount == 0)
|
||||
{
|
||||
mipmapCount = pSurface.Get()->CountMipmaps(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
mipmapCount = (int)settings.Advanced.MipmapLevelCount;
|
||||
}
|
||||
|
||||
pCtx.Get()->SetCudaAcceleration(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);
|
||||
}
|
||||
}
|
||||
73
src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs
Normal file
73
src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs
Normal 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);
|
||||
}
|
||||
127
src/Editor/Ghost.Editor.Core/Assets/AssetHandlerRegistry.cs
Normal file
127
src/Editor/Ghost.Editor.Core/Assets/AssetHandlerRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
151
src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs
Normal file
151
src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
505
src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs
Normal file
505
src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
184
src/Editor/Ghost.Editor.Core/Assets/MeshNode.cs
Normal file
184
src/Editor/Ghost.Editor.Core/Assets/MeshNode.cs
Normal 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>();
|
||||
}
|
||||
368
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Import.cs
Normal file
368
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Import.cs
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
1224
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Meshlet.cs
Normal file
1224
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Meshlet.cs
Normal file
File diff suppressed because it is too large
Load Diff
148
src/Editor/Ghost.Editor.Core/Assets/ShaderAssetHandler.cs
Normal file
148
src/Editor/Ghost.Editor.Core/Assets/ShaderAssetHandler.cs
Normal 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."));
|
||||
}
|
||||
}
|
||||
497
src/Editor/Ghost.Editor.Core/Assets/TextureAssetHandler.cs
Normal file
497
src/Editor/Ghost.Editor.Core/Assets/TextureAssetHandler.cs
Normal 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."));
|
||||
}
|
||||
}
|
||||
434
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.Compress.cs
Normal file
434
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.Compress.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
371
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.GGXMip.cs
Normal file
371
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.GGXMip.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ public class EditorInjectionAttribute : DiscoverableAttributeBase
|
||||
{
|
||||
Singleton,
|
||||
Transient,
|
||||
Scoped
|
||||
}
|
||||
|
||||
public ServiceLifetime Lifetime
|
||||
@@ -63,12 +62,12 @@ public class EditorInjectionAttribute : DiscoverableAttributeBase
|
||||
get;
|
||||
}
|
||||
|
||||
public Type? ImplementationType
|
||||
public Type ImplementationType
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public EditorInjectionAttribute(ServiceLifetime lifetime, Type? implementationType = null)
|
||||
public EditorInjectionAttribute(ServiceLifetime lifetime, Type implementationType)
|
||||
{
|
||||
Lifetime = lifetime;
|
||||
ImplementationType = implementationType;
|
||||
@@ -99,4 +98,4 @@ public sealed class ContextMenuItemAttribute : DiscoverableAttributeBase
|
||||
Name = name;
|
||||
Group = group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -39,11 +41,22 @@ public sealed class AssetChangedEventArgs : EventArgs
|
||||
|
||||
public interface IAssetRegistry : IDisposable
|
||||
{
|
||||
event EventHandler<AssetChangedEventArgs>? OnAssetChanged;
|
||||
event EventHandler<Guid>? OnAssetImported;
|
||||
|
||||
AssetCatalog GetAssetCatalog();
|
||||
|
||||
string? GetAssetPath(Guid id);
|
||||
Guid GetAssetGuid(string assetPath);
|
||||
|
||||
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
|
||||
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
|
||||
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default);
|
||||
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default);
|
||||
ValueTask<Result<IAsset>> LoadAssetAsync(Guid id, 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();
|
||||
}
|
||||
|
||||
66
src/Editor/Ghost.Editor.Core/Contracts/IShaderCompiler.cs
Normal file
66
src/Editor/Ghost.Editor.Core/Contracts/IShaderCompiler.cs
Normal 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);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public sealed partial class ContextFlyout : MenuFlyout
|
||||
Opening += ContextFlyout_Opening;
|
||||
}
|
||||
|
||||
// Recursively sorts nodes and calculates folder groups
|
||||
// Recursively sorts nodes and calculates folder pGroups
|
||||
private static void PrepareNodes(List<MenuNode> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Ghost.Core.Utilities;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
@@ -6,15 +7,26 @@ namespace Ghost.Editor.Core;
|
||||
public static class EditorApplication
|
||||
{
|
||||
public const string ASSETS_FOLDER_NAME = "Assets";
|
||||
public const string SOURCES_FOLDER_NAME = "Sources";
|
||||
public const string PACKAGES_FOLDER_NAME = "Packages";
|
||||
public const string CACHES_FOLDER_NAME = "Caches";
|
||||
public const string LIBRARY_FOLDER_NAME = "Library";
|
||||
public const string CACHE_FOLDER_NAME = "Cache";
|
||||
public const string CONFIG_FOLDER_NAME = "Config";
|
||||
|
||||
public const string IMPORTS_FOLDER_NAME = "Imports";
|
||||
|
||||
private static IServiceProvider? s_serviceProvider;
|
||||
|
||||
private static string s_currentProjectPath = 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;
|
||||
|
||||
internal static Application CurrentApplication => Application.Current;
|
||||
@@ -22,11 +34,12 @@ public static class EditorApplication
|
||||
public static string ProjectPath => s_currentProjectPath;
|
||||
public static string ProjectName => s_currentProjectName;
|
||||
|
||||
public static string AssetsFolderPath => Path.Combine(ProjectPath, ASSETS_FOLDER_NAME);
|
||||
public static string SourcesFolderPath => Path.Combine(ProjectPath, SOURCES_FOLDER_NAME);
|
||||
public static string PackagesFolderPath => Path.Combine(ProjectPath, PACKAGES_FOLDER_NAME);
|
||||
public static string CachesFolderPath => Path.Combine(ProjectPath, CACHES_FOLDER_NAME);
|
||||
public static string ConfigFolderPath => Path.Combine(ProjectPath, CONFIG_FOLDER_NAME);
|
||||
public static string AssetsFolderPath => s_assetsFolderPath;
|
||||
public static string PackagesFolderPath => s_packagesFolderPath;
|
||||
public static string LibraryFolderPath => s_libraryFolderPath;
|
||||
public static string ConfigFolderPath => s_configFolderPath;
|
||||
public static string CacheFolderPath => s_cacheFolderPath;
|
||||
public static string LibraryImportsFolderPath => s_libraryImportsFolderPath;
|
||||
|
||||
public static DispatcherQueue DispatcherQueue
|
||||
{
|
||||
@@ -43,9 +56,29 @@ public static class EditorApplication
|
||||
|
||||
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
||||
{
|
||||
projectPath = PathUtility.Normalize(projectPath);
|
||||
|
||||
Environment.CurrentDirectory = projectPath;
|
||||
|
||||
s_serviceProvider = serviceProvider;
|
||||
s_currentProjectPath = projectPath;
|
||||
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)
|
||||
@@ -67,4 +100,4 @@ public static class EditorApplication
|
||||
internal static void Shutdown()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
|
||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||
<RootNamespace>Ghost.Editor.Core</RootNamespace>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||
@@ -13,8 +14,12 @@
|
||||
<langversion>preview</langversion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" />
|
||||
<Content Remove="Assets\MeshNode.cs" />
|
||||
</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="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
||||
@@ -23,7 +28,12 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.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.Ufbx\Ghost.Ufbx.csproj" />
|
||||
<ProjectReference Include="..\..\ThridParty\Ghost.StbI\Ghost.StbI.csproj" />
|
||||
<ProjectReference Include="..\Ghost.DSL\Ghost.DSL.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -37,4 +47,4 @@
|
||||
<SubType>Designer</SubType>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Ghost.Editor.Core": {
|
||||
"commandName": "Project",
|
||||
"debugEngines": "managed"
|
||||
}
|
||||
}
|
||||
}
|
||||
335
src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs
Normal file
335
src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace TestProject.AssetDB;
|
||||
|
||||
internal partial class AssetRegistry
|
||||
{
|
||||
// TODO: Sqlite backend implementation
|
||||
}
|
||||
@@ -1,510 +1,430 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Ghost.Core.Utilities;
|
||||
using Ghost.Editor.Core.Assets;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
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)
|
||||
{
|
||||
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 AssetCatalog _catalog;
|
||||
private readonly ImportCoordinator _importCoordinator;
|
||||
private readonly FileSystemWatcher _watcher;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Guid> _pathToGuid;
|
||||
private readonly ConcurrentDictionary<Guid, string> _guidToPath;
|
||||
private readonly ConcurrentDictionary<Guid, WeakReference<IAsset>> _loadedAssets;
|
||||
private readonly SemaphoreSlim _loadLock;
|
||||
|
||||
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander;
|
||||
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
|
||||
private readonly ConcurrentDictionary<string, bool> _ignoreMetaWrites;
|
||||
private readonly ConcurrentHashSet<Guid> _dirtyAssets;
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _eventDebouncers;
|
||||
|
||||
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph;
|
||||
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache;
|
||||
|
||||
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
|
||||
|
||||
private readonly SemaphoreSlim _cacheSlim;
|
||||
private readonly Lock _pathLock;
|
||||
|
||||
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
|
||||
|
||||
public AssetRegistry(string rootDirectory)
|
||||
public event EventHandler<AssetChangedEventArgs>? OnAssetChanged;
|
||||
public event EventHandler<Guid>? OnAssetImported
|
||||
{
|
||||
if (!Directory.Exists(rootDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException("The specified root directory does not exist.");
|
||||
}
|
||||
add => _importCoordinator.OnImportCompleted += value;
|
||||
remove => _importCoordinator.OnImportCompleted -= value;
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(rootDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("The specified root directory must be an absolute path.");
|
||||
}
|
||||
public AssetRegistry()
|
||||
{
|
||||
var dbPath = Path.Combine(EditorApplication.LibraryFolderPath, "AssetDB.sqlite");
|
||||
|
||||
_rootDirectory = rootDirectory;
|
||||
_watcher = new FileSystemWatcher(rootDirectory)
|
||||
_catalog = new AssetCatalog(dbPath);
|
||||
_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,
|
||||
EnableRaisingEvents = true,
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
|
||||
};
|
||||
|
||||
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer());
|
||||
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512);
|
||||
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16);
|
||||
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512);
|
||||
|
||||
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
|
||||
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
|
||||
|
||||
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_cacheSlim = new SemaphoreSlim(1, 1);
|
||||
_pathLock = new Lock();
|
||||
|
||||
LoadExistingAssets();
|
||||
|
||||
_watcher.Created += OnFileSystemOp;
|
||||
_watcher.Deleted += OnFileSystemOp;
|
||||
_watcher.Changed += OnFileSystemOp;
|
||||
_watcher.Renamed += OnFileSystemRenameOp;
|
||||
_watcher.Created += OnFileSystemEvent;
|
||||
_watcher.Deleted += OnFileSystemEvent;
|
||||
_watcher.Changed += OnFileSystemEvent;
|
||||
_watcher.Renamed += OnFileSystemRenameEvent;
|
||||
}
|
||||
|
||||
// TODO: DB Cache
|
||||
private unsafe void LoadExistingAssets()
|
||||
private void SyncCatalogWithDisk()
|
||||
{
|
||||
Span<byte> guidBuffer = stackalloc byte[sizeof(Guid)];
|
||||
foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories))
|
||||
if (!Directory.Exists(EditorApplication.AssetsFolderPath))
|
||||
{
|
||||
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);
|
||||
try
|
||||
{
|
||||
fs.Seek(4, SeekOrigin.Begin); // Skip format version
|
||||
fs.ReadExactly(guidBuffer);
|
||||
|
||||
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(guidBuffer));
|
||||
UpdatePathMapping(relativePath, guid);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fs.Dispose();
|
||||
}
|
||||
var sourceRelative = AssetMetaIO.GetSourcePath(Path.GetRelativePath(EditorApplication.ProjectPath, metaPath));
|
||||
_catalog.Upsert(meta, sourceRelative);
|
||||
foundGuids.Add(meta.Guid);
|
||||
}
|
||||
catch (Exception
|
||||
#if DEBUG
|
||||
ex
|
||||
#endif
|
||||
)
|
||||
}
|
||||
|
||||
foreach (var (guid, path) in _catalog.EnumerateAll())
|
||||
{
|
||||
if (path.Contains('#', StringComparison.Ordinal))
|
||||
{
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debugger.BreakForUserUnhandledException(ex);
|
||||
#endif
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateGraph(Guid assetId, IEnumerable<Guid> newDependencies)
|
||||
{
|
||||
// 1. Clean up old references (reverse)
|
||||
if (_dependencyCache.TryGetValue(assetId, out var oldDeps))
|
||||
{
|
||||
foreach (var dep in oldDeps)
|
||||
if (!foundGuids.Contains(guid))
|
||||
{
|
||||
if (_referencerGraph.TryGetValue(dep, out var refs))
|
||||
{
|
||||
refs.Remove(assetId);
|
||||
}
|
||||
_catalog.Remove(guid);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Set new forward dependencies
|
||||
var newDepSet = new HashSet<Guid>(newDependencies);
|
||||
_dependencyCache[assetId] = newDepSet;
|
||||
|
||||
// 3. Add new references (reverse)
|
||||
foreach (var dep in newDepSet)
|
||||
{
|
||||
ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists);
|
||||
if (!exists || referencers is null)
|
||||
{
|
||||
referencers = new HashSet<Guid>();
|
||||
}
|
||||
|
||||
referencers.Add(assetId);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePathMapping(string relativePath, Guid guid)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
_pathToGuid[relativePath] = guid;
|
||||
_guidToPath[guid] = relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
private bool RemovePathMappingByPath(string relativePath)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
if (_pathToGuid.Remove(relativePath, out var guid))
|
||||
{
|
||||
return _guidToPath.TryRemove(guid, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
if (_ignoreFileChanges.TryRemove(e.FullPath, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||
var ext = Path.GetExtension(relativePath);
|
||||
|
||||
var changeType = AssetChangeType.None;
|
||||
var fireEvent = false;
|
||||
var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal);
|
||||
var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal);
|
||||
|
||||
switch (e.ChangeType)
|
||||
{
|
||||
case WatcherChangeTypes.Created:
|
||||
changeType = AssetChangeType.Created;
|
||||
if (!isAsset && !isTemp)
|
||||
{
|
||||
var handler = GetAssetHandlerForExtension(ext);
|
||||
if (handler is IImportableAssetHandler importableHandler)
|
||||
{
|
||||
var assetPath = string.Create(e.FullPath.Length - ext.Length + ASSET_EXTENSION.Length, e.FullPath, (destSpan, source) =>
|
||||
{
|
||||
source.AsSpan(0, source.Length - ext.Length).CopyTo(destSpan);
|
||||
ASSET_EXTENSION.AsSpan().CopyTo(destSpan.Slice(source.Length - ext.Length));
|
||||
});
|
||||
|
||||
var newGuid = Guid.NewGuid();
|
||||
await using var sourceStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read);
|
||||
await using var targetStream = new FileStream(assetPath, FileMode.Create, FileAccess.Write);
|
||||
await importableHandler.ImportAsync(sourceStream, targetStream, newGuid);
|
||||
|
||||
File.Delete(assetPath);
|
||||
UpdatePathMapping(relativePath, newGuid);
|
||||
|
||||
fireEvent = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case WatcherChangeTypes.Deleted:
|
||||
changeType = AssetChangeType.Deleted;
|
||||
if (isAsset)
|
||||
{
|
||||
fireEvent = RemovePathMappingByPath(relativePath);
|
||||
}
|
||||
break;
|
||||
|
||||
case WatcherChangeTypes.Changed:
|
||||
changeType = AssetChangeType.Modified;
|
||||
fireEvent = isAsset;
|
||||
break;
|
||||
case WatcherChangeTypes.All:
|
||||
// Can this even happen?
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (fireEvent)
|
||||
{
|
||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
|
||||
private async void OnFileSystemEvent(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
var ext = Path.GetExtension(e.FullPath);
|
||||
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
|
||||
|
||||
if (ext is ".tmp" or ".gtemp")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
|
||||
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||
|
||||
if (_pathToGuid.Remove(oldRelativePath, out var guid))
|
||||
if (_eventDebouncers.TryGetValue(e.FullPath, out var existingCts))
|
||||
{
|
||||
UpdatePathMapping(newRelativePath, guid);
|
||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
|
||||
existingCts.Cancel();
|
||||
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)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
if (_guidToPath.TryGetValue(id, out var path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return _catalog.GetSourcePath(id);
|
||||
}
|
||||
|
||||
public Guid GetAssetGuid(string path)
|
||||
{
|
||||
lock (_pathLock)
|
||||
{
|
||||
if (_pathToGuid.TryGetValue(path, out var guid))
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
private IAssetHandler GetAssetHandler(Type type)
|
||||
{
|
||||
var typeHandle = type.TypeHandle.Value;
|
||||
if (_cachedHander.TryGetValue(typeHandle, out var handler))
|
||||
{
|
||||
return handler;
|
||||
}
|
||||
|
||||
var obj = Activator.CreateInstance(type);
|
||||
if (obj is not IAssetHandler newHandler)
|
||||
{
|
||||
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
|
||||
}
|
||||
|
||||
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||
if (attr is null || attr.AllowCaching)
|
||||
{
|
||||
_cachedHander[typeHandle] = newHandler;
|
||||
}
|
||||
|
||||
return newHandler;
|
||||
}
|
||||
|
||||
private IAssetHandler? GetAssetHandlerForExtension(string extension)
|
||||
{
|
||||
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => assembly.GetTypes())
|
||||
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
||||
{
|
||||
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetAssetHandler(handlerType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId)
|
||||
{
|
||||
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => assembly.GetTypes())
|
||||
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
||||
{
|
||||
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
||||
if (attr is not null && new Guid(attr.ID) == typeId)
|
||||
{
|
||||
return GetAssetHandler(handlerType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return _catalog.GetGuid(path);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
|
||||
{
|
||||
if (!File.Exists(sourceFilePath))
|
||||
{
|
||||
return Result.Failure("Source file not found.");
|
||||
}
|
||||
// Simple copy + wait for FSW or manually trigger?
|
||||
// Current requirement: "returns the new GUID immediately (import happens in background)"
|
||||
|
||||
var ext = Path.GetExtension(sourceFilePath);
|
||||
var handler = GetAssetHandlerForExtension(ext);
|
||||
if (handler is not IImportableAssetHandler importableHandler)
|
||||
{
|
||||
return Result.Failure("No importable asset handler found for the given file extension.");
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetAssetPath)!);
|
||||
File.Copy(sourceFilePath, targetAssetPath, true);
|
||||
|
||||
var guid = Guid.NewGuid();
|
||||
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory);
|
||||
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
|
||||
{
|
||||
return Result.Failure("Asset import failed.");
|
||||
}
|
||||
// FSW will trigger but we can speed it up
|
||||
await HandleNewSourceFileAsync(targetAssetPath);
|
||||
|
||||
UpdatePathMapping(targetAssetPath, guid);
|
||||
return guid;
|
||||
var guid = _catalog.GetGuid(targetAssetPath);
|
||||
return Result.Success(guid);
|
||||
}
|
||||
|
||||
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
|
||||
{
|
||||
var assetPath = GetAssetPath(assetId);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
var path = _catalog.GetSourcePath(assetId);
|
||||
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
|
||||
// (You might want to store SourcePath in metadata later so you don't need to pass it here)
|
||||
var ext = Path.GetExtension(sourceFilePath);
|
||||
var handler = GetAssetHandlerForExtension(ext);
|
||||
if (handler is not IImportableAssetHandler importableHandler)
|
||||
await _importCoordinator.EnqueueAsync(new ImportJob(assetId, path, metaPath, ImportReason.ManualReimport), token);
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
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 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 _loadLock.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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.
|
||||
// We should use GetOrAdd here.
|
||||
if (_loadedAssets.TryGetValue(id, out var weakRef)
|
||||
&& weakRef.TryGetTarget(out var existingAsset))
|
||||
var result = await LoadAssetAsync(id, token);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return existingAsset;
|
||||
return result;
|
||||
}
|
||||
|
||||
await _cacheSlim.WaitAsync(token);
|
||||
|
||||
// Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting.
|
||||
if (_loadedAssets.TryGetValue(id, out weakRef)
|
||||
&& weakRef.TryGetTarget(out existingAsset))
|
||||
{
|
||||
return existingAsset;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = GetAssetPath(id);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var assetPath = Path.GetFullPath(path, _rootDirectory);
|
||||
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
|
||||
int sizeofGuid;
|
||||
unsafe
|
||||
{
|
||||
sizeofGuid = sizeof(Guid);
|
||||
}
|
||||
|
||||
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
|
||||
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
|
||||
fs.ReadExactly(typeIdBuffer);
|
||||
|
||||
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
|
||||
var handler = GetAssetHandlerForTypeId(guid);
|
||||
if (handler == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var assetResult = await handler.LoadAsync(fs, this, token);
|
||||
if (assetResult.IsFailure)
|
||||
{
|
||||
return assetResult;
|
||||
}
|
||||
|
||||
var asset = assetResult.Value;
|
||||
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(asset), (key, oldRef) =>
|
||||
{
|
||||
// If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected.
|
||||
// If the assetResult haven't been loaded before, we are in the addValue path, not here.
|
||||
// If the previous reference has been collected, we can just replace it with the new one.
|
||||
// Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time.
|
||||
oldRef.SetTarget(asset);
|
||||
return oldRef;
|
||||
});
|
||||
|
||||
return assetResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cacheSlim.Release();
|
||||
}
|
||||
return await SaveAssetIfDirtyAsync(result.Value, token);
|
||||
}
|
||||
|
||||
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default)
|
||||
public async ValueTask<Result[]> SaveDirtyAssetsAsync()
|
||||
{
|
||||
var path = GetAssetPath(asset.ID);
|
||||
if (path == null)
|
||||
if (_dirtyAssets.IsEmpty)
|
||||
{
|
||||
return Result.Failure("Asset not found.");
|
||||
return Array.Empty<Result>();
|
||||
}
|
||||
|
||||
var handler = GetAssetHandlerForTypeId(asset.TypeID);
|
||||
if (handler == null)
|
||||
var tasks = new Task<Result>[_dirtyAssets.Count];
|
||||
|
||||
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);
|
||||
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
||||
return await handler.SaveAsync(asset, fs, this, token);
|
||||
_dirtyAssets.Clear();
|
||||
return await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cacheSlim.Dispose();
|
||||
_watcher.Dispose();
|
||||
_importCoordinator.Dispose();
|
||||
_loadLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
274
src/Editor/Ghost.Editor.Core/Services/DXCShaderCompiler.cs
Normal file
274
src/Editor/Ghost.Editor.Core/Services/DXCShaderCompiler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
214
src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs
Normal file
214
src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
src/Editor/Ghost.Editor.Core/Utilities/ShaderCompilerUtility.cs
Normal file
167
src/Editor/Ghost.Editor.Core/Utilities/ShaderCompilerUtility.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,13 @@ namespace Ghost.Editor.Core.Utilities;
|
||||
|
||||
public static class TypeCache
|
||||
{
|
||||
private static TypeInfo[] s_types;
|
||||
private static Dictionary<nint, List<MethodInfo>> s_attributeMethodCache;
|
||||
private static TypeInfo[] s_types = null!;
|
||||
private static Dictionary<nint, List<MethodInfo>> s_attributeMethodCache = null!;
|
||||
private static Dictionary<nint, List<int>> s_attributeTypeCache = null!;
|
||||
|
||||
static TypeCache()
|
||||
{
|
||||
s_types = LoadTypes();
|
||||
s_attributeMethodCache = FindMethodWithAttribute();
|
||||
Reload();
|
||||
}
|
||||
|
||||
private static TypeInfo[] LoadTypes()
|
||||
@@ -62,6 +62,29 @@ public static class TypeCache
|
||||
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()
|
||||
{
|
||||
// Intentionally left blank.
|
||||
@@ -72,6 +95,7 @@ public static class TypeCache
|
||||
{
|
||||
s_types = LoadTypes();
|
||||
s_attributeMethodCache = FindMethodWithAttribute();
|
||||
s_attributeTypeCache = FindTypesWithAttribute();
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<TypeInfo> GetTypes()
|
||||
@@ -79,7 +103,7 @@ public static class TypeCache
|
||||
return s_types;
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<MethodInfo>? GetMethodsWithAttribute<T>()
|
||||
public static IEnumerable<MethodInfo>? GetMethodsWithAttribute<T>()
|
||||
where T : DiscoverableAttributeBase
|
||||
{
|
||||
var key = typeof(T).TypeHandle.Value;
|
||||
@@ -90,4 +114,16 @@ public static class TypeCache
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Editor.Core.Utilities;
|
||||
using Ghost.Editor.Models;
|
||||
using Ghost.Engine;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -53,20 +55,25 @@ internal static class ActivationHandler
|
||||
|
||||
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.
|
||||
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.
|
||||
FreeListConcurrencyLevel = Environment.ProcessorCount
|
||||
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.
|
||||
FreeListChunkSize = 64 * 1024,
|
||||
FreeListDefaultAlignment = 8,
|
||||
TLSFInitialChunkSize = 64 * 1024,
|
||||
TLSFAlignment = 8,
|
||||
};
|
||||
|
||||
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.
|
||||
// await Task.Delay(10000); // Wait 10 seconds to simulate work.
|
||||
assetRegistry.OnAssetImported += (sender, e) =>
|
||||
{
|
||||
engineCore.AssetManager.ReimportAsset(e);
|
||||
};
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
<ResourceDictionary Source="/Themes/Generic.xaml" />
|
||||
<ResourceDictionary Source="/Themes/DockingDictionary.xaml" />
|
||||
<core:ControlsDictionary />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -2,11 +2,10 @@ using Ghost.Core;
|
||||
using Ghost.Editor.Core;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Editor.Core.Services;
|
||||
using Ghost.Editor.View.Pages.EngineEditor;
|
||||
using Ghost.Editor.View.Windows;
|
||||
using Ghost.Editor.Core.Utilities;
|
||||
using Ghost.Editor.ViewModels.Controls;
|
||||
using Ghost.Editor.ViewModels.Pages.EngineEditor;
|
||||
using Ghost.Editor.ViewModels.Windows;
|
||||
using Ghost.Editor.Views.Windows;
|
||||
using Ghost.Engine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -52,6 +51,8 @@ public partial class App : Application
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
TypeCache.Initialize();
|
||||
|
||||
Host = Microsoft.Extensions.Hosting.Host.
|
||||
CreateDefaultBuilder().
|
||||
UseContentRoot(AppContext.BaseDirectory).
|
||||
@@ -63,27 +64,40 @@ public partial class App : Application
|
||||
services.AddSingleton<IProgressService, ProgressService>();
|
||||
services.AddSingleton<IInspectorService, InspectorService>();
|
||||
services.AddSingleton<IPreviewService, PreviewService>();
|
||||
// services.AddSingleton<IAssetService, AssetService>();
|
||||
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
||||
services.AddSingleton<IContentProvider, EditorContentProvider>();
|
||||
|
||||
services.AddSingleton<EngineCore>();
|
||||
|
||||
services.AddSingleton<EngineEditorViewModel>();
|
||||
|
||||
services.AddTransient<ProjectBrowserViewModel>();
|
||||
services.AddTransient<ContentBrowserViewModel>();
|
||||
|
||||
#region Should be deleted
|
||||
services.AddTransient<ScenePage>();
|
||||
// TODO: Use source generators to generate this code at compile time instead of using reflection at runtime.
|
||||
foreach (var type in TypeCache.GetTypes())
|
||||
{
|
||||
var data = type.GetCustomAttributesData().FirstOrDefault(a => a.AttributeType == typeof(EditorInjectionAttribute));
|
||||
if (data is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
services.AddTransient<HierarchyPage>();
|
||||
services.AddTransient<HierarchyViewModel>();
|
||||
var lifeTime = (EditorInjectionAttribute.ServiceLifetime)data.ConstructorArguments[0].Value!;
|
||||
var implementationType = (Type)data.ConstructorArguments[1].Value!;
|
||||
var serviceType = type.IsInterface ? type.AsType() : implementationType;
|
||||
|
||||
services.AddTransient<ProjectPage>();
|
||||
services.AddTransient<ProjectViewModel>();
|
||||
|
||||
services.AddTransient<ConsolePage>();
|
||||
services.AddTransient<ConsoleViewModel>();
|
||||
|
||||
services.AddTransient<InspectorPage>();
|
||||
services.AddTransient<InspectorViewModel>();
|
||||
#endregion
|
||||
switch (lifeTime)
|
||||
{
|
||||
case EditorInjectionAttribute.ServiceLifetime.Singleton:
|
||||
services.AddSingleton(serviceType, implementationType);
|
||||
break;
|
||||
case EditorInjectionAttribute.ServiceLifetime.Transient:
|
||||
services.AddTransient(serviceType, implementationType);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -116,25 +130,32 @@ public partial class App : Application
|
||||
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.
|
||||
EditorApplication.SetDispatcherQueue(DispatcherQueue.GetForCurrentThread());
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Activate();
|
||||
Window = splashWindow;
|
||||
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Activate();
|
||||
Window = splashWindow;
|
||||
await Host.StartAsync();
|
||||
await ActivationHandler.HandleAsync(arguments);
|
||||
|
||||
await Host.StartAsync();
|
||||
await ActivationHandler.HandleAsync(arguments);
|
||||
splashWindow.Hide();
|
||||
|
||||
splashWindow.Hide();
|
||||
var editorWindow = new EngineEditorWindow();
|
||||
editorWindow.Activate();
|
||||
Window = editorWindow;
|
||||
|
||||
var editorWindow = new EngineEditorWindow();
|
||||
editorWindow.Activate();
|
||||
Window = editorWindow;
|
||||
|
||||
splashWindow.Close();
|
||||
splashWindow.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex);
|
||||
Environment.Exit(ex.HResult);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClosed(object? sender, WindowEventArgs args)
|
||||
@@ -144,24 +165,24 @@ public partial class App : Application
|
||||
Host.StopAsync().GetAwaiter().GetResult();
|
||||
Host.Dispose();
|
||||
|
||||
//EditorApplication.Shutdown();
|
||||
EditorApplication.Shutdown();
|
||||
ActivationHandler.Shutdown();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debugger.BreakForUserUnhandledException(ex);
|
||||
Logger.Error(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
//Environment.Exit(0);
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Logger.LogError(e.Exception);
|
||||
Logger.Error(e.Exception);
|
||||
#if DEBUG
|
||||
Debugger.BreakForUserUnhandledException(e.Exception);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
|
||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||
<Platforms>x86;x64;ARM64</Platforms>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
@@ -12,8 +12,7 @@
|
||||
<langversion>preview</langversion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="View\Controls\Hierarchy.xaml" />
|
||||
<None Remove="View\Windows\BlankWindow1.xaml" />
|
||||
<None Remove="Views\Controls\Hierarchy.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||
@@ -39,9 +38,9 @@
|
||||
<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.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.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="WinUIEx" Version="2.9.0" />
|
||||
</ItemGroup>
|
||||
@@ -142,6 +141,12 @@
|
||||
<None Update="Assets\icon.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</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">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
@@ -196,6 +201,8 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="ContextMenu\" />
|
||||
<Folder Include="ViewModels\Pages\" />
|
||||
<Folder Include="Views\Pages\" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals" />
|
||||
|
||||
|
||||
@@ -1,27 +1,72 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Ghost.Core.Utilities;
|
||||
using Ghost.Engine;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Ghost.Editor.Models;
|
||||
|
||||
internal class ExplorerItem(string name, string path, bool isDirectory)
|
||||
internal partial class ExplorerItem : ObservableObject
|
||||
{
|
||||
public string Name
|
||||
{
|
||||
get;
|
||||
} = name;
|
||||
}
|
||||
|
||||
public string FullName
|
||||
public string Path
|
||||
{
|
||||
get;
|
||||
} = path;
|
||||
}
|
||||
|
||||
public bool IsDirectory
|
||||
{
|
||||
get;
|
||||
} = isDirectory;
|
||||
}
|
||||
|
||||
public ObservableCollection<ExplorerItem>? Children
|
||||
|
||||
public AssetType AssetType
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Ghost.Editor.Properties {
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// A strongly-typed heap class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
@@ -48,7 +48,7 @@ namespace Ghost.Editor.Properties {
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="/View/Controls/Docking/DockingLayout.xaml" />
|
||||
<ResourceDictionary Source="/View/Controls/Docking/DockGroup.xaml" />
|
||||
<ResourceDictionary Source="/View/Controls/Docking/DockPanel.xaml" />
|
||||
<ResourceDictionary Source="/View/Controls/Docking/DockRegionHighlight.xaml" />
|
||||
<ResourceDictionary Source="/View/Controls/Docking/DockDocument.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -1,7 +1,7 @@
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
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:local="using:Ghost.Editor.Core">
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
</Style>
|
||||
<x:Double x:Key="ControlContentThemeFontSize">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>
|
||||
<Thickness x:Key="TextControlThemePadding">2,2,6,1</Thickness>
|
||||
<x:Double x:Key="ListViewItemMinHeight">32</x:Double>
|
||||
@@ -42,12 +43,133 @@
|
||||
<Setter Property="TabWidthMode" Value="Compact" />
|
||||
</Style>
|
||||
<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 -->
|
||||
<Style
|
||||
x:Key="ToolbarButton"
|
||||
BasedOn="{StaticResource SubtleButtonStyle}"
|
||||
TargetType="Button" />
|
||||
<Style x:Key="ToolbarButton" TargetType="Button">
|
||||
<Setter Property="Padding" Value="2" />
|
||||
<Setter Property="Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
|
||||
<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 -->
|
||||
<x:Double x:Key="ToolbarIconSize">12</x:Double>
|
||||
|
||||
@@ -18,7 +18,7 @@ public partial class AssetPathToGlyphConverter : IValueConverter
|
||||
|
||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||
|
||||
// TODO: Use resource dictionary for icons.
|
||||
// TODO: Use heap dictionary for icons.
|
||||
return extension switch
|
||||
{
|
||||
".ghostscene" => "\uF159",
|
||||
|
||||
@@ -12,7 +12,7 @@ public partial class ExplorerItemToIconUriConverter : IValueConverter
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ public partial class GetDirectoryNameConverter : IValueConverter
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public enum DockTarget
|
||||
{
|
||||
Center,
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
</AppBarToggleButton.Icon>
|
||||
</AppBarToggleButton>
|
||||
<AppBarToggleButton Width="45" IsChecked="{x:Bind ViewModel.ShowWarning, Mode=TwoWay}">
|
||||
<AppBarToggleButton.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</AppBarToggleButton.Icon>
|
||||
</AppBarToggleButton>
|
||||
<AppBarToggleButton Width="45" IsChecked="{x:Bind ViewModel.ShowError, Mode=TwoWay}">
|
||||
<AppBarToggleButton.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
<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="" />
|
||||
<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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
<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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
// }
|
||||
//}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
</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="" />
|
||||
</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="" />
|
||||
</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="" />
|
||||
</TabViewItem.IconSource>
|
||||
<controls:ProjectBrowser />
|
||||
</TabViewItem>
|
||||
<TabViewItem Header="Console">
|
||||
<TabViewItem.IconSource>
|
||||
<FontIconSource Glyph="" />
|
||||
</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=""
|
||||
Visibility="Visible" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="Collapsed">
|
||||
<FontIcon
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="16"
|
||||
Foreground="{ThemeResource SystemFillColorAttentionBrush}"
|
||||
Glyph="" />
|
||||
<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="" />
|
||||
<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="" />
|
||||
<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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using Ghost.Editor.Core;
|
||||
|
||||
namespace Ghost.Editor.View.Controls;
|
||||
namespace Ghost.Editor.Views.Controls;
|
||||
|
||||
internal partial class ProjectBrowser
|
||||
internal partial class ContentBrowser
|
||||
{
|
||||
[ContextMenuItem("project-browser", "Show in Explorer")]
|
||||
private static void ShowInExplorer()
|
||||
@@ -1,23 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="Ghost.Editor.View.Controls.ProjectBrowser"
|
||||
x:Class="Ghost.Editor.Views.Controls.ContentBrowser"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:community="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:converter="using:Ghost.Editor.Utilities.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:ghost="using:Ghost.Editor.Core.Controls"
|
||||
xmlns:local="using:Ghost.Editor.View.Controls"
|
||||
xmlns:local="using:Ghost.Editor.Views.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:model="using:Ghost.Editor.Models"
|
||||
xmlns:sys="using:System"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<converter:ExplorerItemToIconUriConverter x:Key="ExplorerItemToIconUriConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid MinHeight="50">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -137,21 +133,20 @@
|
||||
<BreadcrumbBar />
|
||||
</Border>-->
|
||||
|
||||
<ItemsView
|
||||
<GridView
|
||||
x:Name="PART_FilesView"
|
||||
Grid.Row="0"
|
||||
Padding="8"
|
||||
DoubleTapped="PART_FilesView_DoubleTapped"
|
||||
IsDoubleTapEnabled="True"
|
||||
ItemsSource="{x:Bind ViewModel.Files, Mode=OneWay}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.HorizontalScrollMode="Disabled"
|
||||
ItemsSource="{x:Bind ViewModel.Files}"
|
||||
SelectionChanged="PART_FilesView_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<ItemsView.ItemTemplate>
|
||||
SelectionMode="Extended">
|
||||
<GridView.ItemTemplate>
|
||||
<DataTemplate x:DataType="model:ExplorerItem">
|
||||
<ItemContainer>
|
||||
<Grid
|
||||
Width="72"
|
||||
Padding="8"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
@@ -172,11 +167,7 @@
|
||||
</Grid.ContextFlyout>
|
||||
|
||||
<community:ConstrainedBox Grid.Row="0" AspectRatio="1:1">
|
||||
<Image HorizontalAlignment="Center">
|
||||
<Image.Source>
|
||||
<BitmapImage DecodePixelWidth="48" UriSource="{x:Bind Converter={StaticResource ExplorerItemToIconUriConverter}}" />
|
||||
</Image.Source>
|
||||
</Image>
|
||||
<FontIcon FontSize="36" Glyph="{x:Bind IconGlyph}" />
|
||||
</community:ConstrainedBox>
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
@@ -188,18 +179,11 @@
|
||||
</Grid>
|
||||
</ItemContainer>
|
||||
</DataTemplate>
|
||||
</ItemsView.ItemTemplate>
|
||||
<ItemsView.Layout>
|
||||
<UniformGridLayout
|
||||
ItemsStretch="Fill"
|
||||
MinColumnSpacing="4"
|
||||
MinItemWidth="72"
|
||||
MinRowSpacing="4" />
|
||||
</ItemsView.Layout>
|
||||
<ItemsView.ContextFlyout>
|
||||
</GridView.ItemTemplate>
|
||||
<GridView.ContextFlyout>
|
||||
<ghost:ContextFlyout Tag="project-browser" />
|
||||
</ItemsView.ContextFlyout>
|
||||
</ItemsView>
|
||||
</GridView.ContextFlyout>
|
||||
</GridView>
|
||||
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user