Compare commits
93 Commits
feature/do
...
c6e58b057c
| Author | SHA1 | Date | |
|---|---|---|---|
| c6e58b057c | |||
| 34dc6fc8c9 | |||
| d9343a94dc | |||
| b84ee586bf | |||
| 52dfa12e84 | |||
| 326aee2b1c | |||
| 2cbe1ca789 | |||
| 1fe080dd87 | |||
| 211ea2254d | |||
| 914dde2448 | |||
| 5222e801b9 | |||
| a1c5ccf937 | |||
| 6b501efda0 | |||
| a40140cabd | |||
| 7dac1e4437 | |||
| e04c7eb6a7 | |||
| 84c936bb7a | |||
| 18505cdff6 | |||
| f85cf4edde | |||
| 982ce7d8e0 | |||
| cf5af7ee50 | |||
| 60b807abd7 | |||
| cbaa129d9e | |||
| bb07644580 | |||
| 314b0111f0 | |||
| a95ff01366 | |||
| 7e1db7b908 | |||
| 2ea3c509b0 | |||
| e2bc68d359 | |||
| 1cc65e8218 | |||
| d0076c852f | |||
| ba8694ed0c | |||
| 80e820a858 | |||
| b42398bbce | |||
| d052ca848f | |||
| 744b058e6a | |||
| 5de480e231 | |||
| 8d3e1c91d7 | |||
| bffe05f0ef | |||
| 220db828a0 | |||
| d2bf2f12a2 | |||
| e7fedfd35a | |||
| e384a2f38c | |||
| 0eaf7cd51d | |||
| 631638f3fb | |||
| e3a02437c3 | |||
| 5903ddda2b | |||
| 1a91811621 | |||
| 4757c0c91a | |||
| 3533d3367f | |||
| 884611181a | |||
| cb4092179f | |||
| c249a389e3 | |||
| ed00f205b0 | |||
| 4f5556ee1b | |||
| abd5ad74d5 | |||
| 13bf1501e4 | |||
| 6615fe794e | |||
| d9bfa43663 | |||
| 817b32b8d9 | |||
| c66fda5332 | |||
| f9a6e9cbbe | |||
| 4ed5572ce7 | |||
| 68fda03aa9 | |||
| 0fc449bc78 | |||
| a5c10cfe5a | |||
| 6c96d4cf50 | |||
| c6bdbe0710 | |||
| effd33b285 | |||
| 92970f85ef | |||
| 2dc97f3149 | |||
| ba9e24c46c | |||
| 6321b36ef5 | |||
| d03eb659fa | |||
| e32a24739d | |||
| eb41f23582 | |||
| 3157596b5d | |||
| a00cb27529 | |||
| 0b6e5b8501 | |||
| 89e6c68f2a | |||
| b28b32f502 | |||
| fa617accc3 | |||
| ff22b89ba3 | |||
| 2e6e705558 | |||
| e6e38f5eea | |||
| d15bd22743 | |||
| 15870ffe89 | |||
| 70b7e56eb7 | |||
| 257838b33e | |||
| 8ff98c56be | |||
| 2c84696994 | |||
| a33a150d06 | |||
| 60ef684d80 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,6 +11,10 @@
|
|||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.opencode/
|
||||||
|
.code-review-graph/
|
||||||
|
.antigravitycli/
|
||||||
|
|
||||||
ref/
|
ref/
|
||||||
docfx/
|
docfx/
|
||||||
NUL
|
NUL
|
||||||
|
|||||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright © 2026 Enjie Huang
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
1216
docs/specs/asset_loading_design.md
Normal file
1216
docs/specs/asset_loading_design.md
Normal file
File diff suppressed because it is too large
Load Diff
310
docs/specs/asset_registry_analysis.md
Normal file
310
docs/specs/asset_registry_analysis.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# GhostEngine Asset Registry — Design Analysis & Recommendations
|
||||||
|
|
||||||
|
## 1. Your Current Design at a Glance
|
||||||
|
|
||||||
|
Your current approach is **Unreal-style packed binary** (`.gasset`):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ AssetMetadata (128 bytes, fixed) │
|
||||||
|
│ FormatVersion ─ ID ─ TypeID ─ │
|
||||||
|
│ HandlerVersion ─ DependencyCount ─ │
|
||||||
|
│ DependenciesOffset ─ SettingsOffset/Size ─ │
|
||||||
|
│ ContentOffset/Size │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Settings blob (struct → raw bytes) │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Content blob (e.g. ImageContentHeader + raw) │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Dependencies (Guid[]) │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The AssetRegistry maintains an in-memory GUID↔path index by reading the first 20 bytes of every `.gasset` on startup, with a `FileSystemWatcher` for live updates. A planned SQLite backend (`AssetRegistry.Backend.cs`) would persist this catalog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Unreal vs Unity — The Trade-Off Matrix
|
||||||
|
|
||||||
|
| Dimension | Unreal (Packed Binary `.uasset`) | Unity (Raw File + `.meta` sidecar) |
|
||||||
|
|---|---|---|
|
||||||
|
| **Source control** | Opaque blobs — merges impossible, diffs useless | Raw files are human-readable; `.meta` is text YAML — mergeable |
|
||||||
|
| **Import speed** | One file to open per asset | Two opens per asset (source + meta), but meta is tiny |
|
||||||
|
| **Runtime loading** | One `seek+read` → done (no re-import step) | Must "import" (cook) before runtime loading; raw files are editor-only |
|
||||||
|
| **Artist iteration** | Must re-import through editor | Can drop a PNG in Explorer & it auto-imports |
|
||||||
|
| **Dependency tracking** | Embedded in the binary — self-contained | External DB (`.meta` GUIDs + Library/) — can desync |
|
||||||
|
| **Asset settings versioning** | Binary struct layout is fragile | YAML/JSON → easy to add fields with defaults |
|
||||||
|
| **Corruption resilience** | One corrupted byte → whole asset lost | Source file is unaffected; re-import fixes derived data |
|
||||||
|
| **Build pipeline** | Already cooked (or close to it) | Separate cook step needed for builds |
|
||||||
|
| **Team discoverability** | "What is this .gasset?" → need editor to inspect | "It's a PNG, I can open it anywhere" |
|
||||||
|
|
||||||
|
### Key Insight
|
||||||
|
|
||||||
|
> Unreal doesn't actually store source data inside `.uasset` for most asset types. Unreal stores the **cooked/processed** representation. The source data (FBX, PSD, etc.) lives outside the engine's asset system — artists use a separate "source art" folder. The `.uasset` is a **derived artifact**, not the source of truth.
|
||||||
|
|
||||||
|
Unity's insight was: **leave source files alone, store metadata beside them, and derive everything else into a Library/ cache.** The `.meta` sidecar is tiny (GUID + import settings in YAML), version-control-friendly, and the actual imported data lives in `Library/` (a local, regenerable cache).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Current Design — Issues Found
|
||||||
|
|
||||||
|
### 3.1 Binary Settings Are a Versioning Nightmare
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// TextureAssetHandler — writes settings as raw struct bytes
|
||||||
|
Unsafe.WriteUnaligned(ref address, settings.Basic);
|
||||||
|
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, ...), settings.Advanced);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Adding a single field to `BasicSettings`, `AdvancedSettings`, or `SamplerSettings` changes the struct layout. Every existing `.gasset` file becomes unreadable because the byte offsets shift. You have `HandlerVersion` in the metadata, but no migration logic — and you'd need one per handler per version.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> This is the #1 pain point of the Unreal approach in practice. Epic has dedicated teams managing asset versioning with `FArchive` custom serialization + version tags. For a small team, this is a massive maintenance burden.
|
||||||
|
|
||||||
|
### 3.2 Source File Is Destroyed on Import
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OnFileSystemOp — line 224
|
||||||
|
File.Delete(assetPath); // ← deletes the original source file!
|
||||||
|
```
|
||||||
|
|
||||||
|
After import, the source `.png` is deleted and only the `.gasset` remains. If the user wants to change import settings (e.g. switch from BC7 to BC5 for a normal map), they need to find the original source file elsewhere and re-import.
|
||||||
|
|
||||||
|
### 3.3 Handler Discovery Is O(N × M) per Call
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// GetAssetHandlerForExtension — line 326-338
|
||||||
|
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.SelectMany(assembly => assembly.GetTypes())
|
||||||
|
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) ...))
|
||||||
|
```
|
||||||
|
|
||||||
|
This scans **every type in every loaded assembly** on each call. It's called from `OnFileSystemOp` (FileSystemWatcher callback — frequent!) and `ImportAssetAsync`. The `_cachedHandler` dictionary helps for repeat loads, but the initial scan is expensive and runs every time a new extension is encountered.
|
||||||
|
|
||||||
|
### 3.4 `async void` in FileSystemWatcher Callback
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ImportAsync` throws, the exception is swallowed silently (unobserved). `FileSystemWatcher` callbacks should be synchronous (queue work to a channel/queue), or at minimum wrap the body in `try/catch`.
|
||||||
|
|
||||||
|
### 3.5 Race Conditions in Path Mapping
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ConcurrentDictionary + lock(_pathLock)
|
||||||
|
_pathToGuid = new ConcurrentDictionary<...>(); // concurrent dict
|
||||||
|
lock (_pathLock) { _pathToGuid[relativePath] = guid; } // but manually locked
|
||||||
|
```
|
||||||
|
|
||||||
|
You're using `ConcurrentDictionary` but also taking a `Lock` for every access. These two strategies conflict — either use a plain `Dictionary<>` + lock, or use `ConcurrentDictionary` lock-free. Mixing them gives the worst of both: allocation overhead of `ConcurrentDictionary` with the contention of a lock.
|
||||||
|
|
||||||
|
### 3.6 Missing Content Hash for Cache Invalidation
|
||||||
|
|
||||||
|
The `TextureProcessor` hashes **settings** to build a cache key (`guid_settingsHash.dds`), but doesn't hash the **source content**. If you replace a PNG with a different image of the same name, the stale cache is served because only the settings hash changed (it didn't).
|
||||||
|
|
||||||
|
### 3.7 No Version Migration Path
|
||||||
|
|
||||||
|
The 128-byte `AssetMetadata` header reserves space for expansion — good! But there's no mechanism to detect "this `.gasset` was written by handler v1 and we're now at v3" and upgrade in place. Currently `HandlerVersion` is written but never read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommendation: Hybrid Architecture
|
||||||
|
|
||||||
|
I recommend a **Unity-inspired hybrid** — keep source files untouched, use lightweight sidecar metadata, and produce a separate cooked cache. Here's the concrete design:
|
||||||
|
|
||||||
|
### 4.1 Three-Layer Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ProjectRoot/
|
||||||
|
├── Assets/ ← Source files (PNG, FBX, HLSL, ...)
|
||||||
|
│ ├── Textures/
|
||||||
|
│ │ ├── hero_diffuse.png ← Source of truth (never modified)
|
||||||
|
│ │ └── hero_diffuse.png.gmeta ← Sidecar: GUID + import settings (YAML/JSON)
|
||||||
|
│ └── Models/
|
||||||
|
│ ├── character.fbx
|
||||||
|
│ └── character.fbx.gmeta
|
||||||
|
│
|
||||||
|
├── Library/ ← Derived data cache (local, .gitignore'd)
|
||||||
|
│ ├── AssetDB.sqlite ← Fast GUID↔path + dependency index
|
||||||
|
│ ├── Imports/ ← Cooked assets (DDS, compiled meshes, etc.)
|
||||||
|
│ │ ├── <guid>.imported ← Binary cooked data (current .gasset content section)
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── Thumbnails/
|
||||||
|
│ └── <guid>.thumb
|
||||||
|
│
|
||||||
|
└── .ghostignore ← Patterns to exclude from asset scanning
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `.gmeta` Sidecar File
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# hero_diffuse.png.gmeta
|
||||||
|
guid: 0906f4eb-c3f0-431b-bcea-132c88ab0c3f
|
||||||
|
handler: TextureAssetHandler
|
||||||
|
handlerVersion: 1
|
||||||
|
settings:
|
||||||
|
textureType: Default
|
||||||
|
textureShape: Texture2D
|
||||||
|
isSRGB: true
|
||||||
|
maxSize: 2048
|
||||||
|
filterMode: Anisotropic
|
||||||
|
wrapMode: Repeat
|
||||||
|
generateMipmaps: true
|
||||||
|
compressionLevel: Normal
|
||||||
|
# ... full settings tree
|
||||||
|
dependencies: []
|
||||||
|
labels: [environment, hero] # optional user tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this is better:**
|
||||||
|
|
||||||
|
| Concern | Current `.gasset` | Proposed `.gmeta` |
|
||||||
|
|---|---|---|
|
||||||
|
| Add a field | Binary layout breaks | YAML: missing keys → default values |
|
||||||
|
| Merge conflict | Impossible (binary) | Text merge, trivial |
|
||||||
|
| Inspect settings | Need editor | Open in any text editor |
|
||||||
|
| Source file recovery | Destroyed | Untouched, always available |
|
||||||
|
| Re-import | Need original file | `Library/` rebuild from source + `.gmeta` |
|
||||||
|
| `git diff` | `Binary files differ` | Readable YAML diff |
|
||||||
|
|
||||||
|
### 4.3 SQLite Catalog (`Library/AssetDB.sqlite`)
|
||||||
|
|
||||||
|
Replace the in-memory `ConcurrentDictionary<string, Guid>` mapping with an SQLite database (you already planned this in `AssetRegistry.Backend.cs`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Core asset table
|
||||||
|
CREATE TABLE assets (
|
||||||
|
guid BLOB PRIMARY KEY, -- 16 bytes, exactly sizeof(Guid)
|
||||||
|
path TEXT NOT NULL, -- relative path to .gmeta
|
||||||
|
handler TEXT NOT NULL, -- handler type name
|
||||||
|
content_hash TEXT, -- xxHash64 of source file bytes
|
||||||
|
settings_hash TEXT, -- xxHash64 of import settings
|
||||||
|
imported_at INTEGER, -- unix timestamp of last successful import
|
||||||
|
UNIQUE(path)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dependency edges (forward: asset → dependency)
|
||||||
|
CREATE TABLE dependencies (
|
||||||
|
from_guid BLOB NOT NULL REFERENCES assets(guid),
|
||||||
|
to_guid BLOB NOT NULL REFERENCES assets(guid),
|
||||||
|
PRIMARY KEY (from_guid, to_guid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reverse index for "what depends on me?" queries
|
||||||
|
CREATE INDEX idx_dep_reverse ON dependencies(to_guid);
|
||||||
|
|
||||||
|
-- Full-text search on asset paths and labels
|
||||||
|
CREATE VIRTUAL TABLE assets_fts USING fts5(path, labels);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Startup becomes:**
|
||||||
|
1. Open SQLite DB → instant GUID↔path from indexed table
|
||||||
|
2. Diff `Assets/` tree vs DB → find stale/new/deleted `.gmeta` files
|
||||||
|
3. Queue incremental re-imports only for changed assets
|
||||||
|
|
||||||
|
This is **dramatically faster** than scanning every `.gasset` header on disk (your current `LoadExistingAssets`).
|
||||||
|
|
||||||
|
### 4.4 Import Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Source File Changed
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FileSystemWatcher
|
||||||
|
│
|
||||||
|
├─── No .gmeta exists? → Generate one (new GUID, default settings)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Hash source + settings
|
||||||
|
│
|
||||||
|
├─── Hash matches DB? → Skip (already imported)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Queue ImportJob to background channel
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ImportWorker (background thread pool)
|
||||||
|
│
|
||||||
|
├── Read source file
|
||||||
|
├── Run handler pipeline (e.g. NVTT compress)
|
||||||
|
├── Write Library/Imports/<guid>.imported
|
||||||
|
├── Update SQLite (content_hash, settings_hash, imported_at)
|
||||||
|
└── Fire AssetChanged event on main thread
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Handler Registration — Build Once, Cache Forever
|
||||||
|
|
||||||
|
Replace the per-call assembly scan with a startup-once TypeCache approach (you already have this pattern in the engine):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Startup: build lookup tables once
|
||||||
|
Dictionary<string, Type> _extensionToHandler; // ".png" → typeof(TextureAssetHandler)
|
||||||
|
Dictionary<Guid, Type> _typeIdToHandler; // TypeGuid → handler type
|
||||||
|
|
||||||
|
// Populated once via TypeCache / assembly attribute scan at editor startup
|
||||||
|
foreach (var type in TypeCache.GetTypesWithAttribute<CustomAssetHandlerAttribute>())
|
||||||
|
{
|
||||||
|
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>();
|
||||||
|
_typeIdToHandler[new Guid(attr.ID)] = type;
|
||||||
|
foreach (var ext in attr.SupportedExtensions)
|
||||||
|
_extensionToHandler[ext] = type;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What to Keep from Your Current Design
|
||||||
|
|
||||||
|
Your design has several things done well:
|
||||||
|
|
||||||
|
| Element | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| `AssetMetadata` fixed-size header with offsets | ✅ Keep for the cooked `.imported` files — great for O(1) seeks |
|
||||||
|
| `Handle<GPUTexture>` on `TextureAsset` | ✅ Clean separation of asset data vs GPU resource handle |
|
||||||
|
| `WeakReference<Asset>` cache in registry | ✅ Elegant — auto-evicts when nothing holds the asset |
|
||||||
|
| `IAssetHandler` / `IImportableAssetHandler` split | ✅ Good separation (some assets are import-only, e.g. shaders compiled differently) |
|
||||||
|
| `AssetReference` with internal/external encoding | ✅ Clever — keeps sub-asset refs compact |
|
||||||
|
| `TextureProcessor` cache with settings hash | ✅ Great idea, just needs content hash too |
|
||||||
|
| `Result<T>` return pattern | ✅ Consistent with the rest of GhostEngine |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Summary Recommendation
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ RECOMMENDED APPROACH │
|
||||||
|
│ │
|
||||||
|
│ Source files → untouched, checked into git │
|
||||||
|
│ .gmeta sidecars → GUID + settings (YAML), in git │
|
||||||
|
│ Library/ → derived cache, .gitignored │
|
||||||
|
│ AssetDB.sqlite → fast GUID↔path index │
|
||||||
|
│ Imports/*.imported → cooked binary (your AssetMetadata │
|
||||||
|
│ header + content, no settings) │
|
||||||
|
│ │
|
||||||
|
│ Binary format → for cooked data only, not settings │
|
||||||
|
│ Settings format → YAML/JSON in .gmeta (human + VCS) │
|
||||||
|
│ Handler discovery → one-time TypeCache at startup │
|
||||||
|
│ Watcher callbacks → queue to Channel<T>, no async void │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
- **Unreal's runtime performance** (cooked binary in Library/ → single seek+read)
|
||||||
|
- **Unity's artist workflow** (drop files in Assets/, settings are readable text)
|
||||||
|
- **Clean version control** (text `.gmeta` files merge cleanly)
|
||||||
|
- **Resilient re-import** (source is never touched; Library/ is regenerable)
|
||||||
|
- **Zero startup cost** (SQLite index instead of scanning thousands of file headers)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open Questions for You
|
||||||
|
|
||||||
|
1. **Do you want `.gmeta` in YAML, JSON, or a custom text format?** YAML is more compact and human-friendly, but adds a parser dependency. JSON is built into .NET but more verbose. A custom format is more work.
|
||||||
|
|
||||||
|
2. **Should the cooked `.imported` files keep the 128-byte `AssetMetadata` header?** It's useful for validation on load, but since SQLite already knows the GUID and handler, you could simplify the binary format.
|
||||||
|
|
||||||
|
3. **Do you want hot-reload of import settings?** (Changing `.gmeta` → auto re-import and refresh live asset in editor.) Your current `WeakReference<Asset>` + `RefreshAsync` already supports this.
|
||||||
|
|
||||||
|
4. **How do you want to handle the `Library/` on first clone?** Options: (a) full re-import from source, (b) share a pre-built Library via LFS, (c) asset server that caches imports.
|
||||||
1625
docs/specs/asset_registry_design.md
Normal file
1625
docs/specs/asset_registry_design.md
Normal file
File diff suppressed because it is too large
Load Diff
457
docs/specs/shader_pipeline_architecture.md
Normal file
457
docs/specs/shader_pipeline_architecture.md
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
# Shader Pipeline Architecture — Proposed Design
|
||||||
|
|
||||||
|
> Presented as a design walkthrough. Take what's useful, ignore what doesn't fit your vision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Topology
|
||||||
|
|
||||||
|
The first decision: **where does each responsibility live?**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph EditorProcess["Ghost.Editor Process"]
|
||||||
|
FW["FileWatcher<br/>(monitors .ghost DSL files)"]
|
||||||
|
AR["AssetRegistry<br/>(GUID ↔ file path mapping)"]
|
||||||
|
EP["Editor UI<br/>(status bar, material inspector)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CompilerProcess["GhostShaderServer Process"]
|
||||||
|
DSL["DSL Compiler<br/>(Ghost DSL → HLSL)"]
|
||||||
|
DXC["DXC Compiler<br/>(HLSL → DXIL bytecode)"]
|
||||||
|
MW["Manifest Writer<br/>(updates variant → hash mapping)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RuntimeGraphics["Ghost.Graphics (Runtime)"]
|
||||||
|
SL["ShaderLibrary<br/>(reads bytecode from cache)"]
|
||||||
|
PL["PipelineLibrary<br/>(PSO creation + double-buffer)"]
|
||||||
|
RGC["RenderGraphContext<br/>(binds PSO per draw call)"]
|
||||||
|
BR["IShaderCompilationBridge<br/>(interface, 2 methods)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SharedDisk["Shared Disk (ShaderCache/)"]
|
||||||
|
MF["ShaderManifest.bin<br/>(GUID+variant → content hash)"]
|
||||||
|
BC["Bytecode Files<br/>(content-addressed .bin blobs)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
FW -- "file changed event" --> AR
|
||||||
|
AR -- "GUID + file path<br/>(named pipe)" --> CompilerProcess
|
||||||
|
DSL --> DXC
|
||||||
|
DXC -- "bytecode bytes" --> MW
|
||||||
|
MW -- "write blob" --> BC
|
||||||
|
MW -- "update entry" --> MF
|
||||||
|
|
||||||
|
SL -- "read blob" --> BC
|
||||||
|
SL -- "read mapping" --> MF
|
||||||
|
BR -- "status query<br/>(named pipe)" --> CompilerProcess
|
||||||
|
|
||||||
|
EP -- "poll status" --> BR
|
||||||
|
|
||||||
|
style CompilerProcess fill:#1a1a2e,stroke:#e94560,color:#eee
|
||||||
|
style EditorProcess fill:#1a1a2e,stroke:#0f3460,color:#eee
|
||||||
|
style RuntimeGraphics fill:#1a1a2e,stroke:#16213e,color:#eee
|
||||||
|
style SharedDisk fill:#0f3460,stroke:#533483,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why a Separate Process?
|
||||||
|
|
||||||
|
| Concern | In-process compiler | Separate process |
|
||||||
|
|---------|-------------------|------------------|
|
||||||
|
| DXC crash | Editor dies | Server restarts, editor lives |
|
||||||
|
| DXC memory leak | Editor bloats over time | Kill & restart server periodically |
|
||||||
|
| Parallelism | Threads compete with editor UI | Fully independent CPU budget |
|
||||||
|
| Build pipeline reuse | Need separate build-time path | Same server binary, different mode |
|
||||||
|
| Complexity | Lower (one process) | Higher (IPC needed) |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> If the separate process feels like overkill for your current stage, **start with in-process behind the `IShaderCompilationBridge` interface**, then swap the implementation to out-of-process later. The interface is the same either way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Model — The Manifest
|
||||||
|
|
||||||
|
This is the most important data structure in the entire system. It decouples **identity** from **content**.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph ShaderAsset["Shader Asset (on disk)"]
|
||||||
|
GUID["Asset GUID<br/><i>e.g. 7f3a-...-c82b</i><br/>stable forever"]
|
||||||
|
SRC["Source Code<br/><i>.ghost DSL file</i><br/>changes on edit"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Manifest["ShaderManifest"]
|
||||||
|
E1["Entry:<br/>GUID=7f3a | Pass=0 | Variant=0x00<br/>→ ContentHash=0xABCD"]
|
||||||
|
E2["Entry:<br/>GUID=7f3a | Pass=0 | Variant=0x01<br/>→ ContentHash=0x1234"]
|
||||||
|
E3["Entry:<br/>GUID=7f3a | Pass=1 | Variant=0x00<br/>→ ContentHash=0x5678"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cache["ShaderCache/ (content addressed)"]
|
||||||
|
B1["AB/shader_cache_ABCD...bin"]
|
||||||
|
B2["12/shader_cache_1234...bin"]
|
||||||
|
B3["56/shader_cache_5678...bin"]
|
||||||
|
end
|
||||||
|
|
||||||
|
GUID --> E1
|
||||||
|
GUID --> E2
|
||||||
|
GUID --> E3
|
||||||
|
E1 --> B1
|
||||||
|
E2 --> B2
|
||||||
|
E3 --> B3
|
||||||
|
|
||||||
|
style ShaderAsset fill:#16213e,stroke:#0f3460,color:#eee
|
||||||
|
style Manifest fill:#1a1a2e,stroke:#e94560,color:#eee
|
||||||
|
style Cache fill:#0f3460,stroke:#533483,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manifest Entry Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ManifestKey = Hash(AssetGUID + PassIndex + VariantKeywordMask)
|
||||||
|
ManifestValue = ContentHash (= Hash of compiled bytecode)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ManifestKey** is *structurally* derived — same shader, same pass, same keywords = same key, regardless of source changes.
|
||||||
|
- **ContentHash** is *content-derived* — changes every time the source code changes.
|
||||||
|
- When source changes: the ManifestKey stays the same, but the ContentHash it points to gets updated.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The `Shader` struct in runtime only needs to know the **AssetGUID**. It never stores or cares about content hashes. The `ShaderLibrary` uses the manifest to translate `(GUID, Pass, Variant) → ContentHash → File`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Compilation Flow — What Happens When You Save a Shader
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant FileSystem
|
||||||
|
participant Editor as Ghost.Editor
|
||||||
|
participant Server as ShaderServer
|
||||||
|
participant Cache as ShaderCache/
|
||||||
|
|
||||||
|
User->>FileSystem: Save "water.ghost"
|
||||||
|
FileSystem-->>Editor: FileWatcher event
|
||||||
|
|
||||||
|
Editor->>Editor: Lookup GUID for "water.ghost"<br/>via AssetRegistry
|
||||||
|
Editor->>Server: CompileRequest {<br/> guid: 7f3a-...,<br/> filePath: "water.ghost",<br/> defines: [...],<br/> platform: D3D12<br/>}
|
||||||
|
|
||||||
|
Note over Server: Mark status = Compiling<br/>for this GUID
|
||||||
|
|
||||||
|
Server->>Server: Read .ghost DSL file
|
||||||
|
Server->>Server: DSL Compiler: DSL → HLSL
|
||||||
|
|
||||||
|
alt DSL has syntax errors
|
||||||
|
Server->>Server: Mark status = Error
|
||||||
|
Server-->>Editor: CompileResult {<br/> status: Error,<br/> errors: [...]<br/>}
|
||||||
|
Editor->>Editor: Show errors in<br/>console/inspector
|
||||||
|
else DSL is valid
|
||||||
|
Server->>Server: For each (pass, variant):<br/>DXC Compile HLSL → DXIL
|
||||||
|
|
||||||
|
alt Any DXC error
|
||||||
|
Server->>Server: Mark status = Error
|
||||||
|
Server-->>Editor: CompileResult {<br/> status: Error,<br/> errors: [...]<br/>}
|
||||||
|
else All variants compiled
|
||||||
|
Server->>Cache: Write bytecode blobs<br/>(content-addressed)
|
||||||
|
Server->>Cache: Update manifest entries:<br/>(GUID+pass+variant) → new hash
|
||||||
|
Server->>Server: Mark status = Ready
|
||||||
|
Server-->>Editor: CompileResult {<br/> status: Ready,<br/> variantCount: N<br/>}
|
||||||
|
Editor->>Editor: Show ✓ in status bar
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decision: Compile All Variants Upfront?
|
||||||
|
|
||||||
|
**No.** Only compile variants that are *currently referenced* by materials in the scene. The editor knows which materials reference which shader (via AssetRegistry), and which keyword combinations those materials use. Ship only what's needed.
|
||||||
|
|
||||||
|
For the edit-time hot-reload, you really only need the specific variants the viewport is currently rendering. The full permutation set is a build-time concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Runtime PSO Resolution — The Frame-by-Frame Flow
|
||||||
|
|
||||||
|
This is where most of the complexity lives. Here's what `SetActiveMaterial` does every frame:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["SetActiveMaterial(material)"] --> B["Compute ManifestKey<br/>= f(shader.GUID, passIndex, variantMask)"]
|
||||||
|
B --> C{"PipelineLibrary<br/>has PSO for<br/>ManifestKey?"}
|
||||||
|
|
||||||
|
C -- "Yes (cache hit)" --> D["Bind existing PSO<br/>to command buffer"]
|
||||||
|
D --> Z["Done ✓"]
|
||||||
|
|
||||||
|
C -- "No (cache miss)" --> E{"ShaderLibrary<br/>has bytecode for<br/>ManifestKey?"}
|
||||||
|
|
||||||
|
E -- "Yes (manifest hit)" --> F["Read bytecode<br/>from cache file"]
|
||||||
|
F --> G["Create PSO from bytecode"]
|
||||||
|
G --> H["Store in PipelineLibrary"]
|
||||||
|
H --> D
|
||||||
|
|
||||||
|
E -- "No (manifest miss)" --> I{"Is this Editor<br/>or Runtime?"}
|
||||||
|
|
||||||
|
I -- "Runtime<br/>(shipped game)" --> J["Bind Fallback<br/>ERROR PSO ⚠️"]
|
||||||
|
J --> K["Log error:<br/>missing shader"]
|
||||||
|
K --> Z
|
||||||
|
|
||||||
|
I -- "Editor" --> L{"Query Bridge:<br/>IsCompiling?"}
|
||||||
|
|
||||||
|
L -- "Status = Compiling" --> M["Bind OLD PSO<br/>(keep previous frame's shader)"]
|
||||||
|
M --> Z
|
||||||
|
|
||||||
|
L -- "Status = Error" --> N["Bind ERROR PSO<br/>(magenta)"]
|
||||||
|
N --> Z
|
||||||
|
|
||||||
|
L -- "Status = Ready" --> O["The manifest was just updated.<br/>Re-read manifest entry."]
|
||||||
|
O --> F
|
||||||
|
|
||||||
|
L -- "Status = NotAvailable" --> J
|
||||||
|
|
||||||
|
style A fill:#533483,stroke:#e94560,color:#eee
|
||||||
|
style D fill:#16213e,stroke:#0f3460,color:#eee
|
||||||
|
style J fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||||
|
style M fill:#0f3460,stroke:#533483,color:#eee
|
||||||
|
style N fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||||
|
style Z fill:#16213e,stroke:#16213e,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
### The "Keep Old PSO" Strategy — How It Works Mechanically
|
||||||
|
|
||||||
|
This is the part that makes the UX feel seamless. The trick:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph PipelineLibrary
|
||||||
|
direction TB
|
||||||
|
K["ManifestKey 0xAABB"]
|
||||||
|
K --> CURRENT["current: PSO_v2 ✓<br/>(what we render with)"]
|
||||||
|
K --> PENDING["pending: null<br/>(set during recompilation)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style CURRENT fill:#16213e,stroke:#0f3460,color:#eee
|
||||||
|
style PENDING fill:#1a1a2e,stroke:#e94560,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
When shader source changes and recompilation starts:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph PipelineLibrary_During["During Recompilation"]
|
||||||
|
direction TB
|
||||||
|
K2["ManifestKey 0xAABB"]
|
||||||
|
K2 --> CURRENT2["current: PSO_v2 ✓<br/>(still rendering with this)"]
|
||||||
|
K2 --> PENDING2["pending: COMPILING<br/>(server is working...)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style CURRENT2 fill:#16213e,stroke:#0f3460,color:#eee
|
||||||
|
style PENDING2 fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
When recompilation finishes successfully:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph PipelineLibrary_After["After Swap"]
|
||||||
|
direction TB
|
||||||
|
K3["ManifestKey 0xAABB"]
|
||||||
|
K3 --> CURRENT3["current: PSO_v3 ✓<br/>(new shader, rendering now)"]
|
||||||
|
K3 --> PENDING3["pending: null<br/>(swap complete)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style CURRENT3 fill:#16213e,stroke:#0f3460,color:#eee
|
||||||
|
style PENDING3 fill:#1a1a2e,stroke:#533483,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The old `PSO_v2` is **not immediately destroyed**. It stays alive until the GPU is done with any in-flight frames referencing it (tracked by fence value). This prevents use-after-free on the GPU timeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hot-Reload Sequence — The Complete Picture
|
||||||
|
|
||||||
|
Everything combined into one timeline:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Editor
|
||||||
|
participant Server as ShaderServer
|
||||||
|
participant Cache as Disk Cache
|
||||||
|
participant Runtime as RenderGraphContext
|
||||||
|
participant GPU
|
||||||
|
|
||||||
|
Note over Runtime,GPU: Frame N: Rendering with PSO_v2
|
||||||
|
|
||||||
|
User->>Editor: Edit & save "water.ghost"
|
||||||
|
Editor->>Server: CompileRequest(guid=7f3a)
|
||||||
|
Server->>Server: status[7f3a] = Compiling
|
||||||
|
|
||||||
|
Note over Runtime,GPU: Frame N+1
|
||||||
|
Runtime->>Runtime: SetActiveMaterial()
|
||||||
|
Runtime->>Runtime: ManifestKey lookup → old hash still there
|
||||||
|
Runtime->>Runtime: PipelineLibrary has PSO → use it
|
||||||
|
Note over Runtime: Still rendering with PSO_v2<br/>(user sees no flicker)
|
||||||
|
|
||||||
|
Note over Server: Background: DSL→HLSL→DXC...
|
||||||
|
|
||||||
|
Note over Runtime,GPU: Frame N+2, N+3, ...
|
||||||
|
Runtime->>Runtime: Same as N+1, no visible change
|
||||||
|
|
||||||
|
Server->>Cache: Write new bytecode files
|
||||||
|
Server->>Cache: Update manifest:<br/>key(7f3a,0,0) → new_hash
|
||||||
|
Server->>Server: status[7f3a] = Ready
|
||||||
|
|
||||||
|
Note over Runtime,GPU: Frame N+K (compilation done)
|
||||||
|
Runtime->>Runtime: SetActiveMaterial()
|
||||||
|
Runtime->>Runtime: Manifest read → NEW content hash
|
||||||
|
Runtime->>Runtime: PipelineLibrary miss for new hash
|
||||||
|
Runtime->>Cache: Read new bytecode
|
||||||
|
Runtime->>GPU: Create PSO_v3
|
||||||
|
Runtime->>Runtime: PipelineLibrary: current=PSO_v3
|
||||||
|
Runtime->>Runtime: Bind PSO_v3
|
||||||
|
|
||||||
|
Note over Runtime,GPU: Frame N+K+1: Rendering with PSO_v3 ✓
|
||||||
|
|
||||||
|
Runtime->>Runtime: Defer release PSO_v2<br/>(after GPU fence)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
| Frame | Viewport | Status Bar |
|
||||||
|
|-------|----------|------------|
|
||||||
|
| N | Water renders normally | — |
|
||||||
|
| N+1 | Water renders normally (old shader) | 🔄 Compiling water.ghost... |
|
||||||
|
| N+2 | Water renders normally (old shader) | 🔄 Compiling water.ghost... |
|
||||||
|
| N+K | Water renders with new shader | ✅ water.ghost compiled (2 variants) |
|
||||||
|
|
||||||
|
**Zero flicker. Zero blocking. Zero pink frames.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How the Manifest Key Replaces Your Current Hash Problem
|
||||||
|
|
||||||
|
Here's a before/after of your `Shader` struct:
|
||||||
|
|
||||||
|
### Current Design (problematic)
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Current["Current: Hash = f(source code)"]
|
||||||
|
S1["Shader struct"] --> P1["Pass[0].Key = 0xABCD<br/><i>derived from source hash</i>"]
|
||||||
|
P1 --> V1["ShaderVariantKey = f(0xABCD, keywords)"]
|
||||||
|
V1 --> PK1["PipelineKey = f(variant, rtv, dsv)"]
|
||||||
|
PK1 --> PSO1["PSO lookup in PipelineLibrary"]
|
||||||
|
|
||||||
|
EDIT["User edits source"] -.-> STALE["Pass[0].Key is now STALE ❌<br/>Still 0xABCD, but source changed"]
|
||||||
|
STALE -.-> WRONG["Looks up OLD bytecode<br/>or worse, the old PSO"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style STALE fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||||
|
style WRONG fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proposed Design (stable)
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Proposed["Proposed: Key = f(GUID, pass index)"]
|
||||||
|
S2["Shader struct<br/>assetGUID = 7f3a-..."] --> P2["Pass[0]: index=0<br/><i>no source hash stored</i>"]
|
||||||
|
P2 --> MK["ManifestKey = f(7f3a, 0, keywords)"]
|
||||||
|
MK --> MANIFEST["Manifest Lookup<br/>→ ContentHash = 0x9999"]
|
||||||
|
MANIFEST --> SL2["ShaderLibrary<br/>→ read 99/shader_cache_9999.bin"]
|
||||||
|
SL2 --> PSO2["Create or get PSO"]
|
||||||
|
|
||||||
|
EDIT2["User edits source"] -.-> RECOMP["Server recompiles<br/>→ new ContentHash = 0xBBBB"]
|
||||||
|
RECOMP -.-> MUPD["Manifest updated:<br/>same key → 0xBBBB"]
|
||||||
|
MUPD -.-> NEXT["Next frame: manifest read<br/>picks up 0xBBBB automatically"]
|
||||||
|
end
|
||||||
|
|
||||||
|
style RECOMP fill:#0f3460,stroke:#533483,color:#eee
|
||||||
|
style MUPD fill:#0f3460,stroke:#533483,color:#eee
|
||||||
|
style NEXT fill:#16213e,stroke:#0f3460,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **The `Shader` struct never changes.** No unload, no recreate, no generation counter bump. The manifest is the *only* mutable state, and it lives on disk, outside the runtime's object graph. The runtime just reads it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The Two Interfaces That Make This Work
|
||||||
|
|
||||||
|
Only two abstractions are needed in `Ghost.Graphics` to support the full pipeline:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class IShaderCompilationBridge {
|
||||||
|
<<interface>>
|
||||||
|
+TryGetBytecode(manifestKey: ulong, out bytecode: ReadOnlyMemory~byte~) bool
|
||||||
|
+IsCompiling(manifestKey: ulong) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuntimeStub {
|
||||||
|
+TryGetBytecode() → always from ShaderLibrary cache
|
||||||
|
+IsCompiling() → always false
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditorImplementation {
|
||||||
|
-NamedPipeClient _serverConnection
|
||||||
|
+TryGetBytecode() → check manifest, read cache
|
||||||
|
+IsCompiling() → query server status
|
||||||
|
}
|
||||||
|
|
||||||
|
IShaderCompilationBridge <|.. RuntimeStub : "Shipped game"
|
||||||
|
IShaderCompilationBridge <|.. EditorImplementation : "Editor mode"
|
||||||
|
|
||||||
|
class ShaderLibrary {
|
||||||
|
-string _cacheDirectory
|
||||||
|
+GetCache(contentHash: ulong) Result~bytes~
|
||||||
|
+GetFromManifest(manifestKey: ulong) Result~bytes~
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorImplementation --> ShaderLibrary : reads cache
|
||||||
|
RuntimeStub --> ShaderLibrary : reads cache
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> `RenderGraphContext` doesn't talk to the bridge directly. It talks to `ShaderLibrary`, which internally consults the bridge on cache miss. This keeps the rendering code clean — it never sees compilation status. It just gets bytecode or it doesn't.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Build Pipeline — How Shipped Games Work
|
||||||
|
|
||||||
|
For completeness, here's how the same architecture handles builds:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph BuildTime["Build Pipeline"]
|
||||||
|
SCAN["Scan all materials<br/>in scenes/assets"] --> COLLECT["Collect all referenced<br/>(GUID, pass, variant) tuples"]
|
||||||
|
COLLECT --> COMPILE["Compile all variants<br/>via ShaderServer"]
|
||||||
|
COMPILE --> PACK["Package manifest +<br/>bytecode blobs into<br/>game data archive"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ShippedGame["Runtime (shipped game)"]
|
||||||
|
LOAD["Load manifest +<br/>bytecode from archive"] --> LIB["ShaderLibrary<br/>(read-only, all variants pre-cached)"]
|
||||||
|
LIB --> MISS{"Cache miss?"}
|
||||||
|
MISS -- "Never<br/>(if build is correct)" --> OK["Create PSO normally"]
|
||||||
|
MISS -- "Somehow yes<br/>(bug or modding)" --> ERR["Error PSO<br/>+ log warning"]
|
||||||
|
end
|
||||||
|
|
||||||
|
BuildTime --> ShippedGame
|
||||||
|
|
||||||
|
style BuildTime fill:#1a1a2e,stroke:#0f3460,color:#eee
|
||||||
|
style ShippedGame fill:#16213e,stroke:#533483,color:#eee
|
||||||
|
```
|
||||||
|
|
||||||
|
The beauty: **the same `ShaderLibrary` and `PipelineLibrary` code runs in both editor and shipped game**. The only difference is whether `IShaderCompilationBridge` is the editor implementation or the runtime stub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Key Design Decisions
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|----------|-----------|
|
||||||
|
| 1 | Stable GUID identity, not content hash | Shader struct never needs recreation on edit |
|
||||||
|
| 2 | Content-addressed cache | Deduplication, easy invalidation, git-friendly |
|
||||||
|
| 3 | Manifest as the bridge | Decouples identity from compiled output cleanly |
|
||||||
|
| 4 | Keep old PSO during recompile | Zero flicker, seamless UX |
|
||||||
|
| 5 | Separate compiler process | Crash isolation, independent resource budget |
|
||||||
|
| 6 | Two-method interface in runtime | Minimal coupling, easy to stub for shipped game |
|
||||||
|
| 7 | Deferred PSO release via fence | Prevents GPU use-after-free |
|
||||||
|
| 8 | Same code path for editor + shipped | Fewer bugs, one pipeline to maintain |
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
# DockLayout Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Create a fully-featured, dynamically splittable, multi-window docking layout system using WinUI 3.
|
|
||||||
|
|
||||||
**Architecture:** A C# data model (node tree) drives the recursive generation of XAML `Grid` and `NavigationTabView` controls. Drag and drop events mutate the node tree, and the UI automatically reflects the changes.
|
|
||||||
|
|
||||||
**Tech Stack:** C#, WinUI 3, CommunityToolkit.Mvvm (for ObservableObject).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Create Core Data Models
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write `DockNode` base class**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
|
||||||
|
|
||||||
public abstract partial class DockNode : ObservableObject
|
|
||||||
{
|
|
||||||
[ObservableProperty]
|
|
||||||
private DockGroupNode? _parent;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write `DockGroupNode` class**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
|
||||||
|
|
||||||
public partial class DockGroupNode : DockNode
|
|
||||||
{
|
|
||||||
[ObservableProperty]
|
|
||||||
private Orientation _orientation = Orientation.Horizontal;
|
|
||||||
|
|
||||||
public ObservableCollection<DockNode> Children { get; } = new();
|
|
||||||
|
|
||||||
public void AddChild(DockNode node)
|
|
||||||
{
|
|
||||||
node.Parent = this;
|
|
||||||
Children.Add(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveChild(DockNode node)
|
|
||||||
{
|
|
||||||
if (Children.Remove(node))
|
|
||||||
{
|
|
||||||
node.Parent = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write `DockPanelNode` class**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
|
||||||
|
|
||||||
public partial class DockPanelNode : DockNode
|
|
||||||
{
|
|
||||||
public ObservableCollection<object> Items { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _selectedIndex = -1;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private object? _selectedItem;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/*
|
|
||||||
git commit -m "feat(dock): add core data models for docking system"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Implement Tree Renderer in XAML
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.xaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add DependencyProperty for Root in DockLayout.cs**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Ghost.Editor.Core.Controls.Internal.Docking;
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls;
|
|
||||||
|
|
||||||
public sealed partial class DockLayout : Control
|
|
||||||
{
|
|
||||||
public DockLayout()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(DockLayout);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DockGroupNode? Root
|
|
||||||
{
|
|
||||||
get => (DockGroupNode?)GetValue(RootProperty);
|
|
||||||
set => SetValue(RootProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly DependencyProperty RootProperty =
|
|
||||||
DependencyProperty.Register("Root", typeof(DockGroupNode), typeof(DockLayout), new PropertyMetadata(null, OnRootChanged));
|
|
||||||
|
|
||||||
private static void OnRootChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (d is DockLayout layout)
|
|
||||||
{
|
|
||||||
layout.RenderTree();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderTree()
|
|
||||||
{
|
|
||||||
if (GetTemplateChild("PART_RootGrid") is Grid rootGrid)
|
|
||||||
{
|
|
||||||
rootGrid.Children.Clear();
|
|
||||||
if (Root != null)
|
|
||||||
{
|
|
||||||
var ui = CreateUIForNode(Root);
|
|
||||||
rootGrid.Children.Add(ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private UIElement CreateUIForNode(DockNode node)
|
|
||||||
{
|
|
||||||
if (node is DockGroupNode groupNode)
|
|
||||||
{
|
|
||||||
// Simple visualizer for now, full grid logic in next step
|
|
||||||
var grid = new Grid();
|
|
||||||
foreach (var child in groupNode.Children)
|
|
||||||
{
|
|
||||||
grid.Children.Add(CreateUIForNode(child));
|
|
||||||
}
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
else if (node is DockPanelNode panelNode)
|
|
||||||
{
|
|
||||||
return new Ghost.Editor.Controls.NavigationTabView
|
|
||||||
{
|
|
||||||
ItemsSource = panelNode.Items,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
||||||
VerticalAlignment = VerticalAlignment.Stretch
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return new Grid(); // Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
RenderTree();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Define ControlTemplate in DockLayout.xaml**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.View.Controls">
|
|
||||||
<Style TargetType="local:DockLayout">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:DockLayout">
|
|
||||||
<Grid x:Name="PART_RootGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" />
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.*
|
|
||||||
git commit -m "feat(dock): implement basic recursive tree renderer"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Implement DockGroupNode Grid Builder
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace `CreateUIForNode` Group logic to generate Columns/Rows and GridSplitters**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private UIElement CreateUIForNode(DockNode node)
|
|
||||||
{
|
|
||||||
if (node is DockGroupNode groupNode)
|
|
||||||
{
|
|
||||||
var grid = new Grid();
|
|
||||||
bool isHorizontal = groupNode.Orientation == Orientation.Horizontal;
|
|
||||||
int childCount = groupNode.Children.Count;
|
|
||||||
|
|
||||||
for (int i = 0; i < childCount; i++)
|
|
||||||
{
|
|
||||||
var childNode = groupNode.Children[i];
|
|
||||||
var childUI = CreateUIForNode(childNode);
|
|
||||||
|
|
||||||
if (isHorizontal)
|
|
||||||
{
|
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
||||||
Grid.SetColumn((FrameworkElement)childUI, i * 2);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
|
||||||
Grid.SetRow((FrameworkElement)childUI, i * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.Children.Add(childUI);
|
|
||||||
|
|
||||||
// Add GridSplitter between children
|
|
||||||
if (i < childCount - 1)
|
|
||||||
{
|
|
||||||
if (isHorizontal)
|
|
||||||
{
|
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
||||||
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { Width = 4, HorizontalAlignment = HorizontalAlignment.Center, ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Columns };
|
|
||||||
Grid.SetColumn(splitter, (i * 2) + 1);
|
|
||||||
grid.Children.Add(splitter);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
|
||||||
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { Height = 4, VerticalAlignment = VerticalAlignment.Center, ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Rows };
|
|
||||||
Grid.SetRow(splitter, (i * 2) + 1);
|
|
||||||
grid.Children.Add(splitter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen to CollectionChanged to trigger re-render
|
|
||||||
groupNode.Children.CollectionChanged -= GroupNode_Children_CollectionChanged;
|
|
||||||
groupNode.Children.CollectionChanged += GroupNode_Children_CollectionChanged;
|
|
||||||
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
else if (node is DockPanelNode panelNode)
|
|
||||||
{
|
|
||||||
var tabView = new Ghost.Editor.Controls.NavigationTabView
|
|
||||||
{
|
|
||||||
TabItemsSource = panelNode.Items,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
||||||
VerticalAlignment = VerticalAlignment.Stretch,
|
|
||||||
CanDragTabs = true,
|
|
||||||
AllowDrop = true,
|
|
||||||
Tag = panelNode // Store reference to data node
|
|
||||||
};
|
|
||||||
return tabView;
|
|
||||||
}
|
|
||||||
return new Grid();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void GroupNode_Children_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
// For MVP, just re-render the whole tree when a group changes structure
|
|
||||||
RenderTree();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
|
||||||
git commit -m "feat(dock): implement grid and gridsplitter generation for groups"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Setup Visual Drop Target Overlay
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.xaml`
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add Drop Overlay to ControlTemplate**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<ControlTemplate TargetType="local:DockLayout">
|
|
||||||
<Grid>
|
|
||||||
<Grid x:Name="PART_RootGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" />
|
|
||||||
<Border x:Name="PART_DropTargetOverlay"
|
|
||||||
Background="#660078D4"
|
|
||||||
BorderBrush="#FF0078D4"
|
|
||||||
BorderThickness="2"
|
|
||||||
Visibility="Collapsed"
|
|
||||||
IsHitTestVisible="False" />
|
|
||||||
</Grid>
|
|
||||||
</ControlTemplate>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add Fields and ApplyTemplate logic in DockLayout.cs**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private Border? _dropTargetOverlay;
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
_dropTargetOverlay = GetTemplateChild("PART_DropTargetOverlay") as Border;
|
|
||||||
RenderTree();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper enum for later
|
|
||||||
public enum DockPosition { Center, Top, Bottom, Left, Right, None }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.*
|
|
||||||
git commit -m "feat(dock): add visual drop target overlay"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Implement Drag and Drop Calculations (Highlighting)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Attach TabView Drag Events in `CreateUIForNode`**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
else if (node is DockPanelNode panelNode)
|
|
||||||
{
|
|
||||||
var tabView = new Ghost.Editor.Controls.NavigationTabView
|
|
||||||
{
|
|
||||||
TabItemsSource = panelNode.Items,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
||||||
VerticalAlignment = VerticalAlignment.Stretch,
|
|
||||||
CanDragTabs = true,
|
|
||||||
AllowDrop = true,
|
|
||||||
Tag = panelNode // Store reference to data node
|
|
||||||
};
|
|
||||||
|
|
||||||
tabView.DragOver += TabView_DragOver;
|
|
||||||
tabView.DragLeave += TabView_DragLeave;
|
|
||||||
tabView.Drop += TabView_Drop;
|
|
||||||
tabView.TabDragStarting += TabView_TabDragStarting;
|
|
||||||
|
|
||||||
return tabView;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Implement Drag Handling Logic**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private object? _draggedItem;
|
|
||||||
private DockPanelNode? _sourceNode;
|
|
||||||
private DockPosition _currentDropPosition = DockPosition.None;
|
|
||||||
|
|
||||||
private void TabView_TabDragStarting(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDragStartingEventArgs args)
|
|
||||||
{
|
|
||||||
_draggedItem = args.Item;
|
|
||||||
_sourceNode = sender.Tag as DockPanelNode;
|
|
||||||
args.Data.Properties.Add("DockTab", _draggedItem); // Identify our drag
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TabView_DragOver(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.DataView.Properties.ContainsKey("DockTab") && sender is FrameworkElement targetElement)
|
|
||||||
{
|
|
||||||
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
|
|
||||||
|
|
||||||
var position = e.GetPosition(targetElement);
|
|
||||||
double width = targetElement.ActualWidth;
|
|
||||||
double height = targetElement.ActualHeight;
|
|
||||||
|
|
||||||
double edgeThreshold = 0.25; // 25% of edge triggers split
|
|
||||||
|
|
||||||
if (position.X < width * edgeThreshold) _currentDropPosition = DockPosition.Left;
|
|
||||||
else if (position.X > width * (1 - edgeThreshold)) _currentDropPosition = DockPosition.Right;
|
|
||||||
else if (position.Y < height * edgeThreshold) _currentDropPosition = DockPosition.Top;
|
|
||||||
else if (position.Y > height * (1 - edgeThreshold)) _currentDropPosition = DockPosition.Bottom;
|
|
||||||
else _currentDropPosition = DockPosition.Center;
|
|
||||||
|
|
||||||
UpdateDropOverlay(targetElement, _currentDropPosition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TabView_DragLeave(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (_dropTargetOverlay != null)
|
|
||||||
{
|
|
||||||
_dropTargetOverlay.Visibility = Visibility.Collapsed;
|
|
||||||
_currentDropPosition = DockPosition.None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDropOverlay(FrameworkElement targetElement, DockPosition position)
|
|
||||||
{
|
|
||||||
if (_dropTargetOverlay == null) return;
|
|
||||||
if (position == DockPosition.None)
|
|
||||||
{
|
|
||||||
_dropTargetOverlay.Visibility = Visibility.Collapsed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var transform = targetElement.TransformToVisual(this);
|
|
||||||
var bounds = transform.TransformBounds(new Windows.Foundation.Rect(0, 0, targetElement.ActualWidth, targetElement.ActualHeight));
|
|
||||||
|
|
||||||
_dropTargetOverlay.Visibility = Visibility.Visible;
|
|
||||||
_dropTargetOverlay.Width = double.NaN;
|
|
||||||
_dropTargetOverlay.Height = double.NaN;
|
|
||||||
|
|
||||||
switch (position)
|
|
||||||
{
|
|
||||||
case DockPosition.Center:
|
|
||||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
|
|
||||||
break;
|
|
||||||
case DockPosition.Left:
|
|
||||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - (bounds.Left + bounds.Width / 2), ActualHeight - bounds.Bottom);
|
|
||||||
break;
|
|
||||||
case DockPosition.Right:
|
|
||||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left + bounds.Width / 2, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
|
|
||||||
break;
|
|
||||||
case DockPosition.Top:
|
|
||||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - (bounds.Top + bounds.Height / 2));
|
|
||||||
break;
|
|
||||||
case DockPosition.Bottom:
|
|
||||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top + bounds.Height / 2, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
|
||||||
git commit -m "feat(dock): implement drop highlight calculations"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Implement Dropping (Data Tree Mutation)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Implement `TabView_Drop` logic**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void TabView_Drop(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
|
|
||||||
|
|
||||||
if (_draggedItem == null || _sourceNode == null || !(sender is FrameworkElement targetElement) || !(targetElement.Tag is DockPanelNode targetNode))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (_sourceNode == targetNode && _currentDropPosition == DockPosition.Center)
|
|
||||||
return; // Reordering within same tab is handled natively by TabView
|
|
||||||
|
|
||||||
// 1. Remove from source
|
|
||||||
_sourceNode.Items.Remove(_draggedItem);
|
|
||||||
CleanupEmptyNodes(_sourceNode);
|
|
||||||
|
|
||||||
// 2. Add to target
|
|
||||||
if (_currentDropPosition == DockPosition.Center)
|
|
||||||
{
|
|
||||||
targetNode.Items.Add(_draggedItem);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Split scenario
|
|
||||||
var parentGroup = targetNode.Parent;
|
|
||||||
if (parentGroup != null)
|
|
||||||
{
|
|
||||||
int index = parentGroup.Children.IndexOf(targetNode);
|
|
||||||
parentGroup.Children.RemoveAt(index);
|
|
||||||
|
|
||||||
var newGroup = new DockGroupNode
|
|
||||||
{
|
|
||||||
Orientation = (_currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Right) ? Orientation.Horizontal : Orientation.Vertical
|
|
||||||
};
|
|
||||||
|
|
||||||
var newPanel = new DockPanelNode();
|
|
||||||
newPanel.Items.Add(_draggedItem);
|
|
||||||
|
|
||||||
if (_currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Top)
|
|
||||||
{
|
|
||||||
newGroup.AddChild(newPanel);
|
|
||||||
newGroup.AddChild(targetNode);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
newGroup.AddChild(targetNode);
|
|
||||||
newGroup.AddChild(newPanel);
|
|
||||||
}
|
|
||||||
|
|
||||||
parentGroup.Children.Insert(index, newGroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_draggedItem = null;
|
|
||||||
_sourceNode = null;
|
|
||||||
_currentDropPosition = DockPosition.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CleanupEmptyNodes(DockPanelNode panelNode)
|
|
||||||
{
|
|
||||||
if (panelNode.Items.Count > 0) return;
|
|
||||||
|
|
||||||
var parentGroup = panelNode.Parent;
|
|
||||||
if (parentGroup != null)
|
|
||||||
{
|
|
||||||
parentGroup.RemoveChild(panelNode);
|
|
||||||
|
|
||||||
// If group only has 1 child left, collapse it
|
|
||||||
if (parentGroup.Children.Count == 1)
|
|
||||||
{
|
|
||||||
var onlyChild = parentGroup.Children[0];
|
|
||||||
var grandParent = parentGroup.Parent;
|
|
||||||
if (grandParent != null)
|
|
||||||
{
|
|
||||||
int index = grandParent.Children.IndexOf(parentGroup);
|
|
||||||
parentGroup.RemoveChild(onlyChild);
|
|
||||||
grandParent.Children.RemoveAt(index);
|
|
||||||
grandParent.Children.Insert(index, onlyChild);
|
|
||||||
}
|
|
||||||
else if (parentGroup == Root)
|
|
||||||
{
|
|
||||||
// If root is collapsing, the only child becomes the new root
|
|
||||||
parentGroup.RemoveChild(onlyChild);
|
|
||||||
if (onlyChild is DockGroupNode newRootGroup)
|
|
||||||
{
|
|
||||||
Root = newRootGroup;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Wrap panel in a new group to keep Root as a GroupNode
|
|
||||||
var wrapperGroup = new DockGroupNode();
|
|
||||||
wrapperGroup.AddChild(onlyChild);
|
|
||||||
Root = wrapperGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
|
||||||
git commit -m "feat(dock): implement tree mutation on drop and empty node cleanup"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Implement Window Tear-Off (TabDroppedOutside)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs`
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create `DockWindow` wrapper**
|
|
||||||
|
|
||||||
`DockWindow.xaml`:
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<winex:WindowEx
|
|
||||||
x:Class="Ghost.Editor.View.Windows.DockWindow"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:controls="using:Ghost.Editor.View.Controls"
|
|
||||||
xmlns:winex="using:WinUIEx">
|
|
||||||
<Grid>
|
|
||||||
<controls:DockLayout x:Name="PART_DockLayout" />
|
|
||||||
</Grid>
|
|
||||||
</winex:WindowEx>
|
|
||||||
```
|
|
||||||
|
|
||||||
`DockWindow.xaml.cs`:
|
|
||||||
```csharp
|
|
||||||
using Ghost.Editor.Core.Controls.Internal.Docking;
|
|
||||||
using WinUIEx;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Windows;
|
|
||||||
|
|
||||||
public sealed partial class DockWindow : WindowEx
|
|
||||||
{
|
|
||||||
public DockWindow(object initialTabContent)
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
// Setup initial single panel layout
|
|
||||||
var rootGroup = new DockGroupNode();
|
|
||||||
var panel = new DockPanelNode();
|
|
||||||
panel.Items.Add(initialTabContent);
|
|
||||||
rootGroup.AddChild(panel);
|
|
||||||
|
|
||||||
PART_DockLayout.Root = rootGroup;
|
|
||||||
|
|
||||||
// Optional: Titlebar setup etc.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Handle `TabDroppedOutside` in `DockLayout.cs`**
|
|
||||||
|
|
||||||
Modify `CreateUIForNode` in `DockLayout.cs`:
|
|
||||||
```csharp
|
|
||||||
// ... inside CreateUIForNode for DockPanelNode ...
|
|
||||||
tabView.TabDragStarting += TabView_TabDragStarting;
|
|
||||||
tabView.TabDroppedOutside += TabView_TabDroppedOutside; // NEW
|
|
||||||
|
|
||||||
return tabView;
|
|
||||||
```
|
|
||||||
|
|
||||||
Add event handler:
|
|
||||||
```csharp
|
|
||||||
private void TabView_TabDroppedOutside(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDroppedOutsideEventArgs args)
|
|
||||||
{
|
|
||||||
if (_sourceNode != null && _draggedItem != null)
|
|
||||||
{
|
|
||||||
// Remove from current tree
|
|
||||||
_sourceNode.Items.Remove(_draggedItem);
|
|
||||||
CleanupEmptyNodes(_sourceNode);
|
|
||||||
|
|
||||||
// Create new window
|
|
||||||
var newWindow = new Ghost.Editor.View.Windows.DockWindow(_draggedItem);
|
|
||||||
newWindow.Activate();
|
|
||||||
|
|
||||||
_draggedItem = null;
|
|
||||||
_sourceNode = null;
|
|
||||||
_currentDropPosition = DockPosition.None;
|
|
||||||
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Windows/DockWindow.* src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
|
||||||
git commit -m "feat(dock): implement tab tear-off to new window"
|
|
||||||
```
|
|
||||||
@@ -1,724 +0,0 @@
|
|||||||
# Docking Layout Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build a custom WinUI 3 docking layout system for GhostEngine's editor with Unity/Blender-style region highlighting and dynamic tab creation.
|
|
||||||
|
|
||||||
**Architecture:** A UI-driven approach where custom controls (`DockingLayout`, `DockPanel`, `DockGroup`, `DockDocument`) manage their own state and visual tree. Drag-and-drop manipulates the visual tree directly.
|
|
||||||
|
|
||||||
**Tech Stack:** C#, WinUI 3, Windows App SDK
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Core Enums and Base Classes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Enums**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs`:
|
|
||||||
```csharp
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
public enum DockTarget
|
|
||||||
{
|
|
||||||
Center,
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Top,
|
|
||||||
Bottom
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create DockModule base class**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
public abstract class DockModule : Control
|
|
||||||
{
|
|
||||||
public DockContainer? Owner { get; internal set; }
|
|
||||||
public DockingLayout? Root { get; internal set; }
|
|
||||||
|
|
||||||
public void Detach()
|
|
||||||
{
|
|
||||||
Owner?.Children.Remove(this);
|
|
||||||
Owner = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create DockContainer base class**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs`:
|
|
||||||
```csharp
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
public abstract class DockContainer : DockModule
|
|
||||||
{
|
|
||||||
public ObservableCollection<DockModule> Children { get; } = new();
|
|
||||||
|
|
||||||
protected DockContainer()
|
|
||||||
{
|
|
||||||
Children.CollectionChanged += OnChildrenChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.OldItems != null)
|
|
||||||
{
|
|
||||||
foreach (DockModule module in e.OldItems)
|
|
||||||
{
|
|
||||||
module.Owner = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.NewItems != null)
|
|
||||||
{
|
|
||||||
foreach (DockModule module in e.NewItems)
|
|
||||||
{
|
|
||||||
module.Owner = this;
|
|
||||||
module.Root = Root;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OnChildrenUpdated();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual void OnChildrenUpdated() { }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs
|
|
||||||
git commit -m "feat(docking): add core enums and base classes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: DockDocument and DockGroup
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create DockDocument**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
public class DockDocument : DockModule
|
|
||||||
{
|
|
||||||
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
|
|
||||||
nameof(Title), typeof(string), typeof(DockDocument), new PropertyMetadata(string.Empty));
|
|
||||||
|
|
||||||
public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(
|
|
||||||
nameof(Content), typeof(object), typeof(DockDocument), new PropertyMetadata(null));
|
|
||||||
|
|
||||||
public string Title
|
|
||||||
{
|
|
||||||
get => (string)GetValue(TitleProperty);
|
|
||||||
set => SetValue(TitleProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object Content
|
|
||||||
{
|
|
||||||
get => GetValue(ContentProperty);
|
|
||||||
set => SetValue(ContentProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DockDocument()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(DockDocument);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create DockGroup XAML**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml`:
|
|
||||||
```xml
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
|
||||||
|
|
||||||
<Style TargetType="local:DockGroup">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:DockGroup">
|
|
||||||
<Grid>
|
|
||||||
<TabView x:Name="PART_TabView"
|
|
||||||
IsAddTabButtonVisible="False"
|
|
||||||
CanDragTabs="True"
|
|
||||||
CanReorderTabs="True"
|
|
||||||
AllowDrop="True">
|
|
||||||
</TabView>
|
|
||||||
</Grid>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create DockGroup Code-Behind**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
[TemplatePart(Name = "PART_TabView", Type = typeof(TabView))]
|
|
||||||
public class DockGroup : DockContainer
|
|
||||||
{
|
|
||||||
private TabView? _tabView;
|
|
||||||
|
|
||||||
public DockGroup()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(DockGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
_tabView = GetTemplateChild("PART_TabView") as TabView;
|
|
||||||
UpdateTabs();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnChildrenUpdated()
|
|
||||||
{
|
|
||||||
UpdateTabs();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateTabs()
|
|
||||||
{
|
|
||||||
if (_tabView == null) return;
|
|
||||||
|
|
||||||
_tabView.TabItems.Clear();
|
|
||||||
foreach (var child in Children)
|
|
||||||
{
|
|
||||||
if (child is DockDocument doc)
|
|
||||||
{
|
|
||||||
var tabItem = new TabViewItem
|
|
||||||
{
|
|
||||||
Header = doc.Title,
|
|
||||||
Content = doc.Content,
|
|
||||||
Tag = doc
|
|
||||||
};
|
|
||||||
_tabView.TabItems.Add(tabItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml
|
|
||||||
git commit -m "feat(docking): add DockDocument and DockGroup"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: DockPanel
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create DockPanel XAML**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml`:
|
|
||||||
```xml
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
|
||||||
|
|
||||||
<Style TargetType="local:DockPanel">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:DockPanel">
|
|
||||||
<Grid x:Name="PART_Grid" />
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create DockPanel Code-Behind**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
using CommunityToolkit.WinUI.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
[TemplatePart(Name = "PART_Grid", Type = typeof(Grid))]
|
|
||||||
public class DockPanel : DockContainer
|
|
||||||
{
|
|
||||||
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
|
|
||||||
nameof(Orientation), typeof(Orientation), typeof(DockPanel), new PropertyMetadata(Orientation.Horizontal, OnOrientationChanged));
|
|
||||||
|
|
||||||
public Orientation Orientation
|
|
||||||
{
|
|
||||||
get => (Orientation)GetValue(OrientationProperty);
|
|
||||||
set => SetValue(OrientationProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Grid? _grid;
|
|
||||||
|
|
||||||
public DockPanel()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(DockPanel);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
_grid = GetTemplateChild("PART_Grid") as Grid;
|
|
||||||
UpdateLayoutStructure();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnChildrenUpdated()
|
|
||||||
{
|
|
||||||
UpdateLayoutStructure();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
((DockPanel)d).UpdateLayoutStructure();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateLayoutStructure()
|
|
||||||
{
|
|
||||||
if (_grid == null) return;
|
|
||||||
|
|
||||||
_grid.Children.Clear();
|
|
||||||
_grid.RowDefinitions.Clear();
|
|
||||||
_grid.ColumnDefinitions.Clear();
|
|
||||||
|
|
||||||
if (Children.Count == 0) return;
|
|
||||||
|
|
||||||
if (Orientation == Orientation.Horizontal)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < Children.Count; i++)
|
|
||||||
{
|
|
||||||
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
||||||
var child = Children[i];
|
|
||||||
Grid.SetColumn(child, i * 2);
|
|
||||||
_grid.Children.Add(child);
|
|
||||||
|
|
||||||
if (i < Children.Count - 1)
|
|
||||||
{
|
|
||||||
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
||||||
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Columns, Width = 4 };
|
|
||||||
Grid.SetColumn(splitter, i * 2 + 1);
|
|
||||||
_grid.Children.Add(splitter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (int i = 0; i < Children.Count; i++)
|
|
||||||
{
|
|
||||||
_grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
|
||||||
var child = Children[i];
|
|
||||||
Grid.SetRow(child, i * 2);
|
|
||||||
_grid.Children.Add(child);
|
|
||||||
|
|
||||||
if (i < Children.Count - 1)
|
|
||||||
{
|
|
||||||
_grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
|
||||||
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Rows, Height = 4 };
|
|
||||||
Grid.SetRow(splitter, i * 2 + 1);
|
|
||||||
_grid.Children.Add(splitter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml
|
|
||||||
git commit -m "feat(docking): add DockPanel"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: DockRegionHighlight and DockingLayout
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create DockRegionHighlight XAML**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml`:
|
|
||||||
```xml
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
|
||||||
|
|
||||||
<Style TargetType="local:DockRegionHighlight">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:DockRegionHighlight">
|
|
||||||
<Border Background="#400078D7" BorderBrush="#800078D7" BorderThickness="2" CornerRadius="4" />
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create DockRegionHighlight Code-Behind**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
public class DockRegionHighlight : Control
|
|
||||||
{
|
|
||||||
public DockRegionHighlight()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(DockRegionHighlight);
|
|
||||||
IsHitTestVisible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create DockingLayout XAML**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml`:
|
|
||||||
```xml
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
|
||||||
|
|
||||||
<Style TargetType="local:DockingLayout">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:DockingLayout">
|
|
||||||
<Grid>
|
|
||||||
<ContentPresenter x:Name="PART_Content" Content="{TemplateBinding RootPanel}" />
|
|
||||||
<Canvas x:Name="PART_OverlayCanvas" IsHitTestVisible="False">
|
|
||||||
<local:DockRegionHighlight x:Name="PART_Highlight" Visibility="Collapsed" />
|
|
||||||
</Canvas>
|
|
||||||
</Grid>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create DockingLayout Code-Behind**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
[TemplatePart(Name = "PART_Content", Type = typeof(ContentPresenter))]
|
|
||||||
[TemplatePart(Name = "PART_OverlayCanvas", Type = typeof(Canvas))]
|
|
||||||
[TemplatePart(Name = "PART_Highlight", Type = typeof(DockRegionHighlight))]
|
|
||||||
public class DockingLayout : Control
|
|
||||||
{
|
|
||||||
public static readonly DependencyProperty RootPanelProperty = DependencyProperty.Register(
|
|
||||||
nameof(RootPanel), typeof(DockPanel), typeof(DockingLayout), new PropertyMetadata(null, OnRootPanelChanged));
|
|
||||||
|
|
||||||
public DockPanel? RootPanel
|
|
||||||
{
|
|
||||||
get => (DockPanel?)GetValue(RootPanelProperty);
|
|
||||||
set => SetValue(RootPanelProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Canvas? _overlayCanvas;
|
|
||||||
private DockRegionHighlight? _highlight;
|
|
||||||
|
|
||||||
public DockingLayout()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(DockingLayout);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
_overlayCanvas = GetTemplateChild("PART_OverlayCanvas") as Canvas;
|
|
||||||
_highlight = GetTemplateChild("PART_Highlight") as DockRegionHighlight;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnRootPanelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (d is DockingLayout layout && e.NewValue is DockPanel panel)
|
|
||||||
{
|
|
||||||
panel.Root = layout;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null)
|
|
||||||
{
|
|
||||||
if (RootPanel == null)
|
|
||||||
{
|
|
||||||
RootPanel = new DockPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetGroup == null)
|
|
||||||
{
|
|
||||||
if (RootPanel.Children.Count == 0)
|
|
||||||
{
|
|
||||||
var group = new DockGroup();
|
|
||||||
group.Children.Add(document);
|
|
||||||
RootPanel.Children.Add(group);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
targetGroup = RootPanel.Children[0] as DockGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetGroup != null)
|
|
||||||
{
|
|
||||||
targetGroup.Children.Add(document);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml
|
|
||||||
git commit -m "feat(docking): add DockRegionHighlight and DockingLayout"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Drag and Drop Logic
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Implement Drag and Drop in DockGroup**
|
|
||||||
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs` to handle drag events on the TabView:
|
|
||||||
```csharp
|
|
||||||
// Add to OnApplyTemplate:
|
|
||||||
if (_tabView != null)
|
|
||||||
{
|
|
||||||
_tabView.TabDragStarting += OnTabDragStarting;
|
|
||||||
_tabView.TabDroppedOutside += OnTabDroppedOutside;
|
|
||||||
_tabView.DragOver += OnDragOver;
|
|
||||||
_tabView.Drop += OnDrop;
|
|
||||||
_tabView.DragLeave += OnDragLeave;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add methods:
|
|
||||||
private void OnTabDragStarting(TabView sender, TabViewTabDragStartingEventArgs args)
|
|
||||||
{
|
|
||||||
if (args.Tab.Tag is DockDocument doc)
|
|
||||||
{
|
|
||||||
args.Data.Properties.Add("DockDocument", doc);
|
|
||||||
doc.Detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTabDroppedOutside(TabView sender, TabViewTabDroppedOutsideEventArgs args)
|
|
||||||
{
|
|
||||||
if (args.Tab.Tag is DockDocument doc)
|
|
||||||
{
|
|
||||||
Root?.CreateFloatingWindow(doc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDragOver(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.DataView.Properties.ContainsKey("DockDocument"))
|
|
||||||
{
|
|
||||||
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
|
|
||||||
Root?.ShowHighlight(this, e.GetPosition(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDrop(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.DataView.Properties.TryGetValue("DockDocument", out var obj) && obj is DockDocument doc)
|
|
||||||
{
|
|
||||||
Root?.HandleDrop(doc, this, e.GetPosition(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDragLeave(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
Root?.HideHighlight();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Implement Highlight and Drop in DockingLayout**
|
|
||||||
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs` to add `ShowHighlight`, `HideHighlight`, `HandleDrop`, and `CreateFloatingWindow`:
|
|
||||||
```csharp
|
|
||||||
// Add methods:
|
|
||||||
internal void ShowHighlight(DockGroup targetGroup, Windows.Foundation.Point position)
|
|
||||||
{
|
|
||||||
if (_highlight == null || _overlayCanvas == null) return;
|
|
||||||
|
|
||||||
_highlight.Visibility = Visibility.Visible;
|
|
||||||
var target = CalculateDockTarget(targetGroup, position);
|
|
||||||
|
|
||||||
// Calculate rect based on target (simplified for brevity, needs actual math based on targetGroup's ActualWidth/Height)
|
|
||||||
double width = targetGroup.ActualWidth;
|
|
||||||
double height = targetGroup.ActualHeight;
|
|
||||||
double x = 0, y = 0;
|
|
||||||
|
|
||||||
switch (target)
|
|
||||||
{
|
|
||||||
case DockTarget.Left: width /= 2; break;
|
|
||||||
case DockTarget.Right: width /= 2; x = width; break;
|
|
||||||
case DockTarget.Top: height /= 2; break;
|
|
||||||
case DockTarget.Bottom: height /= 2; y = height; break;
|
|
||||||
case DockTarget.Center: break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var transform = targetGroup.TransformToVisual(_overlayCanvas);
|
|
||||||
var point = transform.TransformPoint(new Windows.Foundation.Point(x, y));
|
|
||||||
|
|
||||||
Canvas.SetLeft(_highlight, point.X);
|
|
||||||
Canvas.SetTop(_highlight, point.Y);
|
|
||||||
_highlight.Width = width;
|
|
||||||
_highlight.Height = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void HideHighlight()
|
|
||||||
{
|
|
||||||
if (_highlight != null) _highlight.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void HandleDrop(DockDocument doc, DockGroup targetGroup, Windows.Foundation.Point position)
|
|
||||||
{
|
|
||||||
HideHighlight();
|
|
||||||
var target = CalculateDockTarget(targetGroup, position);
|
|
||||||
|
|
||||||
if (target == DockTarget.Center)
|
|
||||||
{
|
|
||||||
targetGroup.Children.Add(doc);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Split logic: create new DockPanel, move targetGroup and doc into it
|
|
||||||
var parentPanel = targetGroup.Owner as DockPanel;
|
|
||||||
if (parentPanel != null)
|
|
||||||
{
|
|
||||||
int index = parentPanel.Children.IndexOf(targetGroup);
|
|
||||||
targetGroup.Detach();
|
|
||||||
|
|
||||||
var newPanel = new DockPanel { Orientation = (target == DockTarget.Left || target == DockTarget.Right) ? Orientation.Horizontal : Orientation.Vertical };
|
|
||||||
var newGroup = new DockGroup();
|
|
||||||
newGroup.Children.Add(doc);
|
|
||||||
|
|
||||||
if (target == DockTarget.Left || target == DockTarget.Top)
|
|
||||||
{
|
|
||||||
newPanel.Children.Add(newGroup);
|
|
||||||
newPanel.Children.Add(targetGroup);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
newPanel.Children.Add(targetGroup);
|
|
||||||
newPanel.Children.Add(newGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPanel.Children.Insert(index, newPanel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private DockTarget CalculateDockTarget(DockGroup group, Windows.Foundation.Point position)
|
|
||||||
{
|
|
||||||
double w = group.ActualWidth;
|
|
||||||
double h = group.ActualHeight;
|
|
||||||
double x = position.X;
|
|
||||||
double y = position.Y;
|
|
||||||
|
|
||||||
if (x < w * 0.25) return DockTarget.Left;
|
|
||||||
if (x > w * 0.75) return DockTarget.Right;
|
|
||||||
if (y < h * 0.25) return DockTarget.Top;
|
|
||||||
if (y > h * 0.75) return DockTarget.Bottom;
|
|
||||||
return DockTarget.Center;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void CreateFloatingWindow(DockDocument doc)
|
|
||||||
{
|
|
||||||
// To be implemented in Task 6
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs
|
|
||||||
git commit -m "feat(docking): implement drag and drop logic"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Floating Window
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs`
|
|
||||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create FloatingWindow**
|
|
||||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs`:
|
|
||||||
```csharp
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.View.Controls.Docking;
|
|
||||||
|
|
||||||
public class FloatingWindow : Window
|
|
||||||
{
|
|
||||||
public FloatingWindow(DockDocument document)
|
|
||||||
{
|
|
||||||
var layout = new DockingLayout();
|
|
||||||
var group = new DockGroup();
|
|
||||||
group.Children.Add(document);
|
|
||||||
|
|
||||||
var panel = new DockPanel();
|
|
||||||
panel.Children.Add(group);
|
|
||||||
layout.RootPanel = panel;
|
|
||||||
|
|
||||||
Content = layout;
|
|
||||||
|
|
||||||
// Basic window setup
|
|
||||||
AppWindow.Resize(new Windows.Graphics.SizeInt32(800, 600));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update DockingLayout**
|
|
||||||
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs` to implement `CreateFloatingWindow`:
|
|
||||||
```csharp
|
|
||||||
internal void CreateFloatingWindow(DockDocument doc)
|
|
||||||
{
|
|
||||||
var window = new FloatingWindow(doc);
|
|
||||||
window.Activate();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs
|
|
||||||
git commit -m "feat(docking): add floating window support"
|
|
||||||
```
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# DockLayout System Design
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
To create a fully-featured docking layout system for the Ghost Engine Editor using WinUI 3, supporting tab tearing, window popping, and dynamic splitting of regions in a style similar to Unity or Blender.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The DockLayout will be entirely driven by a C# data model that represents a tree of nodes. The UI (`DockLayout` control) will observe this tree and recursively generate the corresponding XAML `Grid` and `TabView` elements.
|
|
||||||
|
|
||||||
### Core Data Model (The Node Tree)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public abstract class DockNode : INotifyPropertyChanged { }
|
|
||||||
|
|
||||||
// Represents a split region (Grid)
|
|
||||||
public class DockGroupNode : DockNode
|
|
||||||
{
|
|
||||||
public Orientation Orientation { get; set; } // Horizontal or Vertical
|
|
||||||
public ObservableCollection<DockNode> Children { get; }
|
|
||||||
public ObservableCollection<GridLength> Sizes { get; } // Replaces Ratios for better WinUI 3 Grid binding
|
|
||||||
}
|
|
||||||
|
|
||||||
// Represents a leaf node containing a TabView
|
|
||||||
public class DockPanelNode : DockNode
|
|
||||||
{
|
|
||||||
// The items shown in the TabView
|
|
||||||
public ObservableCollection<object> Items { get; }
|
|
||||||
public int SelectedIndex { get; set; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Visual Components
|
|
||||||
|
|
||||||
1. **`DockLayout` (Control)**
|
|
||||||
* The root control.
|
|
||||||
* Takes a `DockNode` (usually a `DockGroupNode`) as its `Root`.
|
|
||||||
* Listens to Drag/Drop events to render the transparent drop target overlay over the layout.
|
|
||||||
|
|
||||||
2. **Node Renderers**
|
|
||||||
* A recursive template selector or code-behind builder that converts `DockGroupNode` into a `Grid` with `GridSplitter`s.
|
|
||||||
* Converts `DockPanelNode` into a `NavigationTabView` (or standard `TabView` with customized drag behaviors).
|
|
||||||
|
|
||||||
3. **`DockDropTarget` (Visual Overlay)**
|
|
||||||
* A simple XAML structure (e.g., a colored `Border` with opacity) that highlights a portion of a `DockPanelNode` based on mouse position during a drag operation (Left/Right/Top/Bottom 25% for splitting, Center 50% for merging).
|
|
||||||
|
|
||||||
## Interactions & Data Flow
|
|
||||||
|
|
||||||
### 1. Internal Dragging (Within the same window)
|
|
||||||
* User starts dragging a tab.
|
|
||||||
* The `DockLayout` tracks the mouse pointer `DragOver` events.
|
|
||||||
* It determines which `DockPanelNode` the mouse is currently hovering over.
|
|
||||||
* It calculates relative coordinates to show the Unity-style drop highlight.
|
|
||||||
* On **Drop**:
|
|
||||||
* If dropped in the **center**: The tab object is moved from its source `DockPanelNode.Items` to the target `DockPanelNode.Items`.
|
|
||||||
* If dropped on an **edge** (e.g., Right): The target `DockPanelNode` is removed from its parent `DockGroupNode`. A new `DockGroupNode` (Horizontal) is created to replace it. The target node and a *new* `DockPanelNode` (containing the dragged tab) are added as children to this new group.
|
|
||||||
|
|
||||||
### 2. Window Tear-Off (Full Docking)
|
|
||||||
* User drags a tab completely outside the main window.
|
|
||||||
* `TabView.TabDroppedOutside` is triggered.
|
|
||||||
* The system creates a new WinUI 3 `Window`.
|
|
||||||
* A new `DockLayout` instance is placed in this window.
|
|
||||||
* The dragged tab object is removed from its original tree and added to a new `DockPanelNode` inside the new window's tree.
|
|
||||||
* *Note: Because WinUI 3 supports multiple windows on the same UI thread, we don't have to worry about cross-thread marshaling of UI elements, making this much simpler than UWP.*
|
|
||||||
|
|
||||||
### 3. Empty Node Cleanup
|
|
||||||
* When a `DockPanelNode`'s `Items` collection reaches 0 (the last tab is dragged away), it is removed from the tree.
|
|
||||||
* If its parent `DockGroupNode` now only has 1 child remaining, that `DockGroupNode` is removed and replaced by its single child, collapsing the tree.
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
1. Define the Data Model (`DockNode` structure).
|
|
||||||
2. Implement the recursive UI generation (binding the tree to nested Grids and TabViews).
|
|
||||||
3. Implement basic tab moving (Merge) between existing `DockPanelNode`s.
|
|
||||||
4. Implement edge dropping (Split) and the drop target highlight overlay.
|
|
||||||
5. Implement empty node cleanup logic.
|
|
||||||
6. Implement multi-window tear-off via `TabDroppedOutside`.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Docking Layout Design
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
A custom WinUI 3 docking layout system for GhostEngine's editor, inspired by `WinUI.Dock` but tailored to support Unity/Blender-style region highlighting and dynamic tab creation.
|
|
||||||
|
|
||||||
## Architecture & Components
|
|
||||||
|
|
||||||
The system uses a UI-driven approach where custom controls manage their own state and visual tree.
|
|
||||||
|
|
||||||
- **`DockingLayout`**: The root control. Manages the overall state, drag-and-drop coordination, and floating windows.
|
|
||||||
- **`DockPanel`**: A container that splits its area horizontally or vertically (using a `Grid` with `GridSplitter`s).
|
|
||||||
- **`DockGroup`**: A container that holds multiple documents, rendered as a WinUI 3 `TabView`.
|
|
||||||
- **`DockDocument`**: The actual content item, representing a single tab and its payload.
|
|
||||||
- **`FloatingWindow`**: A separate WinUI `Window` that hosts a minimal `DockingLayout` internally for torn-off tabs.
|
|
||||||
- **`DockRegionHighlight`**: A visual overlay control (e.g., a semi-transparent blue rectangle) used during drag-and-drop to show exactly where the drop will occur (Left, Right, Top, Bottom, or Center).
|
|
||||||
|
|
||||||
## Drag and Drop Flow
|
|
||||||
|
|
||||||
1. **Start**: Dragging a `TabViewItem` initiates a drag operation. We store a reference to the dragged `DockDocument`.
|
|
||||||
2. **Over**: As the pointer moves over a `DockGroup` or `DockPanel`, we calculate the relative pointer position to determine the target region (Left 25%, Right 25%, Top 25%, Bottom 25%, or Center 50%). We display the `DockRegionHighlight` over that specific area.
|
|
||||||
3. **Drop**:
|
|
||||||
- If dropped on an edge region (Left/Right/Top/Bottom), we split the target container: we create a new `DockPanel`, move the existing content to one side, and place the dragged document on the other.
|
|
||||||
- If dropped in the Center of a `DockGroup`, we add the document as a new tab to that group.
|
|
||||||
- If dropped outside the main window bounds, we create a new `FloatingWindow` and place the document inside it.
|
|
||||||
|
|
||||||
## Dynamic Creation API
|
|
||||||
|
|
||||||
The `DockingLayout` will expose methods to allow programmatically adding new panels or tabs at runtime (e.g., for a "plus icon" scenario).
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
- The controls will be created under `src\Editor\Ghost.Editor\View\Controls\Docking\`.
|
|
||||||
- We will use WinUI 3's built-in drag and drop APIs (`CanDrag`, `DragStarting`, `AllowDrop`, `DragOver`, `Drop`).
|
|
||||||
- The `DockRegionHighlight` will be a `Border` or `Rectangle` added to the `DockingLayout`'s visual tree (e.g., in a `Popup` or an overlay `Grid`) and positioned absolutely based on the current drag target.
|
|
||||||
17
src/Directory.Build.props
Normal file
17
src/Directory.Build.props
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Debug_Editor'">
|
||||||
|
<DefineConstants>$(DefineConstants);DEBUG;TRACE;GHOST_EDITOR;GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>false</Optimize>
|
||||||
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<DebugType>portable</DebugType>
|
||||||
|
<TieredCompilation>false</TieredCompilation>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release_Editor'">
|
||||||
|
<DefineConstants>$(DefineConstants);GHOST_EDITOR;GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release_Dev'">
|
||||||
|
<DefineConstants>$(DefineConstants);GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Ghost.Core.Graphics;
|
||||||
using Misaki.HighPerformance.Mathematics;
|
using Misaki.HighPerformance.Mathematics;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@@ -7,25 +8,6 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace Ghost.DSL.Generator;
|
namespace Ghost.DSL.Generator;
|
||||||
|
|
||||||
public enum PackingRules
|
|
||||||
{
|
|
||||||
Exact,
|
|
||||||
Aligned,
|
|
||||||
}
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Enum)]
|
|
||||||
public class GenerateHLSLAttribute : Attribute
|
|
||||||
{
|
|
||||||
private readonly PackingRules _packingRules;
|
|
||||||
private readonly string? _outputSource;
|
|
||||||
|
|
||||||
public GenerateHLSLAttribute(PackingRules packingRules, string? outputSource)
|
|
||||||
{
|
|
||||||
_packingRules = packingRules;
|
|
||||||
_outputSource = outputSource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static partial class ShaderStructGenerator
|
internal static partial class ShaderStructGenerator
|
||||||
{
|
{
|
||||||
private struct ShaderFieldInfo
|
private struct ShaderFieldInfo
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Antlr4.Runtime.Standard" Version="4.13.1" />
|
<PackageReference Include="Antlr4.Runtime.Standard" Version="4.13.1" />
|
||||||
<PackageReference Include="Antlr4BuildTasks" Version="12.11.0" />
|
<PackageReference Include="Antlr4BuildTasks" Version="12.14.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -17,6 +18,11 @@
|
|||||||
<Listener>false</Listener>
|
<Listener>false</Listener>
|
||||||
<Visitor>true</Visitor>
|
<Visitor>true</Visitor>
|
||||||
</Antlr4>
|
</Antlr4>
|
||||||
|
<Antlr4 Include="Grammar\GhostComputeShaderParser.g4">
|
||||||
|
<Visitor>true</Visitor>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<Listener>false</Listener>
|
||||||
|
</Antlr4>
|
||||||
<Antlr4 Include="Grammar\GhostShaderParser.g4">
|
<Antlr4 Include="Grammar\GhostShaderParser.g4">
|
||||||
<Generator>MSBuild:Compile</Generator>
|
<Generator>MSBuild:Compile</Generator>
|
||||||
<Listener>false</Listener>
|
<Listener>false</Listener>
|
||||||
|
|||||||
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
|
// Keywords
|
||||||
SHADER: 'shader';
|
SHADER: 'shader';
|
||||||
PROPERTIES: 'properties';
|
COMPUTE: 'compute';
|
||||||
PIPELINE: 'pipeline';
|
PIPELINE: 'pipeline';
|
||||||
PASS: 'pass';
|
PASS: 'pass';
|
||||||
DEFINES: 'defines';
|
DEFINES: 'defines';
|
||||||
@@ -11,6 +11,7 @@ INCLUDES: 'includes';
|
|||||||
GLOBAL: 'global';
|
GLOBAL: 'global';
|
||||||
LOCAL: 'local';
|
LOCAL: 'local';
|
||||||
HLSL: 'hlsl';
|
HLSL: 'hlsl';
|
||||||
|
SM: 'sm';
|
||||||
|
|
||||||
// Punctuation
|
// Punctuation
|
||||||
LBRACE: '{';
|
LBRACE: '{';
|
||||||
|
|||||||
@@ -13,23 +13,14 @@ shader:
|
|||||||
RBRACE;
|
RBRACE;
|
||||||
|
|
||||||
shaderBody:
|
shaderBody:
|
||||||
(propertiesBlock | pipelineBlock | passBlock | functionCall)*;
|
shaderModel | (pipelineBlock | passBlock | functionCall)*;
|
||||||
|
|
||||||
// Properties block
|
shaderModel:
|
||||||
propertiesBlock:
|
SM IDENTIFIER SEMICOLON;
|
||||||
PROPERTIES LBRACE
|
|
||||||
propertyDeclaration*
|
|
||||||
RBRACE;
|
|
||||||
|
|
||||||
propertyDeclaration:
|
|
||||||
scope? IDENTIFIER IDENTIFIER (EQUALS LBRACE propertyInitializer RBRACE)? SEMICOLON;
|
|
||||||
|
|
||||||
scope:
|
scope:
|
||||||
GLOBAL | LOCAL;
|
GLOBAL | LOCAL;
|
||||||
|
|
||||||
propertyInitializer:
|
|
||||||
(NUMBER | IDENTIFIER) (COMMA (NUMBER | IDENTIFIER))*;
|
|
||||||
|
|
||||||
// Pipeline block
|
// Pipeline block
|
||||||
pipelineBlock:
|
pipelineBlock:
|
||||||
PIPELINE LBRACE
|
PIPELINE LBRACE
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Core.Graphics;
|
using Ghost.Core.Graphics;
|
||||||
using Ghost.DSL.ShaderParser;
|
using Ghost.DSL.ShaderParser;
|
||||||
|
using Misaki.HighPerformance.Utilities;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace Ghost.DSL.ShaderCompiler;
|
namespace Ghost.DSL.ShaderCompiler;
|
||||||
@@ -17,16 +18,9 @@ public struct DSLShaderError
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class DSLShaderCompiler
|
public static class DSLShaderCompiler
|
||||||
{
|
{
|
||||||
private const string _GLOBAL_PROPERTY_FILE_NAME = "GlobalData.g.hlsl";
|
#if GHOST_SAFETY_CHECKS
|
||||||
private const string _GENERATED_FILE_HEADER = "// Auto-generated shader file. Please do not edit this file directly.";
|
|
||||||
|
|
||||||
private static string GetPassUniqueId(DSLShaderSemantics shader, PassSemantic pass)
|
|
||||||
{
|
|
||||||
return $"{shader.name}_{pass.name}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
||||||
{
|
{
|
||||||
if (semantic == null)
|
if (semantic == null)
|
||||||
@@ -44,103 +38,155 @@ internal static class DSLShaderCompiler
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int LayoutCBufferProperties(Span<PropertyDescriptor> properties)
|
private static Result<string> BuildFinalShaderCode(string shaderPath, ReadOnlySpan<string> includes, string? injectedCode, string? properties)
|
||||||
{
|
{
|
||||||
if (properties.IsEmpty)
|
string shaderCode;
|
||||||
|
if (shaderPath == "hlsl_block")
|
||||||
{
|
{
|
||||||
return 0;
|
if (string.IsNullOrEmpty(injectedCode))
|
||||||
|
{
|
||||||
|
return Result.Failure("Shader code is empty. Either provide a valid shader path or inject shader code directly.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentOffset = 0;
|
shaderCode = string.Empty;
|
||||||
|
|
||||||
foreach (ref var prop in properties)
|
|
||||||
{
|
|
||||||
var size = prop.type.GetSize();
|
|
||||||
|
|
||||||
if ((currentOffset % 16) + size > 16)
|
|
||||||
{
|
|
||||||
currentOffset = (currentOffset + 15) & ~15;
|
|
||||||
}
|
|
||||||
|
|
||||||
prop.offset = currentOffset;
|
|
||||||
prop.size = size;
|
|
||||||
|
|
||||||
currentOffset += size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (currentOffset + 15) & ~15;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
|
||||||
// Currently, we just ignore inheritance.
|
|
||||||
public static ShaderDescriptor ResolveShader(DSLShaderSemantics semantics)
|
|
||||||
{
|
|
||||||
var descriptor = new ShaderDescriptor
|
|
||||||
{
|
|
||||||
name = semantics.name,
|
|
||||||
hlsl = semantics.hlsl
|
|
||||||
};
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
descriptor.passes = Array.Empty<PassDescriptor>();
|
if (!File.Exists(shaderPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Shader file not found: " + shaderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
shaderCode = File.ReadAllText(shaderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
||||||
|
// Currently, we just ignore inheritance.
|
||||||
|
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
||||||
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
|
{
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var i = 0; i < descriptor.Passes.Length; i++)
|
||||||
|
{
|
||||||
|
descriptor.Passes[i].shader = descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
return descriptor;
|
return descriptor;
|
||||||
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<ShaderDescriptor> CompileShader(string shaderPath, string generatedOutputDirectory)
|
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
return CompileGraphicsShaderCode(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(string shaderPath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(shaderPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Shader file not found: " + shaderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = File.ReadAllText(shaderPath);
|
||||||
|
return CompileGraphicsShaderCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<GraphicsShaderDescriptor> CompileGraphicsShaderCode(string shaderCode)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var source = File.ReadAllText(shaderPath);
|
|
||||||
|
|
||||||
// Use ANTLR4 parser
|
// Use ANTLR4 parser
|
||||||
var shaderModels = AntlrShaderCompiler.ParseShaders(source, out var parseErrors);
|
var parseErrors = new List<DSLShaderError>();
|
||||||
|
var shaderModels = AntlrShaderCompiler.ParseShaders(shaderCode, parseErrors);
|
||||||
|
|
||||||
if (parseErrors.Count != 0)
|
if (parseErrors.Count != 0)
|
||||||
{
|
{
|
||||||
@@ -172,37 +218,13 @@ internal static class DSLShaderCompiler
|
|||||||
return Result.Failure("Failed to compile shader due to errors:\n" + errorMessages.ToString());
|
return Result.Failure("Failed to compile shader due to errors:\n" + errorMessages.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
var desc = ResolveShader(model);
|
var result = ResolveShader(model);
|
||||||
var globalPropResult = GenerateGlobalProperties(desc.globalProperties, generatedOutputDirectory);
|
if (result.IsFailure)
|
||||||
if (globalPropResult.IsFailure)
|
|
||||||
{
|
{
|
||||||
return Result.Failure("Failed to generate global properties: " + globalPropResult.Message);
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var generatedResult = GenerateShaderCode(desc, generatedOutputDirectory);
|
return result.Value;
|
||||||
if (generatedResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure("Failed to generate pass files: " + generatedResult.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (ref var pass in desc.passes.AsSpan())
|
|
||||||
{
|
|
||||||
if (pass.includes == null)
|
|
||||||
{
|
|
||||||
pass.includes = new string[2];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Array.Resize(ref pass.includes, pass.includes.Length + 2);
|
|
||||||
// Shift existing includes to make room for the two new includes at the front.
|
|
||||||
pass.includes.AsSpan(0, pass.includes.Length - 2).CopyTo(pass.includes.AsSpan(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
pass.includes[0] = globalPropResult.Value;
|
|
||||||
pass.includes[1] = generatedResult.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return desc;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -210,117 +232,104 @@ 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)
|
||||||
{
|
{
|
||||||
ShaderPropertyType.Float => "float",
|
if (!File.Exists(shaderPath))
|
||||||
ShaderPropertyType.Float2 => "float2",
|
{
|
||||||
ShaderPropertyType.Float3 => "float3",
|
return Result.Failure("Shader file not found: " + shaderPath);
|
||||||
ShaderPropertyType.Float4 => "float4",
|
}
|
||||||
ShaderPropertyType.Int => "int",
|
|
||||||
ShaderPropertyType.Int2 => "int2",
|
var code = File.ReadAllText(shaderPath);
|
||||||
ShaderPropertyType.Int3 => "int3",
|
return CompileComputeShaderCode(code);
|
||||||
ShaderPropertyType.Int4 => "int4",
|
}
|
||||||
ShaderPropertyType.UInt => "uint",
|
|
||||||
ShaderPropertyType.UInt2 => "uint2",
|
public static Result<ComputeShaderDescriptor> CompileComputeShaderCode(string shaderCode)
|
||||||
ShaderPropertyType.UInt3 => "uint3",
|
{
|
||||||
ShaderPropertyType.UInt4 => "uint4",
|
try
|
||||||
ShaderPropertyType.Bool => "bool",
|
{
|
||||||
ShaderPropertyType.Bool2 => "bool2",
|
var parseErrors = new List<DSLShaderError>();
|
||||||
ShaderPropertyType.Bool3 => "bool3",
|
var shaderModels = AntlrShaderCompiler.ParseComputeShaders(shaderCode, parseErrors);
|
||||||
ShaderPropertyType.Bool4 => "bool4",
|
|
||||||
// NOTE: Textures here are bindless, represented as uint (descriptor index).
|
if (parseErrors.Count != 0)
|
||||||
ShaderPropertyType.Texture2D => "TEXTURE2D",
|
{
|
||||||
ShaderPropertyType.Texture3D => "TEXTURE3D",
|
var errorMessages = new StringBuilder();
|
||||||
ShaderPropertyType.TextureCube => "TEXTURECUBE",
|
foreach (var error in parseErrors)
|
||||||
ShaderPropertyType.Texture2DArray => "TEXTURE2D_ARRAY",
|
{
|
||||||
ShaderPropertyType.TextureCubeArray => "TEXTURECUBE_ARRAY",
|
errorMessages.AppendLine(error.ToString());
|
||||||
ShaderPropertyType.Sampler => "SAMPLER",
|
}
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported shader property type: {type}")
|
|
||||||
|
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 GHOST_SAFETY_CHECKS
|
||||||
|
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>()
|
||||||
};
|
};
|
||||||
}
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
public static Result<string> GenerateShaderCode(ShaderDescriptor descriptor, string targetDirectory)
|
#endif
|
||||||
{
|
|
||||||
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,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PropertySemantic
|
public struct ShaderEntryPoint
|
||||||
{
|
{
|
||||||
public PropertyScope scope;
|
public string entry;
|
||||||
public ShaderPropertyType type;
|
public string shaderPath;
|
||||||
public string name = string.Empty;
|
|
||||||
public object? defaultValue;
|
public readonly bool IsCreated => !string.IsNullOrEmpty(entry) && !string.IsNullOrEmpty(shaderPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PipelineSemantic
|
public class PipelineSemantic
|
||||||
@@ -28,7 +28,7 @@ public class PipelineSemantic
|
|||||||
public class PassSemantic
|
public class PassSemantic
|
||||||
{
|
{
|
||||||
public string name = string.Empty;
|
public string name = string.Empty;
|
||||||
public ShaderEntryPoint taskShader;
|
public ShaderEntryPoint amplificationShader;
|
||||||
public ShaderEntryPoint meshShader;
|
public ShaderEntryPoint meshShader;
|
||||||
public ShaderEntryPoint pixelShader;
|
public ShaderEntryPoint pixelShader;
|
||||||
public string? hlsl;
|
public string? hlsl;
|
||||||
@@ -41,8 +41,18 @@ public class PassSemantic
|
|||||||
public class DSLShaderSemantics
|
public class DSLShaderSemantics
|
||||||
{
|
{
|
||||||
public string name = string.Empty;
|
public string name = string.Empty;
|
||||||
public string? hlsl;
|
public ShaderModel shaderModel;
|
||||||
public List<PropertySemantic>? properties;
|
|
||||||
public PipelineSemantic? pipeline;
|
public PipelineSemantic? pipeline;
|
||||||
public List<PassSemantic>? passes;
|
public List<PassSemantic>? passes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class DSLComputeShaderSemantics
|
||||||
|
{
|
||||||
|
public string name = string.Empty;
|
||||||
|
public string? hlsl;
|
||||||
|
public ShaderModel shaderModel;
|
||||||
|
public List<string>? defines;
|
||||||
|
public List<string>? includes;
|
||||||
|
public List<KeywordsGroup>? keywords;
|
||||||
|
public List<ShaderEntryPoint> entryPoints = null!;
|
||||||
|
}
|
||||||
@@ -7,10 +7,8 @@ namespace Ghost.DSL.ShaderParser;
|
|||||||
|
|
||||||
public class AntlrShaderCompiler
|
public class AntlrShaderCompiler
|
||||||
{
|
{
|
||||||
public static List<ShaderModel> ParseShaders(string source, out List<DSLShaderError> errors)
|
public static List<GraphicsShaderModel> ParseShaders(string source, List<DSLShaderError> errors)
|
||||||
{
|
{
|
||||||
errors = new List<DSLShaderError>();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var inputStream = new AntlrInputStream(source);
|
var inputStream = new AntlrInputStream(source);
|
||||||
@@ -33,7 +31,7 @@ public class AntlrShaderCompiler
|
|||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
{
|
{
|
||||||
return new List<ShaderModel>();
|
return new List<GraphicsShaderModel>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var visitor = new ShaderVisitor();
|
var visitor = new ShaderVisitor();
|
||||||
@@ -49,11 +47,91 @@ public class AntlrShaderCompiler
|
|||||||
line = -1,
|
line = -1,
|
||||||
column = -1
|
column = -1
|
||||||
});
|
});
|
||||||
return new List<ShaderModel>();
|
return new List<GraphicsShaderModel>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static DSLShaderSemantics? ConvertToSemantics(ShaderModel model, out List<DSLShaderError> errors)
|
public static List<ComputeShaderModel> ParseComputeShaders(string source, List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
errors = new List<DSLShaderError>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inputStream = new AntlrInputStream(source);
|
||||||
|
var lexer = new GhostShaderLexer(inputStream);
|
||||||
|
|
||||||
|
// Capture lexer errors
|
||||||
|
lexer.RemoveErrorListeners();
|
||||||
|
var lexerErrorListener = new ErrorListener(errors);
|
||||||
|
lexer.AddErrorListener(lexerErrorListener);
|
||||||
|
|
||||||
|
var tokenStream = new CommonTokenStream(lexer);
|
||||||
|
var parser = new GhostComputeShaderParser(tokenStream);
|
||||||
|
|
||||||
|
// Capture parser errors
|
||||||
|
parser.RemoveErrorListeners();
|
||||||
|
var parserErrorListener = new ErrorListener(errors);
|
||||||
|
parser.AddErrorListener(parserErrorListener);
|
||||||
|
|
||||||
|
var tree = parser.computeFile();
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
return new List<ComputeShaderModel>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var visitor = new ComputeShaderVisitor();
|
||||||
|
visitor.Visit(tree);
|
||||||
|
|
||||||
|
return visitor.ComputeShaders;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Unexpected error during parsing: {ex.Message}",
|
||||||
|
line = -1,
|
||||||
|
column = -1
|
||||||
|
});
|
||||||
|
return new List<ComputeShaderModel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetShaderModel(string model, List<DSLShaderError> errors, out ShaderModel shaderModel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(model))
|
||||||
|
{
|
||||||
|
shaderModel = ShaderModel.SM_6_6; // Default to lowest supported shader model for compute shaders
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
switch (model)
|
||||||
|
{
|
||||||
|
case "6_6":
|
||||||
|
shaderModel = ShaderModel.SM_6_6;
|
||||||
|
break;
|
||||||
|
case "6_7":
|
||||||
|
shaderModel = ShaderModel.SM_6_7;
|
||||||
|
break;
|
||||||
|
case "6_8":
|
||||||
|
shaderModel = ShaderModel.SM_6_8;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
shaderModel = default;
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Unknown shader model '{model}'.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DSLComputeShaderSemantics? ConvertToComputeSemantics(ComputeShaderModel model, out List<DSLShaderError> errors)
|
||||||
{
|
{
|
||||||
errors = new List<DSLShaderError>();
|
errors = new List<DSLShaderError>();
|
||||||
|
|
||||||
@@ -61,168 +139,86 @@ public class AntlrShaderCompiler
|
|||||||
{
|
{
|
||||||
errors.Add(new DSLShaderError
|
errors.Add(new DSLShaderError
|
||||||
{
|
{
|
||||||
message = "Shader name cannot be empty.",
|
message = "Compute shader name cannot be empty.",
|
||||||
line = 0,
|
line = 0,
|
||||||
column = 0
|
column = 0
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var semantics = new DSLShaderSemantics
|
var semantics = new DSLComputeShaderSemantics
|
||||||
{
|
{
|
||||||
name = model.Name,
|
name = model.Name,
|
||||||
properties = ConvertProperties(model.Properties, errors),
|
defines = model.Defines?.Defines,
|
||||||
pipeline = ConvertPipeline(model.Pipeline, errors)
|
includes = model.Includes?.Includes,
|
||||||
|
hlsl = model.Hlsl?.Code
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var pass in model.Passes)
|
if (TryGetShaderModel(model.ShaderModel, errors, out var shaderModel))
|
||||||
{
|
{
|
||||||
var passSemantic = ConvertPass(pass, errors);
|
semantics.shaderModel = shaderModel;
|
||||||
if (passSemantic != null)
|
|
||||||
{
|
|
||||||
semantics.passes ??= new List<PassSemantic>();
|
|
||||||
semantics.passes.Add(passSemantic);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.Keywords != null)
|
||||||
|
{
|
||||||
|
semantics.keywords = new List<KeywordsGroup>();
|
||||||
|
foreach (var group in model.Keywords.Groups)
|
||||||
|
{
|
||||||
|
var keywordGroup = new KeywordsGroup
|
||||||
|
{
|
||||||
|
space = group.Scope?.ToLower() == "global" ? KeywordSpace.Global : KeywordSpace.Local,
|
||||||
|
keywords = group.Keywords
|
||||||
|
};
|
||||||
|
semantics.keywords.Add(keywordGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in model.ShaderEntries)
|
||||||
|
{
|
||||||
|
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 = $"Unknown compute shader entry type '{entry.EntryType}'. Expected 'compute' or 'cs'.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semantics.entryPoints == null)
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = $"Compute shader '{model.Name}' must contain a compute/cs entry declaration.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return semantics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<PropertySemantic>? ConvertProperties(PropertiesBlockModel? properties, List<DSLShaderError> errors)
|
|
||||||
{
|
|
||||||
if (properties == null || properties.Properties.Count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<PropertySemantic>();
|
|
||||||
var usedNames = new HashSet<string>();
|
|
||||||
|
|
||||||
foreach (var prop in properties.Properties)
|
|
||||||
{
|
|
||||||
if (usedNames.Contains(prop.Name))
|
|
||||||
{
|
|
||||||
errors.Add(new DSLShaderError
|
|
||||||
{
|
|
||||||
message = $"Duplicate property name '{prop.Name}'.",
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
errors.Add(new DSLShaderError
|
|
||||||
{
|
|
||||||
message = $"Failed to parse property value: {ex.Message}",
|
|
||||||
line = 0,
|
|
||||||
column = 0
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PipelineSemantic? ConvertPipeline(PipelineBlockModel? pipeline, List<DSLShaderError> errors)
|
private static PipelineSemantic? ConvertPipeline(PipelineBlockModel? pipeline, List<DSLShaderError> errors)
|
||||||
{
|
{
|
||||||
if (pipeline == null || pipeline.Statements.Count == 0)
|
if (pipeline == null || pipeline.Statements.Count == 0)
|
||||||
@@ -241,11 +237,11 @@ public class AntlrShaderCompiler
|
|||||||
{
|
{
|
||||||
"disabled" => ZTest.Disabled,
|
"disabled" => ZTest.Disabled,
|
||||||
"less" => ZTest.Less,
|
"less" => ZTest.Less,
|
||||||
"lessequal" => ZTest.LessEqual,
|
"less_equal" => ZTest.LessEqual,
|
||||||
"equal" => ZTest.Equal,
|
"equal" => ZTest.Equal,
|
||||||
"greaterequal" => ZTest.GreaterEqual,
|
"greater_equal" => ZTest.GreaterEqual,
|
||||||
"greater" => ZTest.Greater,
|
"greater" => ZTest.Greater,
|
||||||
"notequal" => ZTest.NotEqual,
|
"not_equal" => ZTest.NotEqual,
|
||||||
"always" => ZTest.Always,
|
"always" => ZTest.Always,
|
||||||
_ => ZTest.Disabled
|
_ => ZTest.Disabled
|
||||||
};
|
};
|
||||||
@@ -269,7 +265,7 @@ public class AntlrShaderCompiler
|
|||||||
"alpha" => Blend.Alpha,
|
"alpha" => Blend.Alpha,
|
||||||
"additive" => Blend.Additive,
|
"additive" => Blend.Additive,
|
||||||
"multiply" => Blend.Multiply,
|
"multiply" => Blend.Multiply,
|
||||||
"premultipliedalpha" => Blend.PremultipliedAlpha,
|
"premultiplied_alpha" => Blend.PremultipliedAlpha,
|
||||||
_ => Blend.Opaque
|
_ => Blend.Opaque
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
@@ -312,20 +308,20 @@ public class AntlrShaderCompiler
|
|||||||
var entryType = entry.EntryType.ToLower();
|
var entryType = entry.EntryType.ToLower();
|
||||||
var shaderEntry = new ShaderEntryPoint
|
var shaderEntry = new ShaderEntryPoint
|
||||||
{
|
{
|
||||||
shader = entry.ShaderPath,
|
shaderPath = entry.ShaderPath,
|
||||||
entry = entry.EntryPoint
|
entry = entry.EntryPoint
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (entryType)
|
switch (entryType)
|
||||||
{
|
{
|
||||||
case "mesh" or "ms":
|
case "ms":
|
||||||
semantic.meshShader = shaderEntry;
|
semantic.meshShader = shaderEntry;
|
||||||
break;
|
break;
|
||||||
case "pixel" or "ps":
|
case "ps":
|
||||||
semantic.pixelShader = shaderEntry;
|
semantic.pixelShader = shaderEntry;
|
||||||
break;
|
break;
|
||||||
case "task" or "ts":
|
case "as":
|
||||||
semantic.taskShader = shaderEntry;
|
semantic.amplificationShader = shaderEntry;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
errors.Add(new DSLShaderError
|
errors.Add(new DSLShaderError
|
||||||
@@ -338,7 +334,7 @@ public class AntlrShaderCompiler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (semantic.meshShader.shader == null || semantic.pixelShader.shader == null)
|
if (semantic.meshShader.shaderPath == null || semantic.pixelShader.shaderPath == null)
|
||||||
{
|
{
|
||||||
errors.Add(new DSLShaderError
|
errors.Add(new DSLShaderError
|
||||||
{
|
{
|
||||||
@@ -351,6 +347,45 @@ public class AntlrShaderCompiler
|
|||||||
return semantic;
|
return semantic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DSLShaderSemantics? ConvertToSemantics(GraphicsShaderModel model, out List<DSLShaderError> errors)
|
||||||
|
{
|
||||||
|
errors = new List<DSLShaderError>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(model.Name))
|
||||||
|
{
|
||||||
|
errors.Add(new DSLShaderError
|
||||||
|
{
|
||||||
|
message = "Shader name cannot be empty.",
|
||||||
|
line = 0,
|
||||||
|
column = 0
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var semantics = new DSLShaderSemantics
|
||||||
|
{
|
||||||
|
name = model.Name,
|
||||||
|
pipeline = ConvertPipeline(model.Pipeline, errors)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (TryGetShaderModel(model.ShaderModel, errors, out var shaderModel))
|
||||||
|
{
|
||||||
|
semantics.shaderModel = shaderModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pass in model.Passes)
|
||||||
|
{
|
||||||
|
var passSemantic = ConvertPass(pass, errors);
|
||||||
|
if (passSemantic != null)
|
||||||
|
{
|
||||||
|
semantics.passes ??= new List<PassSemantic>();
|
||||||
|
semantics.passes.Add(passSemantic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return semantics;
|
||||||
|
}
|
||||||
|
|
||||||
private class ErrorListener : BaseErrorListener, IAntlrErrorListener<int>, IAntlrErrorListener<IToken>
|
private class ErrorListener : BaseErrorListener, IAntlrErrorListener<int>, IAntlrErrorListener<IToken>
|
||||||
{
|
{
|
||||||
private readonly List<DSLShaderError> _errors;
|
private readonly List<DSLShaderError> _errors;
|
||||||
|
|||||||
148
src/Editor/Ghost.DSL/ShaderParser/ComputeShaderVisitor.cs
Normal file
148
src/Editor/Ghost.DSL/ShaderParser/ComputeShaderVisitor.cs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
using Antlr4.Runtime.Misc;
|
||||||
|
using Ghost.DSL.ShaderParser.Model;
|
||||||
|
|
||||||
|
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;
|
namespace Ghost.DSL.ShaderParser.Model;
|
||||||
|
|
||||||
public class ShaderModel
|
public class GraphicsShaderModel
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public PropertiesBlockModel? Properties { get; set; }
|
public string ShaderModel { get; set; } = string.Empty;
|
||||||
public PipelineBlockModel? Pipeline { get; set; }
|
public PipelineBlockModel? Pipeline { get; set; }
|
||||||
public List<PassBlockModel> Passes { get; set; } = new();
|
public List<PassBlockModel> Passes { get; set; } = new();
|
||||||
public List<FunctionCallModel> FunctionCalls { get; set; } = new();
|
public List<FunctionCallModel> FunctionCalls { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PropertiesBlockModel
|
public class ComputeShaderModel
|
||||||
{
|
{
|
||||||
public List<PropertyDeclarationModel> Properties { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PropertyDeclarationModel
|
|
||||||
{
|
|
||||||
public string? Scope { get; set; }
|
|
||||||
public string Type { get; set; } = string.Empty;
|
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public List<string> Initializer { get; set; } = new();
|
public string ShaderModel { get; set; } = string.Empty;
|
||||||
|
public DefinesBlockModel? Defines { get; set; }
|
||||||
|
public IncludesBlockModel? Includes { get; set; }
|
||||||
|
public KeywordsBlockModel? Keywords { get; set; }
|
||||||
|
public HlslBlockModel? Hlsl { get; set; }
|
||||||
|
public List<FunctionCallModel> FunctionCalls { get; set; } = new();
|
||||||
|
public List<ShaderEntryModel> ShaderEntries { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PipelineBlockModel
|
public class PipelineBlockModel
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ namespace Ghost.DSL.ShaderParser;
|
|||||||
|
|
||||||
public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
||||||
{
|
{
|
||||||
public List<ShaderModel> Shaders { get; } = new();
|
public List<GraphicsShaderModel> Shaders { get; } = new();
|
||||||
|
|
||||||
public override object VisitShaderFile([NotNull] GhostShaderParser.ShaderFileContext context)
|
public override object VisitShaderFile([NotNull] GhostShaderParser.ShaderFileContext context)
|
||||||
{
|
{
|
||||||
foreach (var shaderContext in context.shader())
|
foreach (var shaderContext in context.shader())
|
||||||
{
|
{
|
||||||
var shader = (ShaderModel)VisitShader(shaderContext);
|
var shader = (GraphicsShaderModel)VisitShader(shaderContext);
|
||||||
Shaders.Add(shader);
|
Shaders.Add(shader);
|
||||||
}
|
}
|
||||||
return Shaders;
|
return Shaders;
|
||||||
@@ -19,7 +19,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
|||||||
|
|
||||||
public override object VisitShader([NotNull] GhostShaderParser.ShaderContext context)
|
public override object VisitShader([NotNull] GhostShaderParser.ShaderContext context)
|
||||||
{
|
{
|
||||||
var shader = new ShaderModel
|
var shader = new GraphicsShaderModel
|
||||||
{
|
{
|
||||||
Name = StripQuotes(context.STRING_LITERAL().GetText())
|
Name = StripQuotes(context.STRING_LITERAL().GetText())
|
||||||
};
|
};
|
||||||
@@ -27,10 +27,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
|||||||
var shaderBody = context.shaderBody();
|
var shaderBody = context.shaderBody();
|
||||||
if (shaderBody != null)
|
if (shaderBody != null)
|
||||||
{
|
{
|
||||||
foreach (var propBlock in shaderBody.propertiesBlock())
|
shader.ShaderModel = shaderBody.shaderModel()?.GetText() ?? string.Empty;
|
||||||
{
|
|
||||||
shader.Properties = (PropertiesBlockModel)VisitPropertiesBlock(propBlock);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pipelineBlock in shaderBody.pipelineBlock())
|
foreach (var pipelineBlock in shaderBody.pipelineBlock())
|
||||||
{
|
{
|
||||||
@@ -51,47 +48,6 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
|||||||
return shader;
|
return shader;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override object VisitPropertiesBlock([NotNull] GhostShaderParser.PropertiesBlockContext context)
|
|
||||||
{
|
|
||||||
var properties = new PropertiesBlockModel();
|
|
||||||
|
|
||||||
foreach (var propDecl in context.propertyDeclaration())
|
|
||||||
{
|
|
||||||
properties.Properties.Add((PropertyDeclarationModel)VisitPropertyDeclaration(propDecl));
|
|
||||||
}
|
|
||||||
|
|
||||||
return properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override object VisitPropertyDeclaration([NotNull] GhostShaderParser.PropertyDeclarationContext context)
|
|
||||||
{
|
|
||||||
var property = new PropertyDeclarationModel
|
|
||||||
{
|
|
||||||
Type = context.IDENTIFIER(0).GetText(),
|
|
||||||
Name = context.IDENTIFIER(1).GetText()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (context.scope() != null)
|
|
||||||
{
|
|
||||||
property.Scope = context.scope().GetText();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.propertyInitializer() != null)
|
|
||||||
{
|
|
||||||
var init = context.propertyInitializer();
|
|
||||||
foreach (var number in init.NUMBER())
|
|
||||||
{
|
|
||||||
property.Initializer.Add(number.GetText());
|
|
||||||
}
|
|
||||||
foreach (var identifier in init.IDENTIFIER())
|
|
||||||
{
|
|
||||||
property.Initializer.Add(identifier.GetText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return property;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override object VisitPipelineBlock([NotNull] GhostShaderParser.PipelineBlockContext context)
|
public override object VisitPipelineBlock([NotNull] GhostShaderParser.PipelineBlockContext context)
|
||||||
{
|
{
|
||||||
var pipeline = new PipelineBlockModel();
|
var pipeline = new PipelineBlockModel();
|
||||||
@@ -209,7 +165,7 @@ public class ShaderVisitor : GhostShaderParserBaseVisitor<object>
|
|||||||
if (stop >= start)
|
if (stop >= start)
|
||||||
{
|
{
|
||||||
var input = context.Start.InputStream;
|
var input = context.Start.InputStream;
|
||||||
hlsl.Code = input.GetText(new Antlr4.Runtime.Misc.Interval(start, stop));
|
hlsl.Code = input.GetText(new Interval(start, stop));
|
||||||
}
|
}
|
||||||
|
|
||||||
return hlsl;
|
return hlsl;
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandler;
|
|
||||||
|
|
||||||
public abstract class Asset
|
|
||||||
{
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract Guid TypeID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid[] Dependencies
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings? Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Asset(Guid id, Guid[] dependencies, IAssetSettings? settings)
|
|
||||||
{
|
|
||||||
ID = id;
|
|
||||||
Dependencies = dependencies;
|
|
||||||
Settings = settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not change the order of the fields in this struct, as it is used for binary serialization/deserialization.
|
|
||||||
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
|
|
||||||
internal struct AssetMetadata
|
|
||||||
{
|
|
||||||
public const int CURRENT_FORMAT_VERSION = 1;
|
|
||||||
public const int SIZE = 128; // Fixed size for metadata header. We choose 128 bytes to allow future expansion without breaking compatibility.
|
|
||||||
|
|
||||||
public AssetMetadata(Guid id, Guid typeID)
|
|
||||||
{
|
|
||||||
FormatVersion = CURRENT_FORMAT_VERSION;
|
|
||||||
ID = id;
|
|
||||||
TypeID = typeID;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int FormatVersion
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int HandlerVersion
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int DependencyCount
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long DependenciesOffset
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long SettingsOffset
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long SettingsSize
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long ContentOffset
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long ContentSize
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void WriteToStream(Stream stream, scoped ref readonly AssetMetadata metadata)
|
|
||||||
{
|
|
||||||
var buffer = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in metadata, 1));
|
|
||||||
stream.Write(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AssetMetadata ReadFromStream(Stream stream)
|
|
||||||
{
|
|
||||||
Span<byte> buffer = stackalloc byte[SIZE];
|
|
||||||
stream.ReadExactly(buffer);
|
|
||||||
return Unsafe.ReadUnaligned<AssetMetadata>(ref MemoryMarshal.GetReference(buffer));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
|
|
||||||
public readonly struct DependencyInfo
|
|
||||||
{
|
|
||||||
public const int SIZE = 16;
|
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly ReadOnlySpan<byte> AsBytes()
|
|
||||||
{
|
|
||||||
return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly struct AssetReference : IEquatable<AssetReference>
|
|
||||||
{
|
|
||||||
private readonly int _value;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The index of the asset in the dependency list.
|
|
||||||
/// </summary>
|
|
||||||
public int Index
|
|
||||||
{
|
|
||||||
get => Math.Abs(_value) - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AssetReference Null => default;
|
|
||||||
|
|
||||||
public readonly bool IsInternal => _value >= 0;
|
|
||||||
public readonly bool IsExternal => _value < 0;
|
|
||||||
|
|
||||||
public bool Equals(AssetReference other)
|
|
||||||
{
|
|
||||||
return _value == other._value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetHashCode()
|
|
||||||
{
|
|
||||||
return _value.GetHashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool Equals(object? obj)
|
|
||||||
{
|
|
||||||
return obj is AssetReference reference && Equals(reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool operator ==(AssetReference left, AssetReference right)
|
|
||||||
{
|
|
||||||
return left.Equals(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool operator !=(AssetReference left, AssetReference right)
|
|
||||||
{
|
|
||||||
return !(left == right);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IAssetSettings;
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandler;
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class)]
|
|
||||||
public sealed class CustomAssetHandlerAttribute : Attribute
|
|
||||||
{
|
|
||||||
public required string ID
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public required string[] SupportedExtensions
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool AllowCaching
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
} = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IAssetExportOptions;
|
|
||||||
|
|
||||||
public interface IAssetHandler
|
|
||||||
{
|
|
||||||
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default);
|
|
||||||
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IImportableAssetHandler : IAssetHandler
|
|
||||||
{
|
|
||||||
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default);
|
|
||||||
ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class AssetHandlerExtensions
|
|
||||||
{
|
|
||||||
public static async ValueTask<Result> ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
||||||
return await handler.ImportAsync(sourceStream, targetStream, id, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async ValueTask<Result> ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
||||||
return await handler.ExportAsync(assetStream, targetStream, options, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async ValueTask<Result<Asset>> LoadAsync(this IAssetHandler handler, string assetFilePath, IAssetRegistry assetDatabase, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
return await handler.LoadAsync(sourceStream, assetDatabase, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandler;
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class)]
|
|
||||||
public sealed class CustomAssetProcesserAttribute<T> : Attribute
|
|
||||||
{
|
|
||||||
public Type Type => typeof(T);
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly struct AssetProcesserContext
|
|
||||||
{
|
|
||||||
public IAssetRegistry Registry
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string AssetPath
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Asset Asset
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetHandler Handler
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IAssetProcesser
|
|
||||||
{
|
|
||||||
ValueTask ProcessAsync(AssetProcesserContext ctx);
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Editor.Core.Contracts;
|
|
||||||
using Ghost.Graphics.RHI;
|
|
||||||
using Misaki.HighPerformance.Image;
|
|
||||||
using System.Buffers;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using static Ghost.Editor.Core.AssetHandler.TextureAssetSettings;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandler;
|
|
||||||
|
|
||||||
public enum TextureType : uint
|
|
||||||
{
|
|
||||||
Default,
|
|
||||||
Normal,
|
|
||||||
Lightmap,
|
|
||||||
SingleChannel
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum TextureShape : uint
|
|
||||||
{
|
|
||||||
Texture2D,
|
|
||||||
Texture3D,
|
|
||||||
TextureCube
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum TextureSize : uint
|
|
||||||
{
|
|
||||||
Size256 = 256,
|
|
||||||
Size512 = 512,
|
|
||||||
Size1024 = 1024,
|
|
||||||
Size2048 = 2048,
|
|
||||||
Size4096 = 4096,
|
|
||||||
Size8192 = 8192
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum TextureCompressionLevel : uint
|
|
||||||
{
|
|
||||||
Low,
|
|
||||||
Normal,
|
|
||||||
High
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum MipmapFilter : uint
|
|
||||||
{
|
|
||||||
Box,
|
|
||||||
Triangle,
|
|
||||||
Kaiser,
|
|
||||||
MitchellNetravali
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TextureAsset : Asset
|
|
||||||
{
|
|
||||||
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
|
|
||||||
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
|
|
||||||
|
|
||||||
private readonly Handle<Texture> _texture;
|
|
||||||
|
|
||||||
public override Guid TypeID => s_typeGuid;
|
|
||||||
public Handle<Texture> Texture => _texture;
|
|
||||||
|
|
||||||
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings, Handle<Texture> texture)
|
|
||||||
: base(id, dependencies, settings)
|
|
||||||
{
|
|
||||||
_texture = texture;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TextureAssetSettings : IAssetSettings
|
|
||||||
{
|
|
||||||
public struct BasicSettings()
|
|
||||||
{
|
|
||||||
public TextureType TextureType
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = TextureType.Default;
|
|
||||||
|
|
||||||
public TextureShape TextureShape
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = TextureShape.Texture2D;
|
|
||||||
|
|
||||||
public int Columns
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = 1;
|
|
||||||
|
|
||||||
public int Rows
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = 1;
|
|
||||||
|
|
||||||
public bool IsSRGB
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AdvancedSettings()
|
|
||||||
{
|
|
||||||
public bool StretchToPowerOfTwo
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = true;
|
|
||||||
|
|
||||||
public bool VirtualTexture
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
|
|
||||||
public bool GenerateMipmaps
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = true;
|
|
||||||
|
|
||||||
public uint MipmapLevelCount
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = 0; // 0 means generate full mipmap levels.
|
|
||||||
|
|
||||||
public bool GammaCorrection
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = true;
|
|
||||||
|
|
||||||
public bool PremultiplyAlpha
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
|
|
||||||
public MipmapFilter MipmapFilter
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = MipmapFilter.Kaiser;
|
|
||||||
|
|
||||||
public TextureCompressionLevel CompressionLevel
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = TextureCompressionLevel.Normal;
|
|
||||||
|
|
||||||
public bool UseBorderColor
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
|
|
||||||
public Color128 BorderColor
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = new Color128(0, 0, 0, 0);
|
|
||||||
|
|
||||||
public bool ZeroAlphaBorder
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
|
|
||||||
public bool CutoutAlpha
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
|
|
||||||
public byte CutoutAlphaThreshold
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = 127;
|
|
||||||
|
|
||||||
public bool ScaleAlphaForMipCoverage
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
|
|
||||||
public byte ScaleAlphaForMipCoverageThreshold
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = 127;
|
|
||||||
|
|
||||||
public bool MipmapStreaming
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SamplerSettings()
|
|
||||||
{
|
|
||||||
public TextureSize MaxSize
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = TextureSize.Size2048;
|
|
||||||
|
|
||||||
public TextureFilterMode FilterMode
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = TextureFilterMode.Anisotropic;
|
|
||||||
|
|
||||||
public TextureAddressMode WrapMode
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = TextureAddressMode.Repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BasicSettings Basic
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = new BasicSettings();
|
|
||||||
|
|
||||||
public AdvancedSettings Advanced
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = new AdvancedSettings();
|
|
||||||
|
|
||||||
public SamplerSettings Sampler
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = new SamplerSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
[CustomAssetHandler(ID = TextureAsset._TYPE_ID, SupportedExtensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
|
|
||||||
internal class TextureAssetHandler : IImportableAssetHandler
|
|
||||||
{
|
|
||||||
private const int _CURRENT_VERSION = 1;
|
|
||||||
|
|
||||||
private static async ValueTask<Result<long>> WriteSettingsToStreamAsync(TextureAssetSettings settings, Stream stream, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
|
|
||||||
var tempArray = ArrayPool<byte>.Shared.Rent(size);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ref var address = ref MemoryMarshal.GetReference(tempArray);
|
|
||||||
Unsafe.WriteUnaligned(ref address, settings.Basic);
|
|
||||||
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()), settings.Advanced);
|
|
||||||
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()), settings.Sampler);
|
|
||||||
|
|
||||||
await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return Result.Success<long>(size);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ArrayPool<byte>.Shared.Return(tempArray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async ValueTask<Result<IAssetSettings>> ReadSettingsFromStreamAsync(Stream stream, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
|
|
||||||
var tempArray = ArrayPool<byte>.Shared.Rent(size);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Use index-based reads after the await to avoid 'ref across await' errors.
|
|
||||||
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref tempArray[0]);
|
|
||||||
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>()]);
|
|
||||||
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()]);
|
|
||||||
|
|
||||||
var settings = new TextureAssetSettings
|
|
||||||
{
|
|
||||||
Basic = basic,
|
|
||||||
Advanced = advanced,
|
|
||||||
Sampler = sampler
|
|
||||||
};
|
|
||||||
|
|
||||||
return Result.Success<IAssetSettings>(settings);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ArrayPool<byte>.Shared.Return(tempArray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var info = ImageInfo.FromStream(sourceStream);
|
|
||||||
if (info.BitsPerChannel <= 0)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var isFloat = info.BitsPerChannel > 8;
|
|
||||||
var width = info.Width;
|
|
||||||
var height = info.Height;
|
|
||||||
var colorComponents = info.ColorComponents;
|
|
||||||
|
|
||||||
byte[] pixelBytes;
|
|
||||||
|
|
||||||
if (isFloat)
|
|
||||||
{
|
|
||||||
using var image = ImageResultFloat.FromStream(sourceStream, colorComponents);
|
|
||||||
var span = MemoryMarshal.AsBytes(image.AsSpan());
|
|
||||||
pixelBytes = ArrayPool<byte>.Shared.Rent(span.Length);
|
|
||||||
span.CopyTo(pixelBytes);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
using var image = ImageResult.FromStream(sourceStream, colorComponents);
|
|
||||||
var span = image.AsSpan();
|
|
||||||
pixelBytes = ArrayPool<byte>.Shared.Rent(span.Length);
|
|
||||||
span.CopyTo(pixelBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var settings = new TextureAssetSettings();
|
|
||||||
await Task.Run(() =>
|
|
||||||
TextureProcessor.CompressToCache(
|
|
||||||
EditorApplication.CachesFolderPath,
|
|
||||||
id,
|
|
||||||
pixelBytes,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
isFloat,
|
|
||||||
colorComponents,
|
|
||||||
settings),
|
|
||||||
token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
|
|
||||||
{
|
|
||||||
HandlerVersion = _CURRENT_VERSION,
|
|
||||||
SettingsOffset = AssetMetadata.SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
|
|
||||||
var sizeResult = await WriteSettingsToStreamAsync(settings, targetStream, token).ConfigureAwait(false);
|
|
||||||
if (sizeResult.IsFailure)
|
|
||||||
{
|
|
||||||
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content layout (all little-endian):
|
|
||||||
// int32 width
|
|
||||||
// int32 height
|
|
||||||
// byte isFloat (0 = byte, 1 = float)
|
|
||||||
// int32 colorComponents (cast of ColorComponents enum)
|
|
||||||
// byte[] pixelBytes
|
|
||||||
const int _CONTENT_HEADER_SIZE = 4 + 4 + 1 + 4; // 13 bytes
|
|
||||||
|
|
||||||
header.SettingsSize = sizeResult.Value;
|
|
||||||
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
|
|
||||||
header.ContentSize = _CONTENT_HEADER_SIZE + pixelBytes.Length;
|
|
||||||
|
|
||||||
// Write raw image content
|
|
||||||
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
var contentHeader = ArrayPool<byte>.Shared.Rent(_CONTENT_HEADER_SIZE);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
BitConverter.TryWriteBytes(contentHeader.AsSpan(0, 4), width);
|
|
||||||
BitConverter.TryWriteBytes(contentHeader.AsSpan(4, 4), height);
|
|
||||||
contentHeader[8] = isFloat ? (byte)1 : (byte)0;
|
|
||||||
BitConverter.TryWriteBytes(contentHeader.AsSpan(9, 4), (int)colorComponents);
|
|
||||||
|
|
||||||
await targetStream.WriteAsync(contentHeader.AsMemory(0, _CONTENT_HEADER_SIZE), token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ArrayPool<byte>.Shared.Return(contentHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
await targetStream.WriteAsync(pixelBytes, token).ConfigureAwait(false);
|
|
||||||
await targetStream.FlushAsync(token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Patch header now that all sizes are known
|
|
||||||
targetStream.Seek(0, SeekOrigin.Begin);
|
|
||||||
AssetMetadata.WriteToStream(targetStream, ref header);
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ArrayPool<byte>.Shared.Return(pixelBytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
using Ghost.Nvtt;
|
|
||||||
using Misaki.HighPerformance.Image;
|
|
||||||
using Misaki.HighPerformance.LowLevel;
|
|
||||||
using System.IO.Hashing;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandler;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drives the NVTT compression + mipmap pipeline for a single texture asset.
|
|
||||||
///
|
|
||||||
/// Responsibilities:
|
|
||||||
/// 1. Accept raw decoded pixel bytes + settings.
|
|
||||||
/// 2. Determine the cache file path (<c>CachesFolderPath/TextureCache/<guid>_<hash>.dds</c>).
|
|
||||||
/// 3. If the cache is already valid (hash matches), skip compression.
|
|
||||||
/// 4. Otherwise run the full NVTT pipeline and write the DDS to the cache file.
|
|
||||||
///
|
|
||||||
/// The caller owns opening/closing all streams; this class only takes spans and paths.
|
|
||||||
/// </summary>
|
|
||||||
internal static unsafe class TextureProcessor
|
|
||||||
{
|
|
||||||
private const string _TEXTURE_CACHE_SUBFOLDER = "TextureCache";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Compresses <paramref name="pixelData"/> according to <paramref name="settings"/>
|
|
||||||
/// and writes the result to the texture cache.
|
|
||||||
///
|
|
||||||
/// Returns the absolute path of the cache file on success.
|
|
||||||
/// The cache file is skipped if it already exists with a matching content hash.
|
|
||||||
/// </summary>
|
|
||||||
public static string CompressToCache(
|
|
||||||
string cachesFolderPath,
|
|
||||||
Guid assetId,
|
|
||||||
ReadOnlySpan<byte> pixelData,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
bool isFloat,
|
|
||||||
ColorComponents colorComponents,
|
|
||||||
TextureAssetSettings settings)
|
|
||||||
{
|
|
||||||
var cacheDir = Path.Combine(cachesFolderPath, _TEXTURE_CACHE_SUBFOLDER);
|
|
||||||
Directory.CreateDirectory(cacheDir);
|
|
||||||
|
|
||||||
var settingsHash = ComputeSettingsHash(settings);
|
|
||||||
var cacheFileName = $"{assetId:N}_{settingsHash:X16}.dds";
|
|
||||||
var cachePath = Path.Combine(cacheDir, cacheFileName);
|
|
||||||
|
|
||||||
if (File.Exists(cachePath))
|
|
||||||
{
|
|
||||||
return cachePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var stale in Directory.EnumerateFiles(cacheDir, $"{assetId:N}_*.dds"))
|
|
||||||
{
|
|
||||||
File.Delete(stale);
|
|
||||||
}
|
|
||||||
|
|
||||||
RunNvttPipeline(cachePath, pixelData, width, height, isFloat, colorComponents, settings);
|
|
||||||
|
|
||||||
return cachePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RunNvttPipeline(
|
|
||||||
string outputPath,
|
|
||||||
ReadOnlySpan<byte> pixelData,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
bool isFloat,
|
|
||||||
ColorComponents colorComponents,
|
|
||||||
TextureAssetSettings settings)
|
|
||||||
{
|
|
||||||
using var pSurface = new DisposablePtr<NvttSurface>(NvttSurface.Create());
|
|
||||||
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
|
|
||||||
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
|
|
||||||
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
|
|
||||||
|
|
||||||
var inputFormat = isFloat
|
|
||||||
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
|
|
||||||
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
|
|
||||||
|
|
||||||
fixed (void* pData = pixelData)
|
|
||||||
{
|
|
||||||
pSurface.Get()->SetImageData(inputFormat, width, height, 1, pData, NvttBoolean.NVTT_True, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
|
|
||||||
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
|
|
||||||
if (!isFloat)
|
|
||||||
{
|
|
||||||
pSurface.Get()->Swizzle(2, 1, 0, 3, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxExtent = (int)settings.Sampler.MaxSize;
|
|
||||||
if (settings.Advanced.StretchToPowerOfTwo)
|
|
||||||
{
|
|
||||||
pSurface.Get()->ResizeMakeSquare(maxExtent,
|
|
||||||
NvttRoundMode.NVTT_RoundMode_ToNearestPowerOfTwo,
|
|
||||||
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
|
||||||
}
|
|
||||||
else if (pSurface.Get()->Width() > maxExtent || pSurface.Get()->Height() > maxExtent)
|
|
||||||
{
|
|
||||||
pSurface.Get()->ResizeMax(maxExtent,
|
|
||||||
NvttRoundMode.NVTT_RoundMode_None,
|
|
||||||
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.Advanced.UseBorderColor)
|
|
||||||
{
|
|
||||||
var c = settings.Advanced.BorderColor;
|
|
||||||
pSurface.Get()->SetBorder(c.r, c.g, c.b, c.a, null);
|
|
||||||
}
|
|
||||||
else if (settings.Advanced.ZeroAlphaBorder)
|
|
||||||
{
|
|
||||||
pSurface.Get()->SetBorder(0f, 0f, 0f, 0f, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.Basic.IsSRGB && settings.Advanced.GammaCorrection)
|
|
||||||
{
|
|
||||||
pSurface.Get()->ToLinearFromSrgb(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.Advanced.PremultiplyAlpha)
|
|
||||||
{
|
|
||||||
pSurface.Get()->PremultiplyAlpha(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
pCompOpts.Get()->SetFormat(SelectFormat(settings));
|
|
||||||
pCompOpts.Get()->SetQuality(SelectQuality(settings.Advanced.CompressionLevel));
|
|
||||||
|
|
||||||
if (settings.Advanced.CutoutAlpha)
|
|
||||||
{
|
|
||||||
pCompOpts.Get()->SetQuantization(false, false, true,
|
|
||||||
settings.Advanced.CutoutAlphaThreshold);
|
|
||||||
}
|
|
||||||
|
|
||||||
pOutOpts.Get()->SetOutputHeader(true);
|
|
||||||
pOutOpts.Get()->SetSrgbFlag(settings.Basic.IsSRGB);
|
|
||||||
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
|
|
||||||
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(outputPath));
|
|
||||||
|
|
||||||
var nvttFilter = SelectMipmapFilter(settings.Advanced.MipmapFilter);
|
|
||||||
|
|
||||||
int mipmapCount;
|
|
||||||
if (!settings.Advanced.GenerateMipmaps)
|
|
||||||
{
|
|
||||||
mipmapCount = 1;
|
|
||||||
}
|
|
||||||
else if (settings.Advanced.MipmapLevelCount == 0)
|
|
||||||
{
|
|
||||||
mipmapCount = pSurface.Get()->CountMipmaps(1);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mipmapCount = (int)settings.Advanced.MipmapLevelCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
|
|
||||||
|
|
||||||
pCtx.Get()->OutputHeader(pSurface.Get(), mipmapCount, pCompOpts.Get(), pOutOpts.Get());
|
|
||||||
|
|
||||||
using var pMip = new DisposablePtr<NvttSurface>(pSurface.Get()->Clone());
|
|
||||||
|
|
||||||
for (var level = 0; level < mipmapCount; level++)
|
|
||||||
{
|
|
||||||
// Scale alpha for coverage on each pMip (if requested)
|
|
||||||
if (settings.Advanced.ScaleAlphaForMipCoverage && level > 0)
|
|
||||||
{
|
|
||||||
var refCoverage = pMip.Get()->AlphaTestCoverage(
|
|
||||||
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3);
|
|
||||||
pMip.Get()->ScaleAlphaToCoverage(refCoverage,
|
|
||||||
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
pCtx.Get()->Compress(pMip.Get(), 0, level, pCompOpts.Get(), pOutOpts.Get());
|
|
||||||
|
|
||||||
if (level + 1 < mipmapCount)
|
|
||||||
{
|
|
||||||
pMip.Get()->BuildNextMipmapDefaults(nvttFilter, 1, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static NvttFormat SelectFormat(TextureAssetSettings settings)
|
|
||||||
=> settings.Basic.TextureType switch
|
|
||||||
{
|
|
||||||
TextureType.Normal => NvttFormat.NVTT_Format_BC5, // RG normal map
|
|
||||||
TextureType.SingleChannel => NvttFormat.NVTT_Format_BC4, // single channel
|
|
||||||
TextureType.Lightmap => NvttFormat.NVTT_Format_BC6U, // HDR lightmap (unsigned)
|
|
||||||
_ => NvttFormat.NVTT_Format_BC7, // default color
|
|
||||||
};
|
|
||||||
|
|
||||||
private static NvttQuality SelectQuality(TextureCompressionLevel level)
|
|
||||||
=> level switch
|
|
||||||
{
|
|
||||||
TextureCompressionLevel.Low => NvttQuality.NVTT_Quality_Fastest,
|
|
||||||
TextureCompressionLevel.High => NvttQuality.NVTT_Quality_Production,
|
|
||||||
_ => NvttQuality.NVTT_Quality_Normal,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static NvttMipmapFilter SelectMipmapFilter(MipmapFilter filter)
|
|
||||||
=> filter switch
|
|
||||||
{
|
|
||||||
MipmapFilter.Box => NvttMipmapFilter.NVTT_MipmapFilter_Box,
|
|
||||||
MipmapFilter.Triangle => NvttMipmapFilter.NVTT_MipmapFilter_Triangle,
|
|
||||||
MipmapFilter.MitchellNetravali => NvttMipmapFilter.NVTT_MipmapFilter_Mitchell,
|
|
||||||
_ => NvttMipmapFilter.NVTT_MipmapFilter_Kaiser,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static ulong ComputeSettingsHash(TextureAssetSettings s)
|
|
||||||
{
|
|
||||||
var basicSize = Unsafe.SizeOf<TextureAssetSettings.BasicSettings>();
|
|
||||||
var advancedSize = Unsafe.SizeOf<TextureAssetSettings.AdvancedSettings>();
|
|
||||||
var samplerSize = Unsafe.SizeOf<TextureAssetSettings.SamplerSettings>();
|
|
||||||
var total = basicSize + advancedSize + samplerSize;
|
|
||||||
|
|
||||||
Span<byte> buf = stackalloc byte[total];
|
|
||||||
var basic = s.Basic;
|
|
||||||
var advanced = s.Advanced;
|
|
||||||
var sampler = s.Sampler;
|
|
||||||
MemoryMarshal.Write(buf, in basic);
|
|
||||||
MemoryMarshal.Write(buf.Slice(basicSize), in advanced);
|
|
||||||
MemoryMarshal.Write(buf.Slice(basicSize + advancedSize), in sampler);
|
|
||||||
|
|
||||||
return XxHash64.HashToUInt64(buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
73
src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs
Normal file
73
src/Editor/Ghost.Editor.Core/Assets/AssetHandler.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public sealed class CustomAssetHandlerAttribute : Attribute
|
||||||
|
{
|
||||||
|
public required string AssetTypeId
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required AssetType RuntimeAssetType
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required string[] Extensions
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Version
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public bool AllowCaching
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAsset : IDisposable
|
||||||
|
{
|
||||||
|
Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid TypeID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
IAssetSettings? Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAssetExportOptions;
|
||||||
|
|
||||||
|
public interface IAssetHandler
|
||||||
|
{
|
||||||
|
IAssetSettings? CreateDefaultSettings(string ext);
|
||||||
|
|
||||||
|
ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
||||||
|
ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IImportableAssetHandler : IAssetHandler
|
||||||
|
{
|
||||||
|
ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct ImportedSubAsset(Guid Guid, string Kind, string DisplayName, string StablePath, string VirtualSourcePath, Guid AssetTypeId);
|
||||||
|
|
||||||
|
public interface IPackableAssetHandler : IAssetHandler
|
||||||
|
{
|
||||||
|
ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default);
|
||||||
|
}
|
||||||
127
src/Editor/Ghost.Editor.Core/Assets/AssetHandlerRegistry.cs
Normal file
127
src/Editor/Ghost.Editor.Core/Assets/AssetHandlerRegistry.cs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public readonly struct AssetHandlerInfo
|
||||||
|
{
|
||||||
|
public Type HandlerType { get; init; }
|
||||||
|
public AssetType RuntimeAssetType { get; init; }
|
||||||
|
public Guid EditorAssetTypeID { get; init; }
|
||||||
|
public int Version { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AssetHandlerRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, AssetHandlerInfo> s_byExtension;
|
||||||
|
private static readonly Dictionary<Guid, AssetHandlerInfo> s_byTypeId;
|
||||||
|
private static readonly List<(Type Type, string Name)> s_iAssetSettingsTypes;
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<Type, IAssetHandler?> s_handlerCache;
|
||||||
|
|
||||||
|
static AssetHandlerRegistry()
|
||||||
|
{
|
||||||
|
s_byExtension = new Dictionary<string, AssetHandlerInfo>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
s_byTypeId = new Dictionary<Guid, AssetHandlerInfo>();
|
||||||
|
|
||||||
|
s_iAssetSettingsTypes = new List<(Type Type, string Name)>();
|
||||||
|
s_handlerCache = new ConcurrentDictionary<Type, IAssetHandler?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RegisterHandler(Type handlerType, Guid assetTypeId, AssetType runtimeAssetType, int version, bool allowCaching, params ReadOnlySpan<string> extensions)
|
||||||
|
{
|
||||||
|
var info = new AssetHandlerInfo
|
||||||
|
{
|
||||||
|
HandlerType = handlerType,
|
||||||
|
RuntimeAssetType = runtimeAssetType,
|
||||||
|
EditorAssetTypeID = assetTypeId,
|
||||||
|
Version = version
|
||||||
|
};
|
||||||
|
|
||||||
|
s_byTypeId[assetTypeId] = info;
|
||||||
|
|
||||||
|
foreach (var ext in extensions)
|
||||||
|
{
|
||||||
|
var normalizedExt = ext.StartsWith('.') ? ext : "." + ext;
|
||||||
|
s_byExtension[normalizedExt] = info;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowCaching)
|
||||||
|
{
|
||||||
|
s_handlerCache[handlerType] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RegisterIAssetSettingsType(Type type, string name)
|
||||||
|
{
|
||||||
|
s_iAssetSettingsTypes.Add((type, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IAssetHandler? GetByExtension(string extension)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(extension))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
||||||
|
if (!s_byExtension.TryGetValue(normalized, out var info))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (IAssetHandler?)Activator.CreateInstance(t);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IAssetHandler? GetByAssetTypeId(Guid typeId)
|
||||||
|
{
|
||||||
|
if (!s_byTypeId.TryGetValue(typeId, out var info))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s_handlerCache.GetOrAdd(info.HandlerType, t =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (IAssetHandler?)Activator.CreateInstance(t);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetHandlerInfoByAssetTypeId(Guid typeId, out AssetHandlerInfo info)
|
||||||
|
{
|
||||||
|
return s_byTypeId.TryGetValue(typeId, out info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetHandlerInfoByExtension(string extension, out AssetHandlerInfo info)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(extension))
|
||||||
|
{
|
||||||
|
info = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = extension.StartsWith('.') ? extension : "." + extension;
|
||||||
|
return s_byExtension.TryGetValue(normalized, out info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyCollection<(Type Type, string Name)> GetIAssetSettingsTypes()
|
||||||
|
{
|
||||||
|
return s_iAssetSettingsTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs
Normal file
151
src/Editor/Ghost.Editor.Core/Assets/AssetMeta.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark IAssetSettings for polymorphic serialization.
|
||||||
|
/// Each handler type will register its own derived type.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAssetSettings;
|
||||||
|
|
||||||
|
internal sealed class DefaultAssetSettings : IAssetSettings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted as a JSON sidecar (.gmeta) next to every source asset.
|
||||||
|
/// This is the single source of truth for asset identity and import settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssetMeta
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Globally unique identifier for this asset. Generated once, never changes.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid Guid { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Guid that identifies type id of asset.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? AssetTypeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version of the handler that last imported this asset.
|
||||||
|
/// </summary>
|
||||||
|
public int HandlerVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// xxHash64 of the source file content at last successful import.
|
||||||
|
/// </summary>
|
||||||
|
public string? ContentHash { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// xxHash64 of the serialized import settings at last successful import.
|
||||||
|
/// </summary>
|
||||||
|
public string? SettingsHash { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UTC timestamp of last successful import.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastImportedUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GUIDs of other assets this asset depends on.
|
||||||
|
/// </summary>
|
||||||
|
public Guid[] Dependencies { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional user-facing labels for search/filtering in the editor.
|
||||||
|
/// </summary>
|
||||||
|
public string[] Labels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler-specific import settings.
|
||||||
|
/// </summary>
|
||||||
|
public IAssetSettings? Settings { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AssetMetaIO
|
||||||
|
{
|
||||||
|
public const string META_EXTENSION_NAME = "gmeta";
|
||||||
|
public const string META_EXTENSION = ".gmeta";
|
||||||
|
|
||||||
|
internal static readonly JsonSerializerOptions s_options = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
||||||
|
{
|
||||||
|
Modifiers = { ConfigureAssetSettingsPolymorphism }
|
||||||
|
},
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void ConfigureAssetSettingsPolymorphism(JsonTypeInfo typeInfo)
|
||||||
|
{
|
||||||
|
if (typeInfo.Type != typeof(IAssetSettings))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
typeInfo.PolymorphismOptions = new JsonPolymorphismOptions
|
||||||
|
{
|
||||||
|
TypeDiscriminatorPropertyName = "$type",
|
||||||
|
IgnoreUnrecognizedTypeDiscriminators = true,
|
||||||
|
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var setting in AssetHandlerRegistry.GetIAssetSettingsTypes())
|
||||||
|
{
|
||||||
|
typeInfo.PolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(setting.Type, setting.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<AssetMeta?> ReadAsync(string metaPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(metaPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = new FileStream(metaPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
return await JsonSerializer.DeserializeAsync<AssetMeta>(stream, s_options, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask WriteAsync(string metaPath, AssetMeta meta, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var tempPath = metaPath + ".tmp";
|
||||||
|
await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, meta, s_options, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(metaPath))
|
||||||
|
{
|
||||||
|
File.Delete(metaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, metaPath);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetMetaPath(string sourceFilePath)
|
||||||
|
{
|
||||||
|
return sourceFilePath + META_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetSourcePath(string metaPath)
|
||||||
|
{
|
||||||
|
return metaPath[..^META_EXTENSION.Length];
|
||||||
|
}
|
||||||
|
}
|
||||||
505
src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs
Normal file
505
src/Editor/Ghost.Editor.Core/Assets/MeshAssetHandler.cs
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Utilities;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Graphics.Core;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using Misaki.HighPerformance.Mathematics.Geometry;
|
||||||
|
using System.IO.Hashing;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public sealed class ModelManifestSubAsset
|
||||||
|
{
|
||||||
|
public Guid Guid
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string StablePath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public int MaterialSlotCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int VertexCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IndexCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModelManifestMetadata
|
||||||
|
{
|
||||||
|
public string Kind
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string StablePath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ImportedModelAsset : IAsset
|
||||||
|
{
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TypeID => typeof(MeshAsset).GUID;
|
||||||
|
|
||||||
|
public IAssetSettings? Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ModelManifest Manifest
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
Settings = settings;
|
||||||
|
Manifest = manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public abstract class MeshAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
|
||||||
|
|
||||||
|
private MeshNode _root;
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TypeID => typeof(MeshAsset).GUID;
|
||||||
|
|
||||||
|
public MeshNode Root
|
||||||
|
{
|
||||||
|
get => _root;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_root?.Dispose();
|
||||||
|
_root = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal MeshAsset(MeshNode root, Guid id, MeshAssetSettings settings)
|
||||||
|
{
|
||||||
|
_root = root;
|
||||||
|
|
||||||
|
ID = id;
|
||||||
|
Settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_root?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CoordinateAxis
|
||||||
|
{
|
||||||
|
PositiveX,
|
||||||
|
PositiveY,
|
||||||
|
PositiveZ,
|
||||||
|
NegativeX,
|
||||||
|
NegativeY,
|
||||||
|
NegativeZ
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VertexDataSource
|
||||||
|
{
|
||||||
|
Imported,
|
||||||
|
Computed,
|
||||||
|
ComputedIfMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MeshAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
public VertexDataSource NormalDataSource
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VertexDataSource.ComputedIfMissing;
|
||||||
|
|
||||||
|
public VertexDataSource TangentDataSource
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VertexDataSource.ComputedIfMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ObjAssetSettings : MeshAssetSettings
|
||||||
|
{
|
||||||
|
public CoordinateAxis ObjectUpAxis
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = CoordinateAxis.PositiveY;
|
||||||
|
|
||||||
|
public CoordinateAxis ObjectForwardAxis
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = CoordinateAxis.NegativeZ;
|
||||||
|
|
||||||
|
public CoordinateAxis ObjectRightAxis
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = CoordinateAxis.PositiveX;
|
||||||
|
|
||||||
|
public float UnitMeterScale
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FbxAssetSettings : MeshAssetSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = MeshAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
|
||||||
|
internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
};
|
||||||
|
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ObjAssetSettings();
|
||||||
|
}
|
||||||
|
else if (string.Equals(ext, ".fbx", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new FbxAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var importedPath = ImportCoordinator.GetImportedAssetPath(id);
|
||||||
|
if (!File.Exists(importedPath))
|
||||||
|
{
|
||||||
|
return Result.Failure<IAsset>("Imported model manifest does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = new FileStream(importedPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var manifest = await JsonSerializer.DeserializeAsync<ModelManifest>(stream, s_jsonOptions, token).ConfigureAwait(false);
|
||||||
|
return manifest != null
|
||||||
|
? Result.Success<IAsset>(new ImportedModelAsset(id, settings, manifest))
|
||||||
|
: Result.Failure<IAsset>("Failed to deserialize model manifest.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure<IAsset>(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result.Failure("Saving model assets is not supported yet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return Result.Failure<ImportedSubAsset[]>("Source file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meshSettings = ResolveSettings(sourcePath, settings);
|
||||||
|
|
||||||
|
using var root = new MeshNode();
|
||||||
|
var result = await MeshProcessor.ParseMeshAsync(root, sourcePath, AllocationHandle.TLSF, meshSettings, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = new ModelManifest
|
||||||
|
{
|
||||||
|
AssetId = id,
|
||||||
|
};
|
||||||
|
|
||||||
|
var importedSubAssets = new List<ImportedSubAsset>();
|
||||||
|
manifest.Root = await WriteNodeAsync(id, sourcePath, root, string.Empty, manifest, importedSubAssets, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||||
|
|
||||||
|
await using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, manifest, s_jsonOptions, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return importedSubAssets.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure<ImportedSubAsset[]>($"Failed to import mesh asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MeshAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
|
||||||
|
{
|
||||||
|
if (settings is MeshAssetSettings meshSettings)
|
||||||
|
{
|
||||||
|
return meshSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetExtension(sourcePath).Equals(".obj", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? new ObjAssetSettings()
|
||||||
|
: new FbxAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<ModelManifestNode> WriteNodeAsync(
|
||||||
|
Guid parentGuid,
|
||||||
|
string sourcePath,
|
||||||
|
MeshNode node,
|
||||||
|
string parentPath,
|
||||||
|
ModelManifest manifest,
|
||||||
|
List<ImportedSubAsset> importedSubAssets,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var stablePath = string.IsNullOrEmpty(parentPath)
|
||||||
|
? SanitizePathSegment(node.Name)
|
||||||
|
: $"{parentPath}/{SanitizePathSegment(node.Name)}";
|
||||||
|
|
||||||
|
var manifestNode = new ModelManifestNode
|
||||||
|
{
|
||||||
|
Name = node.Name,
|
||||||
|
StablePath = stablePath,
|
||||||
|
LocalTransform = node.LocalTransform,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node is GeometryMeshNode geometry)
|
||||||
|
{
|
||||||
|
var meshGuid = CreateDeterministicSubAssetGuid(parentGuid, "Mesh", stablePath);
|
||||||
|
var meshPath = ImportCoordinator.GetImportedAssetPath(meshGuid);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(meshPath)!);
|
||||||
|
|
||||||
|
var (materialSlotCount, lodLevelCount) = await WriteMeshContentAsync(meshPath, geometry, token).ConfigureAwait(false);
|
||||||
|
manifestNode.MeshGuid = meshGuid;
|
||||||
|
|
||||||
|
manifest.Meshes.Add(new ModelManifestSubAsset
|
||||||
|
{
|
||||||
|
Guid = meshGuid,
|
||||||
|
Name = node.Name,
|
||||||
|
StablePath = stablePath,
|
||||||
|
MaterialSlotCount = materialSlotCount,
|
||||||
|
VertexCount = geometry.Vertices.Count,
|
||||||
|
IndexCount = geometry.Indices.Count,
|
||||||
|
});
|
||||||
|
|
||||||
|
importedSubAssets.Add(new ImportedSubAsset(
|
||||||
|
meshGuid,
|
||||||
|
"Mesh",
|
||||||
|
node.Name,
|
||||||
|
stablePath,
|
||||||
|
$"{sourcePath}#Mesh/{stablePath}",
|
||||||
|
typeof(MeshAsset).GUID));
|
||||||
|
}
|
||||||
|
else if (node is LightMeshNode)
|
||||||
|
{
|
||||||
|
manifest.Metadata.Add(new ModelManifestMetadata
|
||||||
|
{
|
||||||
|
Kind = "Light",
|
||||||
|
Name = node.Name,
|
||||||
|
StablePath = stablePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
manifestNode.Children.Add(await WriteNodeAsync(parentGuid, sourcePath, child, stablePath, manifest, importedSubAssets, token).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifestNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token)
|
||||||
|
{
|
||||||
|
using var meshletData = await MeshProcessor.BuildMeshletsAsync(geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
|
||||||
|
await MeshProcessor.BuildClusterLodHierarchyAsync(meshletData.Share(), token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var bounds = ComputeBounds(geometry.Vertices);
|
||||||
|
var header = new MeshContentHeader
|
||||||
|
{
|
||||||
|
magic = MeshContentHeader.MAGIC,
|
||||||
|
version = MeshContentHeader.VERSION,
|
||||||
|
vertexCount = geometry.Vertices.Count,
|
||||||
|
indexCount = geometry.Indices.Count,
|
||||||
|
materialPartCount = geometry.MaterialParts.Length,
|
||||||
|
meshletCount = meshletData.GetRef().meshlets.Count,
|
||||||
|
meshletGroupCount = meshletData.GetRef().groups.Count,
|
||||||
|
meshletHierarchyNodeCount = meshletData.GetRef().hierarchyNodes.Count,
|
||||||
|
meshletVertexCount = meshletData.GetRef().meshletVertices.Count,
|
||||||
|
meshletTriangleCount = meshletData.GetRef().meshletTriangles.Count,
|
||||||
|
materialSlotCount = meshletData.GetRef().materialSlotCount,
|
||||||
|
lodLevelCount = meshletData.GetRef().lodLevelCount,
|
||||||
|
boundsMin = bounds.Min,
|
||||||
|
boundsMax = bounds.Max,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
stream.Write(header);
|
||||||
|
|
||||||
|
header.vertexOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
|
||||||
|
|
||||||
|
header.indexOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
|
||||||
|
|
||||||
|
header.materialPartOffset = stream.Position;
|
||||||
|
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
|
||||||
|
|
||||||
|
header.meshletOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
|
||||||
|
|
||||||
|
header.meshletGroupOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
|
||||||
|
|
||||||
|
header.meshletHierarchyNodeOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
|
||||||
|
|
||||||
|
header.meshletVertexOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
|
||||||
|
|
||||||
|
header.meshletTriangleOffset = stream.Position;
|
||||||
|
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
stream.Write(header);
|
||||||
|
stream.Flush();
|
||||||
|
|
||||||
|
return (meshletData.GetRef().materialSlotCount, meshletData.GetRef().lodLevelCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AABB ComputeBounds(UnsafeList<Vertex> vertices)
|
||||||
|
{
|
||||||
|
var min = new float3(float.MaxValue);
|
||||||
|
var max = new float3(float.MinValue);
|
||||||
|
for (var i = 0; i < vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var p = vertices[i].position;
|
||||||
|
min = math.min(min, p);
|
||||||
|
max = math.max(max, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AABB(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid CreateDeterministicSubAssetGuid(Guid parentGuid, string kind, string stablePath)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes($"{parentGuid:N}:{kind}:{stablePath}");
|
||||||
|
Span<byte> hash = stackalloc byte[16];
|
||||||
|
var hashValue = XxHash128.HashToUInt128(bytes);
|
||||||
|
Unsafe.WriteUnaligned(ref hash[0], hashValue);
|
||||||
|
|
||||||
|
hash[6] = (byte)((hash[6] & 0x0F) | 0x50);
|
||||||
|
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
|
||||||
|
return new Guid(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizePathSegment(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return "Node";
|
||||||
|
}
|
||||||
|
|
||||||
|
var chars = value.ToCharArray();
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
if (chars[i] == '/' || chars[i] == '\\' || chars[i] == '#')
|
||||||
|
{
|
||||||
|
chars[i] = '_';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMaterialParts(Stream stream, ReadOnlySpan<MaterialPartInfo> parts)
|
||||||
|
{
|
||||||
|
if (parts.IsEmpty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = parts.Length <= 64
|
||||||
|
? stackalloc MeshContentMaterialPart[parts.Length]
|
||||||
|
: new MeshContentMaterialPart[parts.Length];
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
buffer[i] = new MeshContentMaterialPart
|
||||||
|
{
|
||||||
|
materialIndex = parts[i].materialIndex,
|
||||||
|
indexStart = parts[i].indexStart,
|
||||||
|
indexCount = parts[i].indexCount,
|
||||||
|
vertexStart = parts[i].vertexStart,
|
||||||
|
vertexCount = parts[i].vertexCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.Write(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/Editor/Ghost.Editor.Core/Assets/MeshNode.cs
Normal file
181
src/Editor/Ghost.Editor.Core/Assets/MeshNode.cs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public class MeshNode : IDisposable
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public float4x4 LocalTransform
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeshNode? Parent
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<MeshNode> Children
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = Array.Empty<MeshNode>();
|
||||||
|
|
||||||
|
~MeshNode()
|
||||||
|
{
|
||||||
|
Dispose(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeshNode Clone()
|
||||||
|
{
|
||||||
|
return (MeshNode)MemberwiseClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
child.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Parent = null;
|
||||||
|
Children = Array.Empty<MeshNode>();
|
||||||
|
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes one material partition within a unified vertex/index buffer.
|
||||||
|
/// </summary>
|
||||||
|
public struct MaterialPartInfo
|
||||||
|
{
|
||||||
|
/// <summary> The material slot index (from ufbx face_material). </summary>
|
||||||
|
public int materialIndex;
|
||||||
|
/// <summary> Byte offset into the unified index buffer. </summary>
|
||||||
|
public int indexStart;
|
||||||
|
/// <summary> Number of indices belonging to this part. </summary>
|
||||||
|
public int indexCount;
|
||||||
|
/// <summary> Byte offset into the unified vertex buffer. </summary>
|
||||||
|
public int vertexStart;
|
||||||
|
/// <summary> Number of unique vertices belonging to this part. </summary>
|
||||||
|
public int vertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GeometryMeshNode : MeshNode
|
||||||
|
{
|
||||||
|
private UnsafeList<Vertex> _vertices;
|
||||||
|
private UnsafeList<uint> _indices;
|
||||||
|
private UnsafeArray<MaterialPartInfo> _materialParts;
|
||||||
|
|
||||||
|
public UnsafeList<Vertex> Vertices
|
||||||
|
{
|
||||||
|
get => _vertices;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_vertices.Dispose();
|
||||||
|
_vertices = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UnsafeList<uint> Indices
|
||||||
|
{
|
||||||
|
get => _indices;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_indices.Dispose();
|
||||||
|
_indices = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UnsafeArray<MaterialPartInfo> MaterialParts
|
||||||
|
{
|
||||||
|
get => _materialParts;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_materialParts.Dispose();
|
||||||
|
_materialParts = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
_vertices.Dispose();
|
||||||
|
_indices.Dispose();
|
||||||
|
_materialParts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LightMeshNode : MeshNode
|
||||||
|
{
|
||||||
|
public float3 Color
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float Intensity
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModelManifest
|
||||||
|
{
|
||||||
|
public Guid AssetId
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ModelManifestNode Root
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new ModelManifestNode();
|
||||||
|
|
||||||
|
public List<ModelManifestSubAsset> Meshes
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new List<ModelManifestSubAsset>();
|
||||||
|
|
||||||
|
public List<ModelManifestMetadata> Metadata
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new List<ModelManifestMetadata>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModelManifestNode
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public string StablePath
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public float4x4 LocalTransform
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid MeshGuid
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelManifestNode> Children
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new List<ModelManifestNode>();
|
||||||
|
}
|
||||||
376
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Import.cs
Normal file
376
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Import.cs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine.Utilities;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Ghost.Graphics.Utilities;
|
||||||
|
using Ghost.MeshOptimizer;
|
||||||
|
using Ghost.Ufbx;
|
||||||
|
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 unsafe class MeshParsingJob
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
private readonly TaskCompletionSource<Result> _taskCompletionSource;
|
||||||
|
|
||||||
|
public Task<Result> Task => _taskCompletionSource.Task;
|
||||||
|
|
||||||
|
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, MeshAssetSettings settings)
|
||||||
|
{
|
||||||
|
_rootNode = rootNode;
|
||||||
|
_filePath = filePath;
|
||||||
|
_allocationHandle = allocationHandle;
|
||||||
|
_settings = settings;
|
||||||
|
|
||||||
|
_taskCompletionSource = new TaskCompletionSource<Result>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static float4 ComputeTangent(float3 t, float3 n, float3 b)
|
||||||
|
{
|
||||||
|
var proj = n * math.dot(n, t);
|
||||||
|
t = math.normalize(t - proj);
|
||||||
|
var w = math.dot(math.cross(n, t), b) < 0.0f ? -1.0f : 1.0f;
|
||||||
|
return new float4(t.xyz, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static ufbx_coordinate_axis ToUfbxCoordinateAxis(CoordinateAxis axis)
|
||||||
|
{
|
||||||
|
return axis switch
|
||||||
|
{
|
||||||
|
CoordinateAxis.PositiveX => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_POSITIVE_X,
|
||||||
|
CoordinateAxis.PositiveY => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_POSITIVE_Y,
|
||||||
|
CoordinateAxis.PositiveZ => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_POSITIVE_Z,
|
||||||
|
CoordinateAxis.NegativeX => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_NEGATIVE_X,
|
||||||
|
CoordinateAxis.NegativeY => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_NEGATIVE_Y,
|
||||||
|
CoordinateAxis.NegativeZ => ufbx_coordinate_axis.UFBX_COORDINATE_AXIS_NEGATIVE_Z,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static float4x4 ToFloat4x4(ufbx_vec3 t, ufbx_quat q, ufbx_vec3 s)
|
||||||
|
{
|
||||||
|
return float4x4.TRS(
|
||||||
|
new float3(t.x, t.y, t.z),
|
||||||
|
new quaternion(q.x, q.y, q.z, q.w),
|
||||||
|
new float3(s.x, s.y, s.z)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseHierarchy(ufbx_node* node, MeshNode self, AllocationHandle allocationHandle)
|
||||||
|
{
|
||||||
|
var children = new List<MeshNode>();
|
||||||
|
|
||||||
|
self.Name = node->name.ToString();
|
||||||
|
self.LocalTransform = ToFloat4x4(node->local_transform.translation, node->local_transform.rotation, node->local_transform.scale);
|
||||||
|
self.Children = children;
|
||||||
|
|
||||||
|
if (node->mesh != null)
|
||||||
|
{
|
||||||
|
var geoNode = ParseGeometry(node->mesh, allocationHandle);
|
||||||
|
if (geoNode != null)
|
||||||
|
{
|
||||||
|
children.Add(geoNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Handle lights, cameras, and other node types.
|
||||||
|
|
||||||
|
for (var i = 0u; i < node->children.count; i++)
|
||||||
|
{
|
||||||
|
var childNode = new MeshNode();
|
||||||
|
ParseHierarchy(node->children.data[i], childNode, allocationHandle);
|
||||||
|
childNode.Parent = self;
|
||||||
|
|
||||||
|
children.Add(childNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GeometryMeshNode? ParseGeometry(ufbx_mesh* pMesh, AllocationHandle allocationHandle)
|
||||||
|
{
|
||||||
|
if (pMesh->num_faces == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var numMaterials = pMesh->materials.count > 0 ? (int)pMesh->materials.count : 1;
|
||||||
|
|
||||||
|
// Bucket faces by material
|
||||||
|
|
||||||
|
using var materialBuckets = new UnsafeArray<UnsafeList<Vertex>>(numMaterials, allocationHandle);
|
||||||
|
using var missingNormalsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
|
||||||
|
using var missingTangentsBucket = new UnsafeArray<bool>(numMaterials, allocationHandle);
|
||||||
|
|
||||||
|
for (var i = 0; i < numMaterials; i++)
|
||||||
|
{
|
||||||
|
materialBuckets[i] = new UnsafeList<Vertex>(10240, allocationHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
|
||||||
|
|
||||||
|
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, allocationHandle);
|
||||||
|
|
||||||
|
for (var j = 0u; j < pMesh->num_faces; j++)
|
||||||
|
{
|
||||||
|
var face = pMesh->faces.data[j];
|
||||||
|
var materialIdx = pMesh->face_material.count > j ? pMesh->face_material.data[j] : 0;
|
||||||
|
|
||||||
|
var numTris = UfbxApi.TriangulateFace(triIndicesArray.AsSpan(0, maxScratchIndices), pMesh, face);
|
||||||
|
|
||||||
|
var totalIndices = numTris * 3;
|
||||||
|
for (var k = 0; k < totalIndices; k++)
|
||||||
|
{
|
||||||
|
var ufbxTopologyIndex = triIndicesArray[k];
|
||||||
|
|
||||||
|
var posIdx = pMesh->vertex_position.indices.data[ufbxTopologyIndex];
|
||||||
|
var normIdx = pMesh->vertex_normal.exists ? pMesh->vertex_normal.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||||
|
var tanIdx = pMesh->vertex_tangent.exists ? pMesh->vertex_tangent.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||||
|
var uvIdx = pMesh->vertex_uv.exists ? pMesh->vertex_uv.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||||
|
var colIdx = pMesh->vertex_color.exists ? pMesh->vertex_color.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||||
|
var btanIdx = pMesh->vertex_bitangent.exists ? pMesh->vertex_bitangent.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||||
|
|
||||||
|
var position = pMesh->vertex_position.values.data[posIdx];
|
||||||
|
var normal = normIdx != uint.MaxValue ? pMesh->vertex_normal.values.data[normIdx] : default;
|
||||||
|
var uv = uvIdx != uint.MaxValue ? pMesh->vertex_uv.values.data[uvIdx] : default;
|
||||||
|
var color = colIdx != uint.MaxValue ? pMesh->vertex_color.values.data[colIdx] : default;
|
||||||
|
|
||||||
|
var vertex = new Vertex
|
||||||
|
{
|
||||||
|
position = new float3(position.x, position.y, position.z),
|
||||||
|
normal = new float3(normal.x, normal.y, normal.z),
|
||||||
|
uv = new float2(uv.x, uv.y),
|
||||||
|
color = new Color128(color.x, color.y, color.z, color.w)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tanIdx != uint.MaxValue)
|
||||||
|
{
|
||||||
|
var mt = pMesh->vertex_tangent.values.data[tanIdx];
|
||||||
|
var mb = btanIdx != uint.MaxValue ? pMesh->vertex_bitangent.values.data[btanIdx] : default;
|
||||||
|
|
||||||
|
var t = new float3(mt.x, mt.y, mt.z);
|
||||||
|
var n = vertex.normal;
|
||||||
|
var b = btanIdx != uint.MaxValue ? new float3(mb.x, mb.y, mb.z) : math.cross(n, t);
|
||||||
|
vertex.tangent = ComputeTangent(t, n, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
materialBuckets[materialIdx].Add(vertex);
|
||||||
|
|
||||||
|
if (!missingNormalsBucket[materialIdx])
|
||||||
|
{
|
||||||
|
missingNormalsBucket[materialIdx] = normIdx == uint.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!missingTangentsBucket[materialIdx])
|
||||||
|
{
|
||||||
|
missingTangentsBucket[materialIdx] = tanIdx == uint.MaxValue || btanIdx == uint.MaxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-material weld + optimize, collect intermediate results
|
||||||
|
|
||||||
|
using var partResults = new UnsafeList<GeometryPart>(numMaterials, allocationHandle);
|
||||||
|
|
||||||
|
for (var m = 0; m < numMaterials; m++)
|
||||||
|
{
|
||||||
|
ref var flatVertices = ref materialBuckets[m];
|
||||||
|
if (flatVertices.Count == 0)
|
||||||
|
{
|
||||||
|
flatVertices.Dispose();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var numIndices = (uint)flatVertices.Count;
|
||||||
|
|
||||||
|
using var weldedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
|
||||||
|
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, allocationHandle);
|
||||||
|
|
||||||
|
var stream = new ufbx_vertex_stream
|
||||||
|
{
|
||||||
|
data = flatVertices.GetUnsafePtr(),
|
||||||
|
vertex_count = numIndices,
|
||||||
|
vertex_size = (nuint)sizeof(Vertex)
|
||||||
|
};
|
||||||
|
|
||||||
|
var error = new ufbx_error();
|
||||||
|
var numUniqueVertices = UfbxApi.GenerateIndices([stream], weldedIndices, null, &error);
|
||||||
|
if (numUniqueVertices == 0 && error.type != ufbx_error_type.UFBX_ERROR_NONE)
|
||||||
|
{
|
||||||
|
flatVertices.Dispose();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
|
||||||
|
|
||||||
|
// Allocate temporary per-part buffers (will be merged then disposed)
|
||||||
|
var partVertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocationHandle);
|
||||||
|
var partIndices = new UnsafeList<uint>((int)numIndices, allocationHandle);
|
||||||
|
|
||||||
|
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(partVertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
|
||||||
|
|
||||||
|
partVertices.UnsafeSetCount((int)finalVertexCount);
|
||||||
|
|
||||||
|
MemoryUtility.MemCpy(partIndices.GetUnsafePtr(), cachedIndices.GetUnsafePtr(), numIndices * sizeof(uint));
|
||||||
|
partIndices.UnsafeSetCount((int)numIndices);
|
||||||
|
|
||||||
|
var part = new GeometryPart
|
||||||
|
{
|
||||||
|
vertices = partVertices,
|
||||||
|
indices = partIndices,
|
||||||
|
materialIndex = m,
|
||||||
|
missingNormals = missingNormalsBucket[m],
|
||||||
|
missingTangents = missingTangentsBucket[m]
|
||||||
|
};
|
||||||
|
|
||||||
|
partResults.Add(part);
|
||||||
|
flatVertices.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partResults.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all material parts into one unified vertex/index buffer
|
||||||
|
|
||||||
|
var totalVertexCount = 0;
|
||||||
|
var totalIndexCount = 0;
|
||||||
|
for (var i = 0; i < partResults.Count; i++)
|
||||||
|
{
|
||||||
|
totalVertexCount += partResults[i].vertices.Count;
|
||||||
|
totalIndexCount += partResults[i].indices.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mergedVertices = new UnsafeList<Vertex>(totalVertexCount, _allocationHandle);
|
||||||
|
var mergedIndices = new UnsafeList<uint>(totalIndexCount, _allocationHandle);
|
||||||
|
var materialParts = new UnsafeArray<MaterialPartInfo>(partResults.Count, _allocationHandle);
|
||||||
|
|
||||||
|
var vertexOffset = 0;
|
||||||
|
var indexOffset = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < partResults.Count; i++)
|
||||||
|
{
|
||||||
|
ref var part = ref partResults[i];
|
||||||
|
|
||||||
|
// Compute normals/tangents per-part before merge (requires local indices)
|
||||||
|
if (_settings.NormalDataSource == VertexDataSource.Computed || (_settings.NormalDataSource == VertexDataSource.ComputedIfMissing && part.missingNormals))
|
||||||
|
{
|
||||||
|
MeshBuilder.ComputeNormal(part.vertices, part.indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settings.TangentDataSource == VertexDataSource.Computed || (_settings.TangentDataSource == VertexDataSource.ComputedIfMissing && part.missingTangents))
|
||||||
|
{
|
||||||
|
MeshBuilder.ComputeTangents(part.vertices, part.indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
materialParts[i] = new MaterialPartInfo
|
||||||
|
{
|
||||||
|
materialIndex = part.materialIndex,
|
||||||
|
vertexStart = vertexOffset,
|
||||||
|
vertexCount = part.vertices.Count,
|
||||||
|
indexStart = indexOffset,
|
||||||
|
indexCount = part.indices.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
mergedVertices.AddRange(part.vertices.AsSpan());
|
||||||
|
|
||||||
|
// Rebase indices to global vertex space
|
||||||
|
for (var j = 0; j < part.indices.Count; j++)
|
||||||
|
{
|
||||||
|
mergedIndices.Add(part.indices[j] + (uint)vertexOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
vertexOffset += part.vertices.Count;
|
||||||
|
indexOffset += part.indices.Count;
|
||||||
|
|
||||||
|
part.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GeometryMeshNode
|
||||||
|
{
|
||||||
|
Name = pMesh->name.ToString(),
|
||||||
|
LocalTransform = float4x4.identity,
|
||||||
|
Vertices = mergedVertices,
|
||||||
|
Indices = mergedIndices,
|
||||||
|
MaterialParts = materialParts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result Execute()
|
||||||
|
{
|
||||||
|
var error = new ufbx_error();
|
||||||
|
var load_Opts = new ufbx_load_opts
|
||||||
|
{
|
||||||
|
target_unit_meters = 1.0f,
|
||||||
|
target_axes = ufbx_coordinate_axes.left_handed_y_up,
|
||||||
|
// Force z-axis mirroring to correctly convert handedness to Left-Handed,
|
||||||
|
// while preserving correct left/right orientation when viewed from the front.
|
||||||
|
handedness_conversion_axis = ufbx_mirror_axis.UFBX_MIRROR_AXIS_Z,
|
||||||
|
space_conversion = ufbx_space_conversion.UFBX_SPACE_CONVERSION_MODIFY_GEOMETRY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_settings is ObjAssetSettings objSettings)
|
||||||
|
{
|
||||||
|
load_Opts.obj_axes = new ufbx_coordinate_axes
|
||||||
|
{
|
||||||
|
right = ToUfbxCoordinateAxis(objSettings.ObjectRightAxis),
|
||||||
|
up = ToUfbxCoordinateAxis(objSettings.ObjectUpAxis),
|
||||||
|
front = ToUfbxCoordinateAxis(objSettings.ObjectForwardAxis)
|
||||||
|
};
|
||||||
|
|
||||||
|
load_Opts.obj_unit_meters = objSettings.UnitMeterScale;
|
||||||
|
load_Opts.obj_search_mtl_by_filename = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(_filePath) + 1, AllocationHandle.TLSF);
|
||||||
|
var count = Encoding.UTF8.GetBytes(_filePath, str.AsSpan());
|
||||||
|
str[count] = 0;
|
||||||
|
|
||||||
|
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
|
||||||
|
if (scene.Get() == null)
|
||||||
|
{
|
||||||
|
return Result.Failure(error.description.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static partial class MeshProcessor
|
||||||
|
{
|
||||||
|
public static Task<Result> ParseMeshAsync(MeshNode root, string sourcePath, AllocationHandle allocationHandle, MeshAssetSettings meshSettings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var parseJob = new MeshParsingJob(root, sourcePath, allocationHandle, meshSettings);
|
||||||
|
return Task.Run(parseJob.Execute, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
1250
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Meshlet.cs
Normal file
1250
src/Editor/Ghost.Editor.Core/Assets/MeshProcessor.Meshlet.cs
Normal file
File diff suppressed because it is too large
Load Diff
49
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
49
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public sealed class SceneAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "1B5E3F2A-8D91-4C67-BE32-A0F9C6D4E781";
|
||||||
|
|
||||||
|
private static readonly Guid s_typeID = Guid.Parse(GUID);
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TypeID => s_typeID;
|
||||||
|
|
||||||
|
public IAssetSettings? Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SceneName
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int EntityCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneAsset(Guid id, IAssetSettings? settings)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
Settings = settings;
|
||||||
|
SceneName = string.Empty;
|
||||||
|
EntityCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SceneAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
112
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
112
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = SceneAsset.GUID, RuntimeAssetType = AssetType.Scene, Extensions = new[] { ".gscene" })]
|
||||||
|
internal class SceneAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
|
{
|
||||||
|
[AssetOpenHandler(".gscene")]
|
||||||
|
private static async Task<Result> OpenAsync(string path)
|
||||||
|
{
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(path);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to load scene.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
service.LoadSceneIntoEditorWorld(data);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
return new SceneAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
|
||||||
|
var asset = new SceneAsset(id, settings)
|
||||||
|
{
|
||||||
|
SceneName = Path.GetFileNameWithoutExtension(assetPath),
|
||||||
|
EntityCount = data?.Entities?.Count ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Result.Success<IAsset>(asset);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (asset is not SceneAsset sceneAsset)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result.Failure("Asset type is not SceneAsset"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult(Result.Failure("Scene saving is handled by SceneSerializationService directly."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Source scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(sourcePath, token);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
SceneSerializationService.SerializeToBinary(data, stream);
|
||||||
|
|
||||||
|
return Result.Success(Array.Empty<ImportedSubAsset>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to import scene asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneSerializationService.SerializeToBinary(data, targetStream);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to pack scene asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/Editor/Ghost.Editor.Core/Assets/ShaderAssetHandler.cs
Normal file
148
src/Editor/Ghost.Editor.Core/Assets/ShaderAssetHandler.cs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.DSL.ShaderCompiler;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public sealed partial class GraphicsShaderAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "7BD4591C-B017-4814-AA0B-3F30EB3E727E";
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TypeID => typeof(GraphicsShaderAsset).GUID;
|
||||||
|
|
||||||
|
public GraphicsShaderDescriptor Descriptor
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal GraphicsShaderAsset(GraphicsShaderDescriptor descriptor, Guid id)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
Descriptor = descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public sealed partial class ComputeShaderAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A";
|
||||||
|
|
||||||
|
public Guid ID
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? Settings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TypeID => typeof(ComputeShaderAsset).GUID;
|
||||||
|
|
||||||
|
public ComputeShaderDescriptor Descriptor
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
Descriptor = descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shader does not handle import/export via asset registry, it will handled by the hot reload system.
|
||||||
|
[CustomAssetHandler(AssetTypeId = GraphicsShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gshdr" })]
|
||||||
|
internal class GraphicsShaderAssetHandler : IPackableAssetHandler
|
||||||
|
{
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = DSLShaderCompiler.CompileGraphicsShader(assetPath);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphicsShaderAsset(result.Value, id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to load shader asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return new ValueTask<Result>(Result.Failure("Saving shader assets is not supported yet as it's read-only. Please edit the shader source file directly if you need to modify it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = ComputeShaderAsset.GUID, RuntimeAssetType = AssetType.Shader, Extensions = new[] { ".gcomp" })]
|
||||||
|
internal class ComputeShaderAssetHandler : IPackableAssetHandler
|
||||||
|
{
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = DSLShaderCompiler.CompileComputeShaderCode(assetPath);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ComputeShaderAsset(result.Value, id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to load shader asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return new ValueTask<Result>(Result.Failure("Saving shader assets is not supported yet as it's read-only. Please edit the shader source file directly if you need to modify it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return new ValueTask<Result>(Result.Failure("Packing shader assets is not supported yet."));
|
||||||
|
}
|
||||||
|
}
|
||||||
497
src/Editor/Ghost.Editor.Core/Assets/TextureAssetHandler.cs
Normal file
497
src/Editor/Ghost.Editor.Core/Assets/TextureAssetHandler.cs
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Ghost.StbI;
|
||||||
|
using Misaki.HighPerformance.LowLevel;
|
||||||
|
using System.IO.MemoryMappedFiles;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
public enum TextureType : uint
|
||||||
|
{
|
||||||
|
Default,
|
||||||
|
Normal,
|
||||||
|
Lightmap,
|
||||||
|
SingleChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureShape : uint
|
||||||
|
{
|
||||||
|
Texture2D,
|
||||||
|
Texture3D,
|
||||||
|
TextureCube
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureSize : uint
|
||||||
|
{
|
||||||
|
Size256 = 256,
|
||||||
|
Size512 = 512,
|
||||||
|
Size1024 = 1024,
|
||||||
|
Size2048 = 2048,
|
||||||
|
Size4096 = 4096,
|
||||||
|
Size8192 = 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextureCompressionLevel : uint
|
||||||
|
{
|
||||||
|
Low,
|
||||||
|
Normal,
|
||||||
|
High
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MipmapFilter : uint
|
||||||
|
{
|
||||||
|
Box,
|
||||||
|
Triangle,
|
||||||
|
Kaiser,
|
||||||
|
MitchellNetravali
|
||||||
|
}
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public unsafe class TextureAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "27965FFF-860C-40EF-9123-1874D7DE9CDC";
|
||||||
|
|
||||||
|
private static readonly Guid s_typeID = Guid.Parse(GUID);
|
||||||
|
|
||||||
|
private readonly Guid _id;
|
||||||
|
private readonly IAssetSettings _settings;
|
||||||
|
|
||||||
|
private readonly IntPtr _textureData;
|
||||||
|
private readonly uint _width;
|
||||||
|
private readonly uint _height;
|
||||||
|
private readonly uint _depth;
|
||||||
|
private readonly uint _colorComponents;
|
||||||
|
private readonly uint _dimension;
|
||||||
|
|
||||||
|
public Guid ID => _id;
|
||||||
|
public Guid TypeID => typeof(TextureAsset).GUID;
|
||||||
|
public IAssetSettings Settings => _settings;
|
||||||
|
|
||||||
|
public IntPtr TextureData => _textureData;
|
||||||
|
public uint Width => _width;
|
||||||
|
public uint Height => _height;
|
||||||
|
public uint Depth => _depth;
|
||||||
|
public uint Dimension => _dimension;
|
||||||
|
public uint ColorComponents => _colorComponents;
|
||||||
|
|
||||||
|
internal TextureAsset([OwnershipTransfer] IntPtr data, TextureContentHeader header, Guid id, IAssetSettings settings)
|
||||||
|
{
|
||||||
|
_id = id;
|
||||||
|
_settings = settings;
|
||||||
|
|
||||||
|
_textureData = data;
|
||||||
|
_width = header.width;
|
||||||
|
_height = header.height;
|
||||||
|
_depth = header.bpc;
|
||||||
|
_dimension = header.dimension;
|
||||||
|
_colorComponents = header.colorComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
~TextureAsset()
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
StbIApi.ImageFree((void*)_textureData);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TextureAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
public struct BasicSettings()
|
||||||
|
{
|
||||||
|
public TextureType TextureType
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureType.Default;
|
||||||
|
|
||||||
|
public TextureShape TextureShape
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureShape.Texture2D;
|
||||||
|
|
||||||
|
public int Columns
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public int Rows
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public int Depth
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public bool IsSRGB
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AdvancedSettings()
|
||||||
|
{
|
||||||
|
public bool StretchToPowerOfTwo
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
public bool VirtualTexture
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public bool GenerateMipmaps
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
public uint MipmapLevelCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 0; // 0 means generate full mipmap levels.
|
||||||
|
|
||||||
|
public bool PremultiplyAlpha
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public MipmapFilter MipmapFilter
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = MipmapFilter.Kaiser;
|
||||||
|
|
||||||
|
public TextureCompressionLevel CompressionLevel
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureCompressionLevel.Normal;
|
||||||
|
|
||||||
|
public bool UseBorderColor
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public Color128 BorderColor
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new Color128(0, 0, 0, 0);
|
||||||
|
|
||||||
|
public bool ZeroAlphaBorder
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public bool CutoutAlpha
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public byte CutoutAlphaThreshold
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 127;
|
||||||
|
|
||||||
|
public bool ScaleAlphaForMipCoverage
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
|
||||||
|
public byte ScaleAlphaForMipCoverageThreshold
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 127;
|
||||||
|
|
||||||
|
public bool MipmapStreaming
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SamplerSettings()
|
||||||
|
{
|
||||||
|
public TextureSize MaxSize
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureSize.Size2048;
|
||||||
|
|
||||||
|
public TextureFilterMode FilterMode
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureFilterMode.Anisotropic;
|
||||||
|
|
||||||
|
public TextureAddressMode WrapMode
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = TextureAddressMode.Repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BasicSettings Basic
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new BasicSettings();
|
||||||
|
|
||||||
|
public AdvancedSettings Advanced
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new AdvancedSettings();
|
||||||
|
|
||||||
|
public SamplerSettings Sampler
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new SamplerSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = TextureAsset.GUID, RuntimeAssetType = AssetType.Texture, Extensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
|
||||||
|
internal class TextureAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
|
{
|
||||||
|
internal struct TextureInfo
|
||||||
|
{
|
||||||
|
public IntPtr pixelData;
|
||||||
|
public int width;
|
||||||
|
public int height;
|
||||||
|
public int depth;
|
||||||
|
public int bitsPerChannel;
|
||||||
|
public int colorComponents;
|
||||||
|
public bool isHDR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
return new TextureAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextureDimension GetTextureDimension(TextureAssetSettings settings)
|
||||||
|
{
|
||||||
|
if (settings.Basic.Columns > 1 && settings.Basic.Rows > 1)
|
||||||
|
{
|
||||||
|
if (settings.Basic.Depth > 1)
|
||||||
|
{
|
||||||
|
return TextureDimension.Texture3D;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextureDimension.Texture2DArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.Basic.Columns == 1 && settings.Basic.Rows == 1)
|
||||||
|
{
|
||||||
|
if (settings.Basic.Depth == 6)
|
||||||
|
{
|
||||||
|
return TextureDimension.TextureCube;
|
||||||
|
}
|
||||||
|
else if (settings.Basic.Depth > 6 && settings.Basic.Depth % 6 == 0)
|
||||||
|
{
|
||||||
|
return TextureDimension.TextureCubeArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none of the above conditions are met, we will treat it as a regular 2D texture.
|
||||||
|
return TextureDimension.Texture2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe Result<TextureInfo> GetImageInfo(string sourcePath, TextureAssetSettings settings)
|
||||||
|
{
|
||||||
|
using var mmf = MemoryMappedFile.CreateFromFile(sourcePath, FileMode.Open);
|
||||||
|
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
|
||||||
|
|
||||||
|
byte* ptr = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(sourcePath);
|
||||||
|
var isHDR = ext.Equals(".hdr", StringComparison.OrdinalIgnoreCase) || settings.Basic.TextureShape == TextureShape.TextureCube;
|
||||||
|
|
||||||
|
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||||
|
|
||||||
|
int imageWidth, imageHeight, bitsPerChannel, colorComponents;
|
||||||
|
|
||||||
|
var bufferSpan = new ReadOnlySpan<byte>(ptr, (int)accessor.Capacity);
|
||||||
|
bitsPerChannel = StbIApi.Is16BitFromMemory(bufferSpan) > 0 ? 16 : 8;
|
||||||
|
|
||||||
|
void* pPixels;
|
||||||
|
if (isHDR || bitsPerChannel > 8)
|
||||||
|
{
|
||||||
|
pPixels = StbIApi.LoadfFromMemory(bufferSpan, &imageWidth, &imageHeight, &colorComponents, 4);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pPixels = StbIApi.LoadFromMemory(bufferSpan, &imageWidth, &imageHeight, &colorComponents, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextureInfo
|
||||||
|
{
|
||||||
|
pixelData = (IntPtr)pPixels,
|
||||||
|
width = imageWidth,
|
||||||
|
height = imageHeight,
|
||||||
|
depth = 1,
|
||||||
|
bitsPerChannel = bitsPerChannel,
|
||||||
|
colorComponents = 4, // We forced req_comp to 4
|
||||||
|
isHDR = isHDR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<TextureInfo>.Failure($"Failed to get image info: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ptr != null)
|
||||||
|
{
|
||||||
|
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var textureSettings = settings as TextureAssetSettings ?? new TextureAssetSettings();
|
||||||
|
var infoResult = GetImageInfo(assetPath, textureSettings);
|
||||||
|
if (infoResult.IsFailure)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result<IAsset>.Failure(infoResult.Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = infoResult.Value;
|
||||||
|
var contentHeader = new TextureContentHeader
|
||||||
|
{
|
||||||
|
width = (uint)info.width,
|
||||||
|
height = (uint)info.height,
|
||||||
|
bpc = (uint)info.bitsPerChannel,
|
||||||
|
colorComponents = (uint)info.colorComponents,
|
||||||
|
dimension = (uint)GetTextureDimension(textureSettings),
|
||||||
|
};
|
||||||
|
|
||||||
|
return ValueTask.FromResult(Result.Success<IAsset>(new TextureAsset(info.pixelData, contentHeader, id, textureSettings)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result<IAsset>.Failure(ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
|
||||||
|
private static unsafe void WriteCallback(void* context, void* data, int size)
|
||||||
|
{
|
||||||
|
var stream = (Stream)GCHandle.FromIntPtr((IntPtr)context).Target!;
|
||||||
|
var buffer = new ReadOnlySpan<byte>(data, size);
|
||||||
|
stream.Write(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (asset is not TextureAsset textureAsset)
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset type is not TextureAsset");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var targetStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
// It will be safe here to pass the gc handle to c because c will not use it, c will only pass it back to c# in the callback, and we will free the handle after the write operation is done.
|
||||||
|
var gcHandle = GCHandle.Alloc(targetStream, GCHandleType.Normal);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(targetStream.Name);
|
||||||
|
var result = 0;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
switch (ext)
|
||||||
|
{
|
||||||
|
case ".png":
|
||||||
|
result = StbIApi.WritePngToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ".jpg":
|
||||||
|
result = StbIApi.WriteJpgToFunc(&WriteCallback, (void*)GCHandle.ToIntPtr(gcHandle), (int)textureAsset.Width, (int)textureAsset.Height, (int)textureAsset.ColorComponents, (void*)textureAsset.TextureData, 90);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// TODO: Add support for other image formats
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Result.Failure($"Unsupported image format: {ext}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result != 0 ? Result.Success() : Result.Failure("Failed to write image data.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gcHandle.Free();
|
||||||
|
}
|
||||||
|
}, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Source file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var textureSettings = settings as TextureAssetSettings ?? new TextureAssetSettings();
|
||||||
|
var infoResult = GetImageInfo(sourcePath, textureSettings);
|
||||||
|
if (!infoResult.IsSuccess)
|
||||||
|
{
|
||||||
|
return Result.Failure(infoResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = infoResult.Value;
|
||||||
|
var result = await TextureProcessor.GenerateMipAndCompressAsync(EditorApplication.CacheFolderPath, id,
|
||||||
|
info,
|
||||||
|
textureSettings, token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (cachePath, mip) = result.Value;
|
||||||
|
|
||||||
|
await using var targetStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
var header = new TextureContentHeader
|
||||||
|
{
|
||||||
|
width = (uint)info.width,
|
||||||
|
height = (uint)info.height,
|
||||||
|
bpc = (uint)info.bitsPerChannel,
|
||||||
|
colorComponents = (uint)info.colorComponents,
|
||||||
|
mipLevels = (uint)mip,
|
||||||
|
dimension = (uint)GetTextureDimension(textureSettings)
|
||||||
|
};
|
||||||
|
|
||||||
|
targetStream.Write(MemoryMarshal.AsBytes(new Span<TextureContentHeader>(ref header)));
|
||||||
|
|
||||||
|
await using var ddsStream = new FileStream(cachePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
await ddsStream.CopyToAsync(targetStream, token).ConfigureAwait(false);
|
||||||
|
await targetStream.FlushAsync(token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Result.Success(Array.Empty<ImportedSubAsset>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to import texture asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(Result.Failure("Packing texture assets is not supported yet."));
|
||||||
|
}
|
||||||
|
}
|
||||||
434
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.Compress.cs
Normal file
434
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.Compress.cs
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Nvtt;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using Misaki.HighPerformance.LowLevel;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using System.IO.Hashing;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
internal static partial class TextureProcessor
|
||||||
|
{
|
||||||
|
private struct NvttPipelineJob : IJob
|
||||||
|
{
|
||||||
|
private readonly Wrapper<Result<int>> _result;
|
||||||
|
|
||||||
|
private readonly string _outputPath;
|
||||||
|
private readonly TextureAssetHandler.TextureInfo _textureInfo;
|
||||||
|
private readonly TextureAssetSettings _settings;
|
||||||
|
private UnsafeArray<MipLevel> _mipLevels;
|
||||||
|
|
||||||
|
public NvttPipelineJob(Wrapper<Result<int>> result, string outputPath, TextureAssetHandler.TextureInfo textureInfo, TextureAssetSettings settings, UnsafeArray<MipLevel> mipLevels)
|
||||||
|
{
|
||||||
|
_result = result;
|
||||||
|
|
||||||
|
_outputPath = outputPath;
|
||||||
|
_textureInfo = textureInfo;
|
||||||
|
_settings = settings;
|
||||||
|
_mipLevels = mipLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Result<int> RunMipGenCompressionPipeline()
|
||||||
|
{
|
||||||
|
using var pSurface = new DisposablePtr<NvttSurface>(NvttSurface.Create());
|
||||||
|
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
|
||||||
|
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
|
||||||
|
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
|
||||||
|
|
||||||
|
var inputFormat = _textureInfo.colorComponents == 1
|
||||||
|
? NvttInputFormat.NVTT_InputFormat_R_32F
|
||||||
|
: _textureInfo.bitsPerChannel > 8
|
||||||
|
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
|
||||||
|
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
|
||||||
|
|
||||||
|
var isNormal = _settings.Basic.TextureType == TextureType.Normal;
|
||||||
|
if (!pSurface.Get()->SetImageData(inputFormat, _textureInfo.width, _textureInfo.height, _textureInfo.depth, (void*)_textureInfo.pixelData, isNormal, null))
|
||||||
|
{
|
||||||
|
return Result.Failure<int>("Failed to set image data for NVTT compression.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNormal)
|
||||||
|
{
|
||||||
|
pSurface.Get()->SetNormalMap(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
|
||||||
|
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
|
||||||
|
if (_textureInfo.colorComponents > 1 && _textureInfo.bitsPerChannel <= 8)
|
||||||
|
{
|
||||||
|
pSurface.Get()->Swizzle(2, 1, 0, 3, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxExtent = (int)_settings.Sampler.MaxSize;
|
||||||
|
if (_settings.Advanced.StretchToPowerOfTwo)
|
||||||
|
{
|
||||||
|
pSurface.Get()->ResizeMakeSquare(maxExtent,
|
||||||
|
NvttRoundMode.NVTT_RoundMode_ToNearestPowerOfTwo,
|
||||||
|
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
||||||
|
}
|
||||||
|
else if (pSurface.Get()->Width() > maxExtent || pSurface.Get()->Height() > maxExtent)
|
||||||
|
{
|
||||||
|
pSurface.Get()->ResizeMax(maxExtent,
|
||||||
|
NvttRoundMode.NVTT_RoundMode_None,
|
||||||
|
NvttResizeFilter.NVTT_ResizeFilter_Box, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settings.Advanced.UseBorderColor)
|
||||||
|
{
|
||||||
|
var c = _settings.Advanced.BorderColor;
|
||||||
|
pSurface.Get()->SetBorder(c.r, c.g, c.b, c.a, null);
|
||||||
|
}
|
||||||
|
else if (_settings.Advanced.ZeroAlphaBorder)
|
||||||
|
{
|
||||||
|
pSurface.Get()->SetBorder(0f, 0f, 0f, 0f, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settings.Basic.IsSRGB)
|
||||||
|
{
|
||||||
|
pSurface.Get()->ToLinearFromSrgb(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settings.Advanced.PremultiplyAlpha)
|
||||||
|
{
|
||||||
|
pSurface.Get()->PremultiplyAlpha(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
pCompOpts.Get()->SetFormat(SelectFormat(_settings, _textureInfo.isHDR));
|
||||||
|
pCompOpts.Get()->SetQuality(SelectQuality(_settings.Advanced.CompressionLevel));
|
||||||
|
|
||||||
|
if (_settings.Advanced.CutoutAlpha)
|
||||||
|
{
|
||||||
|
pCompOpts.Get()->SetQuantization(false, false, true,
|
||||||
|
_settings.Advanced.CutoutAlphaThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
pOutOpts.Get()->SetOutputHeader(true);
|
||||||
|
pOutOpts.Get()->SetSrgbFlag(_settings.Basic.IsSRGB);
|
||||||
|
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
|
||||||
|
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(_outputPath));
|
||||||
|
|
||||||
|
var nvttFilter = SelectMipmapFilter(_settings.Advanced.MipmapFilter);
|
||||||
|
|
||||||
|
int mipmapCount;
|
||||||
|
if (!_settings.Advanced.GenerateMipmaps)
|
||||||
|
{
|
||||||
|
mipmapCount = 1;
|
||||||
|
}
|
||||||
|
else if (_settings.Advanced.MipmapLevelCount == 0)
|
||||||
|
{
|
||||||
|
mipmapCount = pSurface.Get()->CountMipmaps(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mipmapCount = (int)_settings.Advanced.MipmapLevelCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
|
||||||
|
|
||||||
|
pCtx.Get()->OutputHeader(pSurface.Get(), mipmapCount, pCompOpts.Get(), pOutOpts.Get());
|
||||||
|
|
||||||
|
using var pMip = new DisposablePtr<NvttSurface>(pSurface.Get()->Clone());
|
||||||
|
if (pMip.Get() == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to clone surface for mipmap generation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var level = 0; level < mipmapCount; level++)
|
||||||
|
{
|
||||||
|
// Scale alpha for coverage on each pMip (if requested)
|
||||||
|
if (_settings.Advanced.ScaleAlphaForMipCoverage && level > 0)
|
||||||
|
{
|
||||||
|
var refCoverage = pMip.Get()->AlphaTestCoverage(
|
||||||
|
_settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3);
|
||||||
|
pMip.Get()->ScaleAlphaToCoverage(refCoverage,
|
||||||
|
_settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f, 3, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var compressMip = new DisposablePtr<NvttSurface>(pMip.Get()->Clone());
|
||||||
|
if (_settings.Basic.IsSRGB)
|
||||||
|
{
|
||||||
|
compressMip.Get()->ToSrgb(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pCtx.Get()->Compress(compressMip.Get(), 0, level, pCompOpts.Get(), pOutOpts.Get()))
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to compress mipmap.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level + 1 < mipmapCount)
|
||||||
|
{
|
||||||
|
if (!pMip.Get()->BuildNextMipmapDefaults(nvttFilter, 1, null))
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to build next mipmap.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success(mipmapCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Result<int> RunCubeMapCompressionPipeline()
|
||||||
|
{
|
||||||
|
using var pCompOpts = new DisposablePtr<NvttCompressionOptions>(NvttCompressionOptions.Create());
|
||||||
|
using var pOutOpts = new DisposablePtr<NvttOutputOptions>(NvttOutputOptions.Create());
|
||||||
|
using var pCtx = new DisposablePtr<NvttContext>(NvttContext.Create());
|
||||||
|
|
||||||
|
pCompOpts.Get()->SetFormat(SelectFormat(_settings, _textureInfo.isHDR));
|
||||||
|
pCompOpts.Get()->SetQuality(SelectQuality(_settings.Advanced.CompressionLevel));
|
||||||
|
|
||||||
|
pOutOpts.Get()->SetOutputHeader(true);
|
||||||
|
pOutOpts.Get()->SetSrgbFlag(_settings.Basic.IsSRGB);
|
||||||
|
pOutOpts.Get()->SetContainer(NvttContainer.NVTT_Container_DDS10);
|
||||||
|
pOutOpts.Get()->SetFileName(Encoding.UTF8.GetBytes(_outputPath));
|
||||||
|
|
||||||
|
pCtx.Get()->SetCudaAcceleration(NvttApi.IsCudaSupported());
|
||||||
|
|
||||||
|
var maxCubeMips = _mipLevels.Length;
|
||||||
|
var w0 = _mipLevels[0].width;
|
||||||
|
|
||||||
|
if (!pCtx.Get()->OutputHeaderData(NvttTextureType.NVTT_TextureType_Cube, w0, w0, 1, maxCubeMips, false, pCompOpts.Get(), pOutOpts.Get()))
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to output header for cube map.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var face = 0; face < 6; face++)
|
||||||
|
{
|
||||||
|
for (var level = 0; level < maxCubeMips; level++)
|
||||||
|
{
|
||||||
|
using var faceSurf = new DisposablePtr<NvttSurface>(NvttSurface.Create());
|
||||||
|
var w = _mipLevels[level].width;
|
||||||
|
var faceSize = w * w * _textureInfo.colorComponents;
|
||||||
|
var pSrcData = (float*)_mipLevels[level].data.GetUnsafePtr() + face * faceSize;
|
||||||
|
|
||||||
|
if (!faceSurf.Get()->SetImageData(NvttInputFormat.NVTT_InputFormat_RGBA_32F, w, w, 1, pSrcData, false, null))
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to set image data for NVTT compression.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settings.Basic.IsSRGB)
|
||||||
|
{
|
||||||
|
faceSurf.Get()->ToSrgb(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pCtx.Get()->Compress(faceSurf.Get(), face, level, pCompOpts.Get(), pOutOpts.Get()))
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to compress cube map face.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success(maxCubeMips);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(ref readonly JobExecutionContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Result<int> finalResult;
|
||||||
|
|
||||||
|
if (_settings.Basic.TextureShape == TextureShape.TextureCube)
|
||||||
|
{
|
||||||
|
finalResult = RunCubeMapCompressionPipeline();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
finalResult = RunMipGenCompressionPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
_result.Value = finalResult;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error($"Exception during NVTT compression: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<Result<(string cachePath, int mipmapCount)>> GenerateMipAndCompressAsync(string cachesFolderPath, Guid assetId,
|
||||||
|
TextureAssetHandler.TextureInfo textureInfo,
|
||||||
|
TextureAssetSettings settings, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settingsHash = ComputeSettingsHash(settings);
|
||||||
|
var cacheFileName = $"texturecache_{assetId:N}_{settingsHash:X16}.dds";
|
||||||
|
|
||||||
|
var textureCachePath = Path.Combine(cachesFolderPath, "TextureCache");
|
||||||
|
var cachePath = Path.Combine(textureCachePath, cacheFileName);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(textureCachePath);
|
||||||
|
|
||||||
|
if (File.Exists(cachePath))
|
||||||
|
{
|
||||||
|
var isValid = false;
|
||||||
|
var mipMapCount = 1u;
|
||||||
|
var hasMipMapFlag = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(cachePath, FileMode.Open, FileAccess.Read);
|
||||||
|
using var reader = new BinaryReader(fs);
|
||||||
|
if (reader.ReadUInt32() == 0x20534444)
|
||||||
|
{
|
||||||
|
reader.BaseStream.Seek(4, SeekOrigin.Current);
|
||||||
|
var flags = reader.ReadUInt32();
|
||||||
|
hasMipMapFlag = (flags & 0x00020000) != 0;
|
||||||
|
|
||||||
|
reader.BaseStream.Seek(28, SeekOrigin.Begin);
|
||||||
|
mipMapCount = reader.ReadUInt32();
|
||||||
|
isValid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore read errors and regenerate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
return (cachePath, (!hasMipMapFlag || mipMapCount == 0) ? 1 : (int)mipMapCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(cachePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore deletion errors, maybe file is still locked or we have no permission.
|
||||||
|
// The pipeline will overwrite it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UnsafeArray<MipLevel> mipLevels = default;
|
||||||
|
var scheduler = EditorApplication.GetService<EngineCore>().JobScheduler;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (settings.Basic.TextureShape == TextureShape.TextureCube)
|
||||||
|
{
|
||||||
|
int maxCubeMips;
|
||||||
|
int edge;
|
||||||
|
UnsafeArray<float> baseCubeData;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
using var cubeSurface0 = new DisposablePtr<NvttCubeSurface>(NvttCubeSurface.Create());
|
||||||
|
using var mip0Surf = new DisposablePtr<NvttSurface>(NvttSurface.Create());
|
||||||
|
if (!mip0Surf.Get()->SetImageData(NvttInputFormat.NVTT_InputFormat_RGBA_32F, textureInfo.width, textureInfo.height, 1, (void*)textureInfo.pixelData, false, null))
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to set image data for cube map.");
|
||||||
|
}
|
||||||
|
|
||||||
|
cubeSurface0.Get()->Fold(mip0Surf.Get(), NvttCubeLayout.NVTT_CubeLayout_LatitudeLongitude);
|
||||||
|
edge = cubeSurface0.Get()->EdgeLength();
|
||||||
|
maxCubeMips = (int)Math.Floor(Math.Log2(edge)) + 1;
|
||||||
|
|
||||||
|
var pixelsPerFace = edge * edge;
|
||||||
|
var faceSize = pixelsPerFace * textureInfo.colorComponents;
|
||||||
|
baseCubeData = new UnsafeArray<float>(faceSize * 6, AllocationHandle.FreeList);
|
||||||
|
|
||||||
|
var channels = textureInfo.colorComponents;
|
||||||
|
var channelPtrs = stackalloc float*[channels];
|
||||||
|
for (var face = 0; face < 6; face++)
|
||||||
|
{
|
||||||
|
using var faceSurf = new DisposablePtr<NvttSurface>(cubeSurface0.Get()->Face(face));
|
||||||
|
|
||||||
|
// NVTT stores data in planar format: [RRRR...][GGGG...][BBBB...][AAAA...]
|
||||||
|
// We need to interleave into RGBARGBA... for our sampling code.
|
||||||
|
var pDst = (float*)baseCubeData.GetUnsafePtr() + face * faceSize;
|
||||||
|
|
||||||
|
for (var ch = 0; ch < channels; ch++)
|
||||||
|
{
|
||||||
|
channelPtrs[ch] = faceSurf.Get()->Channel(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var p = 0; p < pixelsPerFace; p++)
|
||||||
|
{
|
||||||
|
for (var ch = 0; ch < channels; ch++)
|
||||||
|
{
|
||||||
|
pDst[p * channels + ch] = channelPtrs[ch][p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = GenerateMipHDRI(scheduler, textureInfo, baseCubeData, edge, maxCubeMips, out mipLevels);
|
||||||
|
await scheduler.WaitAsync(handle, cancellationToken);
|
||||||
|
baseCubeData.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Wrapper<Result<int>>();
|
||||||
|
var nvttJob = new NvttPipelineJob(result, cachePath, textureInfo, settings, mipLevels);
|
||||||
|
var nvttJobHandle = scheduler.Schedule(in nvttJob);
|
||||||
|
await scheduler.WaitAsync(nvttJobHandle, cancellationToken);
|
||||||
|
|
||||||
|
if (result.Value.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(result.Value.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (cachePath, result.Value.Value);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (mipLevels.IsCreated)
|
||||||
|
{
|
||||||
|
var mipDisposeJob = new MipLevelDisposeJob
|
||||||
|
{
|
||||||
|
mipLevels = mipLevels,
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduler.Schedule(in mipDisposeJob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NvttFormat SelectFormat(TextureAssetSettings settings, bool isHDR)
|
||||||
|
=> isHDR
|
||||||
|
? NvttFormat.NVTT_Format_BC6U
|
||||||
|
: settings.Basic.TextureType switch
|
||||||
|
{
|
||||||
|
TextureType.Normal => NvttFormat.NVTT_Format_BC5, // RG normal map
|
||||||
|
TextureType.SingleChannel => NvttFormat.NVTT_Format_BC4, // single channel
|
||||||
|
TextureType.Lightmap => NvttFormat.NVTT_Format_BC6U, // HDR lightmap (unsigned)
|
||||||
|
_ => NvttFormat.NVTT_Format_BC7, // default color
|
||||||
|
};
|
||||||
|
|
||||||
|
private static NvttQuality SelectQuality(TextureCompressionLevel level)
|
||||||
|
=> level switch
|
||||||
|
{
|
||||||
|
TextureCompressionLevel.Low => NvttQuality.NVTT_Quality_Fastest,
|
||||||
|
TextureCompressionLevel.High => NvttQuality.NVTT_Quality_Production,
|
||||||
|
_ => NvttQuality.NVTT_Quality_Normal,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static NvttMipmapFilter SelectMipmapFilter(MipmapFilter filter)
|
||||||
|
=> filter switch
|
||||||
|
{
|
||||||
|
MipmapFilter.Box => NvttMipmapFilter.NVTT_MipmapFilter_Box,
|
||||||
|
MipmapFilter.Triangle => NvttMipmapFilter.NVTT_MipmapFilter_Triangle,
|
||||||
|
MipmapFilter.MitchellNetravali => NvttMipmapFilter.NVTT_MipmapFilter_Mitchell,
|
||||||
|
_ => NvttMipmapFilter.NVTT_MipmapFilter_Kaiser,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ulong ComputeSettingsHash(TextureAssetSettings settings)
|
||||||
|
{
|
||||||
|
var basicSize = Unsafe.SizeOf<TextureAssetSettings.BasicSettings>();
|
||||||
|
var advancedSize = Unsafe.SizeOf<TextureAssetSettings.AdvancedSettings>();
|
||||||
|
var samplerSize = Unsafe.SizeOf<TextureAssetSettings.SamplerSettings>();
|
||||||
|
var total = basicSize + advancedSize + samplerSize;
|
||||||
|
|
||||||
|
Span<byte> buf = stackalloc byte[total];
|
||||||
|
var basic = settings.Basic;
|
||||||
|
var advanced = settings.Advanced;
|
||||||
|
var sampler = settings.Sampler;
|
||||||
|
MemoryMarshal.Write(buf, in basic);
|
||||||
|
MemoryMarshal.Write(buf.Slice(basicSize), in advanced);
|
||||||
|
MemoryMarshal.Write(buf.Slice(basicSize + advancedSize), in sampler);
|
||||||
|
|
||||||
|
return XxHash64.HashToUInt64(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.GGXMip.cs
Normal file
371
src/Editor/Ghost.Editor.Core/Assets/TextureProcessor.GGXMip.cs
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using Misaki.HighPerformance.Mathematics.SPMD;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using static Misaki.HighPerformance.Mathematics.math;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
internal static partial class TextureProcessor
|
||||||
|
{
|
||||||
|
private const int _SAMPLE_COUNT = 1024;
|
||||||
|
|
||||||
|
private struct MipLevel
|
||||||
|
{
|
||||||
|
public UnsafeArray<float> data;
|
||||||
|
public int width;
|
||||||
|
public int height;
|
||||||
|
public int offset;
|
||||||
|
public float roughness;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe struct GGXMipGenerationJobSPMD<TFloat, TInt> : IJobParallelFor
|
||||||
|
where TFloat : unmanaged, ISPMDLane<TFloat, float>
|
||||||
|
where TInt : unmanaged, ISPMDLane<TInt, int>
|
||||||
|
{
|
||||||
|
public float* pImage;
|
||||||
|
public MipLevel* pMipLevels;
|
||||||
|
public float* pRadicalInverse_VdCLut;
|
||||||
|
public int imageWidth;
|
||||||
|
public int imageHeight;
|
||||||
|
public int numMipLevels;
|
||||||
|
public int channelCount;
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
|
private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut)
|
||||||
|
{
|
||||||
|
var x = i / N;
|
||||||
|
var y = TFloat.Load(lut + (int)i[0]);
|
||||||
|
return MathV.Create<TFloat, float>(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GGX Importance Sampling
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
|
private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness)
|
||||||
|
{
|
||||||
|
var a = roughness * roughness; // Disney remap roughness for better visual linearity
|
||||||
|
|
||||||
|
var phi = 2.0f * PI * Xi.x;
|
||||||
|
|
||||||
|
var cosTheta = TFloat.Sqrt((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y));
|
||||||
|
var sinTheta = TFloat.Sqrt(1.0f - cosTheta * cosTheta);
|
||||||
|
|
||||||
|
// Spherical to Cartesian coordinates (Halfway vector)
|
||||||
|
TFloat.SinCos(phi, out var sinPhi, out var cosPhi);
|
||||||
|
var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta);
|
||||||
|
|
||||||
|
// Tangent space to World space
|
||||||
|
var mask = TFloat.Abs(N.z) < 0.999f;
|
||||||
|
var up = MathV.Select(mask, MathV.Create<TFloat, float>(0.0f, 0.0f, 1.0f), MathV.Create<TFloat, float>(1.0f, 0.0f, 0.0f));
|
||||||
|
|
||||||
|
var tangent = MathV.Normalize(MathV.Cross(up, N));
|
||||||
|
var bitangent = MathV.Cross(N, tangent);
|
||||||
|
|
||||||
|
var sampleVec = (tangent * H.x) + (bitangent * H.y) + (N * H.z);
|
||||||
|
return MathV.Normalize(sampleVec);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
|
private static float3 CubemapUVToDir(int face, float u, float v)
|
||||||
|
{
|
||||||
|
var sc = 2.0f * u - 1.0f;
|
||||||
|
var tc = 1.0f - 2.0f * v;
|
||||||
|
|
||||||
|
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||||
|
switch (face)
|
||||||
|
{
|
||||||
|
case 0: x = 1.0f; y = tc; z = -sc; break;
|
||||||
|
case 1: x = -1.0f; y = tc; z = sc; break;
|
||||||
|
case 2: x = sc; y = 1.0f; z = -tc; break;
|
||||||
|
case 3: x = sc; y = -1.0f; z = tc; break;
|
||||||
|
case 4: x = sc; y = tc; z = 1.0f; break;
|
||||||
|
case 5: x = -sc; y = tc; z = -1.0f; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalize(float3(x, y, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
|
||||||
|
private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir)
|
||||||
|
{
|
||||||
|
var absX = TFloat.Abs(dir.x);
|
||||||
|
var absY = TFloat.Abs(dir.y);
|
||||||
|
var absZ = TFloat.Abs(dir.z);
|
||||||
|
|
||||||
|
var isXPos = dir.x >= TFloat.Zero;
|
||||||
|
var isYPos = dir.y >= TFloat.Zero;
|
||||||
|
var isZPos = dir.z >= TFloat.Zero;
|
||||||
|
|
||||||
|
var maxAxis = TFloat.Max(TFloat.Max(absX, absY), absZ);
|
||||||
|
|
||||||
|
var faceIndexF = TFloat.Select(maxAxis == absX,
|
||||||
|
TFloat.Select(isXPos, 0.0f, 1.0f),
|
||||||
|
TFloat.Select(maxAxis == absY,
|
||||||
|
TFloat.Select(isYPos, 2.0f, 3.0f),
|
||||||
|
TFloat.Select(isZPos, 4.0f, 5.0f)));
|
||||||
|
|
||||||
|
var faceIndex = faceIndexF.Cast<TInt, int>();
|
||||||
|
|
||||||
|
var sc = TFloat.Select(maxAxis == absX,
|
||||||
|
TFloat.Select(isXPos, -dir.z, dir.z),
|
||||||
|
TFloat.Select(maxAxis == absY,
|
||||||
|
dir.x,
|
||||||
|
TFloat.Select(isZPos, dir.x, -dir.x)));
|
||||||
|
|
||||||
|
var tc = TFloat.Select(maxAxis == absX,
|
||||||
|
dir.y,
|
||||||
|
TFloat.Select(maxAxis == absY,
|
||||||
|
TFloat.Select(isYPos, -dir.z, dir.z),
|
||||||
|
dir.y));
|
||||||
|
|
||||||
|
var u = 0.5f * (sc / maxAxis + 1.0f);
|
||||||
|
var v = 0.5f * (1.0f - tc / maxAxis);
|
||||||
|
|
||||||
|
var px = (u * (edge - 1.0f)).Cast<TInt, int>();
|
||||||
|
var py = (v * (edge - 1.0f)).Cast<TInt, int>();
|
||||||
|
|
||||||
|
px = TInt.Clamp(px, TInt.Zero, edge - 1);
|
||||||
|
py = TInt.Clamp(py, TInt.Zero, edge - 1);
|
||||||
|
|
||||||
|
var faceOffset = faceIndex * (edge * edge);
|
||||||
|
var idx = (faceOffset + py * edge + px) * c;
|
||||||
|
return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
|
||||||
|
public void Execute(int loopIndex, ref readonly JobExecutionContext ctx)
|
||||||
|
{
|
||||||
|
var m = 0;
|
||||||
|
while (m < numMipLevels - 1 && loopIndex >= pMipLevels[m + 1].offset)
|
||||||
|
{
|
||||||
|
m++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var span = new ReadOnlySpan<MipLevel>(pMipLevels, numMipLevels);
|
||||||
|
var pLevel = &pMipLevels[m];
|
||||||
|
|
||||||
|
var w = pLevel->width;
|
||||||
|
var data = pLevel->data;
|
||||||
|
|
||||||
|
var local_i = loopIndex - pLevel->offset;
|
||||||
|
|
||||||
|
var faceArea = w * w;
|
||||||
|
var face = local_i / faceArea;
|
||||||
|
var face_local_i = local_i % faceArea;
|
||||||
|
var x = face_local_i % w;
|
||||||
|
var y = face_local_i / w;
|
||||||
|
|
||||||
|
var u = (x + 0.5f) / w;
|
||||||
|
var v = (y + 0.5f) / w;
|
||||||
|
|
||||||
|
var N = CubemapUVToDir(face, u, v);
|
||||||
|
|
||||||
|
// For split-sum, we assume View and Reflection directions equal the Normal
|
||||||
|
var V = N;
|
||||||
|
var R = N;
|
||||||
|
|
||||||
|
var vN = MathV.Create<TFloat, float>(
|
||||||
|
TFloat.Create(N.x),
|
||||||
|
TFloat.Create(N.y),
|
||||||
|
TFloat.Create(N.z)
|
||||||
|
);
|
||||||
|
|
||||||
|
var vV = MathV.Create<TFloat, float>(
|
||||||
|
TFloat.Create(V.x),
|
||||||
|
TFloat.Create(V.y),
|
||||||
|
TFloat.Create(V.z)
|
||||||
|
);
|
||||||
|
|
||||||
|
var vPrefilteredColor = Vector3<TFloat, float>.Zero;
|
||||||
|
var vTotalWeight = TFloat.Zero;
|
||||||
|
|
||||||
|
// Monte Carlo Integration Loop
|
||||||
|
|
||||||
|
var vLuma = MathV.Create<TFloat, float>(0.2126f, 0.7152f, 0.0722f);
|
||||||
|
var dynamicSampleCount = (int)max(1.0f, _SAMPLE_COUNT * pLevel->roughness);
|
||||||
|
var dsc = TFloat.Create(dynamicSampleCount);
|
||||||
|
|
||||||
|
for (var i = 0; i < dynamicSampleCount; i += TFloat.LaneWidth)
|
||||||
|
{
|
||||||
|
var laneIndices = TFloat.Sequence(i, 1.0f);
|
||||||
|
var validLaneMask = laneIndices < dsc;
|
||||||
|
|
||||||
|
// Generate a Hammersley random sequence point
|
||||||
|
var Xi = Hammersley(laneIndices, dynamicSampleCount, pRadicalInverse_VdCLut);
|
||||||
|
|
||||||
|
// Get the halfway vector based on GGX NDF
|
||||||
|
var H = ImportanceSampleGGX(Xi, vN, pLevel->roughness);
|
||||||
|
|
||||||
|
// Calculate Light direction
|
||||||
|
var L = MathV.Reflect(-vV, H);
|
||||||
|
L = MathV.Normalize(L);
|
||||||
|
|
||||||
|
var NdotL = TFloat.Max(MathV.Dot(vN, L), TFloat.Zero);
|
||||||
|
var sampleColor = SampleCubemap(pImage, imageWidth, channelCount, L);
|
||||||
|
|
||||||
|
NdotL &= validLaneMask;
|
||||||
|
|
||||||
|
// The Karis Average Weight: 1 / (1 + luma)
|
||||||
|
// A normal sky pixel (luma 1.0) gets a weight of 0.5.
|
||||||
|
// A sun pixel (luma 1000.0) gets a tiny weight of ~0.001, naturally suppressing it.
|
||||||
|
// This introduce bias, but significantly reduces fireflies without needing solid angle sampling or cdf inversion.
|
||||||
|
// And since this is a mip generation step, a little bias is acceptable for much better performance and stability.
|
||||||
|
var luma = MathV.Dot(sampleColor, vLuma);
|
||||||
|
var fireflyWeight = TFloat.One / (TFloat.One + luma);
|
||||||
|
var finalWeight = NdotL * fireflyWeight;
|
||||||
|
|
||||||
|
vPrefilteredColor += sampleColor * finalWeight;
|
||||||
|
vTotalWeight += finalWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalWeight = 0.0f;
|
||||||
|
var prefilteredColor = float3(0.0f, 0.0f, 0.0f);
|
||||||
|
|
||||||
|
for (var i = 0; i < TFloat.LaneWidth; i++)
|
||||||
|
{
|
||||||
|
prefilteredColor.x += vPrefilteredColor.x[i];
|
||||||
|
prefilteredColor.y += vPrefilteredColor.y[i];
|
||||||
|
prefilteredColor.z += vPrefilteredColor.z[i];
|
||||||
|
totalWeight += vTotalWeight[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average the result
|
||||||
|
if (totalWeight > 0.0f)
|
||||||
|
{
|
||||||
|
prefilteredColor *= 1.0f / totalWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to output mip array
|
||||||
|
var out_idx = (face * (w * w) + y * w + x) * channelCount;
|
||||||
|
data[out_idx] = prefilteredColor.x;
|
||||||
|
data[out_idx + 1] = prefilteredColor.y;
|
||||||
|
data[out_idx + 2] = prefilteredColor.z;
|
||||||
|
if (channelCount == 4)
|
||||||
|
{
|
||||||
|
data[out_idx + 3] = 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VdCLutDisposeJob : IJob
|
||||||
|
{
|
||||||
|
public UnsafeArray<float> radicalInverse_VdCLut;
|
||||||
|
|
||||||
|
public void Execute(ref readonly JobExecutionContext ctx)
|
||||||
|
{
|
||||||
|
radicalInverse_VdCLut.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MipLevelDisposeJob : IJob
|
||||||
|
{
|
||||||
|
public UnsafeArray<MipLevel> mipLevels;
|
||||||
|
|
||||||
|
public void Execute(ref readonly JobExecutionContext ctx)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < mipLevels.Length; i++)
|
||||||
|
{
|
||||||
|
mipLevels[i].data.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
mipLevels.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static float RadicalInverse_VdC(uint bits)
|
||||||
|
{
|
||||||
|
bits = (bits << 16) | (bits >> 16);
|
||||||
|
bits = ((bits & 0x55555555u) << 1) | ((bits & 0xAAAAAAAAu) >> 1);
|
||||||
|
bits = ((bits & 0x33333333u) << 2) | ((bits & 0xCCCCCCCCu) >> 2);
|
||||||
|
bits = ((bits & 0x0F0F0F0Fu) << 4) | ((bits & 0xF0F0F0F0u) >> 4);
|
||||||
|
bits = ((bits & 0x00FF00FFu) << 8) | ((bits & 0xFF00FF00u) >> 8);
|
||||||
|
return bits * 2.3283064365386963e-10f; // bits / 0x100000000
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobHandle GenerateMipHDRI(JobScheduler scheduler, TextureAssetHandler.TextureInfo textureInfo, UnsafeArray<float> baseCubeData, int edge, int totalMipLevels, out UnsafeArray<MipLevel> mipLevels)
|
||||||
|
{
|
||||||
|
Logger.DebugAssert(textureInfo.isHDR, "GenerateMipHDRI should only be called for HDR textures.");
|
||||||
|
Logger.DebugAssert(textureInfo.colorComponents >= 3, "Texture must have at least 3 color components for RGB.");
|
||||||
|
|
||||||
|
mipLevels = new UnsafeArray<MipLevel>(totalMipLevels, AllocationHandle.FreeList);
|
||||||
|
var radicalInverse_VdCLut = new UnsafeArray<float>(_SAMPLE_COUNT, AllocationHandle.FreeList);
|
||||||
|
|
||||||
|
for (var i = 0u; i < _SAMPLE_COUNT; i++)
|
||||||
|
{
|
||||||
|
radicalInverse_VdCLut[i] = RadicalInverse_VdC(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
int w;
|
||||||
|
var totalPixel = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < totalMipLevels; i++)
|
||||||
|
{
|
||||||
|
w = Math.Max(1, edge >> i);
|
||||||
|
|
||||||
|
mipLevels[i] = new MipLevel
|
||||||
|
{
|
||||||
|
data = new UnsafeArray<float>(w * w * 6 * textureInfo.colorComponents, AllocationHandle.FreeList),
|
||||||
|
width = w,
|
||||||
|
height = w,
|
||||||
|
offset = totalPixel,
|
||||||
|
roughness = (float)i / (totalMipLevels - 1) // Linear roughness from 0 to 1 across mip levels
|
||||||
|
};
|
||||||
|
|
||||||
|
totalPixel += w * w * 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
JobHandle handle;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if (WideLane.IsSupported)
|
||||||
|
{
|
||||||
|
var job = new GGXMipGenerationJobSPMD<WideLane<float>, WideLane<int>>
|
||||||
|
{
|
||||||
|
pImage = (float*)baseCubeData.GetUnsafePtr(),
|
||||||
|
pMipLevels = (MipLevel*)mipLevels.GetUnsafePtr(),
|
||||||
|
pRadicalInverse_VdCLut = (float*)radicalInverse_VdCLut.GetUnsafePtr(),
|
||||||
|
imageWidth = edge,
|
||||||
|
imageHeight = edge,
|
||||||
|
numMipLevels = totalMipLevels,
|
||||||
|
channelCount = textureInfo.colorComponents,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = scheduler.ScheduleParallelFor(in job, totalPixel, 64);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var job = new GGXMipGenerationJobSPMD<ScalarLane<float>, ScalarLane<int>>
|
||||||
|
{
|
||||||
|
pImage = (float*)baseCubeData.GetUnsafePtr(),
|
||||||
|
pMipLevels = (MipLevel*)mipLevels.GetUnsafePtr(),
|
||||||
|
pRadicalInverse_VdCLut = (float*)radicalInverse_VdCLut.GetUnsafePtr(),
|
||||||
|
imageWidth = edge,
|
||||||
|
imageHeight = edge,
|
||||||
|
numMipLevels = totalMipLevels,
|
||||||
|
channelCount = textureInfo.colorComponents,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = scheduler.ScheduleParallelFor(in job, totalPixel, 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handle.IsValid)
|
||||||
|
{
|
||||||
|
return JobHandle.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disposeJob = new VdCLutDisposeJob
|
||||||
|
{
|
||||||
|
radicalInverse_VdCLut = radicalInverse_VdCLut
|
||||||
|
};
|
||||||
|
|
||||||
|
var disposeHandle = scheduler.Schedule(in disposeJob, handle);
|
||||||
|
Logger.DebugAssert(disposeHandle.IsValid, "Dispose job handle is invalid.");
|
||||||
|
|
||||||
|
return disposeHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,35 +5,6 @@ namespace Ghost.Editor.Core;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class DiscoverableAttributeBase : Attribute;
|
public abstract class DiscoverableAttributeBase : Attribute;
|
||||||
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Method)]
|
|
||||||
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
|
||||||
public string[] Extensions
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AssetOpenHandlerAttribute(params string[] extensions)
|
|
||||||
{
|
|
||||||
Extensions = extensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : '.' + e.ToLowerInvariant()).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
|
||||||
internal class AssetImporterAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
|
||||||
public string[] SupportedExtensions
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AssetImporterAttribute(params string[] supportedExtensions)
|
|
||||||
{
|
|
||||||
SupportedExtensions = supportedExtensions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class)]
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
public class CustomEditorAttribute : DiscoverableAttributeBase
|
public class CustomEditorAttribute : DiscoverableAttributeBase
|
||||||
{
|
{
|
||||||
@@ -48,30 +19,16 @@ public class CustomEditorAttribute : DiscoverableAttributeBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)]
|
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
|
||||||
public class EditorInjectionAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
{
|
||||||
public enum ServiceLifetime
|
internal string[] Extensions
|
||||||
{
|
|
||||||
Singleton,
|
|
||||||
Transient,
|
|
||||||
Scoped
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceLifetime Lifetime
|
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Type? ImplementationType
|
public AssetOpenHandlerAttribute(params string[] extensions)
|
||||||
{
|
{
|
||||||
get;
|
Extensions = extensions;
|
||||||
}
|
|
||||||
|
|
||||||
public EditorInjectionAttribute(ServiceLifetime lifetime, Type? implementationType = null)
|
|
||||||
{
|
|
||||||
Lifetime = lifetime;
|
|
||||||
ImplementationType = implementationType;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.AssetHandler;
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Contracts;
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
@@ -39,11 +40,25 @@ public sealed class AssetChangedEventArgs : EventArgs
|
|||||||
|
|
||||||
public interface IAssetRegistry : IDisposable
|
public interface IAssetRegistry : IDisposable
|
||||||
{
|
{
|
||||||
|
event EventHandler<AssetChangedEventArgs>? OnAssetChanged;
|
||||||
|
event EventHandler<Guid>? OnAssetImported;
|
||||||
|
|
||||||
|
AssetCatalog GetAssetCatalog();
|
||||||
|
|
||||||
string? GetAssetPath(Guid id);
|
string? GetAssetPath(Guid id);
|
||||||
Guid GetAssetGuid(string assetPath);
|
Guid GetAssetGuid(string assetPath);
|
||||||
|
|
||||||
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
|
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
|
||||||
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
|
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
|
||||||
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default);
|
ValueTask<Result<IAsset>> LoadAssetAsync(Guid id, CancellationToken token = default);
|
||||||
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default);
|
ValueTask<Result> SaveAssetAsync(IAsset asset, CancellationToken token = default);
|
||||||
|
ValueTask<Result> SaveAssetAsync(Guid id, CancellationToken token = default);
|
||||||
|
|
||||||
|
void SetAssetDirty(Guid id);
|
||||||
|
ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default);
|
||||||
|
ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default);
|
||||||
|
ValueTask<Result[]> SaveDirtyAssetsAsync();
|
||||||
|
|
||||||
|
Task<Result> OpenAssetAsync(Guid id);
|
||||||
|
Task<Result> OpenAssetAsync(string assetPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,8 @@ public interface IInspectable
|
|||||||
|
|
||||||
UIElement? CreateHeader();
|
UIElement? CreateHeader();
|
||||||
|
|
||||||
UIElement? CreateInspector();
|
IInspectorModel CreateInspectorModel();
|
||||||
|
|
||||||
|
// void OnSelected();
|
||||||
|
// void OnDeselected();
|
||||||
}
|
}
|
||||||
26
src/Editor/Ghost.Editor.Core/Contracts/IInspectorModel.cs
Normal file
26
src/Editor/Ghost.Editor.Core/Contracts/IInspectorModel.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an active model for an object being inspected.
|
||||||
|
/// Responsible for generating its own UI.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInspectorModel : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generate the UI element that represents the body of the inspector for this model.
|
||||||
|
/// </summary>
|
||||||
|
UIElement BuildUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An inspector model that requires continuous synchronization (e.g. per-frame updates).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISyncableInspectorModel : IInspectorModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Called per-frame to sync data (e.g. from ECS to UI and back).
|
||||||
|
/// </summary>
|
||||||
|
void Sync();
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IShaderCompiler : IDisposable
|
||||||
|
{
|
||||||
|
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public sealed partial class PropertyField : ContentControl
|
public sealed partial class PropertyField : ContentControl
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<Type, DependencyProperty> _valueProperties = new()
|
private static readonly Dictionary<Type, DependencyProperty> s_valueProperties = new()
|
||||||
{
|
{
|
||||||
{ typeof(TextBox), TextBox.TextProperty },
|
{ typeof(TextBox), TextBox.TextProperty },
|
||||||
{ typeof(NumberBox), NumberBox.ValueProperty },
|
{ typeof(NumberBox), NumberBox.ValueProperty },
|
||||||
@@ -39,6 +39,18 @@ public sealed partial class PropertyField : ContentControl
|
|||||||
typeof(PropertyField),
|
typeof(PropertyField),
|
||||||
new PropertyMetadata(default(string)));
|
new PropertyMetadata(default(string)));
|
||||||
|
|
||||||
|
public bool IsEditable
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsEditableProperty);
|
||||||
|
set => SetValue(IsEditableProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsEditableProperty = DependencyProperty.Register(
|
||||||
|
nameof(IsEditable),
|
||||||
|
typeof(bool),
|
||||||
|
typeof(PropertyField),
|
||||||
|
new PropertyMetadata(true));
|
||||||
|
|
||||||
public PropertyField()
|
public PropertyField()
|
||||||
{
|
{
|
||||||
DefaultStyleKey = typeof(PropertyField);
|
DefaultStyleKey = typeof(PropertyField);
|
||||||
@@ -48,7 +60,7 @@ public sealed partial class PropertyField : ContentControl
|
|||||||
{
|
{
|
||||||
while (fieldType != null)
|
while (fieldType != null)
|
||||||
{
|
{
|
||||||
if (_valueProperties.TryGetValue(fieldType, out var dp))
|
if (s_valueProperties.TryGetValue(fieldType, out var dp))
|
||||||
{
|
{
|
||||||
return dp;
|
return dp;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,22 @@
|
|||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="local:PropertyField">
|
<ControlTemplate TargetType="local:PropertyField">
|
||||||
<Grid Height="32" Margin="2,4">
|
<StackPanel Margin="2,4" Spacing="4">
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="125" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="0,0,0,4"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Style="{StaticResource BodyTextBlockStyle}"
|
Style="{StaticResource BodyTextBlockStyle}"
|
||||||
Text="{TemplateBinding Label}"
|
Text="{TemplateBinding Label}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<ContentPresenter
|
<ContentControl
|
||||||
Grid.Column="1"
|
Margin="2,0,0,0"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
Content="{TemplateBinding Content}"
|
Content="{TemplateBinding Content}"
|
||||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
</Grid>
|
IsEnabled="{TemplateBinding IsEditable}" />
|
||||||
|
</StackPanel>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public partial class ControlsDictionary : ResourceDictionary
|
public partial class ControlsDictionary : ResourceDictionary
|
||||||
{
|
{
|
||||||
private const string _DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
private const string DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
||||||
|
|
||||||
public ControlsDictionary()
|
public ControlsDictionary()
|
||||||
{
|
{
|
||||||
Source = new Uri(_DICTIONARY_PATH, UriKind.Absolute);
|
Source = new Uri(DICTIONARY_PATH, UriKind.Absolute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,5 @@
|
|||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml" />
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml" />
|
||||||
|
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/Internal/ComponentView.xaml" />
|
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
using Ghost.Editor.Core.Inspector;
|
|
||||||
using Ghost.Editor.Core.Resources;
|
|
||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using Ghost.Entities;
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls;
|
|
||||||
|
|
||||||
internal sealed unsafe partial class ComponentView : Control
|
|
||||||
{
|
|
||||||
private delegate void EditorUpdate();
|
|
||||||
|
|
||||||
private StackPanel? _contentContainer;
|
|
||||||
|
|
||||||
private readonly World? _world;
|
|
||||||
private readonly Entity _entity = Entity.Invalid;
|
|
||||||
private readonly Type? _componentType;
|
|
||||||
private readonly ComponentInfo _componentInfo;
|
|
||||||
|
|
||||||
private object? _managedInstance;
|
|
||||||
private void* _pComponentData;
|
|
||||||
|
|
||||||
private ComponentEditor? _customEditor;
|
|
||||||
private PropertyField[]? _propertyFields;
|
|
||||||
private EditorUpdate? _editorUpdate;
|
|
||||||
|
|
||||||
public string HeaderText
|
|
||||||
{
|
|
||||||
get => (string)GetValue(HeaderTextProperty);
|
|
||||||
set => SetValue(HeaderTextProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly DependencyProperty HeaderTextProperty =
|
|
||||||
DependencyProperty.Register(nameof(HeaderText), typeof(string), typeof(ComponentView), new PropertyMetadata(string.Empty));
|
|
||||||
|
|
||||||
internal ComponentView()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(ComponentView);
|
|
||||||
|
|
||||||
Unloaded += (s, e) =>
|
|
||||||
{
|
|
||||||
_customEditor?.Destroy();
|
|
||||||
|
|
||||||
_contentContainer = null;
|
|
||||||
_customEditor = null;
|
|
||||||
_propertyFields = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public ComponentView(string header, World world, Entity entity, Type componentType) : this()
|
|
||||||
{
|
|
||||||
HeaderText = header;
|
|
||||||
|
|
||||||
_world = world;
|
|
||||||
_entity = entity;
|
|
||||||
_componentType = componentType;
|
|
||||||
_componentInfo = ComponentRegistry.GetComponentInfo(componentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
_contentContainer = (StackPanel)GetTemplateChild("ContentContainer");
|
|
||||||
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
ReBuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReflectionUpdate()
|
|
||||||
{
|
|
||||||
if (_propertyFields == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var propertyField in _propertyFields)
|
|
||||||
{
|
|
||||||
propertyField.UpdateValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CustomEditorUpdate()
|
|
||||||
{
|
|
||||||
_customEditor?.Update();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ReBuild()
|
|
||||||
{
|
|
||||||
if (_contentContainer == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_contentContainer.Children.Clear();
|
|
||||||
if (_world == null || _componentType == null || _entity == Entity.Invalid)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_propertyFields != null)
|
|
||||||
{
|
|
||||||
foreach (var propertyField in _propertyFields)
|
|
||||||
{
|
|
||||||
propertyField.OnValueChanged -= OnPropertyValueChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var componentObject = new ComponentObject(_world, _entity);
|
|
||||||
var editorType = TypeCache.GetTypes().FirstOrDefault(t =>
|
|
||||||
typeof(ComponentEditor).IsAssignableFrom(t) &&
|
|
||||||
t.GetCustomAttribute<CustomEditorAttribute>()?.TargetType.IsAssignableFrom(_componentType) == true);
|
|
||||||
|
|
||||||
if (editorType != null)
|
|
||||||
{
|
|
||||||
_customEditor = (ComponentEditor)Activator.CreateInstance(editorType)!;
|
|
||||||
_customEditor.Initialize(componentObject);
|
|
||||||
_customEditor.Create(_contentContainer);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var fields = _componentType.GetFields(StaticResource.ComponentPropertyBindingFlags);
|
|
||||||
_propertyFields = new PropertyField[fields.Length];
|
|
||||||
|
|
||||||
_pComponentData = _world.EntityManager.GetComponent(_entity, _componentInfo.id);
|
|
||||||
_managedInstance = Marshal.PtrToStructure((nint)_pComponentData, _componentType);
|
|
||||||
if (_managedInstance == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < fields.Length; i++)
|
|
||||||
{
|
|
||||||
var field = fields[i];
|
|
||||||
var propertyField = PropertyField.Create(field.Name, field, _managedInstance);
|
|
||||||
propertyField.OnValueChanged += OnPropertyValueChanged;
|
|
||||||
|
|
||||||
_propertyFields[i] = propertyField;
|
|
||||||
_contentContainer.Children.Add(propertyField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_editorUpdate = _customEditor == null ? ReflectionUpdate : CustomEditorUpdate;
|
|
||||||
_editorUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPropertyValueChanged(PropertyField field)
|
|
||||||
{
|
|
||||||
if (_managedInstance == null || _pComponentData == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Marshal.StructureToPtr(_managedInstance, (nint)_pComponentData, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.Core.Controls">
|
|
||||||
|
|
||||||
<Style TargetType="local:ComponentView">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:ComponentView">
|
|
||||||
<StackPanel Margin="0,0,0,16">
|
|
||||||
<Border
|
|
||||||
Padding="8"
|
|
||||||
HorizontalAlignment="Stretch"
|
|
||||||
Background="{ThemeResource SolidBackgroundFillColorSecondaryBrush}">
|
|
||||||
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{TemplateBinding HeaderText}" />
|
|
||||||
</Border>
|
|
||||||
<StackPanel
|
|
||||||
x:Name="ContentContainer"
|
|
||||||
Margin="8,2,2,0"
|
|
||||||
Spacing="2" />
|
|
||||||
</StackPanel>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
@@ -48,7 +48,7 @@ public sealed partial class ContextFlyout : MenuFlyout
|
|||||||
Opening += ContextFlyout_Opening;
|
Opening += ContextFlyout_Opening;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively sorts nodes and calculates folder groups
|
// Recursively sorts nodes and calculates folder pGroups
|
||||||
private static void PrepareNodes(List<MenuNode> nodes)
|
private static void PrepareNodes(List<MenuNode> nodes)
|
||||||
{
|
{
|
||||||
if (nodes.Count == 0)
|
if (nodes.Count == 0)
|
||||||
|
|||||||
32
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml
Normal file
32
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="Ghost.Editor.Core.Controls.ReferenceField"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
AllowDrop="{x:Bind AllowDrop, Mode=OneWay}"
|
||||||
|
DragOver="OnDragOver"
|
||||||
|
Drop="OnDrop">
|
||||||
|
|
||||||
|
<Grid CornerRadius="4" BorderThickness="1" x:Name="RootBorder" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<FontIcon Grid.Column="0" Margin="8,0,0,0" Glyph="{x:Bind IconGlyph, Mode=OneWay}" FontSize="12" Foreground="{ThemeResource TextFillColorSecondaryBrush}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Margin="8,4,8,4" Text="{x:Bind DisplayText, Mode=OneWay}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" FontSize="12" Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||||
|
|
||||||
|
<Button Grid.Column="2" x:Name="GotoButton" Margin="0,0,4,0" Padding="4" Background="Transparent" BorderThickness="0" Click="OnGotoButtonClicked" Visibility="Collapsed" VerticalAlignment="Center">
|
||||||
|
<FontIcon Glyph="" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" x:Name="ClearButton" Margin="0,0,4,0" Padding="4" Background="Transparent" BorderThickness="0" Click="OnClearButtonClicked" Visibility="Collapsed" VerticalAlignment="Center">
|
||||||
|
<FontIcon Glyph="" FontSize="10" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
158
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml.cs
Normal file
158
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public sealed partial class ReferenceField : UserControl
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty DisplayTextProperty =
|
||||||
|
DependencyProperty.Register(nameof(DisplayText), typeof(string), typeof(ReferenceField), new PropertyMetadata(string.Empty, OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty TypeLabelProperty =
|
||||||
|
DependencyProperty.Register(nameof(TypeLabel), typeof(string), typeof(ReferenceField), new PropertyMetadata("Object", OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IconGlyphProperty =
|
||||||
|
DependencyProperty.Register(nameof(IconGlyph), typeof(string), typeof(ReferenceField), new PropertyMetadata("\uEA86"));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty HasValueProperty =
|
||||||
|
DependencyProperty.Register(nameof(HasValue), typeof(bool), typeof(ReferenceField), new PropertyMetadata(false, OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsReadOnlyProperty =
|
||||||
|
DependencyProperty.Register(nameof(IsReadOnly), typeof(bool), typeof(ReferenceField), new PropertyMetadata(false, OnStateChanged));
|
||||||
|
|
||||||
|
public string DisplayText
|
||||||
|
{
|
||||||
|
get => (string)GetValue(DisplayTextProperty);
|
||||||
|
set => SetValue(DisplayTextProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TypeLabel
|
||||||
|
{
|
||||||
|
get => (string)GetValue(TypeLabelProperty);
|
||||||
|
set => SetValue(TypeLabelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string IconGlyph
|
||||||
|
{
|
||||||
|
get => (string)GetValue(IconGlyphProperty);
|
||||||
|
set => SetValue(IconGlyphProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasValue
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(HasValueProperty);
|
||||||
|
set => SetValue(HasValueProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadOnly
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsReadOnlyProperty);
|
||||||
|
set => SetValue(IsReadOnlyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Func<DragEventArgs, bool>? ValidateDrop;
|
||||||
|
public Action<DragEventArgs>? OnDropAccepted;
|
||||||
|
public Action? OnClearClicked;
|
||||||
|
public Action? OnGotoClicked;
|
||||||
|
|
||||||
|
private readonly SolidColorBrush _accentBrush;
|
||||||
|
private readonly SolidColorBrush _errorBrush;
|
||||||
|
private readonly SolidColorBrush _defaultBorderBrush;
|
||||||
|
|
||||||
|
public ReferenceField()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_accentBrush = (SolidColorBrush)Application.Current.Resources["SystemControlHighlightAccentBrush"];
|
||||||
|
_errorBrush = new SolidColorBrush(Microsoft.UI.Colors.Red);
|
||||||
|
_defaultBorderBrush = (SolidColorBrush)Application.Current.Resources["CardStrokeColorDefaultBrush"];
|
||||||
|
|
||||||
|
UpdateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
((ReferenceField)d).UpdateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateState()
|
||||||
|
{
|
||||||
|
if (HasValue)
|
||||||
|
{
|
||||||
|
ClearButton.Visibility = IsReadOnly ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
GotoButton.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ClearButton.Visibility = Visibility.Collapsed;
|
||||||
|
GotoButton.Visibility = Visibility.Collapsed;
|
||||||
|
if (string.IsNullOrEmpty(DisplayText))
|
||||||
|
{
|
||||||
|
// We shouldn't change DependencyProperty value here to avoid loops,
|
||||||
|
// but we can bind a different text if needed. For now, rely on caller to set DisplayText to "None (Type)".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AllowDrop = !IsReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid = ValidateDrop?.Invoke(e) ?? false;
|
||||||
|
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.Link;
|
||||||
|
RootBorder.BorderBrush = _accentBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.None;
|
||||||
|
// Optionally set error brush
|
||||||
|
RootBorder.BorderBrush = _errorBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDragLeave(DragEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDragLeave(e);
|
||||||
|
RootBorder.BorderBrush = _defaultBorderBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
RootBorder.BorderBrush = _defaultBorderBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
|
||||||
|
if (IsReadOnly) return;
|
||||||
|
|
||||||
|
var isValid = ValidateDrop?.Invoke(e) ?? false;
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
OnDropAccepted?.Invoke(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClearButtonClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OnClearClicked?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGotoButtonClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OnGotoClicked?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,15 @@ public partial class ValueControl<T> : Control
|
|||||||
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the value of the control.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new value to set.</param>
|
||||||
|
public void SetValue(T value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the _value without notifying the change event.
|
/// Sets the _value without notifying the change event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core;
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
public static class EditorApplication
|
public static class EditorApplication
|
||||||
{
|
{
|
||||||
public const string ASSETS_FOLDER_NAME = "Assets";
|
public const string ASSETS_FOLDER_NAME = "Assets";
|
||||||
public const string SOURCES_FOLDER_NAME = "Sources";
|
|
||||||
public const string PACKAGES_FOLDER_NAME = "Packages";
|
public const string PACKAGES_FOLDER_NAME = "Packages";
|
||||||
public const string CACHES_FOLDER_NAME = "Caches";
|
public const string LIBRARY_FOLDER_NAME = "Library";
|
||||||
|
public const string CACHE_FOLDER_NAME = "Cache";
|
||||||
public const string CONFIG_FOLDER_NAME = "Config";
|
public const string CONFIG_FOLDER_NAME = "Config";
|
||||||
|
|
||||||
|
public const string IMPORTS_FOLDER_NAME = "Imports";
|
||||||
|
|
||||||
private static IServiceProvider? s_serviceProvider;
|
private static IServiceProvider? s_serviceProvider;
|
||||||
|
|
||||||
private static string s_currentProjectPath = string.Empty;
|
private static string s_currentProjectPath = string.Empty;
|
||||||
private static string s_currentProjectName = string.Empty;
|
private static string s_currentProjectName = string.Empty;
|
||||||
|
|
||||||
|
private static string s_assetsFolderPath = string.Empty;
|
||||||
|
private static string s_packagesFolderPath = string.Empty;
|
||||||
|
private static string s_libraryFolderPath = string.Empty;
|
||||||
|
private static string s_cacheFolderPath = string.Empty;
|
||||||
|
private static string s_configFolderPath = string.Empty;
|
||||||
|
|
||||||
|
private static string s_libraryImportsFolderPath = string.Empty;
|
||||||
|
|
||||||
private static DispatcherQueue? s_dispatcherQueue;
|
private static DispatcherQueue? s_dispatcherQueue;
|
||||||
|
|
||||||
internal static Application CurrentApplication => Application.Current;
|
internal static Application CurrentApplication => Application.Current;
|
||||||
@@ -22,11 +35,12 @@ public static class EditorApplication
|
|||||||
public static string ProjectPath => s_currentProjectPath;
|
public static string ProjectPath => s_currentProjectPath;
|
||||||
public static string ProjectName => s_currentProjectName;
|
public static string ProjectName => s_currentProjectName;
|
||||||
|
|
||||||
public static string AssetsFolderPath => Path.Combine(ProjectPath, ASSETS_FOLDER_NAME);
|
public static string AssetsFolderPath => s_assetsFolderPath;
|
||||||
public static string SourcesFolderPath => Path.Combine(ProjectPath, SOURCES_FOLDER_NAME);
|
public static string PackagesFolderPath => s_packagesFolderPath;
|
||||||
public static string PackagesFolderPath => Path.Combine(ProjectPath, PACKAGES_FOLDER_NAME);
|
public static string LibraryFolderPath => s_libraryFolderPath;
|
||||||
public static string CachesFolderPath => Path.Combine(ProjectPath, CACHES_FOLDER_NAME);
|
public static string ConfigFolderPath => s_configFolderPath;
|
||||||
public static string ConfigFolderPath => Path.Combine(ProjectPath, CONFIG_FOLDER_NAME);
|
public static string CacheFolderPath => s_cacheFolderPath;
|
||||||
|
public static string LibraryImportsFolderPath => s_libraryImportsFolderPath;
|
||||||
|
|
||||||
public static DispatcherQueue DispatcherQueue
|
public static DispatcherQueue DispatcherQueue
|
||||||
{
|
{
|
||||||
@@ -43,9 +57,29 @@ public static class EditorApplication
|
|||||||
|
|
||||||
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
||||||
{
|
{
|
||||||
|
projectPath = PathUtility.Normalize(projectPath);
|
||||||
|
|
||||||
|
Environment.CurrentDirectory = projectPath;
|
||||||
|
|
||||||
s_serviceProvider = serviceProvider;
|
s_serviceProvider = serviceProvider;
|
||||||
s_currentProjectPath = projectPath;
|
s_currentProjectPath = projectPath;
|
||||||
s_currentProjectName = projectName;
|
s_currentProjectName = projectName;
|
||||||
|
|
||||||
|
s_assetsFolderPath = Path.Combine(projectPath, ASSETS_FOLDER_NAME);
|
||||||
|
s_packagesFolderPath = Path.Combine(projectPath, PACKAGES_FOLDER_NAME);
|
||||||
|
s_libraryFolderPath = Path.Combine(projectPath, LIBRARY_FOLDER_NAME);
|
||||||
|
s_configFolderPath = Path.Combine(projectPath, CONFIG_FOLDER_NAME);
|
||||||
|
s_cacheFolderPath = Path.Combine(projectPath, CACHE_FOLDER_NAME);
|
||||||
|
|
||||||
|
s_libraryImportsFolderPath = Path.Combine(s_libraryFolderPath, IMPORTS_FOLDER_NAME);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(s_assetsFolderPath);
|
||||||
|
Directory.CreateDirectory(s_packagesFolderPath);
|
||||||
|
Directory.CreateDirectory(s_libraryFolderPath);
|
||||||
|
Directory.CreateDirectory(s_configFolderPath);
|
||||||
|
Directory.CreateDirectory(s_cacheFolderPath);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(s_libraryImportsFolderPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void SetDispatcherQueue(DispatcherQueue dispatcherQueue)
|
internal static void SetDispatcherQueue(DispatcherQueue dispatcherQueue)
|
||||||
@@ -56,12 +90,25 @@ public static class EditorApplication
|
|||||||
public static T GetService<T>()
|
public static T GetService<T>()
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
if (s_serviceProvider?.GetService(typeof(T)) is not T service)
|
if (TryGetService<T>(out var service))
|
||||||
{
|
{
|
||||||
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices.");
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
return service;
|
throw new ArgumentException("Requested service of type " + typeof(T).FullName + " is not registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetService<T>([NotNullWhen(true)] out T? service)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (s_serviceProvider?.GetService(typeof(T)) is T resolvedService)
|
||||||
|
{
|
||||||
|
service = resolvedService;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
service = null;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void Shutdown()
|
internal static void Shutdown()
|
||||||
|
|||||||
@@ -3,19 +3,32 @@
|
|||||||
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
|
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
|
||||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||||
<RootNamespace>Ghost.Editor.Core</RootNamespace>
|
<RootNamespace>Ghost.Editor.Core</RootNamespace>
|
||||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
<Platforms>x64;ARM64</Platforms>
|
||||||
|
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||||
<UseWinUI>true</UseWinUI>
|
<UseWinUI>true</UseWinUI>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages -->
|
<NoWarn>$(NoWarn);MVVMTK0050</NoWarn>
|
||||||
<langversion>preview</langversion>
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|x64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|ARM64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|x64'" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'" />
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
|
<Content Remove="Assets\MeshNode.cs" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" />
|
</ItemGroup>
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentIcons.WinUI" Version="2.1.328" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -23,7 +36,12 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
|
<ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
|
<ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Runtime\Ghost.Generator\Ghost.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
|
<ProjectReference Include="..\..\ThridParty\Ghost.DXC\Ghost.DXC.csproj" />
|
||||||
<ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.csproj" />
|
<ProjectReference Include="..\..\ThridParty\Ghost.Nvtt\Ghost.Nvtt.csproj" />
|
||||||
|
<ProjectReference Include="..\..\ThridParty\Ghost.Ufbx\Ghost.Ufbx.csproj" />
|
||||||
|
<ProjectReference Include="..\..\ThridParty\Ghost.StbI\Ghost.StbI.csproj" />
|
||||||
|
<ProjectReference Include="..\Ghost.DSL\Ghost.DSL.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -33,8 +51,5 @@
|
|||||||
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
</Page>
|
</Page>
|
||||||
<Page Update="Controls\Internal\ComponentView.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</Page>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata for an entire ECS component type, including all its editable fields.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ComponentDescriptor
|
||||||
|
{
|
||||||
|
public Type ComponentType { get; }
|
||||||
|
public Identifier<IComponent> ComponentId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public int Size { get; }
|
||||||
|
public bool IsShared { get; }
|
||||||
|
public PropertyDescriptor[] Properties { get; }
|
||||||
|
|
||||||
|
private ComponentDescriptor(Type componentType, Identifier<IComponent> componentId, string displayName, int size, bool isShared, PropertyDescriptor[] properties)
|
||||||
|
{
|
||||||
|
ComponentType = componentType;
|
||||||
|
ComponentId = componentId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
Size = size;
|
||||||
|
IsShared = isShared;
|
||||||
|
Properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentDescriptor Create(Type componentType)
|
||||||
|
{
|
||||||
|
var componentId = ComponentRegistry.GetComponentID(componentType);
|
||||||
|
var info = ComponentRegistry.GetComponentInfo(componentId);
|
||||||
|
|
||||||
|
var nameAttr = componentType.GetCustomAttribute<InspectorNameAttribute>();
|
||||||
|
var displayName = nameAttr?.Name ?? componentType.Name;
|
||||||
|
|
||||||
|
var properties = new List<PropertyDescriptor>();
|
||||||
|
var fields = componentType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
if (field.GetCustomAttribute<HideInInspectorAttribute>() != null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Exclude internal/private fields unless they have a specific attribute, but for now we just show public or specifically included.
|
||||||
|
if (!field.IsPublic && field.GetCustomAttribute<InspectorNameAttribute>() == null)
|
||||||
|
{
|
||||||
|
// In GhostEngine we often use public fields for component data, or private fields with [InspectorName].
|
||||||
|
// We'll just include public fields by default, and any non-public with specific attributes.
|
||||||
|
if (field.GetCustomAttribute<ReadOnlyInInspectorAttribute>() == null &&
|
||||||
|
field.GetCustomAttribute<InspectorGroupAttribute>() == null)
|
||||||
|
{
|
||||||
|
continue; // Skip normal private fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
properties.Add(new PropertyDescriptor(field, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ComponentDescriptor(componentType, componentId, displayName, info.size, info.isShared, properties.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe cache of ComponentDescriptor per component type.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentDescriptorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<nint, ComponentDescriptor> s_cache = new();
|
||||||
|
private static readonly Lock s_lock = new();
|
||||||
|
|
||||||
|
public static ComponentDescriptor GetOrCreate(Type componentType)
|
||||||
|
{
|
||||||
|
var handle = componentType.TypeHandle.Value;
|
||||||
|
|
||||||
|
lock (s_lock)
|
||||||
|
{
|
||||||
|
if (s_cache.TryGetValue(handle, out var descriptor))
|
||||||
|
{
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptor = ComponentDescriptor.Create(componentType);
|
||||||
|
s_cache[handle] = descriptor;
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentDescriptor GetOrCreate(Identifier<IComponent> componentId)
|
||||||
|
{
|
||||||
|
#if DEBUG || GHOST_EDITOR
|
||||||
|
if (ComponentRegistry.s_runtimeIDToType.TryGetValue(componentId.Value, out var type))
|
||||||
|
{
|
||||||
|
return GetOrCreate(type);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
throw new InvalidOperationException($"Cannot resolve ComponentDescriptor for component ID {componentId.Value}. Type mapping not available.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,48 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Inspector;
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
public abstract class ComponentEditor
|
public abstract class ComponentEditor
|
||||||
{
|
{
|
||||||
private ComponentObject _componentObject;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the underlying component object used by this class to manage its functionality.
|
/// Represents the underlying component object used by this class to manage its functionality.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected ComponentObject ComponentObject => _componentObject;
|
private readonly List<IPropertyBinding> _bindings = new();
|
||||||
|
|
||||||
|
protected ComponentObject ComponentObject { get; private set; }
|
||||||
|
|
||||||
internal void Initialize(ComponentObject componentObject)
|
internal void Initialize(ComponentObject componentObject)
|
||||||
{
|
{
|
||||||
_componentObject = componentObject;
|
ComponentObject = componentObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Declarative two-way binding. Replaces manual Update().
|
||||||
|
/// </summary>
|
||||||
|
protected void Bind<T>(
|
||||||
|
ValueControl<T> control,
|
||||||
|
Func<ComponentObject, T> getter,
|
||||||
|
Action<ComponentObject, T> setter)
|
||||||
|
{
|
||||||
|
var binding = new PropertyBinding<T>(control, ComponentObject, getter, setter);
|
||||||
|
_bindings.Add(binding);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called when the component editor is created.
|
/// Called when the component editor is created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="container">The container to add the editor controls to.</param>
|
/// <param name="container">The container to add the editor controls to.</param>
|
||||||
public virtual void Create(StackPanel container)
|
public abstract void Create(Panel container);
|
||||||
|
|
||||||
|
public virtual void Destroy() { }
|
||||||
|
|
||||||
|
internal void SyncBindings()
|
||||||
{
|
{
|
||||||
|
foreach (var binding in _bindings)
|
||||||
|
{
|
||||||
|
binding.Sync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when the component editor needs to update its UI based on the current state of the component data.
|
|
||||||
/// </summary>
|
|
||||||
public virtual void Update()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when the component editor is destroyed.
|
|
||||||
/// </summary>
|
|
||||||
public virtual void Destroy()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry mapping ECS component types to their custom UI editor types.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentEditorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, Type> s_editors = new();
|
||||||
|
|
||||||
|
static ComponentEditorRegistry()
|
||||||
|
{
|
||||||
|
var editorTypes = TypeCache.GetTypesWithAttribute<CustomEditorAttribute>();
|
||||||
|
if (editorTypes == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var editorType in editorTypes)
|
||||||
|
{
|
||||||
|
var attr = editorType.GetCustomAttribute<CustomEditorAttribute>();
|
||||||
|
if (attr != null && attr.TargetType != null)
|
||||||
|
{
|
||||||
|
if (typeof(ComponentEditor).IsAssignableFrom(editorType))
|
||||||
|
{
|
||||||
|
s_editors[attr.TargetType] = editorType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a custom editor exists for the given component type.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasCustomEditor(Type componentType)
|
||||||
|
{
|
||||||
|
return s_editors.ContainsKey(componentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates the custom editor for the given component type, or null if none exists.
|
||||||
|
/// </summary>
|
||||||
|
public static ComponentEditor? CreateCustomEditor(Type componentType)
|
||||||
|
{
|
||||||
|
if (s_editors.TryGetValue(componentType, out var editorType))
|
||||||
|
{
|
||||||
|
return (ComponentEditor?)Activator.CreateInstance(editorType);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,26 @@ public readonly struct ComponentObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ref T GetData<T>()
|
public ref T GetData<T>()
|
||||||
where T : unmanaged, IComponent
|
where T : unmanaged, IComponentData
|
||||||
{
|
{
|
||||||
return ref _world.EntityManager.GetComponent<T>(_entity);
|
return ref _world.EntityManager.GetComponent<T>(_entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetData<T>(in T data)
|
public void SetData<T>(in T data)
|
||||||
where T : unmanaged, IComponent
|
where T : unmanaged, IComponentData
|
||||||
{
|
{
|
||||||
_world.EntityManager.SetComponent(_entity, data);
|
_world.EntityManager.SetComponent(_entity, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ref T GetSharedData<T>()
|
||||||
|
where T : unmanaged, ISharedComponent
|
||||||
|
{
|
||||||
|
return ref _world.EntityManager.GetSharedComponent<T>(_entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSharedData<T>(in T data)
|
||||||
|
where T : unmanaged, ISharedComponent
|
||||||
|
{
|
||||||
|
_world.EntityManager.SetSharedComponent(_entity, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a class as a custom property drawer for a specific type.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public sealed class CustomPropertyDrawerAttribute : DiscoverableAttributeBase
|
||||||
|
{
|
||||||
|
public Type TargetFieldType { get; }
|
||||||
|
|
||||||
|
public CustomPropertyDrawerAttribute(Type targetFieldType)
|
||||||
|
{
|
||||||
|
TargetFieldType = targetFieldType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class EmptyDrawer<T> : PropertyDrawer<T> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
// For a nested struct, the PropertyField will draw the Label,
|
||||||
|
// and this empty border will be the Content (taking no space).
|
||||||
|
// The children properties will be drawn underneath.
|
||||||
|
return new Border();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
internal class EntityDrawer : PropertyDrawer<Entity>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<Entity> model)
|
||||||
|
{
|
||||||
|
var field = new ReferenceField
|
||||||
|
{
|
||||||
|
TypeLabel = "Entity",
|
||||||
|
IconGlyph = "\uF158",
|
||||||
|
Margin = new Thickness(0, 2, 0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
Action<Entity> updateUI = (val) =>
|
||||||
|
{
|
||||||
|
if (val.IsValid)
|
||||||
|
{
|
||||||
|
field.HasValue = true;
|
||||||
|
|
||||||
|
// For now, just display the Entity ID. We could resolve its SceneGraph Node name in the future.
|
||||||
|
field.DisplayText = $"Entity {val.ID}:{val.Generation}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.HasValue = false;
|
||||||
|
field.DisplayText = "None (Entity)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
field.ValidateDrop = (args) =>
|
||||||
|
{
|
||||||
|
// TODO: Implement drag and drop for entities from the hierarchy
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnClearClicked = () =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(Entity.Invalid);
|
||||||
|
model.FlushToECS();
|
||||||
|
updateUI(Entity.Invalid);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateUI(model.Value);
|
||||||
|
|
||||||
|
model.OnValueChanged += (val) =>
|
||||||
|
{
|
||||||
|
field.DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
updateUI(val);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Editor/Ghost.Editor.Core/Inspector/Drawers/EnumDrawer.cs
Normal file
38
src/Editor/Ghost.Editor.Core/Inspector/Drawers/EnumDrawer.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class EnumDrawer<T> : PropertyDrawer<T>
|
||||||
|
where T : unmanaged, Enum
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var comboBox = new ComboBox
|
||||||
|
{
|
||||||
|
ItemsSource = Enum.GetNames(typeof(T)),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsEnabled = !model.Descriptor.IsReadOnly,
|
||||||
|
SelectedItem = model.Value.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
comboBox.SelectionChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (comboBox.SelectedItem is string str)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<T>(str, out var parsed))
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
comboBox.SelectedItem = newVal.ToString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return comboBox;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class Float3Drawer : PropertyDrawer<float3>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(Ghost.Editor.Core.SceneGraph.PropertyNode<float3> model)
|
||||||
|
{
|
||||||
|
var field = new Float3Field
|
||||||
|
{
|
||||||
|
IsEnabled = !model.Descriptor.IsReadOnly,
|
||||||
|
Value = model.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnValueChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(e.NewValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
field.Value = newVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<Handle<T>> model)
|
||||||
|
{
|
||||||
|
static void UpdateUI(HandlePropertyNode<T> handleNode, ReferenceField field)
|
||||||
|
{
|
||||||
|
var guid = handleNode?.AssetGuid ?? Guid.Empty;
|
||||||
|
field.HasValue = guid != Guid.Empty;
|
||||||
|
field.DisplayText = guid != Guid.Empty ? $"{typeof(T).Name} ({guid.ToString().Substring(0, 8)})" : $"None ({typeof(T).Name})";
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = new ReferenceField
|
||||||
|
{
|
||||||
|
TypeLabel = typeof(T).Name,
|
||||||
|
Margin = new Thickness(0, 2, 0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var handleNode = model as HandlePropertyNode<T>;
|
||||||
|
Logger.DebugAssert(handleNode != null);
|
||||||
|
|
||||||
|
field.ValidateDrop = (args) =>
|
||||||
|
{
|
||||||
|
// For now, assume payload has standard string Guid or we implement format
|
||||||
|
return args.DataView.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text);
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnDropAccepted = async (args) =>
|
||||||
|
{
|
||||||
|
if (handleNode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = await args.DataView.GetTextAsync();
|
||||||
|
if (Guid.TryParse(text, out var guid))
|
||||||
|
{
|
||||||
|
handleNode.SetHandleFromAsset(guid);
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnClearClicked = () =>
|
||||||
|
{
|
||||||
|
if (handleNode != null)
|
||||||
|
{
|
||||||
|
handleNode.ClearHandle();
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
|
||||||
|
// When ECS value changes outside of UI
|
||||||
|
model.OnValueChanged += (val) =>
|
||||||
|
{
|
||||||
|
// UI Thread check usually required here, but property model events should be on UI thread or marshaled
|
||||||
|
field.DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class NumberBoxDrawer<T> : PropertyDrawer<T>
|
||||||
|
where T : unmanaged, INumber<T>, IMinMaxValue<T>
|
||||||
|
{
|
||||||
|
private readonly int _fractionDigits;
|
||||||
|
private readonly double _min;
|
||||||
|
private readonly double _max;
|
||||||
|
|
||||||
|
public NumberBoxDrawer(int fractionDigits, double min, double max)
|
||||||
|
{
|
||||||
|
_fractionDigits = fractionDigits;
|
||||||
|
_min = min;
|
||||||
|
_max = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe NumberBoxDrawer<T> CreateFloatingPoint()
|
||||||
|
{
|
||||||
|
var digits = sizeof(T) > 4 ? 6 : 3;
|
||||||
|
return new NumberBoxDrawer<T>(digits, double.CreateTruncating(T.MinValue), double.CreateTruncating(T.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NumberBoxDrawer<T> CreateInteger()
|
||||||
|
{
|
||||||
|
return new NumberBoxDrawer<T>(0, double.CreateTruncating(T.MinValue), double.CreateTruncating(T.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override FrameworkElement CreateControlT(Ghost.Editor.Core.SceneGraph.PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var box = new NumberBox
|
||||||
|
{
|
||||||
|
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
MaxWidth = double.PositiveInfinity, // To fill PropertyField
|
||||||
|
Maximum = _max,
|
||||||
|
Minimum = _min,
|
||||||
|
Value = double.CreateTruncating(model.Value)
|
||||||
|
};
|
||||||
|
|
||||||
|
var formatter = new Windows.Globalization.NumberFormatting.DecimalFormatter
|
||||||
|
{
|
||||||
|
FractionDigits = _fractionDigits
|
||||||
|
};
|
||||||
|
box.NumberFormatter = formatter;
|
||||||
|
|
||||||
|
box.ValueChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (double.IsNaN(e.NewValue)) return;
|
||||||
|
model.SetValueFromUI(T.CreateTruncating(e.NewValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
box.Value = double.CreateTruncating(newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class ReadOnlyDrawer<T> : PropertyDrawer<T> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var box = new TextBox
|
||||||
|
{
|
||||||
|
Text = model.Value.ToString(),
|
||||||
|
IsReadOnly = true,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
box.Text = newVal.ToString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class ToggleSwitchDrawer : PropertyDrawer<bool>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(Ghost.Editor.Core.SceneGraph.PropertyNode<bool> model)
|
||||||
|
{
|
||||||
|
var toggle = new ToggleSwitch
|
||||||
|
{
|
||||||
|
OnContent = "",
|
||||||
|
OffContent = "",
|
||||||
|
IsOn = model.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.Toggled += (s, e) =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(toggle.IsOn);
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
toggle.IsOn = newVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
return toggle;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/Editor/Ghost.Editor.Core/Inspector/EntityInspectorModel.cs
Normal file
211
src/Editor/Ghost.Editor.Core/Inspector/EntityInspectorModel.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Model for an entire entity being inspected.
|
||||||
|
/// Discovers components from archetype, builds ComponentModels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EntityInspectorModel : ISyncableInspectorModel
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
private readonly Entity _entity;
|
||||||
|
private EntityNode? _entityNode;
|
||||||
|
private readonly List<ComponentNode> _components = new();
|
||||||
|
private int _lastArchetypeId = -1;
|
||||||
|
|
||||||
|
public World World => _world;
|
||||||
|
public Entity Entity => _entity;
|
||||||
|
public IReadOnlyList<ComponentNode> Components => _components;
|
||||||
|
|
||||||
|
public EntityInspectorModel(World world, Entity entity)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when entity archetype may have changed.
|
||||||
|
/// Returns true if structure was rebuilt (components added/removed).
|
||||||
|
/// </summary>
|
||||||
|
public bool RefreshStructure()
|
||||||
|
{
|
||||||
|
var locationResult = _world.EntityManager.GetEntityLocation(_entity);
|
||||||
|
if (locationResult.IsFailure)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
if (location.archetypeID == _lastArchetypeId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastArchetypeId = location.archetypeID;
|
||||||
|
RebuildComponentList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all component values from ECS -> model.
|
||||||
|
/// </summary>
|
||||||
|
public void SyncFromECS()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var comp in _components)
|
||||||
|
{
|
||||||
|
foreach (var prop in comp.Properties)
|
||||||
|
{
|
||||||
|
prop.SyncFromECS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write dirty model values -> ECS.
|
||||||
|
/// </summary>
|
||||||
|
public void FlushToECS()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var comp in _components)
|
||||||
|
{
|
||||||
|
foreach (var prop in comp.Properties)
|
||||||
|
{
|
||||||
|
prop.FlushToECS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildComponentList()
|
||||||
|
{
|
||||||
|
_components.Clear();
|
||||||
|
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entityNode == null)
|
||||||
|
{
|
||||||
|
var syncService = EditorApplication.GetService<Services.SceneGraphSyncService>();
|
||||||
|
if (syncService != null && syncService.TryGetNode(_entity, out var node))
|
||||||
|
{
|
||||||
|
_entityNode = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entityNode != null)
|
||||||
|
{
|
||||||
|
// Update components list in EntityNode first
|
||||||
|
_entityNode.BuildComponents();
|
||||||
|
|
||||||
|
foreach (var compNode in _entityNode.Components)
|
||||||
|
{
|
||||||
|
_components.Add(compNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<ComponentEditor> _activeCustomEditors = new();
|
||||||
|
|
||||||
|
public void Sync()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity)) return;
|
||||||
|
RefreshStructure();
|
||||||
|
SyncFromECS();
|
||||||
|
foreach (var editor in _activeCustomEditors)
|
||||||
|
{
|
||||||
|
editor.SyncBindings();
|
||||||
|
}
|
||||||
|
FlushToECS();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UIElement BuildUI()
|
||||||
|
{
|
||||||
|
RefreshStructure();
|
||||||
|
|
||||||
|
var container = new StackPanel { Spacing = 4 };
|
||||||
|
|
||||||
|
foreach (var compNode in _components)
|
||||||
|
{
|
||||||
|
var expander = new Expander
|
||||||
|
{
|
||||||
|
Header = compNode.Descriptor.DisplayName,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsExpanded = true,
|
||||||
|
Margin = new Thickness(4, 2, 4, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var propertiesPanel = new StackPanel { Spacing = 8 };
|
||||||
|
|
||||||
|
if (ComponentEditorRegistry.HasCustomEditor(compNode.ComponentType))
|
||||||
|
{
|
||||||
|
var editor = ComponentEditorRegistry.CreateCustomEditor(compNode.ComponentType);
|
||||||
|
if (editor != null)
|
||||||
|
{
|
||||||
|
var compObject = new ComponentObject(_world, _entity);
|
||||||
|
editor.Initialize(compObject);
|
||||||
|
editor.Create(propertiesPanel);
|
||||||
|
_activeCustomEditors.Add(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var propNode in compNode.Properties)
|
||||||
|
{
|
||||||
|
BuildPropertyUI(propNode, propertiesPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expander.Content = propertiesPanel;
|
||||||
|
container.Children.Add(expander);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildPropertyUI(PropertyNode propNode, Panel container)
|
||||||
|
{
|
||||||
|
var drawer = PropertyDrawerRegistry.GetDrawer(propNode.Descriptor.FieldType);
|
||||||
|
var control = drawer.CreateControl(propNode);
|
||||||
|
|
||||||
|
var propertyField = new Controls.PropertyField
|
||||||
|
{
|
||||||
|
Label = propNode.Descriptor.DisplayName,
|
||||||
|
Content = control,
|
||||||
|
IsEditable = !propNode.Descriptor.IsReadOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
container.Children.Add(propertyField);
|
||||||
|
|
||||||
|
if (propNode.Children != null && propNode.Children.Length > 0)
|
||||||
|
{
|
||||||
|
var childrenPanel = new StackPanel { Spacing = 4, Margin = new Thickness(12, 4, 0, 0) };
|
||||||
|
foreach (var child in propNode.Children)
|
||||||
|
{
|
||||||
|
BuildPropertyUI(child, childrenPanel);
|
||||||
|
}
|
||||||
|
container.Children.Add(childrenPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_components.Clear();
|
||||||
|
_activeCustomEditors.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Editor/Ghost.Editor.Core/Inspector/PropertyBinding.cs
Normal file
40
src/Editor/Ghost.Editor.Core/Inspector/PropertyBinding.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
internal interface IPropertyBinding
|
||||||
|
{
|
||||||
|
void Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PropertyBinding<T> : IPropertyBinding
|
||||||
|
{
|
||||||
|
private readonly ValueControl<T> _control;
|
||||||
|
private readonly ComponentObject _componentObject;
|
||||||
|
private readonly Func<ComponentObject, T> _getter;
|
||||||
|
private readonly Action<ComponentObject, T> _setter;
|
||||||
|
|
||||||
|
public PropertyBinding(
|
||||||
|
ValueControl<T> control,
|
||||||
|
ComponentObject componentObject,
|
||||||
|
Func<ComponentObject, T> getter,
|
||||||
|
Action<ComponentObject, T> setter)
|
||||||
|
{
|
||||||
|
_control = control;
|
||||||
|
_componentObject = componentObject;
|
||||||
|
_getter = getter;
|
||||||
|
_setter = setter;
|
||||||
|
|
||||||
|
// Wire user edits -> ECS write
|
||||||
|
_control.OnValueChanged += (_, args) =>
|
||||||
|
{
|
||||||
|
_setter(_componentObject, args.NewValue!);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Sync()
|
||||||
|
{
|
||||||
|
var current = _getter(_componentObject);
|
||||||
|
_control.SetValueWithoutNotifying(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Editor/Ghost.Editor.Core/Inspector/PropertyDescriptor.cs
Normal file
120
src/Editor/Ghost.Editor.Core/Inspector/PropertyDescriptor.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a single editable field within an ECS component.
|
||||||
|
/// Knows how to read/write a specific field directly from/to unmanaged memory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PropertyDescriptor
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public Type FieldType { get; }
|
||||||
|
public int OffsetInComponent { get; }
|
||||||
|
public bool IsReadOnly { get; }
|
||||||
|
|
||||||
|
// For nested structs (e.g. float4x4 -> float4 -> float)
|
||||||
|
public PropertyDescriptor[]? Children { get; }
|
||||||
|
|
||||||
|
// TODO: Use source generators to build these at compile time and avoid all reflection/attributes at runtime.
|
||||||
|
internal PropertyDescriptor(FieldInfo fieldInfo, int parentOffset)
|
||||||
|
{
|
||||||
|
Name = fieldInfo.Name;
|
||||||
|
FieldType = fieldInfo.FieldType;
|
||||||
|
OffsetInComponent = parentOffset + (int)Marshal.OffsetOf(fieldInfo.DeclaringType!, fieldInfo.Name);
|
||||||
|
|
||||||
|
IsReadOnly = fieldInfo.GetCustomAttribute<ReadOnlyInInspectorAttribute>() != null;
|
||||||
|
|
||||||
|
var nameAttr = fieldInfo.GetCustomAttribute<InspectorNameAttribute>();
|
||||||
|
DisplayName = nameAttr?.Name ?? FormatName(Name);
|
||||||
|
|
||||||
|
// Handle nested structs if this is an unmanaged struct that is not a primitive or common vector type we have custom drawers for.
|
||||||
|
if (FieldType.IsValueType && !FieldType.IsPrimitive && !FieldType.IsEnum)
|
||||||
|
{
|
||||||
|
if (!PropertyDrawerRegistry.HasCustomDrawer(FieldType))
|
||||||
|
{
|
||||||
|
var children = new List<PropertyDescriptor>();
|
||||||
|
var fields = FieldType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
foreach (var nestedField in fields)
|
||||||
|
{
|
||||||
|
if (!nestedField.IsPublic &&
|
||||||
|
nestedField.GetCustomAttribute<InspectorGroupAttribute>() == null &&
|
||||||
|
nestedField.GetCustomAttribute<ReadOnlyInInspectorAttribute>() == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
children.Add(new PropertyDescriptor(nestedField, OffsetInComponent));
|
||||||
|
}
|
||||||
|
if (children.Count > 0)
|
||||||
|
{
|
||||||
|
Children = children.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal PropertyDescriptor(string name, Type type, int offset, bool isReadOnly, PropertyDescriptor[]? children = null)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
DisplayName = FormatName(name);
|
||||||
|
FieldType = type;
|
||||||
|
OffsetInComponent = offset;
|
||||||
|
IsReadOnly = isReadOnly;
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.StartsWith('_'))
|
||||||
|
{
|
||||||
|
name = name.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Length == 0)
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return char.ToUpperInvariant(name[0]) + name.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe object ReadBoxed(void* pComponent)
|
||||||
|
{
|
||||||
|
var src = (byte*)pComponent + OffsetInComponent;
|
||||||
|
return Marshal.PtrToStructure((nint)src, FieldType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void WriteBoxed(void* pComponent, object value)
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dst = (byte*)pComponent + OffsetInComponent;
|
||||||
|
Marshal.StructureToPtr(value, (nint)dst, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe ref T Read<T>(void* pComponent) where T : unmanaged
|
||||||
|
{
|
||||||
|
return ref *(T*)((byte*)pComponent + OffsetInComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void Write<T>(void* pComponent, in T value) where T : unmanaged
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*(T*)((byte*)pComponent + OffsetInComponent) = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawer.cs
Normal file
25
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for type-specific property UI factories.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PropertyDrawer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create the UI control bound to the given property node.
|
||||||
|
/// </summary>
|
||||||
|
public abstract FrameworkElement CreateControl(PropertyNode model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class PropertyDrawer<T> : PropertyDrawer where T : unmanaged
|
||||||
|
{
|
||||||
|
public sealed override FrameworkElement CreateControl(PropertyNode model)
|
||||||
|
{
|
||||||
|
return CreateControlT((PropertyNode<T>)model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract FrameworkElement CreateControlT(PropertyNode<T> model);
|
||||||
|
}
|
||||||
122
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawerRegistry.cs
Normal file
122
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawerRegistry.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers PropertyDrawer subclasses and maps field types to drawers.
|
||||||
|
/// </summary>
|
||||||
|
public static class PropertyDrawerRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, PropertyDrawer> s_drawers = new();
|
||||||
|
private static bool s_initialized;
|
||||||
|
private static readonly Lock s_lock = new();
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
lock (s_lock)
|
||||||
|
{
|
||||||
|
if (s_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register built-in drawers
|
||||||
|
s_drawers[typeof(float)] = NumberBoxDrawer<float>.CreateFloatingPoint();
|
||||||
|
s_drawers[typeof(double)] = NumberBoxDrawer<double>.CreateFloatingPoint();
|
||||||
|
s_drawers[typeof(int)] = NumberBoxDrawer<int>.CreateInteger();
|
||||||
|
s_drawers[typeof(uint)] = NumberBoxDrawer<uint>.CreateInteger();
|
||||||
|
s_drawers[typeof(short)] = NumberBoxDrawer<short>.CreateInteger();
|
||||||
|
s_drawers[typeof(ushort)] = NumberBoxDrawer<ushort>.CreateInteger();
|
||||||
|
s_drawers[typeof(long)] = NumberBoxDrawer<long>.CreateInteger();
|
||||||
|
s_drawers[typeof(ulong)] = NumberBoxDrawer<ulong>.CreateInteger();
|
||||||
|
s_drawers[typeof(sbyte)] = NumberBoxDrawer<sbyte>.CreateInteger();
|
||||||
|
s_drawers[typeof(byte)] = NumberBoxDrawer<byte>.CreateInteger();
|
||||||
|
s_drawers[typeof(bool)] = new ToggleSwitchDrawer();
|
||||||
|
|
||||||
|
s_drawers[typeof(float3)] = new Float3Drawer();
|
||||||
|
|
||||||
|
s_drawers[typeof(Entity)] = new EntityDrawer();
|
||||||
|
|
||||||
|
// Discover user-defined drawers via TypeCache
|
||||||
|
var customDrawers = TypeCache.GetTypesWithAttribute<CustomPropertyDrawerAttribute>();
|
||||||
|
if (customDrawers != null)
|
||||||
|
{
|
||||||
|
foreach (var typeInfo in customDrawers)
|
||||||
|
{
|
||||||
|
var type = typeInfo.AsType();
|
||||||
|
var attr = type.GetCustomAttribute<CustomPropertyDrawerAttribute>();
|
||||||
|
if (attr != null && typeof(PropertyDrawer).IsAssignableFrom(type))
|
||||||
|
{
|
||||||
|
if (Activator.CreateInstance(typeInfo) is PropertyDrawer drawer)
|
||||||
|
{
|
||||||
|
s_drawers[attr.TargetFieldType] = drawer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasCustomDrawer(Type fieldType)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
return s_drawers.ContainsKey(fieldType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyDrawer GetDrawer(Type fieldType)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_drawers.TryGetValue(fieldType, out var drawer))
|
||||||
|
{
|
||||||
|
return drawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType.IsEnum)
|
||||||
|
{
|
||||||
|
var enumDrawerType = typeof(EnumDrawer<>).MakeGenericType(fieldType);
|
||||||
|
var enumDrawer = (PropertyDrawer)Activator.CreateInstance(enumDrawerType)!;
|
||||||
|
s_drawers[fieldType] = enumDrawer;
|
||||||
|
return enumDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Handle<>))
|
||||||
|
{
|
||||||
|
var argType = fieldType.GetGenericArguments()[0];
|
||||||
|
var handleDrawerType = typeof(HandleDrawer<>).MakeGenericType(argType);
|
||||||
|
var handleDrawer = (PropertyDrawer)Activator.CreateInstance(handleDrawerType)!;
|
||||||
|
s_drawers[fieldType] = handleDrawer;
|
||||||
|
return handleDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown types. If it's an unmanaged struct with fields, we use EmptyDrawer
|
||||||
|
// to let the children render. If it's a primitive or something else, use ReadOnlyDrawer.
|
||||||
|
Type genericDrawerType;
|
||||||
|
if (fieldType.IsValueType && !fieldType.IsPrimitive && !fieldType.IsEnum)
|
||||||
|
{
|
||||||
|
genericDrawerType = typeof(EmptyDrawer<>);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
genericDrawerType = typeof(ReadOnlyDrawer<>);
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawerType = genericDrawerType.MakeGenericType(fieldType);
|
||||||
|
var drawerInstance = (PropertyDrawer)Activator.CreateInstance(drawerType)!;
|
||||||
|
s_drawers[fieldType] = drawerInstance;
|
||||||
|
return drawerInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Ghost.Editor.Core": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"debugEngines": "managed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Resources;
|
|
||||||
|
|
||||||
public static class EditorIconSource
|
|
||||||
{
|
|
||||||
public static readonly IconSource scene_24 = new FontIconSource
|
|
||||||
{
|
|
||||||
Glyph = "\uF159",
|
|
||||||
FontSize = 24
|
|
||||||
};
|
|
||||||
|
|
||||||
public static readonly IconSource entity_24 = new FontIconSource
|
|
||||||
{
|
|
||||||
Glyph = "\uF158",
|
|
||||||
FontSize = 24
|
|
||||||
};
|
|
||||||
}
|
|
||||||
172
src/Editor/Ghost.Editor.Core/SceneGraph/ComponentNode.cs
Normal file
172
src/Editor/Ghost.Editor.Core/SceneGraph/ComponentNode.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single component on an entity within the Editor's scene graph.
|
||||||
|
/// Acts as the middleware between the Inspector's PropertyModels and the actual ECS memory.
|
||||||
|
/// </summary>
|
||||||
|
public class ComponentNode
|
||||||
|
{
|
||||||
|
protected readonly World _world;
|
||||||
|
protected readonly Entity _entity;
|
||||||
|
|
||||||
|
public Type ComponentType { get; }
|
||||||
|
public ComponentDescriptor Descriptor { get; }
|
||||||
|
public PropertyNode[] Properties { get; }
|
||||||
|
public string Name => Descriptor.DisplayName;
|
||||||
|
|
||||||
|
internal ComponentNode(World world, Entity entity, Type componentType, ComponentDescriptor descriptor)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
|
||||||
|
ComponentType = componentType;
|
||||||
|
Descriptor = descriptor;
|
||||||
|
|
||||||
|
Properties = new PropertyNode[descriptor.Properties.Length];
|
||||||
|
for (var i = 0; i < descriptor.Properties.Length; i++)
|
||||||
|
{
|
||||||
|
var prop = descriptor.Properties[i];
|
||||||
|
if (prop.FieldType.IsGenericType && prop.FieldType.GetGenericTypeDefinition() == typeof(Ghost.Core.Handle<>))
|
||||||
|
{
|
||||||
|
var nodeType = typeof(HandlePropertyNode<>).MakeGenericType(prop.FieldType.GetGenericArguments()[0]);
|
||||||
|
Properties[i] = (PropertyNode)Activator.CreateInstance(nodeType, prop, this)!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create a standard PropertyNode<T> for non-handle types
|
||||||
|
// We use MakeGenericType to create the correct PropertyNode<T> based on FieldType
|
||||||
|
var nodeType = typeof(PropertyNode<>).MakeGenericType(prop.FieldType);
|
||||||
|
Properties[i] = (PropertyNode)Activator.CreateInstance(nodeType, prop, this, null)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- Data Access ---
|
||||||
|
|
||||||
|
public object ReadBoxedValue(PropertyDescriptor field)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
return field.ReadBoxed(pComponent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public T GetFieldValue<T>(PropertyDescriptor field) where T : unmanaged
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
return field.Read<T>(pComponent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetFieldValue<T>(PropertyDescriptor field, T value) where T : unmanaged
|
||||||
|
{
|
||||||
|
EditorApplication.GetService<Services.IEditorWorldService>().Defer(() =>
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
var ptr = _world.EntityManager.GetSharedComponent(_entity, Descriptor.ComponentId);
|
||||||
|
if (ptr != null)
|
||||||
|
{
|
||||||
|
using var scope = Misaki.HighPerformance.LowLevel.Buffer.AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new Misaki.HighPerformance.LowLevel.Buffer.MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
System.Runtime.CompilerServices.Unsafe.CopyBlock(buffer.GetUnsafePtr(), ptr, (uint)Descriptor.Size);
|
||||||
|
field.Write<T>(buffer.GetUnsafePtr(), value);
|
||||||
|
_world.EntityManager.SetSharedComponent(_entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
field.Write<T>(pComponent, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serialization ---
|
||||||
|
|
||||||
|
/// <summary>Serialize this component to JSON. Base reads from ECS directly.</summary>
|
||||||
|
public virtual void Serialize(Utf8JsonWriter writer, JsonSerializerOptions options, Action<object>? preSerialize = null)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var boxed = System.Runtime.InteropServices.Marshal.PtrToStructure((nint)GetComponentPointer(), ComponentType);
|
||||||
|
if (boxed != null)
|
||||||
|
{
|
||||||
|
preSerialize?.Invoke(boxed);
|
||||||
|
|
||||||
|
var jsonString = JsonSerializer.Serialize(boxed, ComponentType, options);
|
||||||
|
using var doc = JsonDocument.Parse(jsonString);
|
||||||
|
var root = System.Text.Json.Nodes.JsonObject.Create(doc.RootElement);
|
||||||
|
if (root != null)
|
||||||
|
{
|
||||||
|
foreach (var prop in Properties)
|
||||||
|
{
|
||||||
|
prop.SerializeOverride(root, boxed);
|
||||||
|
}
|
||||||
|
root.WriteTo(writer, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonSerializer.Serialize(writer, boxed, ComponentType, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deserialize from JSON and apply to ECS. Base writes to ECS directly.</summary>
|
||||||
|
public virtual void Deserialize(JsonElement element, JsonSerializerOptions options, Action<object>? postDeserialize = null)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var boxed = element.Deserialize(ComponentType, options);
|
||||||
|
if (boxed != null)
|
||||||
|
{
|
||||||
|
postDeserialize?.Invoke(boxed);
|
||||||
|
|
||||||
|
foreach (var prop in Properties)
|
||||||
|
{
|
||||||
|
prop.DeserializeOverride(element, boxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorApplication.GetService<Services.EditorWorldService>().Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
using var scope = Misaki.HighPerformance.LowLevel.Buffer.AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new Misaki.HighPerformance.LowLevel.Buffer.MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
System.Runtime.InteropServices.Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
_world.EntityManager.SetSharedComponent(_entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
System.Runtime.InteropServices.Marshal.StructureToPtr(boxed, (nint)GetComponentPointer(), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void* GetComponentPointer()
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
return _world.EntityManager.GetSharedComponent(_entity, Descriptor.ComponentId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return _world.EntityManager.GetComponent(_entity, Descriptor.ComponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
@@ -6,9 +7,46 @@ namespace Ghost.Editor.Core.SceneGraph;
|
|||||||
|
|
||||||
public sealed partial class EntityNode : SceneGraphNode
|
public sealed partial class EntityNode : SceneGraphNode
|
||||||
{
|
{
|
||||||
private readonly Entity _entity;
|
public Entity Entity
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
public List<ComponentNode> Components { get; } = new();
|
||||||
|
|
||||||
public Entity Entity => _entity;
|
internal EntityNode(World world, Entity entity, string name)
|
||||||
|
: base(world, name)
|
||||||
|
{
|
||||||
|
Entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildComponents()
|
||||||
|
{
|
||||||
|
Components.Clear();
|
||||||
|
var locationResult = World.EntityManager.GetEntityLocation(Entity);
|
||||||
|
if (!locationResult.IsSuccess)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref World.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
var it = archetype._signature.GetIterator();
|
||||||
|
while (it.Next(out var componentID))
|
||||||
|
{
|
||||||
|
if (ComponentRegistry.s_runtimeIDToType.TryGetValue(componentID, out var type))
|
||||||
|
{
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(new Ghost.Core.Identifier<IComponent>(componentID));
|
||||||
|
if (compInfo.isCleanup)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compDescriptor = Inspector.ComponentDescriptor.Create(type);
|
||||||
|
Components.Add(new ComponentNode(World, Entity, type, compDescriptor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override IconSource? CreateIcon()
|
public override IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
@@ -20,26 +58,54 @@ public sealed partial class EntityNode : SceneGraphNode
|
|||||||
|
|
||||||
public override UIElement? CreateHeader()
|
public override UIElement? CreateHeader()
|
||||||
{
|
{
|
||||||
return null;
|
var root = new Grid
|
||||||
}
|
|
||||||
|
|
||||||
public override UIElement? CreateInspector()
|
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
ColumnSpacing = 8,
|
||||||
}
|
};
|
||||||
|
|
||||||
public override DataTemplate GetSceneHierarchyTemplate()
|
root.ColumnDefinitions.Add(new ColumnDefinition
|
||||||
{
|
{
|
||||||
var template = @"
|
Width = new GridLength(1, GridUnitType.Star)
|
||||||
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:Key=""EntityTemplate"" x:DataType=""sg:SceneGraphNode"">
|
});
|
||||||
<TreeViewItem AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}"" ItemsSource=""{x:Bind Children, Mode=OneWay}"">
|
root.ColumnDefinitions.Add(new ColumnDefinition
|
||||||
<StackPanel Margin=""10,0"" Orientation=""Horizontal"">
|
{
|
||||||
<FontIcon FontSize=""14"" Glyph="""" />
|
Width = GridLength.Auto,
|
||||||
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
|
MinWidth = 20
|
||||||
</StackPanel>
|
});
|
||||||
</TreeViewItem>
|
|
||||||
</DataTemplate>";
|
|
||||||
|
|
||||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
var nameBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = Name,
|
||||||
|
FontSize = 14,
|
||||||
|
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
|
||||||
|
};
|
||||||
|
|
||||||
|
nameBox.SetBinding(TextBox.TextProperty, new Microsoft.UI.Xaml.Data.Binding
|
||||||
|
{
|
||||||
|
Source = this,
|
||||||
|
Path = new PropertyPath(nameof(Name)),
|
||||||
|
Mode = Microsoft.UI.Xaml.Data.BindingMode.TwoWay,
|
||||||
|
UpdateSourceTrigger = Microsoft.UI.Xaml.Data.UpdateSourceTrigger.PropertyChanged
|
||||||
|
});
|
||||||
|
|
||||||
|
var entityBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{Entity.ID}:{Entity.Generation}",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(nameBox, 0);
|
||||||
|
Grid.SetColumn(entityBlock, 1);
|
||||||
|
|
||||||
|
root.Children.Add(nameBox);
|
||||||
|
root.Children.Add(entityBlock);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IInspectorModel CreateInspectorModel()
|
||||||
|
{
|
||||||
|
return new Inspector.EntityInspectorModel(World, Entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/Editor/Ghost.Editor.Core/SceneGraph/HandlePropertyNode.cs
Normal file
134
src/Editor/Ghost.Editor.Core/SceneGraph/HandlePropertyNode.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public class HandlePropertyNode<T> : PropertyNode<Handle<T>> where T : unmanaged
|
||||||
|
{
|
||||||
|
public Guid AssetGuid { get; private set; } = Guid.Empty;
|
||||||
|
public long ExpectedHandleValue { get; private set; }
|
||||||
|
|
||||||
|
public HandlePropertyNode(PropertyDescriptor descriptor, ComponentNode parent)
|
||||||
|
: base(descriptor, parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetHandleFromAsset(Guid assetGuid)
|
||||||
|
{
|
||||||
|
var assetManager = EditorApplication.GetService<AssetManager>();
|
||||||
|
|
||||||
|
MethodInfo? resolveMethod = null;
|
||||||
|
if (typeof(T).Name == "GPUTexture")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveTexture", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
else if (typeof(T).Name == "Mesh")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveMesh", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
Handle<T> handle = default;
|
||||||
|
if (resolveMethod != null && assetManager != null)
|
||||||
|
{
|
||||||
|
var res = resolveMethod.Invoke(assetManager, new object[] { assetGuid });
|
||||||
|
if (res != null)
|
||||||
|
{
|
||||||
|
handle = (Handle<T>)res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.Error($"No resolve method found for type {typeof(T).Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetGuid = assetGuid;
|
||||||
|
ExpectedHandleValue = UnsafeGetHandleValue(handle);
|
||||||
|
SetValueFromUI(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearHandle()
|
||||||
|
{
|
||||||
|
AssetGuid = Guid.Empty;
|
||||||
|
ExpectedHandleValue = 0;
|
||||||
|
SetValueFromUI(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long UnsafeGetHandleValue(Handle<T> handle)
|
||||||
|
{
|
||||||
|
return System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SerializeOverride(JsonObject jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
if (AssetGuid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var camelCaseName = char.ToLowerInvariant(Descriptor.Name[0]) + Descriptor.Name.Substring(1);
|
||||||
|
if (jsonRoot.ContainsKey(camelCaseName))
|
||||||
|
jsonRoot[camelCaseName] = AssetGuid.ToString();
|
||||||
|
else
|
||||||
|
jsonRoot[Descriptor.Name] = AssetGuid.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeOverride(JsonElement jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
var camelCaseName = char.ToLowerInvariant(Descriptor.Name[0]) + Descriptor.Name.Substring(1);
|
||||||
|
|
||||||
|
if (jsonRoot.TryGetProperty(camelCaseName, out var propElement) || jsonRoot.TryGetProperty(Descriptor.Name, out propElement))
|
||||||
|
{
|
||||||
|
if (propElement.ValueKind == JsonValueKind.String && Guid.TryParse(propElement.GetString(), out var guid) && guid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var assetManager = EditorApplication.GetService<AssetManager>();
|
||||||
|
|
||||||
|
MethodInfo? resolveMethod = null;
|
||||||
|
if (typeof(T).Name == "GPUTexture")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveTexture", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
else if (typeof(T).Name == "Mesh")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveMesh", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (resolveMethod != null && assetManager != null)
|
||||||
|
{
|
||||||
|
var handleObj = resolveMethod.Invoke(assetManager, new object[] { guid });
|
||||||
|
if (handleObj != null)
|
||||||
|
{
|
||||||
|
var fieldInfo = boxedComponent.GetType().GetField(Descriptor.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
fieldInfo.SetValue(boxedComponent, handleObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = (Handle<T>)handleObj;
|
||||||
|
var handleValue = System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
|
||||||
|
AssetGuid = guid;
|
||||||
|
ExpectedHandleValue = handleValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Validate(object boxedComponent)
|
||||||
|
{
|
||||||
|
if (AssetGuid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var fieldInfo = boxedComponent.GetType().GetField(Descriptor.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
var val = fieldInfo.GetValue(boxedComponent);
|
||||||
|
if (val != null)
|
||||||
|
{
|
||||||
|
var handle = (Handle<T>)val;
|
||||||
|
var currentVal = System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
|
||||||
|
if (currentVal != ExpectedHandleValue)
|
||||||
|
{
|
||||||
|
Logger.Error($"Handle field '{Descriptor.Name}' was modified externally. Guid tracking cleared.");
|
||||||
|
AssetGuid = Guid.Empty;
|
||||||
|
ExpectedHandleValue = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNode.cs
Normal file
46
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNode.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single property/field within a ComponentNode.
|
||||||
|
/// Handles ECS reading/writing as well as serialization overrides (like Guid metadata).
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PropertyNode
|
||||||
|
{
|
||||||
|
public PropertyDescriptor Descriptor { get; }
|
||||||
|
public ComponentNode Parent { get; }
|
||||||
|
public PropertyNode[]? Children { get; protected set; }
|
||||||
|
|
||||||
|
protected PropertyNode(PropertyDescriptor descriptor, ComponentNode parent)
|
||||||
|
{
|
||||||
|
Descriptor = descriptor;
|
||||||
|
Parent = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronize the cached value from the ECS backend.
|
||||||
|
/// </summary>
|
||||||
|
public abstract void SyncFromECS();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flush any dirty UI changes back to the ECS backend.
|
||||||
|
/// </summary>
|
||||||
|
public abstract void FlushToECS();
|
||||||
|
|
||||||
|
// --- Serialization Hooks ---
|
||||||
|
|
||||||
|
public virtual void SerializeOverride(JsonObject jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void DeserializeOverride(JsonElement jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Validate(object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNodeT.cs
Normal file
67
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNodeT.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public class PropertyNode<T> : PropertyNode where T : unmanaged
|
||||||
|
{
|
||||||
|
private T _value;
|
||||||
|
public bool IsDirty { get; private set; }
|
||||||
|
|
||||||
|
public T Value => _value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event fired when the value is updated from ECS. UI controls bind to this.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<T>? OnValueChanged;
|
||||||
|
|
||||||
|
public PropertyNode(PropertyDescriptor descriptor, ComponentNode parent, PropertyNode[]? children = null)
|
||||||
|
: base(descriptor, parent)
|
||||||
|
{
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SyncFromECS()
|
||||||
|
{
|
||||||
|
var newValue = Parent.GetFieldValue<T>(Descriptor);
|
||||||
|
|
||||||
|
if (!EqualityComparer<T>.Default.Equals(_value, newValue))
|
||||||
|
{
|
||||||
|
_value = newValue;
|
||||||
|
OnValueChanged?.Invoke(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Children != null)
|
||||||
|
{
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
child.SyncFromECS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the UI when the user edits the value.
|
||||||
|
/// </summary>
|
||||||
|
public void SetValueFromUI(T newValue)
|
||||||
|
{
|
||||||
|
IsDirty = true;
|
||||||
|
_value = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void FlushToECS()
|
||||||
|
{
|
||||||
|
if (IsDirty)
|
||||||
|
{
|
||||||
|
Parent.SetFieldValue(Descriptor, _value);
|
||||||
|
IsDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Children != null)
|
||||||
|
{
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
child.FlushToECS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Architecture Plan: Scene Graph and Scene Representation
|
|
||||||
|
|
||||||
The Scene Graph is a hierarchical structure that represents all the objects and entities within a 3D scene in the Ghost Editor.
|
|
||||||
|
|
||||||
## Scene Graph (Editor representation of runtime data)
|
|
||||||
|
|
||||||
There should be three main types of nodes in the Scene Graph for now:
|
|
||||||
|
|
||||||
1. **Scene Graph Node**: The base class for all nodes in the Scene Graph.
|
|
||||||
2. **Entity Node**: Represents an individual entity within a scene. Name stored here, not runtime component.
|
|
||||||
3. **Scene Node**: Represents a Scene object, which can contain multiple entities. Name stored here not runtime data.
|
|
||||||
|
|
||||||
### Editor World
|
|
||||||
|
|
||||||
Editor contains a different world compares to the runtime world. When user click the Play button, we will create a runtime world and load the scene data from the editor world to the runtime world.
|
|
||||||
This allows us to
|
|
||||||
|
|
||||||
1. Unload the runtime only systems like physics, rendering, etc when user stop playing.
|
|
||||||
2. Load editor only systems like gizmos, debug, etc when user stop playing.
|
|
||||||
3. Allow editor only entities like editor camera, editor lights, etc to exist in the editor world without affecting the runtime world.
|
|
||||||
|
|
||||||
### Editor Hierarchy
|
|
||||||
|
|
||||||
The Scene Graph should be represented as a tree structure in the editor (TreeView in WinUI 3), where:
|
|
||||||
|
|
||||||
- The top level nodes represents the loaded Scenes in the editor world.
|
|
||||||
- Levels below the Scene nodes represents the Entity nodes that belong to that scene.
|
|
||||||
- Each Entity node can have child Entity nodes representing parent-child relationships between entities.
|
|
||||||
|
|
||||||
An example hierarchy could look like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
- Scene 1
|
|
||||||
- Entity A
|
|
||||||
- Entity B
|
|
||||||
- Entity C
|
|
||||||
- Scene 2
|
|
||||||
- Entity D
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scene (The runtime representation)
|
|
||||||
|
|
||||||
A Scene is a collection of entities with SceneID component from a world that are grouped together. There can be multiple scenes in a world.
|
|
||||||
|
|
||||||
### Save a Scene
|
|
||||||
|
|
||||||
When save a scene, all entities with the SceneID component matching the scene's ID should be included in the saved data.
|
|
||||||
When an Entity references another Entity in the same scene, we should store the file local id instead of the global entity id.
|
|
||||||
For example, if Entity A (id: 10, 5th in scene) references Entity B (id: 20, 50th in scene) in the same scene, in the saved data for Entity A,
|
|
||||||
we should store 50 (the file local id) as the reference to Entity B instead of 20 (the global entity id).
|
|
||||||
|
|
||||||
> We does not allow cross-scene references for now because ideally it's not a good practice to have cross-scene references.
|
|
||||||
> We can use query or singleton pattern to access entities from other scenes if needed because they are in the same world.
|
|
||||||
|
|
||||||
### Load a Scene
|
|
||||||
|
|
||||||
When loading a scene, we need to reconstruct the entities and their relationships based on the saved data.
|
|
||||||
|
|
||||||
1. We allocate the entities in the world and assign them new global entity IDs.
|
|
||||||
2. We remap the file local IDs to the new global entity IDs and change the references accordingly.
|
|
||||||
For example if Entity A (file local id: 5) references Entity B (file local id: 50) in the saved data,
|
|
||||||
we need to find the new global entity IDs for both entities after loading and update the reference in Entity A to point to the new global entity ID of Entity B.
|
|
||||||
|
|
||||||
### Data format
|
|
||||||
|
|
||||||
The scene data should be stored in a structured format (JSON and binary) that includes:
|
|
||||||
|
|
||||||
- List of entities with their components and properties (Entities must in the order that file local id directly maps to the index in the list)
|
|
||||||
- References between entities using file local IDs
|
|
||||||
|
|
||||||
> The name of the saved scene file should match the name of the scene node in the editor.
|
|
||||||
|
|
||||||
JSON should only be used in the editor and JSON serialization/deserialization logic should also only exist in the editor codebase (Ghost.Editor.Core). Reflection is allowed here.
|
|
||||||
Binary format should be used in the runtime for better performance. The runtime codebase (Ghost.Engine) must be aot compatible.
|
|
||||||
|
|
||||||
Currently we strict the IComponent to must be unmanaged and blittable types.
|
|
||||||
However, we also support ManagedEntity and ManagedEntityRef with ScriptComponent to allow OOP like logic for common gameplay logic that DOD pattern is not suitable for.
|
|
||||||
Serializing/deserializing with those components will be tricky. We can use MemoryPack (already installed) for binary serialization/deserialization because it supports both unmanaged and managed types.
|
|
||||||
|
|
||||||
## What need to implement
|
|
||||||
|
|
||||||
- [ ] Scene type for the runtime representation if needed
|
|
||||||
- [ ] Scene Graph data structures (SceneNode, EntityNode)
|
|
||||||
- [ ] Editor World management (loading/unloading scenes, managing entities)
|
|
||||||
- [ ] Scene saving/loading logic with file local ID remapping
|
|
||||||
- [ ] Serialization/deserialization logic for scene data (JSON for editor, binary for runtime)
|
|
||||||
- [ ] UI integration for displaying and managing the Scene Graph in the editor with WinUI 3 TreeView
|
|
||||||
162
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
162
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public static class SceneGraphBuilder
|
||||||
|
{
|
||||||
|
public static List<SceneNode> Build(World world, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
var sceneNodes = new List<SceneNode>();
|
||||||
|
var sceneEntities = GroupEntitiesByScene(world);
|
||||||
|
|
||||||
|
foreach (var (scene, entities) in sceneEntities)
|
||||||
|
{
|
||||||
|
var sceneName = GetDefaultSceneName(scene);
|
||||||
|
var sceneNode = new SceneNode(world, new Scene(scene), sceneName);
|
||||||
|
BuildEntityTree(entities, sceneNode, initialNames);
|
||||||
|
sceneNodes.Add(sceneNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sceneNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<ushort, List<Entity>> GroupEntitiesByScene(World world)
|
||||||
|
{
|
||||||
|
var sceneMap = new Dictionary<ushort, List<Entity>>();
|
||||||
|
var queryID = new QueryBuilder().WithAll<SceneID>().Build(world);
|
||||||
|
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
|
||||||
|
|
||||||
|
foreach (var chunk in query.GetChunkIterator())
|
||||||
|
{
|
||||||
|
var entities = chunk.GetEntities();
|
||||||
|
var scene = chunk.GetSharedComponent<SceneID>();
|
||||||
|
|
||||||
|
if (scene.value == Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < chunk.EntityCount; i++)
|
||||||
|
{
|
||||||
|
if (!sceneMap.TryGetValue(scene.value, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
sceneMap[scene.value] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entities[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sceneMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildEntityTree(List<Entity> entities, SceneGraphNode parentNode, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
var entitySet = new HashSet<Entity>(entities);
|
||||||
|
var childrenByParent = new Dictionary<Entity, List<Entity>>();
|
||||||
|
var roots = new List<Entity>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
Hierarchy hierarchy = default;
|
||||||
|
var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy);
|
||||||
|
|
||||||
|
if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(hierarchy.parent, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
childrenByParent[hierarchy.parent] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rootEntity in roots)
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(rootEntity, out var n) ? n : "Entity";
|
||||||
|
var entityNode = new EntityNode(parentNode.World, rootEntity, name);
|
||||||
|
parentNode.Children.Add(entityNode);
|
||||||
|
BuildSubtree(entityNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildSubtree(EntityNode parentNode, Dictionary<Entity, List<Entity>> childrenByParent, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(parentNode.Entity, out var childList))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Hierarchy parentHierarchy = default;
|
||||||
|
if (!TryGetHierarchyComponent(parentNode.World, parentNode.Entity, ref parentHierarchy))
|
||||||
|
{
|
||||||
|
foreach (var childEntity in childList)
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(childEntity, out var n) ? n : "Entity";
|
||||||
|
var childNode = new EntityNode(parentNode.World, childEntity, name);
|
||||||
|
parentNode.Children.Add(childNode);
|
||||||
|
BuildSubtree(childNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sibling = parentHierarchy.firstChild;
|
||||||
|
while (sibling.IsValid)
|
||||||
|
{
|
||||||
|
if (childList.Contains(sibling))
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(sibling, out var n) ? n : "Entity";
|
||||||
|
var childNode = new EntityNode(parentNode.World, sibling, name);
|
||||||
|
parentNode.Children.Add(childNode);
|
||||||
|
BuildSubtree(childNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
Hierarchy siblingHierarchy = default;
|
||||||
|
if (!TryGetHierarchyComponent(parentNode.World, sibling, ref siblingHierarchy))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sibling = siblingHierarchy.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy)
|
||||||
|
{
|
||||||
|
var location = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (!location.IsSuccess)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID);
|
||||||
|
var hierarchyID = ComponentTypeID<Hierarchy>.Value;
|
||||||
|
if (!archetype.HasComponent(hierarchyID))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID);
|
||||||
|
if (pData == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hierarchy = *(Hierarchy*)pData;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultSceneName(ushort sceneID)
|
||||||
|
{
|
||||||
|
return $"NewScene ({sceneID})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.SceneGraph;
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
[ObservableObject]
|
||||||
|
public abstract partial class SceneGraphNode : GhostObject, IInspectable
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Name
|
public partial string Name
|
||||||
@@ -14,14 +17,41 @@ public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
|||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public World World
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<SceneGraphNode> Children
|
public ObservableCollection<SceneGraphNode> Children
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
} = new();
|
} = new();
|
||||||
|
|
||||||
public abstract IconSource? CreateIcon();
|
protected SceneGraphNode(World world, string name)
|
||||||
public abstract UIElement? CreateHeader();
|
{
|
||||||
public abstract UIElement? CreateInspector();
|
World = world;
|
||||||
|
Name = name;
|
||||||
public abstract DataTemplate GetSceneHierarchyTemplate();
|
}
|
||||||
|
|
||||||
|
public override void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
Name = reader.ReadString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual IconSource? CreateIcon()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual UIElement? CreateHeader()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract IInspectorModel CreateInspectorModel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
@@ -5,6 +8,17 @@ namespace Ghost.Editor.Core.SceneGraph;
|
|||||||
|
|
||||||
public sealed partial class SceneNode : SceneGraphNode
|
public sealed partial class SceneNode : SceneGraphNode
|
||||||
{
|
{
|
||||||
|
public Scene Scene
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal SceneNode(World world, Scene scene, string name)
|
||||||
|
: base(world, name)
|
||||||
|
{
|
||||||
|
Scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
public override IconSource? CreateIcon()
|
public override IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
return new FontIconSource
|
return new FontIconSource
|
||||||
@@ -13,33 +27,13 @@ public sealed partial class SceneNode : SceneGraphNode
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement custom header and inspector UI for the SceneNode
|
|
||||||
public override UIElement? CreateHeader()
|
public override UIElement? CreateHeader()
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override UIElement? CreateInspector()
|
public override IInspectorModel CreateInspectorModel()
|
||||||
{
|
{
|
||||||
return null;
|
return null!;
|
||||||
}
|
|
||||||
|
|
||||||
public override DataTemplate GetSceneHierarchyTemplate()
|
|
||||||
{
|
|
||||||
var template = @"
|
|
||||||
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:DataType=""sg:SceneGraphNode"">
|
|
||||||
<TreeViewItem
|
|
||||||
AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}""
|
|
||||||
Background=""{ThemeResource ControlSolidFillColorDefaultBrush}""
|
|
||||||
IsExpanded=""True""
|
|
||||||
ItemsSource=""{ x:Bind Children, Mode=OneWay}"" >
|
|
||||||
<StackPanel Orientation=""Horizontal"" >
|
|
||||||
<FontIcon FontSize=""14"" Glyph=""""/>
|
|
||||||
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
|
|
||||||
</StackPanel>
|
|
||||||
</TreeViewItem>
|
|
||||||
</DataTemplate>";
|
|
||||||
|
|
||||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
363
src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs
Normal file
363
src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe SQLite-backed asset catalog.
|
||||||
|
/// Uses connection pooling and local command creation for safe multi-threaded access.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class AssetCatalog
|
||||||
|
{
|
||||||
|
public readonly record struct SubAssetInfo(Guid Guid, Guid ParentGuid, string Kind, string DisplayName, string StablePath, string SourcePath, Guid AssetTypeId);
|
||||||
|
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
private const string SqlGetGuid = "SELECT guid FROM assets WHERE source_path = @path";
|
||||||
|
private const string SqlGetPath = "SELECT source_path FROM assets WHERE guid = @guid";
|
||||||
|
private const string SqlGetAssetTypeId = "SELECT asset_type_id FROM assets WHERE guid = @guid";
|
||||||
|
private const string SqlGetImportedAt = "SELECT imported_at_ms FROM assets WHERE guid = @guid";
|
||||||
|
private const string SqlUpsert = @"
|
||||||
|
INSERT INTO assets (guid, source_path, asset_type_id, handler_version, content_hash, settings_hash, imported_at_ms, parent_guid, subasset_kind, display_name, stable_path)
|
||||||
|
VALUES (@guid, @path, @asset_type_id, @version, @content_hash, @settings_hash, @imported_at_ms, @parent_guid, @subasset_kind, @display_name, @stable_path)
|
||||||
|
ON CONFLICT(guid) DO UPDATE SET
|
||||||
|
source_path = excluded.source_path,
|
||||||
|
asset_type_id = excluded.asset_type_id,
|
||||||
|
handler_version = excluded.handler_version,
|
||||||
|
content_hash = excluded.content_hash,
|
||||||
|
settings_hash = excluded.settings_hash,
|
||||||
|
imported_at_ms = excluded.imported_at_ms,
|
||||||
|
parent_guid = excluded.parent_guid,
|
||||||
|
subasset_kind = excluded.subasset_kind,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
stable_path = excluded.stable_path";
|
||||||
|
private const string SqlDelete = "DELETE FROM assets WHERE guid = @guid";
|
||||||
|
private const string SqlGetReferencers = "SELECT from_guid FROM dependencies WHERE to_guid = @guid";
|
||||||
|
private const string SqlGetDependencies = "SELECT to_guid FROM dependencies WHERE from_guid = @guid";
|
||||||
|
private const string SqlInsertDep = "INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)";
|
||||||
|
private const string SqlClearDeps = "DELETE FROM dependencies WHERE from_guid = @guid";
|
||||||
|
private const string SqlEnumerate = "SELECT guid, source_path FROM assets";
|
||||||
|
private const string SqlEnumerateSubAssets = "SELECT guid, parent_guid, subasset_kind, display_name, stable_path, source_path, asset_type_id FROM assets WHERE parent_guid = @parent_guid ORDER BY stable_path";
|
||||||
|
private const string SqlDeleteSubAssetsForParent = "DELETE FROM assets WHERE parent_guid = @parent_guid";
|
||||||
|
|
||||||
|
public AssetCatalog(string dbPath)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||||
|
|
||||||
|
var builder = new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = dbPath,
|
||||||
|
ForeignKeys = true,
|
||||||
|
Pooling = true,
|
||||||
|
};
|
||||||
|
_connectionString = builder.ToString();
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using (var cmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA journal_mode = WAL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateSchemaInternal(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection OpenConnection()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateSchemaInternal(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
|
guid BLOB (16) PRIMARY KEY NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
asset_type_id BLOB (16),
|
||||||
|
handler_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
content_hash TEXT,
|
||||||
|
settings_hash TEXT,
|
||||||
|
imported_at_ms INTEGER,
|
||||||
|
parent_guid BLOB (16),
|
||||||
|
subasset_kind TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
stable_path TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_type_id ON assets(asset_type_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dependencies (
|
||||||
|
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
||||||
|
to_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (from_guid, to_guid)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dep_reverse ON dependencies(to_guid);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS labels (
|
||||||
|
guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (guid, label)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToUniversalPath(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(path).Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid GetGuid(string sourcePath)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlGetGuid;
|
||||||
|
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
||||||
|
var result = cmd.ExecuteScalar();
|
||||||
|
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetSourcePath(Guid guid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = SqlGetPath;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
return cmd.ExecuteScalar() as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpsertInternal(AssetMeta meta, string sourcePath, Guid? parentGuid, string? kind, string? displayName, string? stablePath)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = SqlUpsert;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray());
|
||||||
|
cmd.Parameters.AddWithValue("@path", ToUniversalPath(sourcePath));
|
||||||
|
cmd.Parameters.AddWithValue("@asset_type_id", meta.AssetTypeId?.ToByteArray() ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@version", meta.HandlerVersion);
|
||||||
|
cmd.Parameters.AddWithValue("@content_hash", meta.ContentHash ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@settings_hash", meta.SettingsHash ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@imported_at_ms", meta.LastImportedUtc?.Ticks ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@parent_guid", parentGuid?.ToByteArray() ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@subasset_kind", (object?)kind ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@display_name", (object?)displayName ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@stable_path", (object?)stablePath ?? DBNull.Value);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Upsert(AssetMeta meta, string sourcePath) => UpsertInternal(meta, sourcePath, null, null, null, null);
|
||||||
|
|
||||||
|
public void UpsertSubAsset(Guid parentGuid, AssetMeta meta, string sourcePath, string kind, string displayName, string stablePath)
|
||||||
|
=> UpsertInternal(meta, sourcePath, parentGuid, kind, displayName, stablePath);
|
||||||
|
|
||||||
|
public bool Remove(Guid guid)
|
||||||
|
{
|
||||||
|
var subAssets = GetSubAssets(guid);
|
||||||
|
foreach (var sub in subAssets)
|
||||||
|
{
|
||||||
|
Remove(sub.Guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlDelete;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
return cmd.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid GetAssetTypeId(Guid guid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlGetAssetTypeId;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
var result = cmd.ExecuteScalar();
|
||||||
|
return result is byte[] bytes ? new Guid(bytes) : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime? GetImportedAt(Guid guid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlGetImportedAt;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
var result = cmd.ExecuteScalar();
|
||||||
|
return result is long ticks ? new DateTime(ticks, DateTimeKind.Utc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var tx = connection.BeginTransaction();
|
||||||
|
|
||||||
|
using (var clearCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
clearCmd.Transaction = tx;
|
||||||
|
clearCmd.CommandText = SqlClearDeps;
|
||||||
|
clearCmd.Parameters.AddWithValue("@guid", assetId.ToByteArray());
|
||||||
|
clearCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dependencies.Length > 0)
|
||||||
|
{
|
||||||
|
using var insertCmd = connection.CreateCommand();
|
||||||
|
insertCmd.Transaction = tx;
|
||||||
|
insertCmd.CommandText = SqlInsertDep;
|
||||||
|
var fromParam = insertCmd.Parameters.Add("@from", SqliteType.Blob);
|
||||||
|
var toParam = insertCmd.Parameters.Add("@to", SqliteType.Blob);
|
||||||
|
fromParam.Value = assetId.ToByteArray();
|
||||||
|
|
||||||
|
foreach (var dep in dependencies)
|
||||||
|
{
|
||||||
|
toParam.Value = dep.ToByteArray();
|
||||||
|
insertCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Guid> GetReferencers(Guid guid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlGetReferencers;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
var list = new List<Guid>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
list.Add(new Guid((byte[])reader[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Guid> GetDependencies(Guid guid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlGetDependencies;
|
||||||
|
cmd.Parameters.AddWithValue("@guid", guid.ToByteArray());
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
var list = new List<Guid>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
list.Add(new Guid((byte[])reader[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll()
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlEnumerate;
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
yield return (new Guid((byte[])reader[0]), reader.GetString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Guid> EnumerateByTypes(params Guid[] assetTypeIds)
|
||||||
|
{
|
||||||
|
if (assetTypeIds.Length == 0)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
var parameterNames = new List<string>(assetTypeIds.Length);
|
||||||
|
for (var i = 0; i < assetTypeIds.Length; i++)
|
||||||
|
{
|
||||||
|
var paramName = $"@typeId{i}";
|
||||||
|
parameterNames.Add(paramName);
|
||||||
|
cmd.Parameters.AddWithValue(paramName, assetTypeIds[i].ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.CommandText = $"SELECT guid FROM assets WHERE asset_type_id IN ({string.Join(", ", parameterNames)})";
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
yield return new Guid((byte[])reader[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SubAssetInfo> GetSubAssets(Guid parentGuid)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = SqlEnumerateSubAssets;
|
||||||
|
cmd.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
var list = new List<SubAssetInfo>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
list.Add(new SubAssetInfo(
|
||||||
|
new Guid((byte[])reader[0]),
|
||||||
|
new Guid((byte[])reader[1]),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetString(3),
|
||||||
|
reader.GetString(4),
|
||||||
|
reader.GetString(5),
|
||||||
|
new Guid((byte[])reader[6])));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveSubAssetsExcept(Guid parentGuid, ReadOnlySpan<Guid> keepGuids)
|
||||||
|
{
|
||||||
|
if (keepGuids.Length == 0)
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = SqlDeleteSubAssetsForParent;
|
||||||
|
cmd.Parameters.AddWithValue("@parent_guid", parentGuid.ToByteArray());
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep = new HashSet<Guid>(keepGuids.Length);
|
||||||
|
foreach (var guid in keepGuids)
|
||||||
|
{
|
||||||
|
keep.Add(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var subAsset in GetSubAssets(parentGuid))
|
||||||
|
{
|
||||||
|
if (!keep.Contains(subAsset.Guid))
|
||||||
|
{
|
||||||
|
Remove(subAsset.Guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace TestProject.AssetDB;
|
|
||||||
|
|
||||||
internal partial class AssetRegistry
|
|
||||||
{
|
|
||||||
// TODO: Sqlite backend implementation
|
|
||||||
}
|
|
||||||
@@ -1,510 +1,463 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.AssetHandler;
|
using Ghost.Core.Utilities;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace TestProject.AssetDB;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
internal class PathComparer : IEqualityComparer<string>
|
/// <summary>
|
||||||
|
/// Central asset registry for the GhostEngine editor.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
||||||
{
|
{
|
||||||
private static string ToCanonicalPath(string? path)
|
private readonly AssetCatalog _catalog;
|
||||||
{
|
private readonly ImportCoordinator _importCoordinator;
|
||||||
return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Equals(string? x, string? y)
|
|
||||||
{
|
|
||||||
return string.Equals(
|
|
||||||
ToCanonicalPath(x),
|
|
||||||
ToCanonicalPath(y),
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int GetHashCode(string str)
|
|
||||||
{
|
|
||||||
return ToCanonicalPath(str).GetHashCode(StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Path based locking for multi-threaded access?
|
|
||||||
// Is it actually necessary since this is mostly used in editor environment where single-threaded access is common (99.999%)?
|
|
||||||
internal partial class AssetRegistry : IAssetRegistry
|
|
||||||
{
|
|
||||||
public const string ASSET_EXTENSION = ".gasset";
|
|
||||||
public const string TEMP_EXTENSION = ".gtemp";
|
|
||||||
|
|
||||||
private readonly string _rootDirectory;
|
|
||||||
private readonly FileSystemWatcher _watcher;
|
private readonly FileSystemWatcher _watcher;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, Guid> _pathToGuid;
|
private readonly ConcurrentDictionary<Guid, WeakReference<IAsset>> _loadedAssets;
|
||||||
private readonly ConcurrentDictionary<Guid, string> _guidToPath;
|
private readonly SemaphoreSlim _loadLock;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander;
|
private readonly ConcurrentDictionary<string, bool> _ignoreMetaWrites;
|
||||||
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
|
private readonly ConcurrentHashSet<Guid> _dirtyAssets;
|
||||||
|
private readonly ConcurrentDictionary<string, CancellationTokenSource> _eventDebouncers;
|
||||||
|
|
||||||
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph;
|
public event EventHandler<AssetChangedEventArgs>? OnAssetChanged;
|
||||||
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache;
|
public event EventHandler<Guid>? OnAssetImported
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
|
|
||||||
|
|
||||||
private readonly SemaphoreSlim _cacheSlim;
|
|
||||||
private readonly Lock _pathLock;
|
|
||||||
|
|
||||||
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
|
|
||||||
|
|
||||||
public AssetRegistry(string rootDirectory)
|
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(rootDirectory))
|
add => _importCoordinator.OnImportCompleted += value;
|
||||||
{
|
remove => _importCoordinator.OnImportCompleted -= value;
|
||||||
throw new DirectoryNotFoundException("The specified root directory does not exist.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Path.IsPathFullyQualified(rootDirectory))
|
public AssetRegistry()
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The specified root directory must be an absolute path.");
|
var dbPath = Path.Combine(EditorApplication.LibraryFolderPath, "AssetDB.sqlite");
|
||||||
}
|
|
||||||
|
|
||||||
_rootDirectory = rootDirectory;
|
_catalog = new AssetCatalog(dbPath);
|
||||||
_watcher = new FileSystemWatcher(rootDirectory)
|
_importCoordinator = new ImportCoordinator(_catalog);
|
||||||
|
|
||||||
|
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<IAsset>>();
|
||||||
|
_loadLock = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
|
_ignoreMetaWrites = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
_dirtyAssets = new ConcurrentHashSet<Guid>();
|
||||||
|
_eventDebouncers = new ConcurrentDictionary<string, CancellationTokenSource>();
|
||||||
|
|
||||||
|
SyncCatalogWithDisk();
|
||||||
|
|
||||||
|
_watcher = new FileSystemWatcher(EditorApplication.AssetsFolderPath)
|
||||||
{
|
{
|
||||||
IncludeSubdirectories = true,
|
IncludeSubdirectories = true,
|
||||||
EnableRaisingEvents = true,
|
EnableRaisingEvents = true,
|
||||||
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
|
||||||
};
|
};
|
||||||
|
|
||||||
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer());
|
_watcher.Created += OnFileSystemEvent;
|
||||||
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512);
|
_watcher.Deleted += OnFileSystemEvent;
|
||||||
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16);
|
_watcher.Changed += OnFileSystemEvent;
|
||||||
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512);
|
_watcher.Renamed += OnFileSystemRenameEvent;
|
||||||
|
|
||||||
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
|
|
||||||
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
|
|
||||||
|
|
||||||
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
_cacheSlim = new SemaphoreSlim(1, 1);
|
|
||||||
_pathLock = new Lock();
|
|
||||||
|
|
||||||
LoadExistingAssets();
|
|
||||||
|
|
||||||
_watcher.Created += OnFileSystemOp;
|
|
||||||
_watcher.Deleted += OnFileSystemOp;
|
|
||||||
_watcher.Changed += OnFileSystemOp;
|
|
||||||
_watcher.Renamed += OnFileSystemRenameOp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: DB Cache
|
private void SyncCatalogWithDisk()
|
||||||
private unsafe void LoadExistingAssets()
|
|
||||||
{
|
{
|
||||||
Span<byte> guidBuffer = stackalloc byte[sizeof(Guid)];
|
if (!Directory.Exists(EditorApplication.AssetsFolderPath))
|
||||||
foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
var relativePath = Path.GetRelativePath(_rootDirectory, filePath);
|
|
||||||
|
|
||||||
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 _))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
var metaFiles = Directory.EnumerateFiles(EditorApplication.AssetsFolderPath, "*.gmeta", SearchOption.AllDirectories);
|
||||||
var ext = Path.GetExtension(relativePath);
|
var foundGuids = new HashSet<Guid>();
|
||||||
|
|
||||||
|
foreach (var metaPath in metaFiles)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (guid, path) in _catalog.EnumerateAll())
|
||||||
|
{
|
||||||
|
if (path.Contains('#', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
|
||||||
|
var fileExists = File.Exists(e.FullPath);
|
||||||
|
|
||||||
|
if (ext == AssetMetaIO.META_EXTENSION)
|
||||||
|
{
|
||||||
|
if (fileExists)
|
||||||
|
{
|
||||||
|
var meta = await AssetMetaIO.ReadAsync(e.FullPath);
|
||||||
|
if (meta != null)
|
||||||
|
{
|
||||||
|
_catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath));
|
||||||
|
await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), relativePath, ImportReason.SettingsChanged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var changeType = AssetChangeType.None;
|
var changeType = AssetChangeType.None;
|
||||||
var fireEvent = false;
|
var guid = _catalog.GetGuid(relativePath);
|
||||||
var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal);
|
|
||||||
var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal);
|
|
||||||
|
|
||||||
switch (e.ChangeType)
|
if (!fileExists)
|
||||||
{
|
{
|
||||||
case WatcherChangeTypes.Created:
|
// The file is no longer on disk. Wait safely completed.
|
||||||
changeType = AssetChangeType.Created;
|
if (guid != Guid.Empty)
|
||||||
if (!isAsset && !isTemp)
|
|
||||||
{
|
{
|
||||||
var handler = GetAssetHandlerForExtension(ext);
|
_catalog.Remove(guid);
|
||||||
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;
|
changeType = AssetChangeType.Deleted;
|
||||||
if (isAsset)
|
}
|
||||||
|
|
||||||
|
Logger.DebugAssert(e.ChangeType == WatcherChangeTypes.Deleted);
|
||||||
|
}
|
||||||
|
else if (guid == Guid.Empty)
|
||||||
{
|
{
|
||||||
fireEvent = RemovePathMappingByPath(relativePath);
|
// The file exists but isn't located inside our catalog yet -> Essentially a Creation
|
||||||
|
await HandleNewSourceFileAsync(relativePath);
|
||||||
|
changeType = AssetChangeType.Created;
|
||||||
}
|
}
|
||||||
break;
|
else
|
||||||
|
{
|
||||||
case WatcherChangeTypes.Changed:
|
// 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;
|
changeType = AssetChangeType.Modified;
|
||||||
fireEvent = isAsset;
|
|
||||||
break;
|
|
||||||
case WatcherChangeTypes.All:
|
|
||||||
// Can this even happen?
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fireEvent)
|
if (changeType != AssetChangeType.None)
|
||||||
{
|
{
|
||||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
|
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(e.FullPath);
|
Logger.Warning($"FileSystemEvent exception: {ex.Message}");
|
||||||
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFileSystemRenameEvent(object sender, RenamedEventArgs e)
|
||||||
|
{
|
||||||
|
var oldRelative = Path.GetRelativePath(EditorApplication.ProjectPath, e.OldFullPath);
|
||||||
|
var newRelative = Path.GetRelativePath(EditorApplication.ProjectPath, e.FullPath);
|
||||||
|
|
||||||
|
var guid = _catalog.GetGuid(oldRelative);
|
||||||
|
if (guid != Guid.Empty)
|
||||||
|
{
|
||||||
|
_catalog.Remove(guid);
|
||||||
|
var metaFile = AssetMetaIO.GetMetaPath(newRelative);
|
||||||
|
if (File.Exists(metaFile))
|
||||||
|
{
|
||||||
|
var meta = AssetMetaIO.ReadAsync(metaFile).AsTask().Result;
|
||||||
|
if (meta != null)
|
||||||
|
{
|
||||||
|
_catalog.Upsert(meta, newRelative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelative, oldRelative, AssetChangeType.Renamed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleNewSourceFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(relativePath);
|
||||||
|
var handler = AssetHandlerRegistry.GetByExtension(ext);
|
||||||
|
|
||||||
|
var metaPath = AssetMetaIO.GetMetaPath(relativePath);
|
||||||
|
if (File.Exists(metaPath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
|
var assetTypeId = Guid.Empty;
|
||||||
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
|
if (AssetHandlerRegistry.TryGetHandlerInfoByExtension(ext, out var handlerInfo))
|
||||||
|
|
||||||
if (_pathToGuid.Remove(oldRelativePath, out var guid))
|
|
||||||
{
|
{
|
||||||
UpdatePathMapping(newRelativePath, guid);
|
assetTypeId = handlerInfo.EditorAssetTypeID;
|
||||||
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var meta = new AssetMeta
|
||||||
|
{
|
||||||
|
Guid = Guid.NewGuid(),
|
||||||
|
AssetTypeId = assetTypeId,
|
||||||
|
HandlerVersion = 1,
|
||||||
|
Settings = handler?.CreateDefaultSettings(ext)
|
||||||
|
};
|
||||||
|
|
||||||
|
_ignoreMetaWrites[metaPath] = true;
|
||||||
|
await AssetMetaIO.WriteAsync(metaPath, meta);
|
||||||
|
|
||||||
|
_catalog.Upsert(meta, relativePath);
|
||||||
|
|
||||||
|
await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, relativePath, metaPath, ImportReason.NewAsset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetCatalog GetAssetCatalog()
|
||||||
|
{
|
||||||
|
return _catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetAssetPath(Guid id)
|
public string? GetAssetPath(Guid id)
|
||||||
{
|
{
|
||||||
lock (_pathLock)
|
return _catalog.GetSourcePath(id);
|
||||||
{
|
|
||||||
if (_guidToPath.TryGetValue(id, out var path))
|
|
||||||
{
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid GetAssetGuid(string path)
|
public Guid GetAssetGuid(string path)
|
||||||
{
|
{
|
||||||
lock (_pathLock)
|
return _catalog.GetGuid(path);
|
||||||
{
|
|
||||||
if (_pathToGuid.TryGetValue(path, out var guid))
|
|
||||||
{
|
|
||||||
return guid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Guid.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IAssetHandler GetAssetHandler(Type type)
|
|
||||||
{
|
|
||||||
var typeHandle = type.TypeHandle.Value;
|
|
||||||
if (_cachedHander.TryGetValue(typeHandle, out var handler))
|
|
||||||
{
|
|
||||||
return handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
var obj = Activator.CreateInstance(type);
|
|
||||||
if (obj is not IAssetHandler newHandler)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
|
||||||
if (attr is null || attr.AllowCaching)
|
|
||||||
{
|
|
||||||
_cachedHander[typeHandle] = newHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IAssetHandler? GetAssetHandlerForExtension(string extension)
|
|
||||||
{
|
|
||||||
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
|
||||||
.SelectMany(assembly => assembly.GetTypes())
|
|
||||||
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
|
||||||
{
|
|
||||||
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
|
||||||
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return GetAssetHandler(handlerType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId)
|
|
||||||
{
|
|
||||||
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
|
|
||||||
.SelectMany(assembly => assembly.GetTypes())
|
|
||||||
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
|
|
||||||
{
|
|
||||||
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
|
|
||||||
if (attr is not null && new Guid(attr.ID) == typeId)
|
|
||||||
{
|
|
||||||
return GetAssetHandler(handlerType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
|
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
if (!File.Exists(sourceFilePath))
|
// Simple copy + wait for FSW or manually trigger?
|
||||||
{
|
// Current requirement: "returns the new GUID immediately (import happens in background)"
|
||||||
return Result.Failure("Source file not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var ext = Path.GetExtension(sourceFilePath);
|
Directory.CreateDirectory(Path.GetDirectoryName(targetAssetPath)!);
|
||||||
var handler = GetAssetHandlerForExtension(ext);
|
File.Copy(sourceFilePath, targetAssetPath, true);
|
||||||
if (handler is not IImportableAssetHandler importableHandler)
|
|
||||||
{
|
|
||||||
return Result.Failure("No importable asset handler found for the given file extension.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var guid = Guid.NewGuid();
|
// FSW will trigger but we can speed it up
|
||||||
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory);
|
await HandleNewSourceFileAsync(targetAssetPath);
|
||||||
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
|
|
||||||
{
|
|
||||||
return Result.Failure("Asset import failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdatePathMapping(targetAssetPath, guid);
|
var guid = _catalog.GetGuid(targetAssetPath);
|
||||||
return guid;
|
return Result.Success(guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
|
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
var assetPath = GetAssetPath(assetId);
|
var path = _catalog.GetSourcePath(assetId);
|
||||||
if (string.IsNullOrEmpty(assetPath))
|
if (path == null)
|
||||||
{
|
{
|
||||||
return Result.Failure("Asset not found in DB");
|
return Result.Failure("Asset not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullAssetPath = Path.GetFullPath(assetPath, _rootDirectory);
|
var metaPath = AssetMetaIO.GetMetaPath(path);
|
||||||
|
|
||||||
// 2. Identify the Handler
|
await _importCoordinator.EnqueueAsync(new ImportJob(assetId, path, metaPath, ImportReason.ManualReimport), token);
|
||||||
// (You might want to store SourcePath in metadata later so you don't need to pass it here)
|
return Result.Success();
|
||||||
var ext = Path.GetExtension(sourceFilePath);
|
|
||||||
var handler = GetAssetHandlerForExtension(ext);
|
|
||||||
if (handler is not IImportableAssetHandler importableHandler)
|
|
||||||
{
|
|
||||||
return Result.Failure("No importable asset handler found for the given file extension.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ignoreFileChanges[fullAssetPath] = true;
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(Guid id, CancellationToken token = default)
|
||||||
|
|
||||||
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read);
|
|
||||||
await using var targetStream = new FileStream(fullAssetPath, FileMode.Create, FileAccess.Write);
|
|
||||||
|
|
||||||
await importableHandler.ImportAsync(sourceStream, targetStream, assetId, token);
|
|
||||||
if (_loadedAssets.TryGetValue(assetId, out var weakRef) && weakRef.TryGetTarget(out var liveAsset))
|
|
||||||
{
|
{
|
||||||
await liveAsset.RefreshAsync(this, token);
|
if (_loadedAssets.TryGetValue(id, out var weakRef) && weakRef.TryGetTarget(out var asset))
|
||||||
|
{
|
||||||
|
return Result.Success(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _loadLock.WaitAsync(token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_loadedAssets.TryGetValue(id, out weakRef) && weakRef.TryGetTarget(out asset))
|
||||||
|
{
|
||||||
|
return Result.Success(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = GetAssetPath(id);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = AssetHandlerRegistry.GetByExtension(Path.GetExtension(path));
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
return Result.Failure("No Available handler type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = await AssetMetaIO.ReadAsync(AssetMetaIO.GetMetaPath(path), token);
|
||||||
|
if (meta is null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Meta file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handler.LoadAssetAsync(path, id, meta.Settings, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetAsync(IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = GetAssetPath(asset.ID);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = AssetHandlerRegistry.GetByAssetTypeId(asset.TypeID);
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
return Result.Failure("No Avaliable handler type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will trigger the fsw and reimport automatically.
|
||||||
|
return await handler.SaveAssetAsync(path, asset, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetAsync(Guid id, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var result = await LoadAssetAsync(id, token);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SaveAssetAsync(result.Value, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetAssetDirty(Guid id)
|
||||||
|
{
|
||||||
|
_dirtyAssets.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (_dirtyAssets.Contains(asset.ID))
|
||||||
|
{
|
||||||
|
var result = await SaveAssetAsync(asset, token);
|
||||||
|
_dirtyAssets.Remove(asset.ID);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default)
|
public async ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
// TODO: weakRef based locking instead of global lock for better concurrency.
|
var result = await LoadAssetAsync(id, token);
|
||||||
// We should use GetOrAdd here.
|
if (result.IsFailure)
|
||||||
if (_loadedAssets.TryGetValue(id, out var weakRef)
|
|
||||||
&& weakRef.TryGetTarget(out var existingAsset))
|
|
||||||
{
|
{
|
||||||
return existingAsset;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _cacheSlim.WaitAsync(token);
|
return await SaveAssetIfDirtyAsync(result.Value, token);
|
||||||
|
|
||||||
// Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting.
|
|
||||||
if (_loadedAssets.TryGetValue(id, out weakRef)
|
|
||||||
&& weakRef.TryGetTarget(out existingAsset))
|
|
||||||
{
|
|
||||||
return existingAsset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
public async ValueTask<Result[]> SaveDirtyAssetsAsync()
|
||||||
|
{
|
||||||
|
if (_dirtyAssets.IsEmpty)
|
||||||
|
{
|
||||||
|
return Array.Empty<Result>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = new Task<Result>[_dirtyAssets.Count];
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
foreach (var id in _dirtyAssets)
|
||||||
|
{
|
||||||
|
tasks[i++] = SaveAssetIfDirtyAsync(id).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
_dirtyAssets.Clear();
|
||||||
|
return await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Result> OpenAssetAsync(Guid id)
|
||||||
{
|
{
|
||||||
var path = GetAssetPath(id);
|
var path = GetAssetPath(id);
|
||||||
if (string.IsNullOrEmpty(path))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var assetPath = Path.GetFullPath(path, _rootDirectory);
|
|
||||||
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
|
|
||||||
int sizeofGuid;
|
|
||||||
unsafe
|
|
||||||
{
|
|
||||||
sizeofGuid = sizeof(Guid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
|
|
||||||
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
|
|
||||||
fs.ReadExactly(typeIdBuffer);
|
|
||||||
|
|
||||||
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
|
|
||||||
var handler = GetAssetHandlerForTypeId(guid);
|
|
||||||
if (handler == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var assetResult = await handler.LoadAsync(fs, this, token);
|
|
||||||
if (assetResult.IsFailure)
|
|
||||||
{
|
|
||||||
return assetResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
var asset = assetResult.Value;
|
|
||||||
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(asset), (key, oldRef) =>
|
|
||||||
{
|
|
||||||
// If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected.
|
|
||||||
// If the assetResult haven't been loaded before, we are in the addValue path, not here.
|
|
||||||
// If the previous reference has been collected, we can just replace it with the new one.
|
|
||||||
// Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time.
|
|
||||||
oldRef.SetTarget(asset);
|
|
||||||
return oldRef;
|
|
||||||
});
|
|
||||||
|
|
||||||
return assetResult;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_cacheSlim.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
var path = GetAssetPath(asset.ID);
|
|
||||||
if (path == null)
|
if (path == null)
|
||||||
{
|
{
|
||||||
return Result.Failure("Asset not found.");
|
return Task.FromResult(Result.Failure("Asset not found."));
|
||||||
}
|
}
|
||||||
|
|
||||||
var handler = GetAssetHandlerForTypeId(asset.TypeID);
|
return OpenAssetAsync(path);
|
||||||
if (handler == null)
|
}
|
||||||
|
|
||||||
|
public Task<Result> OpenAssetAsync(string assetPath)
|
||||||
{
|
{
|
||||||
return Result.Failure("No asset handler found for the given asset type.");
|
try
|
||||||
|
{
|
||||||
|
var method = TypeCache.GetMethodsWithAttribute<AssetOpenHandlerAttribute>()?
|
||||||
|
.FirstOrDefault(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>()?.Extensions.Contains(Path.GetExtension(assetPath)) ?? false);
|
||||||
|
|
||||||
|
if (method == null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure("No handler for this asset type."));
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullPath = Path.GetFullPath(path, _rootDirectory);
|
return (Task<Result>)method.Invoke(null, new object[] { assetPath })!;
|
||||||
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
}
|
||||||
return await handler.SaveAsync(asset, fs, this, token);
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure($"Failed to open asset: {ex.Message}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_cacheSlim.Dispose();
|
|
||||||
_watcher.Dispose();
|
_watcher.Dispose();
|
||||||
|
_importCoordinator.Dispose();
|
||||||
|
_loadLock.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
274
src/Editor/Ghost.Editor.Core/Services/DXCShaderCompiler.cs
Normal file
274
src/Editor/Ghost.Editor.Core/Services/DXCShaderCompiler.cs
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.DXC;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Graphics.D3D12.Utilities;
|
||||||
|
using Misaki.HighPerformance.LowLevel;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using static Ghost.DXC.UUID;
|
||||||
|
|
||||||
|
namespace Ghost.Graphics.Core;
|
||||||
|
|
||||||
|
internal sealed partial class DXCShaderCompiler
|
||||||
|
{
|
||||||
|
private static string GetProfileString(ShaderStage stage, ShaderModel version)
|
||||||
|
{
|
||||||
|
return (stage, version) switch
|
||||||
|
{
|
||||||
|
(ShaderStage.TaskShader, ShaderModel.SM_6_6) => "as_6_6",
|
||||||
|
(ShaderStage.PixelShader, ShaderModel.SM_6_6) => "ps_6_6",
|
||||||
|
(ShaderStage.MeshShader, ShaderModel.SM_6_6) => "ms_6_6",
|
||||||
|
(ShaderStage.ComputeShader, ShaderModel.SM_6_6) => "cs_6_6",
|
||||||
|
(ShaderStage.Library, ShaderModel.SM_6_6) => "lib_6_6",
|
||||||
|
(ShaderStage.TaskShader, ShaderModel.SM_6_7) => "as_6_7",
|
||||||
|
(ShaderStage.PixelShader, ShaderModel.SM_6_7) => "ps_6_7",
|
||||||
|
(ShaderStage.MeshShader, ShaderModel.SM_6_7) => "ms_6_7",
|
||||||
|
(ShaderStage.ComputeShader, ShaderModel.SM_6_7) => "cs_6_7",
|
||||||
|
(ShaderStage.Library, ShaderModel.SM_6_7) => "lib_6_7",
|
||||||
|
(ShaderStage.TaskShader, ShaderModel.SM_6_8) => "as_6_8",
|
||||||
|
(ShaderStage.PixelShader, ShaderModel.SM_6_8) => "ps_6_8",
|
||||||
|
(ShaderStage.MeshShader, ShaderModel.SM_6_8) => "ms_6_8",
|
||||||
|
(ShaderStage.ComputeShader, ShaderModel.SM_6_8) => "cs_6_8",
|
||||||
|
(ShaderStage.Library, ShaderModel.SM_6_8) => "lib_6_8",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(stage), "Unsupported shader stage or compiler version")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOptimizeLevelString(CompilerOptimizeLevel level)
|
||||||
|
{
|
||||||
|
return level switch
|
||||||
|
{
|
||||||
|
CompilerOptimizeLevel.O0 => "-O0",
|
||||||
|
CompilerOptimizeLevel.O1 => "-O1",
|
||||||
|
CompilerOptimizeLevel.O2 => "-O2",
|
||||||
|
CompilerOptimizeLevel.O3 => "-O3",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(level), "Unsupported optimization level")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetCompilerArguments(ref readonly ShaderCompilationConfig config)
|
||||||
|
{
|
||||||
|
var argsArray = new List<string>
|
||||||
|
{
|
||||||
|
"-T", GetProfileString(config.stage, config.model), // Target profile (ms_6_6, ps_6_6)
|
||||||
|
"-E", config.entryPoint, // Entry point
|
||||||
|
"-HV", "2021", // HLSL version 2021
|
||||||
|
"-enable-16bit-types", // Enable 16-bit types
|
||||||
|
GetOptimizeLevelString(config.optimizeLevel), // Optimization level
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var define in config.defines)
|
||||||
|
{
|
||||||
|
argsArray.Add("-D");
|
||||||
|
argsArray.Add(define);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.stage == ShaderStage.TaskShader
|
||||||
|
|| config.stage == ShaderStage.MeshShader
|
||||||
|
|| config.stage == ShaderStage.PixelShader)
|
||||||
|
{
|
||||||
|
argsArray.Add("-D");
|
||||||
|
argsArray.Add("__GRAPHICS__");
|
||||||
|
}
|
||||||
|
else if (config.stage == ShaderStage.ComputeShader)
|
||||||
|
{
|
||||||
|
argsArray.Add("-D");
|
||||||
|
argsArray.Add("__COMPUTE__");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.options.HasFlag(CompilerOption.KeepDebugInfo))
|
||||||
|
{
|
||||||
|
argsArray.Add("-Qstrip_debug");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.options.HasFlag(CompilerOption.KeepReflections))
|
||||||
|
{
|
||||||
|
argsArray.Add("-Qstrip_reflect");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.options.HasFlag(CompilerOption.WarnAsError))
|
||||||
|
{
|
||||||
|
argsArray.Add("-WX");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.options.HasFlag(CompilerOption.SpirvCrossCompile))
|
||||||
|
{
|
||||||
|
argsArray.Add("-spirv");
|
||||||
|
}
|
||||||
|
|
||||||
|
argsArray.Add("-rootsig-define");
|
||||||
|
argsArray.Add("GLOBAL_BINDLESS_SIG");
|
||||||
|
|
||||||
|
return argsArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed unsafe partial class DXCShaderCompiler : IShaderCompiler
|
||||||
|
{
|
||||||
|
private UniquePtr<IDxcCompiler3> _compiler;
|
||||||
|
private UniquePtr<IDxcUtils> _utils;
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public DXCShaderCompiler()
|
||||||
|
{
|
||||||
|
IDxcCompiler3* pCompiler = default;
|
||||||
|
IDxcUtils* pUtils = default;
|
||||||
|
var hr = Api.DxcCreateInstance((Guid*)Unsafe.AsPointer(in Api.CLSID_DxcCompiler), __uuidof(pCompiler), (void**)&pCompiler);
|
||||||
|
if (hr < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to create DXC compiler instance. HRESULT: 0x{hr:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
hr = Api.DxcCreateInstance((Guid*)Unsafe.AsPointer(in Api.CLSID_DxcUtils), __uuidof(pUtils), (void**)&pUtils);
|
||||||
|
if (hr < 0)
|
||||||
|
{
|
||||||
|
pCompiler->Release();
|
||||||
|
throw new InvalidOperationException($"Failed to create DXC utils instance. HRESULT: 0x{hr:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_compiler.Attach(pCompiler);
|
||||||
|
_utils.Attach(pUtils);
|
||||||
|
}
|
||||||
|
|
||||||
|
~DXCShaderCompiler()
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle allocationHandle)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
IDxcIncludeHandler* includeHandler = default;
|
||||||
|
IDxcBlobEncoding* sourceBlob = default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hr = _utils.Get()->CreateDefaultIncludeHandler(&includeHandler);
|
||||||
|
if (hr < 0)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to create default include handler. HRESULT: 0x{hr:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed (byte* pCode = Encoding.UTF8.GetBytes(config.shaderCode))
|
||||||
|
{
|
||||||
|
var sizeInBytes = Encoding.UTF8.GetByteCount(config.shaderCode);
|
||||||
|
hr = _utils.Get()->CreateBlobFromPinned(pCode, (uint)sizeInBytes, Api.DXC_CP_UTF8, &sourceBlob);
|
||||||
|
if (hr < 0)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to create blob from shader code. HRESULT: 0x{hr:X8}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var argsArray = GetCompilerArguments(in config);
|
||||||
|
var argPtrs = stackalloc char*[argsArray.Count];
|
||||||
|
for (var i = 0; i < argsArray.Count; i++)
|
||||||
|
{
|
||||||
|
argPtrs[i] = (char*)Marshal.StringToHGlobalUni(argsArray[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
IDxcResult* result = default;
|
||||||
|
IDxcBlob* bytecodeBlob = default;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Compile shader
|
||||||
|
var buffer = new DxcBuffer
|
||||||
|
{
|
||||||
|
Ptr = sourceBlob->GetBufferPointer(),
|
||||||
|
Size = sourceBlob->GetBufferSize(),
|
||||||
|
Encoding = Api.DXC_CP_UTF8
|
||||||
|
};
|
||||||
|
|
||||||
|
hr = _compiler.Get()->Compile(&buffer, argPtrs, (uint)argsArray.Count, includeHandler, __uuidof(result), (void**)&result);
|
||||||
|
if (hr < 0)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to compile shader. HRESULT: 0x{hr:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check compilation result
|
||||||
|
int hrStatus;
|
||||||
|
result->GetStatus(&hrStatus);
|
||||||
|
if (hrStatus < 0)
|
||||||
|
{
|
||||||
|
// Get error messages
|
||||||
|
IDxcBlobEncoding* pErrorBlob = default;
|
||||||
|
result->GetErrorBuffer(&pErrorBlob);
|
||||||
|
|
||||||
|
if (pErrorBlob != null)
|
||||||
|
{
|
||||||
|
var errorMessage = Marshal.PtrToStringUTF8((IntPtr)pErrorBlob->GetBufferPointer());
|
||||||
|
pErrorBlob->Release();
|
||||||
|
|
||||||
|
return Result.Failure($"DXC shader compilation failed:\n{errorMessage}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Result.Failure("DXC shader compilation failed with unknown error.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get compiled bytecode
|
||||||
|
hr = result->GetResult(&bytecodeBlob);
|
||||||
|
if (hr < 0)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to get compiled shader bytecode. HRESULT: 0x{hr:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytecodeSize = bytecodeBlob->GetBufferSize();
|
||||||
|
var bytecode = new UnsafeArray<byte>((int)bytecodeSize, allocationHandle);
|
||||||
|
|
||||||
|
NativeMemory.Copy(bytecodeBlob->GetBufferPointer(), bytecode.GetUnsafePtr(), (nuint)bytecodeSize);
|
||||||
|
|
||||||
|
return bytecode;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
result->Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytecodeBlob != null)
|
||||||
|
{
|
||||||
|
bytecodeBlob->Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < argsArray.Count; i++)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal((nint)argPtrs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (includeHandler != null)
|
||||||
|
{
|
||||||
|
includeHandler->Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceBlob != null)
|
||||||
|
{
|
||||||
|
sourceBlob->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_compiler.Get()->Release();
|
||||||
|
_utils.Get()->Release();
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal class EditorContentProvider : IContentProvider
|
||||||
|
{
|
||||||
|
private readonly AssetCatalog _catalog;
|
||||||
|
|
||||||
|
public EditorContentProvider(IAssetRegistry assetRegistry)
|
||||||
|
{
|
||||||
|
_catalog = assetRegistry.GetAssetCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasAsset(Guid guid)
|
||||||
|
{
|
||||||
|
return _catalog.GetSourcePath(guid) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<Stream> OpenRead(Guid guid, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var importedPath = ImportCoordinator.GetImportedAssetPath(guid);
|
||||||
|
if (!File.Exists(importedPath))
|
||||||
|
{
|
||||||
|
return Result.Failure($"Imported asset not found for GUID: {guid}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileStream(importedPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid[] GetDependencies(Guid guid)
|
||||||
|
{
|
||||||
|
return _catalog.GetDependencies(guid).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetType GetAssetType(Guid guid)
|
||||||
|
{
|
||||||
|
var assetTypeID = _catalog.GetAssetTypeId(guid);
|
||||||
|
if (AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(assetTypeID, out var info))
|
||||||
|
{
|
||||||
|
return info.RuntimeAssetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AssetType.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal sealed class EditorShaderCompilerBridge : IShaderCompilationBridge
|
||||||
|
{
|
||||||
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IShaderCompiler _compiler;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<ulong, Guid> _shaderIdToAssetId = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, Dictionary<int, string>[]> _assetKeywordMappings = new();
|
||||||
|
private Task? _shaderDictionaryPopulated;
|
||||||
|
|
||||||
|
public event ShaderVariantCompiledHandler? OnShaderVariantCompiled;
|
||||||
|
public event Action<ulong>? OnShaderInvalidated;
|
||||||
|
|
||||||
|
public EditorShaderCompilerBridge(IAssetRegistry assetRegistry, IServiceProvider serviceProvider, IShaderCompiler shaderCompiler)
|
||||||
|
{
|
||||||
|
_assetRegistry = assetRegistry;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_compiler = shaderCompiler;
|
||||||
|
|
||||||
|
_assetRegistry.OnAssetImported += OnAssetImported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAssetImported(object? sender, Guid guid)
|
||||||
|
{
|
||||||
|
var path = _assetRegistry.GetAssetPath(guid);
|
||||||
|
if (path != null && (path.EndsWith(".gshdr") || path.EndsWith(".gcomp")))
|
||||||
|
{
|
||||||
|
var result = _assetRegistry.LoadAssetAsync(guid).AsTask().Result;
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var nameHash = ExtractNameHash(result.Value);
|
||||||
|
if (nameHash != 0)
|
||||||
|
{
|
||||||
|
_shaderIdToAssetId[nameHash] = guid;
|
||||||
|
BuildKeywordMappings(result.Value, guid);
|
||||||
|
|
||||||
|
OnShaderInvalidated?.Invoke(nameHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static ulong ExtractNameHash(IAsset asset)
|
||||||
|
{
|
||||||
|
if (asset is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
return RHIUtility.GetShaderID(graphicsAsset.Descriptor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
return RHIUtility.GetShaderID(computeAsset.Descriptor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task EnsureShaderDictionaryPopulatedAsync()
|
||||||
|
{
|
||||||
|
var existing = Volatile.Read(ref _shaderDictionaryPopulated);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var original = Interlocked.CompareExchange(ref _shaderDictionaryPopulated, tcs.Task, null);
|
||||||
|
if (original != null)
|
||||||
|
{
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var catalog = _assetRegistry.GetAssetCatalog();
|
||||||
|
var assetGuids = catalog.EnumerateByTypes(typeof(GraphicsShaderAsset).GUID, typeof(ComputeShaderAsset).GUID);
|
||||||
|
|
||||||
|
foreach (var assetGuid in assetGuids)
|
||||||
|
{
|
||||||
|
var result = await _assetRegistry.LoadAssetAsync(assetGuid);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var nameHash = ExtractNameHash(result.Value);
|
||||||
|
if (nameHash != 0)
|
||||||
|
{
|
||||||
|
_shaderIdToAssetId[nameHash] = assetGuid;
|
||||||
|
BuildKeywordMappings(result.Value, assetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.SetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildKeywordMappings(IAsset asset, Guid assetId)
|
||||||
|
{
|
||||||
|
if (asset is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
var passes = graphicsAsset.Descriptor.Passes;
|
||||||
|
var mappings = new Dictionary<int, string>[passes.Length];
|
||||||
|
for (var i = 0; i < passes.Length; i++)
|
||||||
|
{
|
||||||
|
mappings[i] = BuildKeywordMappingFromGroups(passes[i].keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
_assetKeywordMappings[assetId] = mappings;
|
||||||
|
}
|
||||||
|
else if (asset is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
var entryCount = computeAsset.Descriptor.ShaderCodes.Length;
|
||||||
|
var mappings = new Dictionary<int, string>[entryCount];
|
||||||
|
var sharedMapping = BuildKeywordMappingFromGroups(computeAsset.Descriptor.Keywords);
|
||||||
|
for (var i = 0; i < entryCount; i++)
|
||||||
|
{
|
||||||
|
mappings[i] = sharedMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
_assetKeywordMappings[assetId] = mappings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<int, string> BuildKeywordMappingFromGroups(KeywordsGroup[] groups)
|
||||||
|
{
|
||||||
|
var mapping = new Dictionary<int, string>();
|
||||||
|
var localIndex = 0;
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group.keywords == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.space != KeywordSpace.Local)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kw in group.keywords)
|
||||||
|
{
|
||||||
|
mapping[localIndex++] = kw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] BuildVariantDefines(LocalKeywordSet keywordMask, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
if (keywordMapping == null || keywordMapping.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var defines = new List<string>(keywordMapping.Count);
|
||||||
|
foreach (var (localIndex, keywordName) in keywordMapping)
|
||||||
|
{
|
||||||
|
if (keywordMask.IsKeywordEnabled(localIndex))
|
||||||
|
{
|
||||||
|
defines.Add(keywordName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defines.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReadOnlySpan<string> CombineDefines(ReadOnlySpan<string> staticDefines, ReadOnlySpan<string> variantDefines)
|
||||||
|
{
|
||||||
|
if (variantDefines.Length == 0)
|
||||||
|
{
|
||||||
|
return staticDefines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staticDefines.Length == 0)
|
||||||
|
{
|
||||||
|
return variantDefines;
|
||||||
|
}
|
||||||
|
|
||||||
|
var combined = new string[staticDefines.Length + variantDefines.Length];
|
||||||
|
staticDefines.CopyTo(combined);
|
||||||
|
variantDefines.CopyTo(combined.AsSpan(staticDefines.Length));
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestCompilation(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask)
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await EnsureShaderDictionaryPopulatedAsync();
|
||||||
|
|
||||||
|
if (!_shaderIdToAssetId.TryGetValue(shaderId, out var assetId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetResult = await _assetRegistry.LoadAssetAsync(assetId);
|
||||||
|
if (assetResult.IsFailure)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, string>? keywordMapping = null;
|
||||||
|
if (_assetKeywordMappings.TryGetValue(assetId, out var mappings) && passIndex < mappings.Length)
|
||||||
|
{
|
||||||
|
keywordMapping = mappings[passIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetResult.Value is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
var pass = graphicsAsset.Descriptor.Passes[passIndex];
|
||||||
|
await CompileGraphicsPassAsync(shaderId, passIndex, variantKey, keywordMask, pass, graphicsAsset.Descriptor.ShaderModel, keywordMapping);
|
||||||
|
}
|
||||||
|
else if (assetResult.Value is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
await CompileComputePassAsync(shaderId, passIndex, variantKey, keywordMask, computeAsset.Descriptor, passIndex, keywordMapping);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Task CompileGraphicsPassAsync(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask, PassDescriptor descriptor, ShaderModel shaderModel, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
var variantDefines = BuildVariantDefines(keywordMask, keywordMapping);
|
||||||
|
|
||||||
|
var additionalConfig = new ShaderCompilationConfig
|
||||||
|
{
|
||||||
|
defines = variantDefines,
|
||||||
|
model = shaderModel,
|
||||||
|
optimizeLevel = CompilerOptimizeLevel.O3,
|
||||||
|
options = CompilerOption.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var compileResult = _compiler.CompileShaderPass(ref descriptor, ref additionalConfig, AllocationHandle.Persistent);
|
||||||
|
if (compileResult.IsFailure)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to compile graphics shader {shaderId}: {compileResult.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var compiled = compileResult.Value;
|
||||||
|
|
||||||
|
var stageCount = 0;
|
||||||
|
if (compiled.asResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.msResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.psResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byteCodes = stackalloc ShaderByteCode[stageCount];
|
||||||
|
var idx = 0;
|
||||||
|
if (compiled.asResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.asResult.GetUnsafePtr(), size = (ulong)compiled.asResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.msResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.msResult.GetUnsafePtr(), size = (ulong)compiled.msResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.psResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.psResult.GetUnsafePtr(), size = (ulong)compiled.psResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
OnShaderVariantCompiled?.Invoke(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(byteCodes, stageCount));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Task CompileComputePassAsync(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask, ComputeShaderDescriptor descriptor, int entryIndex, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
var variantDefines = BuildVariantDefines(keywordMask, keywordMapping);
|
||||||
|
var fullDefines = CombineDefines(descriptor.Defines, variantDefines);
|
||||||
|
|
||||||
|
var code = descriptor.ShaderCodes[entryIndex];
|
||||||
|
var config = new ShaderCompilationConfig
|
||||||
|
{
|
||||||
|
shaderCode = code.code,
|
||||||
|
entryPoint = code.entryPoint,
|
||||||
|
stage = ShaderStage.ComputeShader,
|
||||||
|
defines = fullDefines,
|
||||||
|
model = descriptor.ShaderModel,
|
||||||
|
optimizeLevel = CompilerOptimizeLevel.O3,
|
||||||
|
options = CompilerOption.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var compileResult = _compiler.Compile(ref config, AllocationHandle.Persistent);
|
||||||
|
if (compileResult.IsFailure)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to compile compute shader {shaderId}: {compileResult.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bytecodeArray = compileResult.Value;
|
||||||
|
|
||||||
|
var byteCode = new ShaderByteCode
|
||||||
|
{
|
||||||
|
pCode = (byte*)bytecodeArray.GetUnsafePtr(),
|
||||||
|
size = (ulong)bytecodeArray.Length
|
||||||
|
};
|
||||||
|
|
||||||
|
OnShaderVariantCompiled?.Invoke(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(ref byteCode));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_assetRegistry.OnAssetImported -= OnAssetImported;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Editor/Ghost.Editor.Core/Services/EditorTickEngine.cs
Normal file
87
src/Editor/Ghost.Editor.Core/Services/EditorTickEngine.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public sealed class EditorTickEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly DispatcherQueueTimer _timer;
|
||||||
|
private bool _isStarted;
|
||||||
|
|
||||||
|
// Time data
|
||||||
|
private TimeData _timeData;
|
||||||
|
private long _startTimestamp;
|
||||||
|
private long _lastFrameTimestamp;
|
||||||
|
|
||||||
|
public event Action? OnSafeZone;
|
||||||
|
public event Action? OnSystemUpdate;
|
||||||
|
public event Action? OnInspectorSync;
|
||||||
|
public event Action? OnFireEvents;
|
||||||
|
|
||||||
|
public EditorTickEngine(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_timer = EditorApplication.DispatcherQueue.CreateTimer();
|
||||||
|
_timer.Interval = TimeSpan.FromMilliseconds(16); // ~60Hz
|
||||||
|
_timer.Tick += OnTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
_lastFrameTimestamp = _startTimestamp;
|
||||||
|
_timeData = new TimeData();
|
||||||
|
|
||||||
|
_timer.Start();
|
||||||
|
_isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(DispatcherQueueTimer sender, object args)
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
var dt = (float)(now - _lastFrameTimestamp) / Stopwatch.Frequency;
|
||||||
|
var elapsed = (double)(now - _startTimestamp) / Stopwatch.Frequency;
|
||||||
|
|
||||||
|
_timeData = new TimeData
|
||||||
|
{
|
||||||
|
FrameCount = _timeData.FrameCount + 1,
|
||||||
|
DeltaTime = dt,
|
||||||
|
ElapsedTime = elapsed
|
||||||
|
};
|
||||||
|
|
||||||
|
_lastFrameTimestamp = now;
|
||||||
|
|
||||||
|
// Phase 1: Safe Zone (Drain Commands & ECB)
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
OnSafeZone?.Invoke();
|
||||||
|
|
||||||
|
// Phase 2: Editor Systems
|
||||||
|
_worldService.EditorWorld.SystemManager.UpdateAll(_timeData);
|
||||||
|
OnSystemUpdate?.Invoke();
|
||||||
|
|
||||||
|
// Phase 3: Inspector Sync
|
||||||
|
OnInspectorSync?.Invoke();
|
||||||
|
|
||||||
|
// Phase 4: Fire Events
|
||||||
|
_worldService.FirePendingEvents();
|
||||||
|
OnFireEvents?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isStarted && _timer != null)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
_timer.Tick -= OnTick;
|
||||||
|
_isStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
305
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public interface IEditorWorldService : IDisposable
|
||||||
|
{
|
||||||
|
World EditorWorld { get; }
|
||||||
|
ObservableCollection<SceneNode> RootNodes { get; }
|
||||||
|
|
||||||
|
event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
event Action<Entity>? EntityDestroyed;
|
||||||
|
event Action<Entity, Entity, Entity>? EntityParentChanged;
|
||||||
|
event Action<Entity, string>? EntityNameChanged;
|
||||||
|
event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
void ChangeEntityScene(Entity entity, ushort sceneID);
|
||||||
|
void CreateDefaultScene();
|
||||||
|
void CreateEntity(string name, ushort sceneID, Entity parent = default);
|
||||||
|
void Defer(Action action);
|
||||||
|
void DestroyEntity(Entity entity);
|
||||||
|
void FirePendingEvents();
|
||||||
|
void FlushCommands();
|
||||||
|
ushort GetEntitySceneID(Entity entity);
|
||||||
|
void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null);
|
||||||
|
Error RemoveParent(Entity child);
|
||||||
|
void RenameEntity(Entity entity, string newName);
|
||||||
|
Error SetParent(Entity child, Entity parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EditorWorldService : IEditorWorldService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<Action> _deferredActions = new();
|
||||||
|
private readonly ConcurrentQueue<Action> _pendingEvents = new();
|
||||||
|
|
||||||
|
public World EditorWorld
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<SceneNode> RootNodes
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
public event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
public event Action<Entity>? EntityDestroyed;
|
||||||
|
public event Action<Entity, Entity, Entity>? EntityParentChanged; // (child, oldParent, newParent)
|
||||||
|
public event Action<Entity, string>? EntityNameChanged;
|
||||||
|
public event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
public EditorWorldService(JobScheduler? jobScheduler = null)
|
||||||
|
{
|
||||||
|
EditorWorld = World.Create(jobScheduler, 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Defer(Action action)
|
||||||
|
{
|
||||||
|
_deferredActions.Enqueue(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FlushCommands()
|
||||||
|
{
|
||||||
|
while (_deferredActions.TryDequeue(out var action))
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FirePendingEvents()
|
||||||
|
{
|
||||||
|
while (_pendingEvents.TryDequeue(out var evt))
|
||||||
|
{
|
||||||
|
evt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateEntity(string name, ushort sceneID, Entity parent = default)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var entity = EditorWorld.EntityManager.CreateEntity();
|
||||||
|
|
||||||
|
EditorWorld.EntityManager.AddComponent(entity, new Engine.Components.Hierarchy
|
||||||
|
{
|
||||||
|
parent = Entity.Invalid,
|
||||||
|
firstChild = Entity.Invalid,
|
||||||
|
nextSibling = Entity.Invalid
|
||||||
|
});
|
||||||
|
|
||||||
|
EditorWorld.EntityManager.AddSharedComponent(entity, new Engine.Components.SceneID
|
||||||
|
{
|
||||||
|
value = sceneID
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
HierarchyUtility.SetParent(EditorWorld, entity, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorWorld.AdvanceVersion();
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(() =>
|
||||||
|
{
|
||||||
|
EntityCreated?.Invoke(entity, name, sceneID);
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
EntityParentChanged?.Invoke(entity, Entity.Invalid, parent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DestroyEntity(Entity entity)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
DestroyEntityRecursive(entity);
|
||||||
|
EditorWorld.AdvanceVersion();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyEntityRecursive(Entity entity)
|
||||||
|
{
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
ref var hierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(entity);
|
||||||
|
var child = hierarchy.firstChild;
|
||||||
|
while (child.IsValid)
|
||||||
|
{
|
||||||
|
ref var childHierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child);
|
||||||
|
var next = childHierarchy.nextSibling;
|
||||||
|
DestroyEntityRecursive(child);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HierarchyUtility.RemoveParent(EditorWorld, entity);
|
||||||
|
EditorWorld.EntityManager.DestroyEntity(entity);
|
||||||
|
_pendingEvents.Enqueue(() => EntityDestroyed?.Invoke(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSceneIDRecursive(Entity entity, ushort sceneID)
|
||||||
|
{
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(entity))
|
||||||
|
{
|
||||||
|
EditorWorld.EntityManager.SetSharedComponent(entity, new Engine.Components.SceneID { value = sceneID });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
ref var hierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(entity);
|
||||||
|
var child = hierarchy.firstChild;
|
||||||
|
while (child.IsValid)
|
||||||
|
{
|
||||||
|
ref var childHierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child);
|
||||||
|
var next = childHierarchy.nextSibling;
|
||||||
|
UpdateSceneIDRecursive(child, sceneID);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ChangeEntityScene(Entity entity, ushort sceneID)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
|
||||||
|
UpdateSceneIDRecursive(entity, sceneID);
|
||||||
|
EditorWorld.AdvanceVersion();
|
||||||
|
_pendingEvents.Enqueue(() => EntityParentChanged?.Invoke(entity, Entity.Invalid, Entity.Invalid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error SetParent(Entity child, Entity parent)
|
||||||
|
{
|
||||||
|
if (!child.IsValid) return Error.InvalidArgument;
|
||||||
|
|
||||||
|
Error err = Error.None;
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
err = HierarchyUtility.IsValidParent(EditorWorld, child, parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(child))
|
||||||
|
{
|
||||||
|
err = Error.NotFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err != Error.None)
|
||||||
|
{
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var oldParent = Entity.Invalid;
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(child))
|
||||||
|
{
|
||||||
|
oldParent = EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child).parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
HierarchyUtility.SetParent(EditorWorld, child, parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
HierarchyUtility.RemoveParent(EditorWorld, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.IsValid && EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(parent))
|
||||||
|
{
|
||||||
|
var locRes = EditorWorld.EntityManager.GetEntityLocation(parent);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref EditorWorld.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
var chunkView = new ChunkView(in archetype, in chunk);
|
||||||
|
var parentSceneID = chunkView.GetSharedComponent<Engine.Components.SceneID>().value;
|
||||||
|
UpdateSceneIDRecursive(child, parentSceneID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorWorld.AdvanceVersion();
|
||||||
|
_pendingEvents.Enqueue(() => EntityParentChanged?.Invoke(child, oldParent, parent));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Error.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error RemoveParent(Entity child)
|
||||||
|
{
|
||||||
|
return SetParent(child, Entity.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetEntitySceneID(Entity entity)
|
||||||
|
{
|
||||||
|
if (!entity.IsValid)
|
||||||
|
{
|
||||||
|
return Scene.INVALID_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(entity))
|
||||||
|
{
|
||||||
|
var locRes = EditorWorld.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref EditorWorld.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
var chunkView = new ChunkView(in archetype, in chunk);
|
||||||
|
return chunkView.GetSharedComponent<Engine.Components.SceneID>().value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scene.INVALID_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RenameEntity(Entity entity, string newName)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
_pendingEvents.Enqueue(() => EntityNameChanged?.Invoke(entity, newName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateDefaultScene()
|
||||||
|
{
|
||||||
|
var scene = SceneManager.CreateScene();
|
||||||
|
CreateEntity("Entity", scene.ID);
|
||||||
|
}
|
||||||
|
public void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var sceneNodes = SceneGraphBuilder.Build(EditorWorld, initialNames);
|
||||||
|
_pendingEvents.Enqueue(() =>
|
||||||
|
{
|
||||||
|
RootNodes.Clear();
|
||||||
|
foreach (var node in sceneNodes)
|
||||||
|
{
|
||||||
|
RootNodes.Add(node);
|
||||||
|
}
|
||||||
|
SceneGraphRebuilt?.Invoke();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
World.Destroy(EditorWorld.ID);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs
Normal file
230
src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _importChannel.Writer.WriteAsync(job, token);
|
||||||
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WorkerLoop(CancellationToken token)
|
||||||
|
{
|
||||||
|
await foreach (var job in _importChannel.Reader.ReadAllAsync(token))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessImportAsync(job, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetImportedAssetPath(Guid assetGuid)
|
||||||
|
{
|
||||||
|
var fileName = $"{assetGuid:N}{IMPORTED_EXTENSION}";
|
||||||
|
var folderName = fileName.Substring(0, 2);
|
||||||
|
|
||||||
|
var importsFolder = Path.Combine(EditorApplication.LibraryImportsFolderPath, folderName);
|
||||||
|
var finalPath = Path.Combine(importsFolder, fileName);
|
||||||
|
Directory.CreateDirectory(importsFolder);
|
||||||
|
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask ProcessImportAsync(ImportJob job, CancellationToken token)
|
||||||
|
{
|
||||||
|
var meta = await AssetMetaIO.ReadAsync(job.MetaPath, token);
|
||||||
|
if (meta is null)
|
||||||
|
{
|
||||||
|
Logger.Error("Missing .gmeta file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = meta.AssetTypeId.HasValue
|
||||||
|
? AssetHandlerRegistry.GetByAssetTypeId(meta.AssetTypeId.Value)
|
||||||
|
: AssetHandlerRegistry.GetByExtension(Path.GetExtension(job.SourcePath));
|
||||||
|
|
||||||
|
var contentHash = await ComputeFileHashAsync(job.SourcePath, token);
|
||||||
|
var settingsHash = ComputeSettingsHash(meta.Settings);
|
||||||
|
var handlerVersion = AssetHandlerRegistry.TryGetHandlerInfoByAssetTypeId(meta.AssetTypeId ?? Guid.Empty, out var info)
|
||||||
|
? info.Version
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Check if we can skip (if not a manual reimport)
|
||||||
|
if (job.Reason != ImportReason.ManualReimport &&
|
||||||
|
meta.ContentHash == contentHash &&
|
||||||
|
meta.SettingsHash == settingsHash &&
|
||||||
|
meta.HandlerVersion == handlerVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var importResult = Result.Success();
|
||||||
|
var subAssets = Array.Empty<ImportedSubAsset>();
|
||||||
|
if (handler is IImportableAssetHandler importable)
|
||||||
|
{
|
||||||
|
var targetPath = GetImportedAssetPath(job.AssetGuid);
|
||||||
|
var subAssetResult = await importable.ImportAsync(job.SourcePath, targetPath, job.AssetGuid, meta.Settings, token);
|
||||||
|
importResult = subAssetResult;
|
||||||
|
if (subAssetResult.IsSuccess)
|
||||||
|
{
|
||||||
|
subAssets = subAssetResult.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importResult.IsSuccess)
|
||||||
|
{
|
||||||
|
meta.ContentHash = contentHash;
|
||||||
|
meta.SettingsHash = settingsHash;
|
||||||
|
meta.HandlerVersion = handlerVersion;
|
||||||
|
meta.LastImportedUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await AssetMetaIO.WriteAsync(job.MetaPath, meta, token);
|
||||||
|
|
||||||
|
if (subAssets.Length > 0)
|
||||||
|
{
|
||||||
|
var dependencies = new Guid[subAssets.Length];
|
||||||
|
for (var i = 0; i < subAssets.Length; i++)
|
||||||
|
{
|
||||||
|
var subAsset = subAssets[i];
|
||||||
|
dependencies[i] = subAsset.Guid;
|
||||||
|
|
||||||
|
var subMeta = new AssetMeta
|
||||||
|
{
|
||||||
|
Guid = subAsset.Guid,
|
||||||
|
AssetTypeId = subAsset.AssetTypeId,
|
||||||
|
HandlerVersion = meta.HandlerVersion,
|
||||||
|
ContentHash = contentHash,
|
||||||
|
SettingsHash = settingsHash,
|
||||||
|
LastImportedUtc = meta.LastImportedUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
_catalog.UpsertSubAsset(job.AssetGuid, subMeta, subAsset.VirtualSourcePath, subAsset.Kind, subAsset.DisplayName, subAsset.StablePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
_catalog.RemoveSubAssetsExcept(job.AssetGuid, dependencies);
|
||||||
|
_catalog.SetDependencies(job.AssetGuid, dependencies);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_catalog.RemoveSubAssetsExcept(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
|
||||||
|
_catalog.SetDependencies(job.AssetGuid, ReadOnlySpan<Guid>.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnImportCompleted?.Invoke(null, job.AssetGuid);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.Error(importResult.Message ?? "Unknown import error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async ValueTask<string> ComputeFileHashAsync(string filePath, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasher = new XxHash128();
|
||||||
|
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
await hasher.AppendAsync(stream, token);
|
||||||
|
|
||||||
|
Span<byte> hash = stackalloc byte[16];
|
||||||
|
hasher.GetCurrentHash(hash);
|
||||||
|
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSettingsHash(IAssetSettings? settings)
|
||||||
|
{
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = XxHash128.HashToUInt128(JsonSerializer.SerializeToUtf8Bytes(settings, settings.GetType()));
|
||||||
|
Span<byte> bytes = stackalloc byte[16];
|
||||||
|
Unsafe.WriteUnaligned(ref bytes[0], hash);
|
||||||
|
|
||||||
|
return Convert.ToHexString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_importChannel.Writer.TryComplete();
|
||||||
|
_cts.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Task.WaitAll(_workers);
|
||||||
|
}
|
||||||
|
catch (AggregateException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Syncs the inspector model from ECS data on every editor tick (Phase 3).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InspectorSyncService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EditorTickEngine _tickEngine;
|
||||||
|
private ISyncableInspectorModel? _activeModel;
|
||||||
|
private bool _isStarted;
|
||||||
|
|
||||||
|
public InspectorSyncService(EditorTickEngine tickEngine)
|
||||||
|
{
|
||||||
|
_tickEngine = tickEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickEngine.OnInspectorSync += OnInspectorSync;
|
||||||
|
_isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind(ISyncableInspectorModel model)
|
||||||
|
{
|
||||||
|
_activeModel = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind()
|
||||||
|
{
|
||||||
|
_activeModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInspectorSync()
|
||||||
|
{
|
||||||
|
if (_activeModel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeModel.Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
_tickEngine.OnInspectorSync -= OnInspectorSync;
|
||||||
|
_isStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
191
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal class SceneGraphSyncService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly Dictionary<Entity, EntityNode> _nodeMap = new();
|
||||||
|
|
||||||
|
public SceneGraphSyncService(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_worldService.EntityCreated += OnEntityCreated;
|
||||||
|
_worldService.EntityDestroyed += OnEntityDestroyed;
|
||||||
|
_worldService.EntityParentChanged += OnEntityParentChanged;
|
||||||
|
_worldService.EntityNameChanged += OnEntityNameChanged;
|
||||||
|
_worldService.SceneGraphRebuilt += OnSceneGraphRebuilt;
|
||||||
|
|
||||||
|
// Initialize node map from current root nodes
|
||||||
|
OnSceneGraphRebuilt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetNode(Entity entity, out EntityNode node)
|
||||||
|
{
|
||||||
|
return _nodeMap.TryGetValue(entity, out node!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_worldService.EntityCreated -= OnEntityCreated;
|
||||||
|
_worldService.EntityDestroyed -= OnEntityDestroyed;
|
||||||
|
_worldService.EntityParentChanged -= OnEntityParentChanged;
|
||||||
|
_worldService.EntityNameChanged -= OnEntityNameChanged;
|
||||||
|
_worldService.SceneGraphRebuilt -= OnSceneGraphRebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSceneGraphRebuilt()
|
||||||
|
{
|
||||||
|
_nodeMap.Clear();
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
PopulateNodeMapRecursive(sceneNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateNodeMapRecursive(SceneGraphNode node)
|
||||||
|
{
|
||||||
|
if (node is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_nodeMap[entityNode.Entity] = entityNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
PopulateNodeMapRecursive(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityCreated(Entity entity, string name, ushort sceneID)
|
||||||
|
{
|
||||||
|
if (_nodeMap.ContainsKey(entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = new EntityNode(_worldService.EditorWorld, entity, name);
|
||||||
|
_nodeMap[entity] = node;
|
||||||
|
|
||||||
|
// By default, add to the scene's root collection
|
||||||
|
var sceneNode = FindOrCreateSceneNode(sceneID);
|
||||||
|
sceneNode.Children.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityDestroyed(Entity entity)
|
||||||
|
{
|
||||||
|
if (!_nodeMap.TryGetValue(entity, out var node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively remove from node map
|
||||||
|
RemoveNodeAndDescendantsRecursive(node);
|
||||||
|
|
||||||
|
// Remove from its parent's Children collection (or from RootNodes if it was a scene's root entity)
|
||||||
|
RemoveNodeFromParent(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveNodeFromParent(EntityNode node)
|
||||||
|
{
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
if (sceneNode.Children.Remove(node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveNodeFromChildrenRecursive(sceneNode.Children, node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RemoveNodeFromChildrenRecursive(System.Collections.ObjectModel.ObservableCollection<SceneGraphNode> children, EntityNode target)
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
if (child.Children.Remove(target))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveNodeFromChildrenRecursive(child.Children, target))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveNodeAndDescendantsRecursive(EntityNode node)
|
||||||
|
{
|
||||||
|
_nodeMap.Remove(node.Entity);
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
if (child is EntityNode childEntityNode)
|
||||||
|
{
|
||||||
|
RemoveNodeAndDescendantsRecursive(childEntityNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityParentChanged(Entity child, Entity oldParent, Entity newParent)
|
||||||
|
{
|
||||||
|
if (!_nodeMap.TryGetValue(child, out var childNode))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from the old parent collection (wherever it currently is)
|
||||||
|
RemoveNodeFromParent(childNode);
|
||||||
|
|
||||||
|
// Add to the new parent collection (prepend at index 0 to match HierarchyUtility firstChild behavior)
|
||||||
|
if (newParent.IsValid && _nodeMap.TryGetValue(newParent, out var newParentNode))
|
||||||
|
{
|
||||||
|
newParentNode.Children.Insert(0, childNode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Add to the scene's root collection
|
||||||
|
if (_worldService.EditorWorld.EntityManager.HasComponent<SceneID>(child))
|
||||||
|
{
|
||||||
|
var sceneID = _worldService.GetEntitySceneID(child);
|
||||||
|
if (sceneID != Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
var sceneNode = FindOrCreateSceneNode(sceneID);
|
||||||
|
sceneNode.Children.Insert(0, childNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityNameChanged(Entity entity, string newName)
|
||||||
|
{
|
||||||
|
if (_nodeMap.TryGetValue(entity, out var node))
|
||||||
|
{
|
||||||
|
node.Name = newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SceneNode FindOrCreateSceneNode(ushort sceneID)
|
||||||
|
{
|
||||||
|
foreach (var existing in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
if (existing.Scene.ID == sceneID)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sceneName = $"NewScene ({sceneID})";
|
||||||
|
var newSceneNode = new SceneNode(_worldService.EditorWorld, new Scene(sceneID), sceneName);
|
||||||
|
_worldService.RootNodes.Add(newSceneNode);
|
||||||
|
return newSceneNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal sealed class SceneSaveData
|
||||||
|
{
|
||||||
|
public uint FormatVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public List<EntitySaveData> Entities
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EntitySaveData
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = "Entity";
|
||||||
|
|
||||||
|
public Dictionary<string, JsonElement> Components
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Serialize shared components.
|
||||||
|
internal class SceneSerializationService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, FieldInfo[]> s_entityFieldsCache = new();
|
||||||
|
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||||
|
{
|
||||||
|
IncludeFields = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new EntityJsonConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class EntityJsonConverter : JsonConverter<Entity>
|
||||||
|
{
|
||||||
|
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var localId = reader.GetInt32();
|
||||||
|
return new Entity(localId, localId >= 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteNumberValue(value.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
|
private readonly SceneGraphSyncService _syncService;
|
||||||
|
|
||||||
|
public SceneSerializationService(IEditorWorldService worldService, IAssetRegistry assetRegistry, SceneGraphSyncService syncService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
_assetRegistry = assetRegistry;
|
||||||
|
_syncService = syncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static int FileLocalIndexOf(Dictionary<Entity, int> reverseMap, Entity entity)
|
||||||
|
{
|
||||||
|
if (reverseMap.TryGetValue(entity, out var index))
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldInfo[] GetEntityFields(Type type)
|
||||||
|
{
|
||||||
|
if (!s_entityFieldsCache.TryGetValue(type, out var fields))
|
||||||
|
{
|
||||||
|
var list = new List<FieldInfo>();
|
||||||
|
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
if (field.FieldType == typeof(Entity))
|
||||||
|
{
|
||||||
|
list.Add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = list.ToArray();
|
||||||
|
s_entityFieldsCache[type] = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemapEntityFieldsToLocal(object boxed, Type type, Dictionary<Entity, int> reverseMap)
|
||||||
|
{
|
||||||
|
var entityFields = GetEntityFields(type);
|
||||||
|
foreach (var field in entityFields)
|
||||||
|
{
|
||||||
|
var entity = (Entity)field.GetValue(boxed)!;
|
||||||
|
var localIndex = FileLocalIndexOf(reverseMap, entity);
|
||||||
|
field.SetValue(boxed, new Entity(localIndex, localIndex >= 0 ? 0 : -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemapLocalFieldsToEntity(object boxed, Type type, Dictionary<int, Entity> forwardMap)
|
||||||
|
{
|
||||||
|
var entityFields = GetEntityFields(type);
|
||||||
|
foreach (var field in entityFields)
|
||||||
|
{
|
||||||
|
var localAsEntity = (Entity)field.GetValue(boxed)!;
|
||||||
|
var localIndex = localAsEntity.ID;
|
||||||
|
if (!forwardMap.TryGetValue(localIndex, out var entity))
|
||||||
|
{
|
||||||
|
entity = Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.SetValue(boxed, entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Binary Serialization
|
||||||
|
|
||||||
|
private static uint GetTypeNameHash(string typeName)
|
||||||
|
{
|
||||||
|
var hash = 2166136261u;
|
||||||
|
for (var i = 0; i < typeName.Length; i++)
|
||||||
|
{
|
||||||
|
var c = typeName[i];
|
||||||
|
hash ^= c;
|
||||||
|
hash *= 16777619u;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe void SerializeToBinary(SceneSaveData data, Stream targetStream)
|
||||||
|
{
|
||||||
|
using var writer = new BinaryWriter(targetStream, Encoding.UTF8, true);
|
||||||
|
|
||||||
|
var header = new SceneContentHeader
|
||||||
|
{
|
||||||
|
magic = SceneContentHeader.MAGIC,
|
||||||
|
version = SceneContentHeader.VERSION,
|
||||||
|
entityCount = data.Entities.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
writer.Write(MemoryMarshal.AsBytes(new ReadOnlySpan<SceneContentHeader>(ref header)));
|
||||||
|
|
||||||
|
if (data.Entities == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entity in data.Entities)
|
||||||
|
{
|
||||||
|
if (entity.Components == null)
|
||||||
|
{
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(entity.Components.Count);
|
||||||
|
|
||||||
|
foreach (var (typeName, componentElement) in entity.Components)
|
||||||
|
{
|
||||||
|
var typeHash = GetTypeNameHash(typeName);
|
||||||
|
var componentType = TypeCache.GetTypes(typeName);
|
||||||
|
if (componentType == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentType == null)
|
||||||
|
{
|
||||||
|
writer.Write(typeHash);
|
||||||
|
|
||||||
|
var nameBytes = Encoding.UTF8.GetBytes(typeName);
|
||||||
|
writer.Write(nameBytes.Length);
|
||||||
|
writer.Write(nameBytes);
|
||||||
|
|
||||||
|
var jsonBytes = Encoding.UTF8.GetBytes(componentElement.GetRawText());
|
||||||
|
writer.Write(jsonBytes.Length);
|
||||||
|
writer.Write(jsonBytes);
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxed = componentElement.Deserialize(componentType, s_jsonOptions);
|
||||||
|
if (boxed == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(componentType);
|
||||||
|
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)compInfo.size, (nuint)compInfo.alignment, scope.AllocationHandle);
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
|
||||||
|
var entityFieldOffsets = GetEntityFields(componentType);
|
||||||
|
var offsets = new int[entityFieldOffsets.Length];
|
||||||
|
for (var i = 0; i < entityFieldOffsets.Length; i++)
|
||||||
|
{
|
||||||
|
offsets[i] = (int)Marshal.OffsetOf(componentType, entityFieldOffsets[i].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(typeHash);
|
||||||
|
|
||||||
|
var nameBytes2 = Encoding.UTF8.GetBytes(typeName);
|
||||||
|
writer.Write(nameBytes2.Length);
|
||||||
|
writer.Write(nameBytes2);
|
||||||
|
|
||||||
|
writer.Write((int)buffer.Size);
|
||||||
|
writer.Write(buffer.AsSpan<byte>());
|
||||||
|
writer.Write(offsets.Length);
|
||||||
|
|
||||||
|
foreach (var off in offsets)
|
||||||
|
{
|
||||||
|
writer.Write(off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Scene File Deserialization (static, used by handler too)
|
||||||
|
|
||||||
|
public static async ValueTask<SceneSaveData?> DeserializeSceneFileAsync(string jsonPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(jsonPath, token);
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var root = document.RootElement;
|
||||||
|
var data = new SceneSaveData
|
||||||
|
{
|
||||||
|
FormatVersion = root.TryGetProperty("formatVersion", out var v) ? v.GetUInt32() : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.TryGetProperty("entities", out var entitiesElement))
|
||||||
|
{
|
||||||
|
foreach (var entityElement in entitiesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var entityData = new EntitySaveData();
|
||||||
|
|
||||||
|
if (entityElement.TryGetProperty("name", out var nameElement))
|
||||||
|
{
|
||||||
|
entityData.Name = nameElement.GetString() ?? "Entity";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityElement.TryGetProperty("components", out var componentsElement))
|
||||||
|
{
|
||||||
|
foreach (var componentProperty in componentsElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
entityData.Components[componentProperty.Name] = componentProperty.Value.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Entities.Add(entityData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Load Scene into Editor World
|
||||||
|
|
||||||
|
public unsafe void LoadSceneIntoEditorWorld(SceneSaveData data, SceneLoadingType loadingType = SceneLoadingType.Single)
|
||||||
|
{
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (loadingType == SceneLoadingType.Single)
|
||||||
|
{
|
||||||
|
_worldService.EditorWorld.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
var activeScene = SceneManager.CreateScene();
|
||||||
|
|
||||||
|
var entityCount = data.Entities.Count;
|
||||||
|
var forwardMap = new Dictionary<int, Entity>(entityCount);
|
||||||
|
if (entityCount == 0)
|
||||||
|
{
|
||||||
|
goto RebuildAndReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = AllocationManager.CreateStackScope();
|
||||||
|
var typeIds = new UnsafeArray<UnsafeList<Identifier<IComponent>>>(entityCount, scope.AllocationHandle);
|
||||||
|
for (var i = 0; i < typeIds.Length; i++)
|
||||||
|
{
|
||||||
|
typeIds[i] = new UnsafeList<Identifier<IComponent>>(16, scope.AllocationHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
var entityData = data.Entities[fileIndex];
|
||||||
|
ref var list = ref typeIds[fileIndex];
|
||||||
|
|
||||||
|
list.Add(ComponentRegistry.GetOrRegisterComponentID<SceneID>());
|
||||||
|
|
||||||
|
foreach (var (typeName, _) in entityData.Components)
|
||||||
|
{
|
||||||
|
var compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compId = RegisterComponentByType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(compId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentSet = new ComponentSetView(list);
|
||||||
|
var entity = world.EntityManager.CreateEntity(componentSet);
|
||||||
|
forwardMap[fileIndex] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var buffer = new MemoryBlock(1024, 16, scope.AllocationHandle);
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
if (!forwardMap.TryGetValue(fileIndex, out var entity))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.EntityManager.SetSharedComponent(entity, new SceneID { value = activeScene.ID });
|
||||||
|
|
||||||
|
var entityData = data.Entities[fileIndex];
|
||||||
|
|
||||||
|
foreach (var (typeName, componentElement) in entityData.Components)
|
||||||
|
{
|
||||||
|
var compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentType = ComponentRegistry.s_runtimeIDToType[compId];
|
||||||
|
|
||||||
|
if (_syncService.TryGetNode(entity, out var node))
|
||||||
|
{
|
||||||
|
node.BuildComponents();
|
||||||
|
var compNode = node.Components.FirstOrDefault(c => c.ComponentType == componentType);
|
||||||
|
if (compNode != null)
|
||||||
|
{
|
||||||
|
compNode.Deserialize(componentElement, s_jsonOptions, (boxed) =>
|
||||||
|
{
|
||||||
|
RemapLocalFieldsToEntity(boxed, componentType, forwardMap);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct deserialization
|
||||||
|
var boxedLegacy = componentElement.Deserialize(componentType, s_jsonOptions);
|
||||||
|
if (boxedLegacy == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemapLocalFieldsToEntity(boxedLegacy, componentType, forwardMap);
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(boxedLegacy, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
|
||||||
|
world.EntityManager.SetComponent(entity, compId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope.Dispose();
|
||||||
|
|
||||||
|
for (var i = 0; i < typeIds.Length; i++)
|
||||||
|
{
|
||||||
|
typeIds[i].Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
typeIds.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildAndReturn:
|
||||||
|
var initialNames = new Dictionary<Entity, string>();
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
if (forwardMap.TryGetValue(fileIndex, out var entity))
|
||||||
|
{
|
||||||
|
initialNames[entity] = data.Entities[fileIndex].Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_worldService.RebuildSceneGraph(initialNames);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Identifier<IComponent> RegisterComponentByType(Type type)
|
||||||
|
{
|
||||||
|
var getOrRegisterMethod = typeof(ComponentRegistry).GetMethod(
|
||||||
|
"GetOrRegisterComponentID",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static,
|
||||||
|
Array.Empty<Type>());
|
||||||
|
|
||||||
|
if (getOrRegisterMethod == null)
|
||||||
|
{
|
||||||
|
return Identifier<IComponent>.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
return Identifier<IComponent>.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericMethod = getOrRegisterMethod.MakeGenericMethod(type);
|
||||||
|
return (Identifier<IComponent>)genericMethod.Invoke(null, null)!;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Save Scene from Editor World
|
||||||
|
|
||||||
|
public unsafe void SaveSceneFromEditorWorld(string filePath, Scene scene)
|
||||||
|
{
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var sceneEntities = SceneManager.GetSceneEntities(world, scene, scope.AllocationHandle);
|
||||||
|
|
||||||
|
var entities = new List<Entity>(sceneEntities.Count);
|
||||||
|
for (var i = 0; i < sceneEntities.Count; i++)
|
||||||
|
{
|
||||||
|
entities.Add(sceneEntities[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = SortEntitiesByHierarchy(world, entities);
|
||||||
|
|
||||||
|
var reverseMap = new Dictionary<Entity, int>();
|
||||||
|
for (var i = 0; i < sorted.Count; i++)
|
||||||
|
{
|
||||||
|
reverseMap[sorted[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new SceneSaveData
|
||||||
|
{
|
||||||
|
FormatVersion = SceneContentHeader.VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteNumber("formatVersion", SceneContentHeader.VERSION);
|
||||||
|
writer.WriteStartArray("entities");
|
||||||
|
|
||||||
|
foreach (var entity in sorted)
|
||||||
|
{
|
||||||
|
var locationResult = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (!locationResult.IsSuccess)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
|
||||||
|
var entityName = "Entity";
|
||||||
|
SceneGraph.EntityNode? node = null;
|
||||||
|
if (_syncService != null && _syncService.TryGetNode(entity, out node))
|
||||||
|
{
|
||||||
|
entityName = node.Name;
|
||||||
|
}
|
||||||
|
writer.WriteString("name", entityName);
|
||||||
|
|
||||||
|
writer.WriteStartObject("components");
|
||||||
|
|
||||||
|
if (node != null)
|
||||||
|
{
|
||||||
|
node.BuildComponents(); // Ensure latest
|
||||||
|
|
||||||
|
foreach (var compNode in node.Components)
|
||||||
|
{
|
||||||
|
var type = compNode.ComponentType;
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
writer.WritePropertyName(fullName);
|
||||||
|
compNode.Serialize(writer, s_jsonOptions, (boxed) =>
|
||||||
|
{
|
||||||
|
RemapEntityFieldsToLocal(boxed, type, reverseMap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var layout in archetype._layouts)
|
||||||
|
{
|
||||||
|
var type = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
||||||
|
if (type == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(layout.componentID);
|
||||||
|
|
||||||
|
var pData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
|
||||||
|
if (pData == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxed = Marshal.PtrToStructure((nint)pData, type);
|
||||||
|
if (boxed == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemapEntityFieldsToLocal(boxed, type, reverseMap);
|
||||||
|
|
||||||
|
writer.WritePropertyName(fullName);
|
||||||
|
JsonSerializer.Serialize(writer, boxed, type, s_jsonOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndArray();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.Flush();
|
||||||
|
|
||||||
|
File.WriteAllBytes(filePath, stream.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entity> SortEntitiesByHierarchy(World world, List<Entity> entities)
|
||||||
|
{
|
||||||
|
var entitySet = new HashSet<Entity>(entities);
|
||||||
|
var roots = new List<Entity>();
|
||||||
|
var childrenMap = new Dictionary<Entity, List<Entity>>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
if (!world.EntityManager.HasComponent<Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var hierarchy = ref world.EntityManager.GetComponent<Hierarchy>(entity);
|
||||||
|
if (hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
|
||||||
|
{
|
||||||
|
if (!childrenMap.TryGetValue(hierarchy.parent, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
childrenMap[hierarchy.parent] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = new List<Entity>(entities.Count);
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
AddEntityAndDescendants(sorted, root, childrenMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEntityAndDescendants(List<Entity> sorted, Entity entity, Dictionary<Entity, List<Entity>> childrenMap)
|
||||||
|
{
|
||||||
|
sorted.Add(entity);
|
||||||
|
if (childrenMap.TryGetValue(entity, out var children))
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
AddEntityAndDescendants(sorted, child, childrenMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user