# 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
(monitors .ghost DSL files)"] AR["AssetRegistry
(GUID ↔ file path mapping)"] EP["Editor UI
(status bar, material inspector)"] end subgraph CompilerProcess["GhostShaderServer Process"] DSL["DSL Compiler
(Ghost DSL → HLSL)"] DXC["DXC Compiler
(HLSL → DXIL bytecode)"] MW["Manifest Writer
(updates variant → hash mapping)"] end subgraph RuntimeGraphics["Ghost.Graphics (Runtime)"] SL["ShaderLibrary
(reads bytecode from cache)"] PL["PipelineLibrary
(PSO creation + double-buffer)"] RGC["RenderGraphContext
(binds PSO per draw call)"] BR["IShaderCompilationBridge
(interface, 2 methods)"] end subgraph SharedDisk["Shared Disk (ShaderCache/)"] MF["ShaderManifest.bin
(GUID+variant → content hash)"] BC["Bytecode Files
(content-addressed .bin blobs)"] end FW -- "file changed event" --> AR AR -- "GUID + file path
(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
(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
e.g. 7f3a-...-c82b
stable forever"] SRC["Source Code
.ghost DSL file
changes on edit"] end subgraph Manifest["ShaderManifest"] E1["Entry:
GUID=7f3a | Pass=0 | Variant=0x00
→ ContentHash=0xABCD"] E2["Entry:
GUID=7f3a | Pass=0 | Variant=0x01
→ ContentHash=0x1234"] E3["Entry:
GUID=7f3a | Pass=1 | Variant=0x00
→ 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"
via AssetRegistry Editor->>Server: CompileRequest {
guid: 7f3a-...,
filePath: "water.ghost",
defines: [...],
platform: D3D12
} Note over Server: Mark status = Compiling
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 {
status: Error,
errors: [...]
} Editor->>Editor: Show errors in
console/inspector else DSL is valid Server->>Server: For each (pass, variant):
DXC Compile HLSL → DXIL alt Any DXC error Server->>Server: Mark status = Error Server-->>Editor: CompileResult {
status: Error,
errors: [...]
} else All variants compiled Server->>Cache: Write bytecode blobs
(content-addressed) Server->>Cache: Update manifest entries:
(GUID+pass+variant) → new hash Server->>Server: Mark status = Ready Server-->>Editor: CompileResult {
status: Ready,
variantCount: N
} 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
= f(shader.GUID, passIndex, variantMask)"] B --> C{"PipelineLibrary
has PSO for
ManifestKey?"} C -- "Yes (cache hit)" --> D["Bind existing PSO
to command buffer"] D --> Z["Done ✓"] C -- "No (cache miss)" --> E{"ShaderLibrary
has bytecode for
ManifestKey?"} E -- "Yes (manifest hit)" --> F["Read bytecode
from cache file"] F --> G["Create PSO from bytecode"] G --> H["Store in PipelineLibrary"] H --> D E -- "No (manifest miss)" --> I{"Is this Editor
or Runtime?"} I -- "Runtime
(shipped game)" --> J["Bind Fallback
ERROR PSO ⚠️"] J --> K["Log error:
missing shader"] K --> Z I -- "Editor" --> L{"Query Bridge:
IsCompiling?"} L -- "Status = Compiling" --> M["Bind OLD PSO
(keep previous frame's shader)"] M --> Z L -- "Status = Error" --> N["Bind ERROR PSO
(magenta)"] N --> Z L -- "Status = Ready" --> O["The manifest was just updated.
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 ✓
(what we render with)"] K --> PENDING["pending: null
(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 ✓
(still rendering with this)"] K2 --> PENDING2["pending: COMPILING
(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 ✓
(new shader, rendering now)"] K3 --> PENDING3["pending: null
(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
(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:
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
(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
derived from source hash"] 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 ❌
Still 0xABCD, but source changed"] STALE -.-> WRONG["Looks up OLD bytecode
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
assetGUID = 7f3a-..."] --> P2["Pass[0]: index=0
no source hash stored"] P2 --> MK["ManifestKey = f(7f3a, 0, keywords)"] MK --> MANIFEST["Manifest Lookup
→ ContentHash = 0x9999"] MANIFEST --> SL2["ShaderLibrary
→ read 99/shader_cache_9999.bin"] SL2 --> PSO2["Create or get PSO"] EDIT2["User edits source"] -.-> RECOMP["Server recompiles
→ new ContentHash = 0xBBBB"] RECOMP -.-> MUPD["Manifest updated:
same key → 0xBBBB"] MUPD -.-> NEXT["Next frame: manifest read
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 { <> +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
in scenes/assets"] --> COLLECT["Collect all referenced
(GUID, pass, variant) tuples"] COLLECT --> COMPILE["Compile all variants
via ShaderServer"] COMPILE --> PACK["Package manifest +
bytecode blobs into
game data archive"] end subgraph ShippedGame["Runtime (shipped game)"] LOAD["Load manifest +
bytecode from archive"] --> LIB["ShaderLibrary
(read-only, all variants pre-cached)"] LIB --> MISS{"Cache miss?"} MISS -- "Never
(if build is correct)" --> OK["Create PSO normally"] MISS -- "Somehow yes
(bug or modding)" --> ERR["Error PSO
+ 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 |