Compare commits
52 Commits
feature/do
...
e7fedfd35a
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
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;
|
||||
|
||||
23
src/Editor/Ghost.DSL/ShaderPropertyRegistry.cs
Normal file
23
src/Editor/Ghost.DSL/ShaderPropertyRegistry.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Ghost.DSL;
|
||||
|
||||
public struct ShaderPropertyInfo
|
||||
{
|
||||
public string shaderName;
|
||||
public string code;
|
||||
public uint size;
|
||||
}
|
||||
|
||||
public static class ShaderPropertiesRegistry
|
||||
{
|
||||
private static readonly Dictionary<string, ShaderPropertyInfo> s_nameToCode = new Dictionary<string, ShaderPropertyInfo>(StringComparer.Ordinal);
|
||||
|
||||
public static void Register(string name, string code, uint size)
|
||||
{
|
||||
s_nameToCode[name] = new ShaderPropertyInfo { shaderName = name, code = code, size = size };
|
||||
}
|
||||
|
||||
public static bool TryGetInfo(string name, out ShaderPropertyInfo info)
|
||||
{
|
||||
return s_nameToCode.TryGetValue(name, out info);
|
||||
}
|
||||
}
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
55
src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs
Normal file
55
src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Engine;
|
||||
|
||||
namespace Ghost.Editor.Core.Assets;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class CustomAssetHandlerAttribute : Attribute
|
||||
{
|
||||
public CustomAssetHandlerAttribute(string assetTypeID, string[] supportedExtensions, int version = 1)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAsset : IDisposable
|
||||
{
|
||||
public Guid ID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public Guid TypeID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public IAssetSettings? Settings
|
||||
{
|
||||
get;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAssetExportOptions;
|
||||
|
||||
public interface IAssetHandler
|
||||
{
|
||||
AssetType RuntimeAssetType { get; }
|
||||
Guid EditorAssetTypeID { get; }
|
||||
|
||||
IAssetSettings? CreateDefaultSettings();
|
||||
|
||||
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
|
||||
{
|
||||
bool CanExport { get; }
|
||||
ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
||||
ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default);
|
||||
}
|
||||
|
||||
public interface IPackableAssetHandler : IAssetHandler
|
||||
{
|
||||
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);
|
||||
}
|
||||
86
src/Editor/Ghost.Editor.Core/Assets/AssetHandlerRegistry.cs
Normal file
86
src/Editor/Ghost.Editor.Core/Assets/AssetHandlerRegistry.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Ghost.Engine;
|
||||
|
||||
namespace Ghost.Editor.Core.Assets;
|
||||
|
||||
public static class AssetHandlerRegistry
|
||||
{
|
||||
private static readonly Dictionary<string, IAssetHandler> s_byExtension;
|
||||
private static readonly Dictionary<string, AssetType> s_typeByExtension;
|
||||
private static readonly Dictionary<Guid, IAssetHandler> s_byTypeId;
|
||||
private static readonly Dictionary<Guid, int> s_versionByTypeId;
|
||||
|
||||
private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes;
|
||||
|
||||
static AssetHandlerRegistry()
|
||||
{
|
||||
s_byExtension = new Dictionary<string, IAssetHandler>(StringComparer.OrdinalIgnoreCase);
|
||||
s_typeByExtension = new Dictionary<string, AssetType>(StringComparer.OrdinalIgnoreCase);
|
||||
s_byTypeId = new Dictionary<Guid, IAssetHandler>();
|
||||
s_versionByTypeId = new Dictionary<Guid, int>();
|
||||
|
||||
s_iAssetSettingsTypes = new List<(Type Type, string Name)>();
|
||||
}
|
||||
|
||||
public static void RegisterHandler(IAssetHandler handler, Guid assetTypeId, ReadOnlySpan<string> extensions, int version)
|
||||
{
|
||||
s_byTypeId[assetTypeId] = handler;
|
||||
s_versionByTypeId[assetTypeId] = version;
|
||||
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
var normalizedExt = ext.StartsWith('.') ? ext : "." + ext;
|
||||
s_byExtension[normalizedExt] = handler;
|
||||
s_typeByExtension[normalizedExt] = handler.RuntimeAssetType;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
s_byExtension.TryGetValue(normalized, out var handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
public static IAssetHandler? GetByAssetTypeId(Guid typeId)
|
||||
{
|
||||
s_byTypeId.TryGetValue(typeId, out var handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
public static int GetVersionByAssetTypeId(Guid typeId)
|
||||
{
|
||||
s_versionByTypeId.TryGetValue(typeId, out var version);
|
||||
return version;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetSupportedExtensions()
|
||||
{
|
||||
return s_byExtension.Keys;
|
||||
}
|
||||
|
||||
public static AssetType GetRuntimeAssetTypeByExtension(string extension)
|
||||
{
|
||||
if (string.IsNullOrEmpty(extension))
|
||||
{
|
||||
return AssetType.Unknown;
|
||||
}
|
||||
|
||||
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
||||
return s_typeByExtension.GetValueOrDefault(normalized, AssetType.Unknown);
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes()
|
||||
{
|
||||
return s_iAssetSettingsTypes;
|
||||
}
|
||||
}
|
||||
150
src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs
Normal file
150
src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
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 which IAssetHandler processes this asset.
|
||||
/// </summary>
|
||||
public Guid? HandlerTypeId { 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";
|
||||
|
||||
private 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];
|
||||
}
|
||||
}
|
||||
280
src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs
Normal file
280
src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Engine;
|
||||
using Ghost.Graphics.RHI;
|
||||
using Misaki.HighPerformance.LowLevel.Collections;
|
||||
using Misaki.HighPerformance.Mathematics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Ghost.Editor.Core.Assets;
|
||||
|
||||
public class MeshNode : IDisposable
|
||||
{
|
||||
public string Name
|
||||
{
|
||||
get; set;
|
||||
} = string.Empty;
|
||||
|
||||
public float4x4 LocalTransform
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public MeshNode? Parent
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<MeshNode> Children
|
||||
{
|
||||
get; set;
|
||||
} = Array.Empty<MeshNode>();
|
||||
|
||||
~MeshNode()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
|
||||
public MeshNode Clone()
|
||||
{
|
||||
return (MeshNode)MemberwiseClone();
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var child in Children)
|
||||
{
|
||||
child.Dispose();
|
||||
}
|
||||
|
||||
Parent = null;
|
||||
Children = Array.Empty<MeshNode>();
|
||||
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes one material partition within a unified vertex/index buffer.
|
||||
/// </summary>
|
||||
public struct MaterialPartInfo
|
||||
{
|
||||
/// <summary> The material slot index (from ufbx face_material). </summary>
|
||||
public int materialIndex;
|
||||
/// <summary> Byte offset into the unified index buffer. </summary>
|
||||
public int indexStart;
|
||||
/// <summary> Number of indices belonging to this part. </summary>
|
||||
public int indexCount;
|
||||
/// <summary> Byte offset into the unified vertex buffer. </summary>
|
||||
public int vertexStart;
|
||||
/// <summary> Number of unique vertices belonging to this part. </summary>
|
||||
public int vertexCount;
|
||||
}
|
||||
|
||||
public class GeometryMeshNode : MeshNode
|
||||
{
|
||||
private UnsafeList<Vertex> _vertices;
|
||||
private UnsafeList<uint> _indices;
|
||||
private UnsafeArray<MaterialPartInfo> _materialParts;
|
||||
|
||||
public UnsafeList<Vertex> Vertices
|
||||
{
|
||||
get => _vertices;
|
||||
set
|
||||
{
|
||||
_vertices.Dispose();
|
||||
_vertices = value;
|
||||
}
|
||||
}
|
||||
|
||||
public UnsafeList<uint> Indices
|
||||
{
|
||||
get => _indices;
|
||||
set
|
||||
{
|
||||
_indices.Dispose();
|
||||
_indices = value;
|
||||
}
|
||||
}
|
||||
|
||||
public UnsafeArray<MaterialPartInfo> MaterialParts
|
||||
{
|
||||
get => _materialParts;
|
||||
set
|
||||
{
|
||||
_materialParts.Dispose();
|
||||
_materialParts = value;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_vertices.Dispose();
|
||||
_indices.Dispose();
|
||||
_materialParts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class LightMeshNode : MeshNode
|
||||
{
|
||||
public float3 Color
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public float Intensity
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class MeshAsset : IAsset
|
||||
{
|
||||
private MeshNode _root;
|
||||
|
||||
public Guid ID
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public IAssetSettings Settings
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public Guid TypeID => typeof(MeshAsset).GUID;
|
||||
|
||||
public MeshNode Root
|
||||
{
|
||||
get => _root;
|
||||
set
|
||||
{
|
||||
_root?.Dispose();
|
||||
_root = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal MeshAsset(MeshNode root, Guid id, MeshAssetSettings settings)
|
||||
{
|
||||
_root = root;
|
||||
|
||||
ID = id;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_root?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Guid(GUID)]
|
||||
public partial class FBXAsset : MeshAsset
|
||||
{
|
||||
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
|
||||
|
||||
internal FBXAsset(MeshNode root, Guid id, FbxAssetSettings settings)
|
||||
: base(root, id, settings)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public enum CoordinateAxis
|
||||
{
|
||||
PositiveX,
|
||||
PositiveY,
|
||||
PositiveZ,
|
||||
NegativeX,
|
||||
NegativeY,
|
||||
NegativeZ
|
||||
}
|
||||
|
||||
public enum VertexDataSource
|
||||
{
|
||||
Imported,
|
||||
Computed,
|
||||
ComputedIfMissing
|
||||
}
|
||||
|
||||
public class MeshAssetSettings : IAssetSettings
|
||||
{
|
||||
public VertexDataSource NormalDataSource
|
||||
{
|
||||
get; set;
|
||||
} = VertexDataSource.ComputedIfMissing;
|
||||
|
||||
public VertexDataSource TangentDataSource
|
||||
{
|
||||
get; set;
|
||||
} = VertexDataSource.ComputedIfMissing;
|
||||
}
|
||||
|
||||
internal class ObjAssetSettings : MeshAssetSettings
|
||||
{
|
||||
public CoordinateAxis ObjectUpAxis
|
||||
{
|
||||
get; set;
|
||||
} = CoordinateAxis.PositiveY;
|
||||
|
||||
public CoordinateAxis ObjectForwardAxis
|
||||
{
|
||||
get; set;
|
||||
} = CoordinateAxis.NegativeZ;
|
||||
|
||||
public CoordinateAxis ObjectRightAxis
|
||||
{
|
||||
get; set;
|
||||
} = CoordinateAxis.PositiveX;
|
||||
|
||||
public float UnitMeterScale
|
||||
{
|
||||
get; set;
|
||||
} = 1.0f;
|
||||
}
|
||||
|
||||
internal class FbxAssetSettings : MeshAssetSettings
|
||||
{
|
||||
}
|
||||
|
||||
internal class FBXAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||
{
|
||||
public AssetType RuntimeAssetType => AssetType.Mesh;
|
||||
|
||||
public Guid EditorAssetTypeID => typeof(FBXAsset).GUID;
|
||||
|
||||
public bool CanExport => false;
|
||||
|
||||
public IAssetSettings? CreateDefaultSettings()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public ValueTask<Result> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
366
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Import.cs
Normal file
366
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Import.cs
Normal file
@@ -0,0 +1,366 @@
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
childNode.Parent = self;
|
||||
|
||||
children.Add(childNode);
|
||||
}
|
||||
}
|
||||
|
||||
private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh)
|
||||
{
|
||||
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.FreeList);
|
||||
using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, AllocationHandle.FreeList);
|
||||
using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, AllocationHandle.FreeList);
|
||||
|
||||
for (var i = 0; i < numMaterials; i++)
|
||||
{
|
||||
materialBuckets[i] = new UnsafeList<Vertex>(1024, AllocationHandle.FreeList);
|
||||
}
|
||||
|
||||
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
|
||||
|
||||
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, AllocationHandle.FreeList);
|
||||
|
||||
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.FreeList);
|
||||
|
||||
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.FreeList);
|
||||
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, AllocationHandle.FreeList);
|
||||
|
||||
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.FreeList);
|
||||
var partIndices = new UnsafeList<uint>((int)numIndices, AllocationHandle.FreeList);
|
||||
|
||||
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.FreeList);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class MeshProcessor
|
||||
{
|
||||
|
||||
}
|
||||
1111
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Meshlet.cs
Normal file
1111
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Meshlet.cs
Normal file
File diff suppressed because it is too large
Load Diff
154
src/Editor/Ghost.Editor.Core/Assets/ShaderAssetHandler.cs
Normal file
154
src/Editor/Ghost.Editor.Core/Assets/ShaderAssetHandler.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
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(GraphicsShaderAsset.GUID, [".gshdr"], 1)]
|
||||
internal class GraphicsShaderAssetHandler : IPackableAssetHandler
|
||||
{
|
||||
public AssetType RuntimeAssetType => AssetType.Shader;
|
||||
public Guid EditorAssetTypeID => typeof(GraphicsShaderAsset).GUID;
|
||||
|
||||
public IAssetSettings? CreateDefaultSettings()
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
[CustomAssetHandler(ComputeShaderAsset.GUID, [".gcomp"], 1)]
|
||||
internal class ComputeShaderAssetHandler : IPackableAssetHandler
|
||||
{
|
||||
public AssetType RuntimeAssetType => AssetType.Shader;
|
||||
public Guid EditorAssetTypeID => typeof(ComputeShaderAsset).GUID;
|
||||
|
||||
public IAssetSettings? CreateDefaultSettings()
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
506
src/Editor/Ghost.Editor.Core/Assets/TextureAssetHandler.cs
Normal file
506
src/Editor/Ghost.Editor.Core/Assets/TextureAssetHandler.cs
Normal file
@@ -0,0 +1,506 @@
|
||||
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(TextureAsset.GUID, [".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr"], 1)]
|
||||
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 bool CanExport => false;
|
||||
public AssetType RuntimeAssetType => AssetType.Texture;
|
||||
public Guid EditorAssetTypeID => typeof(TextureAsset).GUID;
|
||||
|
||||
public IAssetSettings? CreateDefaultSettings()
|
||||
{
|
||||
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> 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure($"Failed to import texture asset: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<Result> ExportAsync(string assetPath, string targetPath, IAssetExportOptions? options, CancellationToken token = default)
|
||||
{
|
||||
return ValueTask.FromResult(Result.Failure("Exporting texture assets is not supported yet."));
|
||||
}
|
||||
|
||||
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -6,15 +6,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 +33,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 +55,27 @@ public static class EditorApplication
|
||||
|
||||
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
||||
{
|
||||
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 +97,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,9 @@
|
||||
<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" />
|
||||
<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 +25,11 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -37,4 +43,4 @@
|
||||
<SubType>Designer</SubType>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
262
src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs
Normal file
262
src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs
Normal file
@@ -0,0 +1,262 @@
|
||||
using Ghost.Editor.Core.Assets;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Ghost.Editor.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe SQLite-backed asset catalog.
|
||||
/// Replaces the in-memory dictionary approach with persistent storage.
|
||||
/// </summary>
|
||||
public sealed partial class AssetCatalog : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly Lock _writeLock = new();
|
||||
|
||||
// Prepared statements
|
||||
private readonly SqliteCommand _cmdGetGuid;
|
||||
private readonly SqliteCommand _cmdGetPath;
|
||||
private readonly SqliteCommand _cmdUpsert;
|
||||
private readonly SqliteCommand _cmdDelete;
|
||||
|
||||
private readonly SqliteCommand _cmdGetHandlerTypeId;
|
||||
private readonly SqliteCommand _cmdGetReferencers;
|
||||
private readonly SqliteCommand _cmdGetDependencies;
|
||||
private readonly SqliteCommand _cmdGetImportedAt;
|
||||
|
||||
private readonly SqliteCommand _cmdInsertDep;
|
||||
private readonly SqliteCommand _cmdClearDeps;
|
||||
private readonly SqliteCommand _cmdEnumerate;
|
||||
|
||||
public AssetCatalog(string dbPath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
|
||||
var connString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = dbPath,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
}.ToString();
|
||||
|
||||
_connection = new SqliteConnection(connString);
|
||||
_connection.Open();
|
||||
|
||||
using (var pragma = _connection.CreateCommand())
|
||||
{
|
||||
pragma.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;";
|
||||
pragma.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
CreateSchema();
|
||||
|
||||
_cmdGetGuid = CreateCommand("SELECT guid FROM assets WHERE source_path = @path");
|
||||
_cmdGetPath = CreateCommand("SELECT source_path FROM assets WHERE guid = @guid");
|
||||
_cmdGetHandlerTypeId = CreateCommand("SELECT handler_type_id FROM assets WHERE guid = @guid");
|
||||
_cmdGetImportedAt = CreateCommand("SELECT imported_at_ms FROM assets WHERE guid = @guid");
|
||||
|
||||
_cmdUpsert = CreateCommand(@"
|
||||
INSERT INTO assets (guid, source_path, handler_type_id, handler_version, content_hash, settings_hash, imported_at_ms)
|
||||
VALUES (@guid, @path, @handler_id, @version, @content_hash, @settings_hash, @imported_at_ms)
|
||||
ON CONFLICT(guid) DO UPDATE SET
|
||||
source_path = excluded.source_path,
|
||||
handler_type_id = excluded.handler_type_id,
|
||||
handler_version = excluded.handler_version,
|
||||
content_hash = excluded.content_hash,
|
||||
settings_hash = excluded.settings_hash,
|
||||
imported_at_ms = excluded.imported_at_ms");
|
||||
_cmdDelete = CreateCommand("DELETE FROM assets WHERE guid = @guid");
|
||||
_cmdGetReferencers = CreateCommand("SELECT from_guid FROM dependencies WHERE to_guid = @guid");
|
||||
_cmdGetDependencies = CreateCommand("SELECT to_guid FROM dependencies WHERE from_guid = @guid");
|
||||
|
||||
_cmdInsertDep = CreateCommand("INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)");
|
||||
_cmdClearDeps = CreateCommand("DELETE FROM dependencies WHERE from_guid = @guid");
|
||||
_cmdEnumerate = CreateCommand("SELECT guid, source_path FROM assets");
|
||||
}
|
||||
|
||||
private SqliteCommand CreateCommand(string sql)
|
||||
{
|
||||
var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private void CreateSchema()
|
||||
{
|
||||
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,
|
||||
handler_type_id BLOB(16),
|
||||
handler_version INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT,
|
||||
settings_hash TEXT,
|
||||
imported_at_ms INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
|
||||
|
||||
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)
|
||||
{
|
||||
_cmdGetGuid.Parameters.Clear();
|
||||
_cmdGetGuid.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
||||
var result = _cmdGetGuid.ExecuteScalar();
|
||||
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
||||
}
|
||||
|
||||
public string? GetSourcePath(Guid guid)
|
||||
{
|
||||
_cmdGetPath.Parameters.Clear();
|
||||
_cmdGetPath.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||
return _cmdGetPath.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
public void Upsert(AssetMeta meta, string sourcePath)
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
_cmdUpsert.Parameters.Clear();
|
||||
_cmdUpsert.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray());
|
||||
_cmdUpsert.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
||||
_cmdUpsert.Parameters.AddWithValue("@handler_id", meta.HandlerTypeId?.ToByteArray() ?? (object)DBNull.Value);
|
||||
_cmdUpsert.Parameters.AddWithValue("@version", meta.HandlerVersion);
|
||||
_cmdUpsert.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value);
|
||||
_cmdUpsert.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value);
|
||||
_cmdUpsert.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value);
|
||||
_cmdUpsert.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(Guid guid)
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
_cmdDelete.Parameters.Clear();
|
||||
_cmdDelete.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||
return _cmdDelete.ExecuteNonQuery() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid GetHandlerTypeId(Guid guid)
|
||||
{
|
||||
_cmdGetHandlerTypeId.Parameters.Clear();
|
||||
_cmdGetHandlerTypeId.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||
var result = _cmdGetHandlerTypeId.ExecuteScalar();
|
||||
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
||||
}
|
||||
|
||||
public DateTime? GetImportedAt(Guid guid)
|
||||
{
|
||||
_cmdGetImportedAt.Parameters.Clear();
|
||||
_cmdGetImportedAt.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||
var result = _cmdGetImportedAt.ExecuteScalar();
|
||||
|
||||
if (result is long ticks)
|
||||
{
|
||||
return new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
_cmdClearDeps.Transaction = tx;
|
||||
_cmdClearDeps.Parameters.Clear();
|
||||
_cmdClearDeps.Parameters.AddWithValue("@guid", assetId.ToByteArray());
|
||||
_cmdClearDeps.ExecuteNonQuery();
|
||||
|
||||
_cmdInsertDep.Transaction = tx;
|
||||
foreach (var dep in dependencies)
|
||||
{
|
||||
_cmdInsertDep.Parameters.Clear();
|
||||
_cmdInsertDep.Parameters.AddWithValue("@from", assetId.ToByteArray());
|
||||
_cmdInsertDep.Parameters.AddWithValue("@to", dep.ToByteArray());
|
||||
_cmdInsertDep.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Guid> GetReferencers(Guid guid)
|
||||
{
|
||||
_cmdGetReferencers.Parameters.Clear();
|
||||
_cmdGetReferencers.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||
|
||||
using var reader = _cmdGetReferencers.ExecuteReader();
|
||||
var list = new List<Guid>();
|
||||
while (reader.Read())
|
||||
{
|
||||
list.Add(new Guid((byte[])reader[0]));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public List<Guid> GetDependencies(Guid guid)
|
||||
{
|
||||
_cmdGetDependencies.Parameters.Clear();
|
||||
_cmdGetDependencies.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||
|
||||
using var reader = _cmdGetDependencies.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 reader = _cmdEnumerate.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
yield return (new Guid((byte[])reader[0]), reader.GetString(1));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cmdGetGuid.Dispose();
|
||||
_cmdGetPath.Dispose();
|
||||
_cmdUpsert.Dispose();
|
||||
_cmdDelete.Dispose();
|
||||
_cmdGetHandlerTypeId.Dispose();
|
||||
_cmdGetReferencers.Dispose();
|
||||
_cmdGetDependencies.Dispose();
|
||||
_cmdInsertDep.Dispose();
|
||||
_cmdClearDeps.Dispose();
|
||||
_cmdEnumerate.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace TestProject.AssetDB;
|
||||
|
||||
internal partial class AssetRegistry
|
||||
{
|
||||
// TODO: Sqlite backend implementation
|
||||
}
|
||||
@@ -1,510 +1,412 @@
|
||||
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
|
||||
};
|
||||
|
||||
_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))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(_rootDirectory, filePath);
|
||||
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
catch (Exception
|
||||
#if DEBUG
|
||||
ex
|
||||
#endif
|
||||
)
|
||||
{
|
||||
#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 (_referencerGraph.TryGetValue(dep, out var refs))
|
||||
{
|
||||
refs.Remove(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Set new forward dependencies
|
||||
var newDepSet = new HashSet<Guid>(newDependencies);
|
||||
_dependencyCache[assetId] = newDepSet;
|
||||
|
||||
// 3. Add new references (reverse)
|
||||
foreach (var dep in newDepSet)
|
||||
{
|
||||
ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists);
|
||||
if (!exists || referencers is null)
|
||||
{
|
||||
referencers = new HashSet<Guid>();
|
||||
}
|
||||
|
||||
referencers.Add(assetId);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePathMapping(string relativePath, Guid guid)
|
||||
{
|
||||
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 _))
|
||||
if (!Directory.Exists(EditorApplication.AssetsFolderPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||
var ext = Path.GetExtension(relativePath);
|
||||
var metaFiles = Directory.EnumerateFiles(EditorApplication.AssetsFolderPath, "*.gmeta", SearchOption.AllDirectories);
|
||||
var foundGuids = new HashSet<Guid>();
|
||||
|
||||
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)
|
||||
foreach (var metaPath in metaFiles)
|
||||
{
|
||||
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;
|
||||
var meta = AssetMetaIO.ReadAsync(metaPath).AsTask().Result;
|
||||
if (meta != null)
|
||||
{
|
||||
var sourceRelative = AssetMetaIO.GetSourcePath(Path.GetRelativePath(EditorApplication.ProjectPath, metaPath));
|
||||
_catalog.Upsert(meta, sourceRelative);
|
||||
foundGuids.Add(meta.Guid);
|
||||
}
|
||||
}
|
||||
|
||||
if (fireEvent)
|
||||
foreach (var (guid, path) in _catalog.EnumerateAll())
|
||||
{
|
||||
if (!foundGuids.Contains(guid))
|
||||
{
|
||||
_catalog.Remove(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnFileSystemEvent(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
var ext = Path.GetExtension(e.FullPath);
|
||||
|
||||
if (ext is ".tmp" or ".gtemp")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_eventDebouncers.TryGetValue(e.FullPath, out var existingCts))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
|
||||
private void OnFileSystemRenameEvent(object sender, RenamedEventArgs e)
|
||||
{
|
||||
var ext = Path.GetExtension(e.FullPath);
|
||||
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
|
||||
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 oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
|
||||
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
||||
|
||||
if (_pathToGuid.Remove(oldRelativePath, out var guid))
|
||||
var handlerTypeId = handler?.EditorAssetTypeID;
|
||||
var meta = new AssetMeta
|
||||
{
|
||||
UpdatePathMapping(newRelativePath, guid);
|
||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
|
||||
}
|
||||
Guid = Guid.NewGuid(),
|
||||
HandlerTypeId = handlerTypeId,
|
||||
HandlerVersion = 1,
|
||||
Settings = handler?.CreateDefaultSettings()
|
||||
};
|
||||
|
||||
_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();
|
||||
_catalog.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,44 @@
|
||||
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 handlerID = _catalog.GetHandlerTypeId(guid);
|
||||
var handler = AssetHandlerRegistry.GetByAssetTypeId(handlerID);
|
||||
return handler?.RuntimeAssetType ?? AssetType.Unknown;
|
||||
}
|
||||
}
|
||||
175
src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs
Normal file
175
src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
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.HandlerTypeId.HasValue
|
||||
? AssetHandlerRegistry.GetByAssetTypeId(meta.HandlerTypeId.Value)
|
||||
: AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath));
|
||||
|
||||
var contentHash = await ComputeFileHashAsync(job.SourcePath, token);
|
||||
var settingsHash = ComputeSettingsHash(meta.Settings);
|
||||
|
||||
// Check if we can skip (if not a manual reimport)
|
||||
if (job.Reason != ImportReason.ManualReimport &&
|
||||
meta.ContentHash == contentHash &&
|
||||
meta.SettingsHash == settingsHash &&
|
||||
meta.HandlerVersion == AssetHandlerRegistry.GetVersionByAssetTypeId(meta.HandlerTypeId ?? Guid.Empty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var importResult = Result.Success();
|
||||
if (handler is IImportableAssetHandler importable)
|
||||
{
|
||||
var targetPath = GetImportedAssetPath(job.AssetGuid);
|
||||
importResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token);
|
||||
}
|
||||
|
||||
if (importResult.IsSuccess)
|
||||
{
|
||||
meta.ContentHash = contentHash;
|
||||
meta.SettingsHash = settingsHash;
|
||||
meta.HandlerVersion = AssetHandlerRegistry.GetVersionByAssetTypeId(meta.HandlerTypeId ?? Guid.Empty);
|
||||
meta.LastImportedUtc = DateTime.UtcNow;
|
||||
|
||||
await AssetMetaIO.WriteAsync(job.MetaPath, meta, token);
|
||||
|
||||
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,24 @@ 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.
|
||||
FreeListChunkSize = 64 * 1024 * 1024,
|
||||
FreeListDefaultAlignment = 8,
|
||||
FreeListConcurrencyLevel = Environment.ProcessorCount
|
||||
};
|
||||
|
||||
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,31 @@ 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)
|
||||
{
|
||||
Environment.Exit(ex.HResult);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClosed(object? sender, WindowEventArgs args)
|
||||
@@ -144,7 +164,7 @@ public partial class App : Application
|
||||
Host.StopAsync().GetAwaiter().GetResult();
|
||||
Host.Dispose();
|
||||
|
||||
//EditorApplication.Shutdown();
|
||||
EditorApplication.Shutdown();
|
||||
ActivationHandler.Shutdown();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -153,15 +173,15 @@ public partial class App : Application
|
||||
}
|
||||
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,175 @@
|
||||
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);
|
||||
assetType = AssetHandlerRegistry.GetRuntimeAssetTypeByExtension(ext);
|
||||
}
|
||||
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.GetRuntimeAssetTypeByExtension(ext);
|
||||
|
||||
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"
|
||||
@@ -1,3 +1,4 @@
|
||||
using CommunityToolkit.WinUI;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Editor.Models;
|
||||
using Ghost.Editor.ViewModels.Controls;
|
||||
@@ -5,11 +6,11 @@ using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
|
||||
namespace Ghost.Editor.View.Controls;
|
||||
namespace Ghost.Editor.Views.Controls;
|
||||
|
||||
internal sealed partial class ProjectBrowser : UserControl
|
||||
internal sealed partial class ContentBrowser : UserControl
|
||||
{
|
||||
public static ProjectBrowser? LastFocused
|
||||
public static ContentBrowser? LastFocused
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
@@ -18,22 +19,20 @@ internal sealed partial class ProjectBrowser : UserControl
|
||||
private readonly IInspectorService _inspectorService;
|
||||
private bool _isUpdatingSelection = false;
|
||||
|
||||
public ProjectBrowserViewModel ViewModel
|
||||
public ContentBrowserViewModel ViewModel
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
public ProjectBrowser()
|
||||
public ContentBrowser()
|
||||
{
|
||||
_inspectorService = App.GetService<IInspectorService>();
|
||||
ViewModel = App.GetService<ProjectBrowserViewModel>();
|
||||
ViewModel = App.GetService<ContentBrowserViewModel>();
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
Loaded += ProjectBrowser_Loaded;
|
||||
Unloaded += ProjectBrowser_Unloaded;
|
||||
|
||||
GettingFocus += ProjectBrowser_GettingFocus;
|
||||
}
|
||||
|
||||
private void ProjectBrowser_GettingFocus(UIElement sender, GettingFocusEventArgs args)
|
||||
@@ -49,11 +48,13 @@ internal sealed partial class ProjectBrowser : UserControl
|
||||
private void ProjectBrowser_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_inspectorService.OnSelectionChanged += _inspectorService_OnSelectionChanged;
|
||||
GettingFocus += ProjectBrowser_GettingFocus;
|
||||
}
|
||||
|
||||
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
||||
GettingFocus -= ProjectBrowser_GettingFocus;
|
||||
|
||||
if (LastFocused == this)
|
||||
{
|
||||
@@ -63,7 +64,7 @@ internal sealed partial class ProjectBrowser : UserControl
|
||||
|
||||
private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Source is not ProjectBrowserViewModel)
|
||||
if (e.Source is not ContentBrowserViewModel)
|
||||
{
|
||||
PART_FilesView.DeselectAll();
|
||||
PART_DirectoriesView.SelectedNodes.Clear();
|
||||
@@ -83,13 +84,13 @@ internal sealed partial class ProjectBrowser : UserControl
|
||||
if (args.AddedItems.Count > 0 && args.AddedItems[0] is ExplorerItem selectedItem)
|
||||
{
|
||||
ViewModel.SelectedItem = selectedItem;
|
||||
ViewModel.NavigateToDirectory(selectedItem.FullName);
|
||||
ViewModel.NavigateToDirectory(selectedItem.Path);
|
||||
}
|
||||
|
||||
_isUpdatingSelection = false;
|
||||
}
|
||||
|
||||
private void PART_FilesView_SelectionChanged(ItemsView sender, ItemsViewSelectionChangedEventArgs args)
|
||||
private void PART_FilesView_SelectionChanged(object sender, SelectionChangedEventArgs args)
|
||||
{
|
||||
if (_isUpdatingSelection)
|
||||
{
|
||||
@@ -107,7 +108,7 @@ internal sealed partial class ProjectBrowser : UserControl
|
||||
_isUpdatingSelection = false;
|
||||
}
|
||||
|
||||
private async void PART_FilesView_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e)
|
||||
private async void PART_FilesView_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
|
||||
{
|
||||
if (PART_FilesView.SelectedItem is ExplorerItem selectedItem)
|
||||
{
|
||||
@@ -136,7 +137,7 @@ internal sealed partial class ProjectBrowser : UserControl
|
||||
else if (navigatedItem.Item2 == 1)
|
||||
{
|
||||
var index = ViewModel.Files.IndexOf(navigatedItem.Item1);
|
||||
PART_FilesView.Select(index);
|
||||
PART_FilesView.SelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="Ghost.Editor.View.Controls.Hierarchy"
|
||||
x:Class="Ghost.Editor.Views.Controls.Hierarchy"
|
||||
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.Controls"
|
||||
xmlns:local="using:Ghost.Editor.Views.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user