The Faithful Pipeline
ShadowDusk runs one faithful pipeline on every host — desktop, CLI, and in-browser WASM. A host never swaps in a different frontend/compiler to "make a platform work": a different compiler produces different output and would silently break the "identical to mgfxc" promise.
HLSL → [DXC] → SPIR-V → [SPIRV-Cross] → GLSL → [managed: reflect + MojoShader-dialect rewrite + MGFX writer] → .mgfx
(or vkd3d-shader → DXBC for DirectX)
- OpenGL / WebGL: HLSL → DXC → SPIR-V → SPIRV-Cross → GLSL, then the managed
MonoGameGlslRewriter(MojoShader dialect) and the MGFX writer. - DirectX (DX11): HLSL →
vkd3d-shader→ DXBC (SM5). DXC is not used here — it only emits SM6 DXIL, which MonoGame's DX11 runtime cannot load. See DirectX DXBC (vkd3d) Path.
The diagram and per-phase notes below are maintained in the repository and reproduced here as the single source of truth:
ShadowDusk — Compilation Pipeline
How a .fx source becomes loadable shader bytes, stage by stage, with what each
component is, how it works, and why it is done that way.
ShadowDusk is one faithful compiler with three backend tails. The front half (read the FX file, resolve includes, inject platform macros) is shared. The back half forks by target: OpenGL/WebGL, DirectX 11, and FNA. The headline pipeline is the OpenGL branch:
HLSL →[DXC]→ SPIR-V →[SPIRV-Cross]→ GLSL →[MonoGameGlslRewriter]→ .mgfx
The principle behind every stage: each host runs the same faithful components, and the
output is deterministic (same source + version + target ⇒ byte-identical bytes on every
OS, pinned by CrossHostByteIdentityTests). "Faithful" means the result loads and renders
like the reference compiler (mgfxc for MonoGame/KNI, fxc /T fx_2_0 for FNA), not that
it is byte-identical to it (byte-identity to mgfxc is a non-goal).
INPUT shader.fx (HLSL + FX9 effect blocks)
│
▼
┌──────────────────────────────┐ Stage 1
│ FX9 pre-parser (lexer + │ strips technique/pass/sampler_state/render-state/
│ flat token scanner) │ annotation blocks (not valid HLSL); rewrites legacy
└───────────────┬──────────────┘ texture/sampler/tex2D forms
┌────────┴────────┐
▼ ▼
StrippedHLSL Metadata (techniques, passes [VS/PS entry + profile],
(pure HLSL) samplers, render states, annotations)
│
▼
┌──────────────────────────────┐ Stage 2
│ Preprocessor │ flatten #includes; inject platform macros
│ (managed; leaves #if to DXC)│ (DX: MGFX,HLSL,SM4 · GL: MGFX,GLSL,OPENGL · FNA: FNA,HLSL,SM3)
└───────────────┬──────────────┘
▼
┌───────────────── compile fork (by target) ─────────────────┐
▼ ▼ ▼
DirectX 11 OpenGL / WebGL FNA
│ │ │
vkd3d-shader DXC (Vortice.Dxc) vkd3d-shader
(or d3dcompiler_47) -spirv D3D_BYTECODE
│ │ │
▼ ▼ ▼
DXBC (SM≤5) SPIR-V ──[SPIRV-Cross]──► GLSL 140 D3D9 token stream
│ │ │
│ ▼ │
│ MonoGameGlslRewriter │
│ (MojoShader-dialect GLSL) │
│ │ │
└────────────┬─────────────┘ │
▼ ▼
Reflection (RdefReader / DXIL oracle / CTAB reflection
SpirvReflector → one ReflectedEffect) │
│ ▼
▼ Fx2EffectWriter
MgfxWriter (.mgfx v10) (fx_2_0 .fxb)
│
┌────────┴────────┐
▼ ▼
file on disk byte[] in memory
(CLI) (library / WASM / KNI)
Stage 1 — FX9 pre-parser
What it is. A lexer plus a flat, single-pass token scanner (src/ShadowDusk.HLSL/Lexer/FxLexer.cs,
src/ShadowDusk.HLSL/FxPreParser.cs). FX9 effect blocks (technique, pass,
sampler_state, render states, FX annotations) are a legacy D3DX format inherited by XNA
and MonoGame. No HLSL compiler can parse them, so they must be removed before any compiler
sees the file.
How it works. The lexer turns the source into tokens, emitting single-character tokens
for { } < > ( ) ; = , / * . - and silently dropping the operator characters
: + [ ] & | ! ? % ^ ~ (they only ever occur inside code bodies). The scanner then walks
the token stream and:
- extracts the technique/pass tree (each pass's VS and PS entry-point names and shader profiles), the samplers, the render states, and the parameter annotations into a metadata object; and
- emits StrippedHlsl: the original source text with the FX blocks erased and a handful of legacy constructs rewritten forward so a modern compiler accepts them.
The stripped output is rebuilt from the original source characters (erase/replace spans), never regenerated from tokens, so the dropped operator characters never corrupt emitted code, they only affect the scanner's own pattern matching.
It runs in one of two modes. RewriteToSm4 (MonoGame/KNI, the default) rewrites legacy D3D9
forms forward for DXC/vkd3d: legacy texture/sampler declarations to Texture2D/SamplerState,
tex2D(s, uv) to s.Sample(...), and a : COLOR pixel return semantic to : SV_Target.
PreserveSm3 (FNA) leaves all of that verbatim so vkd3d compiles the D3D9 source natively.
Why. Stripping FX syntax up front means every downstream compiler sees plain HLSL, and
keeping the transform a text-span edit preserves line numbers for diagnostics. The forward
rewrites are what let a single classic #if OPENGL … #else … shader compile on the modern
SM4-level GL/DX path and the SM3 FNA path from one source.
The flat, scope-unaware design is also the source of a known bug class: because operator characters are dropped, a fragmented token stream inside a function body can look like an FX construct. The annotation, sampler, render-state, and legacy-texture heuristics each carry a discriminator to avoid mis-firing on valid HLSL. See
docs/glsl-uniform-naming.mdand the per-heuristic guards inFxPreParser.cs.
Stage 2 — Preprocessor
What it is. A pure-managed include-flattener and macro-injector
(src/ShadowDusk.Core/Preprocessor/Preprocessor.cs). It deliberately does not evaluate
#if/#define/macro expansion, it inlines #includes and prepends #define lines, leaving
all conditional evaluation to the native compiler downstream.
How it works. Preprocessor.Flatten runs over the StrippedHlsl from Stage 1. It resolves
each #include against the including file's own directory first, then the configured search
paths (first match wins), honors #pragma once, and emits #line directives so downstream
diagnostics point at the original files. Cycle detection uses an include stack: a diamond
(two paths reaching one common header) is legal and the header is emitted twice; a true cycle
fails with SD0002. A missing include fails with SD0001. Directive scanning is comment- and
string-aware, so a commented-out #include is never honored.
It then prepends the platform macro set for the target. The macros are simple presence flags
(each defined as 1):
| Target | Macros injected |
|---|---|
| DirectX | MGFX, HLSL, SM4 |
| OpenGL | MGFX, GLSL, OPENGL |
| Vulkan | MGFX, HLSL, VULKAN, SM6 |
| FNA | FNA, HLSL, SM3 (intentionally no MGFX) |
Why. Flattening to one self-contained translation unit means no include handler has to be
threaded into the native compilers, and all three backends see identical pre-resolved source.
The macro sets are load-bearing: the OpenGL set must stay free of SM4/SM6, or every
#if OPENGL / #if SM4 shader would take the wrong branch and the legacy DX9/SM2 branch fed
to the GL DXC→SPIR-V backend crashes DXC's SPIR-V codegen. The FNA set omits MGFX and
the SM4/SM6 flags so the standard MonoGame Macros.fxh falls through to its D3D9/SM2 branch.
Stage 3 — the compile fork
The same StrippedHlsl is handed to a different backend per target, because the three runtimes load different bytecode:
| Target | Frontend / backend | Produces | Loaded by |
|---|---|---|---|
| OpenGL / WebGL | DXC → SPIR-V → SPIRV-Cross → GLSL → rewriter | MojoShader-dialect GLSL text | MonoGame/KNI GL runtime |
| DirectX 11 | vkd3d-shader (default) or d3dcompiler_47 |
DXBC (SM ≤ 5) | MonoGame/KNI DX11 runtime |
| FNA | vkd3d-shader (D3D_BYTECODE) | D3D9 token stream in an fx_2_0 container | FNA3D + MojoShader |
A pass's VS and PS entry points are compiled separately, one native invocation per (stage, entry-point).
Stage 3a — DXC (the HLSL frontend, OpenGL and Vulkan)
What it is. Microsoft's DirectXShaderCompiler, via the Vortice.Dxc package
(src/ShadowDusk.HLSL/Dxc/DxcShaderCompiler.cs). It is the faithful HLSL → SPIR-V frontend.
It is used only on the GL/Vulkan/Metal paths (and to produce a DXIL blob for reflection on
GL); it never produces the DX11 shader payload.
How it works. Each entry point is compiled with -T <profile> and -E <entry> (the
.fx-declared name flows straight through, no mangling). For OpenGL the profile is ps_5_0 /
vs_5_0 with -spirv -fvk-use-dx-layout (and -auto-binding-space 1 for the pixel stage),
so SPIR-V carries D3D constant-buffer offsets and clip-space conventions. Matrices are packed
row-major (-Zpr). On the desktop GL path the source is actually compiled twice: once
to DXIL (used only to feed the native reflection oracle) and once to SPIR-V (fed to SPIRV-Cross);
the DXIL compile is skipped when a managed SPIR-V reflector is injected (the browser path). DXC
is constructed lazily and invoked through a per-platform raw vtable call rather than Vortice's
string wrapper, which is what makes it run on Linux and macOS at all.
Why. DXC is the canonical, cross-platform HLSL compiler, the whole "no Wine, no Windows SDK" promise rests on it running natively everywhere. The DX-layout flags keep reflection and the transpiled GLSL in agreement about cbuffer offsets.
Stage 3b — the DXBC backend (DirectX 11)
What it is. A native D3D-bytecode backend behind the IDxbcShaderCompiler seam, with two
implementations: vkd3d-shader (the default, cross-platform;
src/ShadowDusk.HLSL/Vkd3d/Vkd3dShaderCompiler.cs) and d3dcompiler_47 (a Windows-only
correctness oracle, opt-in via CompilerOptions.DxbcBackend).
How it works and why DXC is not used here. MonoGame 3.8's DX11 runtime loads DXBC
(Shader Model ≤ 5), but DXC's minimum DirectX output is SM6 DXIL, which that runtime
rejects. So the DX11 payload comes from a real DXBC producer. vkd3d-shader compiles the
StrippedHlsl to DXBC on every OS; the same DXBC bytes are also the reflection source
(parsed as DXBC, see Stage 4). One subtlety: the d3dcompiler oracle packs matrices
column-major (matching fxc, the runtime, and vkd3d), while the DXC/GL path uses
row-major, this is intentional and the two paths are validated independently.
Stage 3c — the FNA fx_2_0 path
What it is. A wholly separate path for PlatformTarget.Fna
(CompilationPipeline.RunFna), producing a D3D9 Effects-framework binary (fx_2_0).
How it works. Stage 1 runs in PreserveSm3 mode, so D3D9 constructs (sampler2D, tex2D,
: COLOR outputs) pass through unchanged. vkd3d compiles each SM2–3 stage to a D3D9 token
stream (VKD3D_SHADER_TARGET_D3D_BYTECODE); a small patch canonicalizes a few instruction
forms MojoShader is strict about. The per-shader constant table (CTAB) is reflected, and
Fx2EffectWriter assembles the .fxb. SM4+ is rejected (SD0300, MojoShader's ceiling is
vs_3_0/ps_3_0). This path shares nothing with the MGFX path, which guarantees that adding
FNA cannot change the GL/DX output.
Why. FNA consumes one fx_2_0 .fxb for all of its backends via FNA3D + MojoShader, a
different container and a different shader model from MGFX, so it gets its own pipeline.
Stage 4 — Reflection
What it is. Three interchangeable reflectors that each parse a different bytecode format
into the same ReflectedEffect shape (constant buffers, textures, samplers, signatures):
RdefReader— DXBC RDEF (SM5), pure-managed, proven deeply equal to the WindowsD3DReflectoracle.DxilReflectionExtractor— DXIL (SM6), the nativeID3D12ShaderReflectionoracle (runs inside the bundleddxcompiler; the one piece WASM cannot run).SpirvReflector— SPIR-V, pure-managed, mirrors the DXIL oracle's field semantics for the browser path.
How it works. A ConstantBufferReflection carries its variables in offset order, and each
VariableReflection records its StartOffset (byte offset within the buffer), size, and type
shape. A shared ParameterListBuilder flattens those cbuffer variables into the user-settable
ParameterReflection list (merging the FX annotations from Stage 1) and appends the texture
(and, on GL, sampler) object parameters. Because the SPIR-V was emitted with -fvk-use-dx-layout,
the managed SPIR-V reflector reads byte offsets directly from the SPIR-V Offset decorations,
so all three reflectors report the same layout. (SPIR-V discards HLSL semantic strings, so its
input/output signatures are intentionally empty, which is why it only serves the PS-only browser
corpus.)
Why pure-managed reflectors matter. Removing the Windows-only D3DReflect dependency lets
DirectX .mgfx compiles run on Linux, macOS, and in the browser. Only the source of the
ReflectedEffect changes per host; the .mgfx bytes do not.
Stage 5 — SPIRV-Cross (OpenGL only)
What it is. Khronos' SPIR-V → GLSL transpiler, driven via P/Invoke on desktop and via JS
interop in the browser (src/ShadowDusk.GLSL/SpirvCrossGlslTranspiler.cs). It turns the SPIR-V
from Stage 3a into GLSL.
How it works. ShadowDusk sets exactly these options:
| Option | Value | Why |
|---|---|---|
FlipVertexY |
off | The Y-flip is not done here, see the rewriter's posFixup below |
FixupClipspaceDepthConvention |
on | Emits the D3D→GL depth line; the rewriter uses it as an insertion anchor |
GlslVersion |
140 | Stripped again by the rewriter; the value only needs to be modern enough to transpile |
GlslEs / VulkanSemantics |
off / off | Desktop GLSL, no Vulkan binding semantics |
| combined image samplers | always built | Required, a safe no-op on texture-free shaders but a crash if skipped on textured ones |
Why FlipVertexY is off (a deliberate correction). A statically baked gl_Position.y = -gl_Position.y; only matches MonoGame's render-target case and renders normal backbuffer draws
upside-down. The real contract is mgfxc/MojoShader's runtime posFixup uniform (set per draw
to +1 for the backbuffer, -1 for a render target), which the rewriter injects in Stage 6.
Turning FlipVertexY on would double-flip every vertex-driven GL effect.
Reserved-word renaming. SPIRV-Cross has its own internal sanitizer: if a SPIR-V identifier
collides with a GLSL reserved word (for example a uniform named noise, which clashes with the
deprecated GLSL noiseN built-ins), it renames it (typically by prefixing _, e.g. _noise)
so the emitted GLSL is legal. ShadowDusk does not configure or extend that list. This rename is
correct and required, but it has a downstream consequence, see Design notes below.
Stage 6 — MonoGameGlslRewriter (OpenGL only)
What it is. A pure, dependency-free string transform
(src/ShadowDusk.GLSL/MonoGameGlslRewriter.cs) that converts SPIRV-Cross's modern GLSL into
the legacy MojoShader-era dialect MonoGame's GL runtime actually binds against. The full
contract is in docs/glsl-uniform-naming.md.
Why it exists (the core reason). MonoGame's OpenGL runtime looks resources up by fixed
names, not the author's HLSL names: free uniforms are one packed array ps_uniforms_vec4[N] /
vs_uniforms_vec4[N], samplers are ps_s{slot}, stage varyings are vFrontColor / vTexCoord{n},
and the pixel output is gl_FragColor. SPIRV-Cross emits a modern type_Globals UBO,
in_var_TEXCOORD0 varyings, and an opaque _39 sampler. Loaded as-is, glGetUniformLocation
returns -1 for everything, every parameter reads zero, and the shader loads but renders
black. The rewrite is what makes the output a faithful drop-in.
How it works. It strips the #version line (MojoShader GLSL is versionless 110-era), merges
the UBO block(s) into the packed register array (member uses become ps_uniforms_vec4[i].<swizzle>),
renames samplers to ps_s{slot}, maps stage I/O to the legacy varying names, routes the pixel
output to gl_FragColor (via a #define ps_oC0 gl_FragColor alias, MRT slots to gl_FragData[n]),
lowers texture() to dimension-specific legacy builtins (texture2D/textureCube/texture3D),
lowers round/roundEven to floor((x)+0.5) (valid in every GLSL profile), and on the vertex
stage injects the posFixup uniform plus its two fixup lines (the Y-flip and half-pixel offset),
remaps a legacy : POSITION output to gl_Position, and reconstructs mat4 uniforms transposed
(to cancel SPIRV-Cross's mul(v, M) → M * v operand swap; a naive reconstruction renders
geometry inside-out). It is aggressively fail-loud: an unmodelled uniform member type, sampler
kind, vertex semantic, or a vertex-stage sampler fails with SD0210 rather than emitting GLSL
that silently mis-renders.
Crucially, the packed array is indexed by register position, not by the member's emitted name, so whatever name SPIRV-Cross chose for a uniform is irrelevant to the GLSL body itself.
Stage 7 — the binary writers
.mgfx (MonoGame/KNI) — src/ShadowDusk.Core/MgfxWriter.cs. The body is serialized first so
the header can hash it. Layout: the 4-byte signature MGFX, a version byte, a profile byte
(OpenGL = 0, DirectX 11 = 1, Vulkan = 3), and a 4-byte effect key (a managed MD5 over the body,
managed because the WASM runtime lacks MD5); then the body, constant buffers → shaders → parameters
→ techniques/passes (with their render-state blocks); then the trailing MGFX footer the
runtime validates. The shader blob is GLSL text for the GL profile and DXBC for the DX profile.
Size guards fail loudly (SD0020–SD0022) rather than truncate into a corrupt file. The default
version is v10, the most backwards-compatible choice: a v10 .mgfx loads on MonoGame 3.8.2,
every newer MonoGame, and KNI. (A --mgfx-version 11 escape hatch adds the two per-shader
diagnostic strings MonoGame's v11 reader expects; it is opt-in and never required for correct
output.)
fx_2_0 .fxb (FNA) — src/ShadowDusk.Core/Fx2EffectWriter.cs. A fundamentally different,
offset-addressed D3D9 container: an 8-byte header, a "data pool" of typedef/string/value blobs
addressed by offset, then a structured stream of parameter/technique/pass/object records. Because
MojoShader does no bounds-checking on those offsets, the writer runs an exhaustive validator that
enforces every invariant MojoShader relies on (ASCII-only names, square matrices only, textures
declared before the samplers that reference them, and every shader CTAB constant matching an
effect parameter name exactly). Full spec: docs/fx2-binary-format.md.
KNIFX v11 (KNI) — src/ShadowDusk.Core/KnifxWriter.cs. An additive newer container (signature
KNIF) carrying the same MojoShader-dialect GLSL body as the MGFX path, wrapped in a
multi-backend directory with packed-integer encoding. The render-state blocks are byte-identical
to v10 (it reuses the MGFX writer's render-state routine). It is opt-in; the default product
output stays MGFX v10.
Design notes
Parameter binding: name today, location is the robust contract
A .mgfx must let the game set parameters by name (effect.Parameters["Foo"].SetValue(...)),
so the file records, for each register in a constant-buffer record, which effect parameter it
holds. On the GL path that correlation is currently made by name: the rewriter's per-uniform
layout (the names parsed out of the SPIRV-Cross GLSL) is matched against the reflected parameter
list (IndexOfParam, by ParameterReflection.Name).
This was the one place that depends on the emitted GLSL identifier, and it is the weak link. When
SPIRV-Cross renames a reserved-word uniform (Stage 5), the GLSL side says _noise while the
reflected parameter is still noise, so a pure name match fails. DirectX and FNA never go through
GLSL, so they are unaffected; and mgfxc never hits this because MojoShader packs constants by
D3D9 register index and never emits named uniforms at all.
The fix is to fall back to binding by location the moment the name match
misses — never for a shader that already resolves by name, so existing output is byte-identical.
The data is already in the reflection: a ConstantBufferReflection lists its VariableReflection
members in offset order with both the original name and the byte StartOffset, and the GL uniform
layout carries each member's base register. CompilationPipeline.IndexOfParamByRegister correlates
the GL uniform's BaseRegister × 16 to the cbuffer member's StartOffset, then takes that
member's original name, recovering the parameter without ever trusting the SPIRV-Cross-emitted
spelling and without replicating SPIRV-Cross's reserved-word list (the brittle alternative). The
parameter stays exposed under its original name in the .mgfx, so effect.Parameters["noise"] .SetValue(...) binds. The bridge is restricted to the single-$Globals case (the reserved-word
case is always a free global, where a variable's effective offset is exactly its StartOffset); a
multi-cbuffer shape — where the rewriter's GLSL-declaration merge order is not guaranteed to match
the reflection's cbuffer order — falls through and keeps the loud SD0012 rather than risk
mis-mapping (correctness over coverage). Only the previously-failing name-miss path is new, so the
mgfxc-equivalence and cross-host byte-identity of every existing shader are untouched.
Determinism and cross-host byte-identity
Output is deterministic: the same source, the same compiler version, and the same target
produce byte-identical bytes on Windows, Linux, and macOS, pinned by CrossHostByteIdentityTests
for the GL, DX, and FNA outputs. Every stage above is referentially transparent in this sense, the
native compilers are pinned, the reflectors are byte-transparent (only the source of the
reflection changes per host, never the result), and the writers are fully managed. This is
ShadowDusk's own reproducibility guarantee. It is distinct from, and does not imply, byte-identity
to mgfxc, the bar against the reference compiler is behavioral and render equivalence, not equal
bytes.
Notes and cross-references
- DirectX does not use DXC. DXC only emits SM6 DXIL, but MonoGame 3.8's DX11 runtime loads
DXBC (SM ≤ 5), so the DX path routes HLSL → DXBC through vkd3d-shader (cross-platform default)
with
d3dcompiler_47as a Windows-only oracle. The DXBC bytes are also the reflection source. - In the browser. The same pinned DXC and vkd3d-shader are compiled to WebAssembly and ship in
ShadowDusk.Wasm, so every target compiles in-browser byte-identically to the desktop output; SPIRV-Cross runs as a JS-interop call there. The in-browser shader-fiddle is a sample of this reach, not the product. - The GLSL dialect contract the rewriter enforces (uniform/sampler/varying naming, the
posFixupand matrix conventions) is documented in full indocs/glsl-uniform-naming.md. - The FNA container format is documented in
docs/fx2-binary-format.md.