GLSL Dialect Rewrite
SPIRV-Cross emits standards-conformant GLSL, but MonoGame's OpenGL Effect loader expects the MojoShader dialect — a specific uniform-naming and fragment-output convention inherited from XNA. ShadowDusk's managed MonoGameGlslRewriter (in ShadowDusk.GLSL) bridges that gap so the .mgfx loads and renders exactly like mgfxc's.
The exact, as-built uniform-naming / dialect contract this rewrite enforces is documented in the repository and reproduced here as the single source of truth:
GLSL Uniform Naming: MonoGame / MojoShader Convention
ShadowDusk's
MonoGameGlslRewriter(src/ShadowDusk.GLSL/MonoGameGlslRewriter.cs) implements the convention described here. This document records the GLSL uniform-naming and dialect contract it enforces.
This rewrite is the managed MojoShader-dialect step of the OpenGL branch in the
overall compilation pipeline; see docs/references/compilation-pipeline.md for where it
sits (HLSL →[DXC]→ SPIR-V →[SPIRV-Cross]→ GLSL → this rewrite → .mgfx).
Background
When MonoGame's OpenGL backend loads a compiled .mgfx effect, it calls glGetUniformLocation
on the GLSL shader source embedded in the file. The name string passed to glGetUniformLocation
must exactly match the uniform declarations in the GLSL source.
MonoGame's Expected Convention
MonoGame's OpenGL path uses the MojoShader uniform naming convention. The runtime binds free
(non-resource) uniforms as a single vec4[N] array named after the constant buffer —
ConstantBuffer.PlatformApply calls GetUniformLocation(cbufferName) and uploads with
glUniform4fv. mgfxc names that cbuffer ps_uniforms_vec4 / vs_uniforms_vec4:
uniform vec4 vs_uniforms_vec4[N]; // vertex constant buffer
uniform vec4 ps_uniforms_vec4[N]; // pixel constant buffer
MonoGame looks up uniforms by these fixed array names, not by the original HLSL variable
names (WorldViewProj, DiffuseColor, …). It also expects:
- Samplers named
ps_s{k}(e.g.ps_s0), looked up by uniform name: the GL runtime callsglUniform1i(GetUniformLocation("ps_s{k}"), TextureSlot)and then binds the record's texture parameter to that unit.kis the record's position in the table, not an HLSLregister(sN)bind slot — a shader whose only sampler isregister(s3)still emitsps_s0. - Stage I/O carried over legacy
varyingnames that match the built-inSpriteEffectVS outputs (MonoGame links the VS to the custom PS by varying name):vFrontColor(COLOR0),vBackColor(COLOR1),vTexCoord{n}(TEXCOORD{n}). - Pixel output via
gl_FragColor(orgl_FragData[n]), legacytexture2D()sampling, and no#versiondirective (MojoShader GLSL is GLSL 110-era).
What SPIRV-Cross Emits by Default
SPIRV-Cross emits modern GLSL that is incompatible with the above: a #version 140
directive, in/out stage variables (in_var_TEXCOORD0, out_var_SV_Target), texture()
sampling, an opaque sampler name (_39), and free uniforms packed into a std140
type_Globals UBO block. Loaded as-is, GetUniformLocation("type_Globals") returns -1,
ConstantBuffer.PlatformApply early-returns, and every parameter reads zero (e.g. a tint
shader renders black) even though the GLSL itself compiles cleanly.
So a byte-correct .mgfx container is necessary but not sufficient — the embedded GLSL
must also be in MonoGame's dialect or the custom PS will not link with the built-in VS.
Implemented Design — Strategy 1 (GLSL post-process)
MonoGameGlslRewriter.Rewrite(glsl, stage) is a pure string transform (no SPIRV-Cross /
native dependency) run over the SPIRV-Cross output. It is invoked from
CompilationPipeline whenever the monoGameGl gate is set (any OpenGL effect — see
Vertex stage below for the symmetric VS rules);
other targets keep the unmodified SPIRV-Cross dialect. The pixel-stage transform, by rule:
| # | SPIRV-Cross input | Rewritten to | ||||
|---|---|---|---|---|---|---|
| 1 | #version … line; the GL_ARB_shading_language_420pack extension block |
dropped; an #ifdef GL_ES precision header is prepended: highp float/int under #ifdef GL_FRAGMENT_PRECISION_HIGH, else mgfxc's mediump (issue #406: real mobile GPUs run mediump as fp16). The vertex stage always gets highp float |
||||
| 3 | uniform sampler2D <id>; |
uniform sampler2D ps_s{slot}; — one uniform per combined (texture, sampler) pair, slot coming from fxc's own allocator, not the pair's position in SPIRV-Cross's first-use pair list; uses renamed in the body. The rule, in one sentence: in texture-declaration order, a pair whose sampler declared an explicit register takes it, and every other pair takes the lowest register neither already taken nor reserved. The legacy and modern spellings are therefore not equivalent: at ps_3_0 a texture and a sampler are ONE object in ONE register namespace, so a legacy sampler X : register(sN) is that combined object and lands on N, while a modern SamplerState S : register(sN) is a sampler-only object that merely reserves N — one texture plus SamplerState S : register(s0) yields ps_s1, not ps_s0. Verified against the pinned mgfxc on ten shapes (issue #189) |
||||
| 4 | in <type> in_var_<SEM>; |
varying vec4 <legacy>; — COLOR0→vFrontColor, COLOR1→vBackColor, TEXCOORD{n}→vTexCoord{n}; uses get a width-truncating swizzle. An omitted semantic index is index 0 (COLOR ≡ COLOR0, TEXCOORD ≡ TEXCOORD0, matching fxc/mgfxc), so the bare spellings DXC passes through verbatim map to vFrontColor / vTexCoord0 — without that collapse a pixel-only pass declaring : COLOR emitted var_COLOR, which MonoGame's built-in SpriteEffect VS can never link against. |
||||
| 4c | in mat<C>[x<R>] in_var_TEXCOORD<n>; (a matrix interpolant, e.g. HLSL float3x3 TangentToWorld : TEXCOORD2) |
one varying vec4 vTexCoord{n+k}; per register, the registers fxc gives it; each use becomes the matrix rebuilt from them, the inverse of the vertex stage's copy-out (below): column_major mat3(vec3(vTexCoord2.x, vTexCoord3.x, vTexCoord4.x), vec3(… .y …), vec3(… .z …)), explicit row_major mat3(vTexCoord2.xyz, vTexCoord3.xyz, vTexCoord4.xyz). Only a TEXCOORD semantic is modelled (any other, e.g. a matrix at COLOR0, fails loudly), and an interpolant whose name overlaps the matrix's range (a TEXCOORD3 beside a float3x3 at TEXCOORD2) fails loudly, as fxc refuses the overlap. |
||||
| 5 | out vec4 out_var_SV_Target<N?>; |
declaration dropped; uses → gl_FragColor (or gl_FragData[N]) |
||||
| 6 | texture() |
dimension-specific legacy builtin per the sampler's declared type: texture2D() / textureCube() / texture3D() |
||||
| 6b | textureLod() / textureGrad() / textureProj() |
dimension-specific legacy names — texture2DLod/textureCubeLod/texture3DLod, texture2DGrad (2D only; cube/3D gradients fail loudly — no GLSL defines a legacy spelling), texture2DProj/texture3DProj — plus MojoShader's guarded extension header prepended once: #if __VERSION__ >= 300 maps the legacy names back to the generic builtins (KNI HiDef/WebGL2, mirroring MojoShader's GLSLES3 preflight), #elif defined(GL_ARB_shader_texture_lod) / #elif defined(GL_EXT_gpu_shader4) enable the fragment-stage builtins on legacy desktop GL (Mesa accepts this; the bare generic spelling was a Linux Effect-load failure), #else degrades to a plain texture2D()-family call — never a compile failure. One artifact serves Reach, HiDef and desktop. |
||||
| 7 | every layout(binding=…, std140) uniform <Type> { … } <Inst>; block — type_Globals { … } _Globals for loose globals AND type_<Name> { … } <Name> for each named cbuffer |
ONE merged uniform vec4 ps_uniforms_vec4[N]; covering all blocks in declaration order (MojoShader's model: D3D9 has a single float-constant register file per stage); member uses <Inst>.<m> → ps_uniforms_vec4[i]<swizzle>; array members <Inst>.<m>[idx] → ps_uniforms_vec4[base + idx]<swizzle> (element stride 1 register; mat4 arrays stride 4, reconstructed per element); unmodelled member types (int/bool/mat3/struct/…), whole-array uses, and any surviving block-instance reference fail loudly (SD0210) instead of shipping GLSL that references a deleted block |
||||
| 8 | roundEven(x) / round(x) (GLSL ES 3.00 / GL 1.30 only) |
floor((x) + 0.5) — valid in every GLSL profile incl. WebGL1 / GLSL ES 1.00 (KNI's Reach profile), and exactly what mgfxc/MojoShader emits for HLSL round. Argument captured by a balanced-paren scan; on a replacement the scan resumes INSIDE the replaced text, so a call nested in another's argument — round(round(x) * 0.5) — is lowered too (issue #140; the replacement's floor(( prefix can never re-match, so the scan still terminates). |
||||
| 9a | one-shot do { … } while(false); that is a direct child of main's body or of a plain-block chain rooted at main (SPIRV-Cross's wrapper for early returns — the entry point's own, and each inlined helper's, which nests inside the entry wrapper and lands in the plain block a previous unwrap leaves behind) |
unwrapped: the body becomes a plain brace block, and each break whose nearest enclosing loop/switch is the one-shot loop becomes the statements control would run after the loop — the return-value-phi output writes, flattened through the enclosing plain blocks and through a trailing { … return; } block a previous unwrap produced — plus return;; the fall-through path runs the in-place tail unchanged (issue #136). Unwraps iterate outside-in: entry wrapper first, then helper wrappers inside it. Straight-line main with conditional early returns is valid in every GLSL profile incl. ESSL 1.00, and is the exact shape mgfxc/MojoShader emits. This exists because ANGLE's D3D11 backend (WebGL in every Windows browser) silently zeroes every gradient op (dFdx/dFdy, and implicit-LOD mip selection) inside any loop with a divergent exit — a conditional break or discard — so the Rule-9b for-loop form, while load-safe, kills derivative-based AA there with no compile or link error. Preconditions the unwrap must prove (else it defers to 9b): not inside an if/else/loop/switch body; no loop-level continue; each tail parseable as simple ;-terminated statements optionally ending in one { … return; } block; and the duplicated tail must contain no divergence-sensitive op (dFdx/dFdy/fwidth or an implicit-LOD texture* sample — duplication would move it from convergent into divergent flow, undefined per GLSL §8.13.1; the explicit-LOD *Lod/*Grad spellings are safe). |
||||
| 9b | any remaining one-shot do { … } while(false); (inside an if/else/loop/switch body, a loop-level continue, an unprovable tail, or a divergence-sensitive tail) |
for (int _spvonce_N = 0; _spvonce_N < 1; _spvonce_N++) { … } — semantically identical (one iteration; break/continue/fall-through unchanged) but uses the GLSL ES 1.00 Appendix-A loop form WebGL1 / KNI Reach guarantees, so the effect loads in WebGL instead of failing on the do-while (issue #107). Genuine multi-iteration do-whiles are left untouched. Residual: a gradient op inside a 9b-lowered loop would still be ANGLE-poisoned; with 9a's plain-block recursion this now requires an unusual shape (e.g. a gradient-taking early-return helper called inside an if body, or a loop-level continue). The AposShapesAa_OpenGl_NoGradientOpInsideDivergentLoop_Issue136 + EarlyReturnHelperGradient_NoGradientInsideDivergentLoop_Issue136 pins fail if a corpus shader ever produces such a shape. |
||||
| 10 | pow(x, 2.0) with a simple base operand (identifier / swizzle / literal, optionally signed) |
((x) * (x)) — GLSL leaves pow undefined for a negative base (drivers lowering to exp2(y*log2(x)) return NaN), while fxc constant-folds pow(x, 2) into a multiply, so the multiply is both the well-defined form and the reference compiler's semantics, and exact where pow was approximate (issue #127). Complex bases are never duplicated — they keep the original pow. |
||||
| 11 | 1.0 / (a / b) where the division is provably the parenthesized group's root operator |
((b) / (a)) — one correctly-rounded division instead of two, matching fxc's folding of the reciprocal-of-quotient shape; value-equivalent across the zero/infinity edge cases (issue #127). Ambiguous shapes (top-level additive/ternary content, or a later */% displacing the division as root) are left untouched. |
||||
| 12 | a for loop whose index is advanced in the body, not the header — for (int i = 0; i < N; ) { …; i++; continue; } (constant-bounded, empty increment) |
the increment is hoisted into the header (for (int i = 0; i < N; i++)) whenever the rewrite can prove it safe (no other write to the index, no other continue in the body) — GLSL ES 1.00 Appendix A (WebGL1 / KNI Reach) requires the increment to live in the header, so the unrewritten shape used to fail to load there even though it warned only (SD0402, issue #138, shape 2). Left as-is (still warns via SD0402) when the safety proof fails. |
||||
| 13 | a header-less for (;;) { if (i < bound) {…} else break; } whose bound is traceable to a compile-time-constant expression (a literal, or a ternary between two literals) |
the header is given that real, exact bound and the increment is hoisted the same way as Rule 12 — SPIRV-Cross renamed the bound into a runtime-looking temporary, but since its value is still knowable at compile time the rewrite is exact, not an approximation (issue #138, shape 1, e.g. Apos.Shapes' Newton-iteration SDF); the emitted header compares <= provenMax, so the else finalizer stays reachable at the max trip count (issue #160). The rewrite is deliberately narrow: an inclusive (<=) inner comparison is rewritten only when the bound can be extended to provenMax + 1, and a descending or compound step, or an index read after the loop, declines to the SD0402 path (the rewriter source is the authority on the exact accepted shapes). A loop bounded by a genuine runtime uniform has no derivable constant and is left for SD0402 to keep warning about. The bound must also be provably loop-invariant: if either branch writes it, or a statement between its declaration and the loop writes it, provenMax is not the real ceiling — the synthesized header would exit before the terminal else finalizer runs and leave its output undefined (issue #160 again) — so those shapes decline to SD0402 too. |
||||
| 14 | any dFdx( / dFdy( / fwidth( use in the rewritten fragment body |
#extension GL_OES_standard_derivatives : enable prepended as the first line, before the precision header — mgfxc's exact behavior and position (ShaderData.mojo.cs; issue #139). In ESSL 1.00 the derivative builtins exist only under this extension, so strict GLES2 compilers reject derivative shaders without it; where derivatives are core (ES 3.00 / desktop 1.30+) the enable is at most a warning, so the one artifact still serves Reach, HiDef, and desktop. The scan includes fwidth, which SPIRV-Cross emits directly and mgfxc's two-token dFdx/dFdy scan never had to handle. |
||||
| 15 | trunc(x) (GLSL ES 3.00 / GL 1.30 only) |
(sign((x)) * floor(abs((x)))) — truncate-toward-zero built from sign/floor/abs, all available since GLSL ES 1.00 / GLSL 1.10, component-wise for float/vecN alike. SPIRV-Cross emits bare trunc() when lowering HLSL's truncating %/fmod (a - b * trunc(a / b); GLSL's own mod() is floored with sign-follows-divisor, so it can't stand in for fmod's sign-follows-dividend). Strict GLSL ES 1.00 front ends (ANGLE on macOS DesktopGL) reject trunc as an undeclared identifier where lenient desktop drivers accept it (Apos.Shapes issue #34). Argument captured by the same balanced-paren, resume-inside-replacement scan as Rule 8. The whole product is parenthesized: trunc(x) was a primary expression, so splicing a bare a * b over it re-associates wherever the surrounding operator binds at least as tightly — 1.0 / trunc(x) became (1.0 / sign(x)) * floor(abs(x)), valid GLSL with a silently wrong value. |
||||
| 16 | sin(x) / cos(x) / tan(x) whose argument is not a bare numeric literal |
sin(sd_reduce_angle(x)) / cos(sd_reduce_angle(x)) / tan(sd_reduce_angle(x)), with four sd_reduce_angle overloads (float/vec2/vec3/vec4) declared once after the global declarations and before the FIRST top-level function definition (a [noinline] HLSL function survives as its own GLSL function ahead of main, and GLSL 1.10 / ES 1.00 require declaration before use; for a main-only shader that is directly before main). If the shader already uses the identifier sd_reduce_angle (e.g. a [noinline] function of that name), the helper takes the first free sd_reduce_angle_N name instead, so valid input never fails: k = floor(x * 0.15915494309189535 + 0.5); r = (x - k * 6.28125) - k * 0.0019353071795864769 — a Cody-Waite two-constant split of 2π, so r lands in [-π, π] and the driver's sin/cos/tan never sees a large argument (issue #215). SPIRV-Cross passes HLSL sin/cos/tan straight to the GLSL builtin on the raw argument, which hands large arguments to the driver's own range reduction: Dots.fx reaches ~792 rad and Intel UHD missed the mgfxc golden by 19/255 while llvmpipe and NVIDIA matched. fxc always reduces before its D3D9 sincos (defined only on [-π, π]), so mgfxc's GLSL never calls sin on a large value; this restores that property with more accurate constants than mgfxc's six-decimal ones. Measured in fp32 over 4M random arguments: max phase error 1.3e-7 rad for |
x | ≤ 1000 (single-constant fract(x/2π + 0.5)·2π − π: 1.0e-4; mgfxc's printed constants: 3.8e-4); a driver that reassociates the split degrades to the single-constant figure, never worse. C1 = 6.28125 has 8 significant bits, so k·C1 is exact for |
x | up to ~400 000 rad. Applied to every non-literal argument in both stages: the rewrite has no value-range analysis, and a uniform, varying or texture read can be arbitrarily large. A literal argument (which DXC normally folds anyway) and a shader with no sin/cos/tan are byte-unchanged. tan is reduced with the same helper (2π is two of its π periods, so the reduction is exact for it), because fxc reduces it too: D3D9 has no tan instruction, so fxc /T ps_3_0 lowers tan(input.TexCoord.x * 800.0 * scale) to mad / frc / mad / sincos / rcp / mul, the same reduction followed by sin/cos, and mgfxc's GLSL therefore never takes the tangent of a large value (measured 2026-10-01; sin, cos and sincos emit the same mad / frc / mad / sincos prefix, and HLSL sincos reaches the GLSL as a separate sin and cos). Those are all the angle-taking builtins: asin/acos/atan (GLSL's spelling of atan2 too) take a ratio and sinh/cosh/tanh are not periodic (fxc lowers them to exp with no reduction), so they are left alone, as is any identifier that merely contains one of the three names. |
| 17 | any non-square matrix (mat2x3 … mat4x3, and a square type spelled matNxN) in either stage, or a matrix-from-matrix constructor in a shader that has one |
the matrix is held as a zero-padded square matN, N = max(columns, rows): logical column c is padded with zero rows, columns past the logical count are zero. Declarations become matN; constructors are padded (mat3x4(a, b, c) → mat4(a, b, c, vec4(0.0)), a scalar list grouped into padded columns, a scalar diagonal stopping at the logical rows); M * v → (M * vecN(v, 0.0)).xyz, v * M → (vecN(v, 0.0) * M).xy (the narrowing swizzle only when the logical result is smaller than N); a column read M[i] → M[i].xyz unless a component or swizzle is taken from it; a matrix product is computed at the largest size involved (a factor widened with zero columns/rows) and narrowed back to the product's own size (mat4x3 * mat3x4 is a mat3); a matrix-from-matrix constructor (mat3x4(mat4), mat3(mat4)) is spelled out column by column with GLSL's identity fill. GLSL 1.10 / ESSL 1.00 have no non-square types and no matrix-from-matrix constructor: strict front ends (Mesa, macOS, WebGL) refused such a shader at Effect load and MonoGame 3.8.6 DesktopGL4 aborted the process (issue #453). They come from SPIRV-Cross's truncation locals (mul(float3, float4x4), a (float3x3) cast: KNI's DeferredBasicEffect.fx declared mat3x4 _60) and from Rule 4c's own non-square interpolant temporary and pixel-stage rebuild. Every padding element is an exact zero added to the logical sums, so the arithmetic is unchanged. #version 120 is not an option: the same artifact must load on GLES / WebGL1. The pass types each statement that touches one (declared types, builtin return types, swizzles) and is all-or-nothing: a shape it does not model (a non-square struct member, function parameter or interface variable, a matrix plus a scalar, an operand whose type it cannot see) leaves the whole text unchanged, so SD0403 still warns. A shader with no non-square type is returned byte-identical. |
||||
| 18 | any array constructor (float[](a, b, c), float[3](…), S[](S(…), …)) in either stage: a module-level const T name[N] = T[](…); (DXC hoists an HLSL lookup table) or a local T name[N] = T[](…); |
declared plain, then one assignment per element: a local in place, a module-level array at the top of main (every other function runs from main). const is dropped; values and indexing are unchanged. The array constructor is GLSL 1.20 / ESSL 3.00, so WebGL1 / KNI Reach refused the shader at load while lenient desktop drivers accepted it (issue #475). All-or-nothing: an array of arrays, a count that differs from the declared size, or a module-level array read by another module-level initializer makes the pass decline, and SD0403 then still warns. |
Rewrite returns the rewritten GLSL plus the discovered sampler list (ps_s{k}) and the
ps_uniforms_vec4 register count. The pipeline pairs this with the .mgfx side:
- The cbuffer is named
ps_uniforms_vec4, with one 16-byte register per free parameter, register-aligned by size (SM 3.0 constant-register layout), soEffect.Parameters[name] .SetValue(…)lands in the rightvec4slot.- Each register joins its parameter by the HLSL name, paired by position (issues #438,
#440). SPIRV-Cross renames a member GLSL cannot take (
noise→_noise,my__val→my_val,input→_input_1beside a real_input), so the rewriter's layout is renamed back from DXC's SPIR-VOpMemberNames (the HLSL names): blocks pair with the reflected buffers by name and member count (in order when every count agrees), and members by index, since SPIRV-Cross keeps the member order. A join on the GLSL spelling bound a PS's renamednoiseto the VS's real_noiseparameter (SetValue("noise")then reached only a dead register) and could not map a rename in a stage with two cbuffers. Only when that pairing declines does the old name join plus the single-cbuffer declaration-order bridge apply, and failing both isSD0012. The same pairing gives the SkSL and raylib converters their defaults (issue #435).
- Each register joins its parameter by the HLSL name, paired by position (issues #438,
#440). SPIRV-Cross renames a member GLSL cannot take (
- The per-shader sampler table has one record per combined (texture, sampler) pair — the same
list as the sampler uniforms the emitted GLSL declares. That is the only list that can be
correct, because the GL runtime resolves each record by its uniform name. Each record carries
Name = ps_s{slot},TextureSlot = SamplerSlot = slot,Parameter= the index of the pair's texture, and the bakedsampler_stateof the pair's sampler — soSpriteBatch's texture reaches the sampler. Records are emitted sorted by slot, the waymgfxc's goldens lay the table out.slotis the pair's HLSL DECLARATION index, not its position in the pair list. SPIRV-Cross declares combined samplers in first-use order, but fxc — and thereforemgfxc— allocates GL sampler slots in declaration order. Numbering by list position put aregister(s0)sampler on unit 1 whenever it was not also sampled first, and sinceSpriteBatchforces the sprite onto unit 0 afterEffectPass.Apply(), that silently swapped textures (GitHub issue #189). The index comes from SPIR-V module order, not from theBindingdecoration: those agree only while DXC auto-allocates, and once the source is annotated the binding is register order instead. Both the uniform names and the records come fromSpirvCombinedSamplerPairs.ResolveSlots, the single definition of the rule, so the table and the GLSL cannot disagree.- An explicit register is honoured, but a legacy and a modern one mean different things.
The rule, in texture-declaration order: a pair whose sampler declared a register TAKES it;
every other pair takes the lowest register neither already taken nor RESERVED by a modern
SamplerState : register(sN). Atps_3_0a texture and a sampler are one object in one register namespace, so a legacysampler X : register(sN)is the combined object and lands onN, while a modernSamplerStatemerely occupies its register and pushes the synthesized combined samplers around it — one texture plusSamplerState S : register(s0)yieldsps_s1, notps_s0. Verified against the pinnedmgfxcon ten shapes. Both facts are read off a managed preprocessed view of the source (FxPreParser.CollectGlSamplerSlots, conditionals evaluated and macros expanded with the compile's own macros), the waymgfxcreads them: a register inside an inactive#ifbranch does not count and one spelled through a macro does, for the reservation (issue #283) and for the legacy pin (issue #299) alike. The reservation is made by EVERY sampler type keyword (sampler,sampler2D,samplerCUBE,SamplerComparisonState, not onlySamplerState), whether or not anything reads the sampler and in every entry point of the effect, because fxc keeps an explicitly bound register out of circulation for the whole source (issue #309, measured on 20 shapes); a legacy sampler atex2Dreads is therefore pinned for its own pair and reserved for every other. The SM4 rewrite that drops the clauses works on the raw text; a legacy sampler it cannot recognize there (declared in an#included file, or declared or read through a macro) makes the raw compile fail, after which the pre-parse is repeated on the preprocessed source and the compile runs again on that text (issue #308,LegacySamplerRecovery; an effect that compiles from its raw source is never touched). - Per pair, not per sampler. Two textures read through one shared
SamplerState(the diffuse+lightmap idiom) produce two records; two samplers over one texture (the linear+point idiom) also produce two, each with its own state. Keying on the reflected samplers instead was the Phase 51 A7 bug: it rejected the first shape outright and, because SPIRV-Cross declares combined samplers in first-use rather than declaration order, silently swapped the texture parameter and sampler-type byte in the second. The linear+point shape is the one case where two pairs share a texture and so share a declaration index;ResolveSlotsfalls back to positional numbering there rather than colliding two uniforms onto one unit. - The pair list is derived in pure managed code from the SPIR-V (
SpirvCombinedSamplerPairs) rather than via SPIRV-Cross'sspvc_compiler_get_combined_image_samplers, which the browser host'sspirv-cross.wasmdoes not export — a native call would fix desktop only and break the guarantee that the CLI and the browser emit identical bytes. - The count is cross-checked against the sampler uniforms actually declared in the emitted
GLSL; a mismatch fails loudly with
SD0217rather than shipping a mis-bound table. (SD0215/SD0216, which encoded the old sampler-keyed model, are retired and their numbers are do-not-reuse.)
Rejected alternatives
- Patch the MonoGame runtime (look up by HLSL name) — breaks drop-in compatibility with
stock
mgfxc-compiled.mgfx; not viable. - Ship a UBO + binding points — requires MonoGame runtime changes; same problem.
Verification
ShadowDusk's OpenGL .mgfx (this rewrite + the MgfxWriter format rework) loads into a
real MonoGame.Framework.DesktopGL Effect and renders pixel-equivalent to the mgfxc
goldens, including the uniform-driven shaders with parameters set by name
(validation/Candidate; unit coverage in MonoGameGlslRewriterTests).
Vertex stage
Rewrite is stage-symmetric: for ShaderStage.Vertex it emits the VS-side
MojoShader dialect that MonoGame's GL runtime links against. The shared passes run here
too — version/420pack strip, matrix expansion, and (since issue #137) the stage-agnostic
body lowerings Rule 8 (round), Rule 9b (one-shot do-while → for), Rule 10
(pow-square), Rule 11 (reciprocal fold), (since issue #138) Rule 12 /
Rule 13 (loop-hoist fixes), and (since Apos.Shapes issue #34) Rule 15
(trunc() → sign(x)*floor(abs(x))), (since issue #215) Rule 16 (sin/cos/tan
range reduction), (since issue #453) Rule 17 (non-square matrices held as a zero-padded
square matN), and (since issue #475) Rule 18 (array constructors lowered to per-element
assignments), applied before posFixup injection.
Rule 9a is deliberately pixel-only: it turns loop breaks into early return;
statements, and an early return in a VS would skip the posFixup tail appended at
end-of-main (the Y-flip / half-pixel contract); the 9b for-loop form has a single
fall-through exit, so the fixup always runs — and no derivative ops exist in the vertex
stage, so skipping 9a loses no issue-#136 coverage. Rules 5/6/6b/14 (fragment outputs,
texture builtins, the derivatives header) stay pixel-stage. The register prefix and the
in/out direction are the other stage knobs:
| SPIRV-Cross VS input | Rewritten to |
|---|---|
layout(std140) uniform type_Globals { … } |
uniform vec4 vs_uniforms_vec4[N]; (a mat4 counts as four registers) |
in <type> in_var_<SEM>; (vertex inputs) |
attribute vec4 vs_v{k}; — renamed in declaration order; uses get a width-truncating swizzle (vec4(vs_v0.xyz, 1.0)) |
out mat<C>[x<R>] out_var_TEXCOORD<n>; (a matrix interpolant output) |
one varying vec4 vTexCoord{n+k}; per register plus a global mat<C>[x<R>] vs_oT{n}_mat; (a non-square one is then held as a zero-padded matN by Rule 17) the body writes, copied out at the end of main (before the position fixup) as fxc packs it, one HLSL column per register (column_major, the default): vTexCoord{n+k}.xyz = vec3(vs_oT{n}_mat[0][k], vs_oT{n}_mat[1][k], vs_oT{n}_mat[2][k]);. DXC makes an HLSL floatRxC a GLSL matrix of R columns (the HLSL rows), so register k is GLSL row k; mgfxc's DeferredBasicEffect-shaped golden writes vs_o2 = (Tangent.x, Binormal.x, Normal.x), the same register content. An explicit row_major interpolant is packed one HLSL row per register, as fxc packs it: vTexCoord{n+c}.xyz = vs_oT{n}_mat[c];. DXC emits byte-identical GLSL for row_major and column_major, so the majorness is read off the preprocessed HLSL per entry point (GlSamplerSlots.EntryPointInterpolants: the signature of the function each pass compiles, its parameters, return value and return semantic, the members of the FILE-SCOPE structs those use at any depth (a struct or typedef local to a function body never replaces the file-scope one; a type name defined twice at file scope is unmodelled), comma declarators, typedefs of matrix and struct types, matrix<T, R, C>; expansion is memoized and bounded for untrusted input; semantics normalized by value, TEXCOORD00 is TEXCOORD0) and handed to the rewrite as MonoGameGlslInterpolantPacking. Each stage is laid out from its own declaration, as fxc lays it out, so a row_major VS output read by a column_major PS input, or two techniques declaring one semantic differently, render as in mgfxc, and an unused struct affects nothing; #pragma pack_matrix(row_major) does not change an interpolant, in fxc or here. The layout matters whenever the two stages declare the interpolant differently (a matrix written whole and read as separate vectors, or the reverse): before 2026-10-28 a row_major one was packed by columns and such a pair read the wrong registers (measured in real DesktopGL: tangent row (178,204,229) in mgfxc, (178,102,25) in ours). Loud failures: a vertex shader with a return statement (the copy-out would be skipped on that path); a matrix interpolant the entry point's scanned signature does not declare (a shape the scan does not model, e.g. a semantic on a struct-typed member whose struct holds a matrix), or any matrix interpolant when the rewrite is called without the packing argument, never packed by a guessed majorness; a column_major float1xN or row_major floatNx1 VERTEX INPUT (fxc gives it N attributes). The same degenerate shape as an INTERPOLANT is split the way fxc lays it out, N one-component registers: the VS writes a vecN vs_oT{n}_vec and copies vTexCoord{n+k}.x = vs_oT{n}_vec.<k>, the PS reads vecN(vTexCoord{n}.x, …). (An overloaded entry point never reaches the rewrite: DXC refuses it, X0000: definition with same mangled name, where mgfxc compiles it.) A matrix vertex INPUT, a nointerpolation (flat) matrix interpolant and an ARRAY of matrix interpolants are not modelled and stay loud failures (mgfxc compiles the last two). A non-square matrix interpolant always leaves a mat4x3-style type in at least one stage, which warns SD0403 (strict front ends refuse it). |
out <type> out_var_<SEM>; (vertex outputs) |
varying vec4 <legacy>; — the SAME names the PS reads (vFrontColor/vTexCoord{n}), so MonoGame links VS→PS by name; a narrower output writes a swizzled LHS (vTexCoord0.xy = vs_v2.xy;). Exception — out_var_POSITION{0} → gl_Position (issue #70): a VS output carrying the legacy D3D9 POSITION/POSITION0 semantic is the clip position (the stock MonoGame GL template emits this via #define SV_POSITION POSITION). DXC (Shader Model 6) treats : POSITION as an ordinary user output (only : SV_Position is the builtin position), so without this remap the transform would land in a dead var_POSITION varying and gl_Position would be left unwritten — silently-broken geometry. The rewriter (IsPositionSemantic) drops its varying decl and rewrites its uses to gl_Position, so posFixup then applies as for any SV_Position shader. mgfxc maps : POSITION to the position natively (D3D9 SM3). |
gl_Position = … ; (from SV_Position) |
kept, then the mgfxc/MojoShader posFixup contract is injected: uniform vec4 posFixup; (declared after vs_uniforms_vec4[], the golden's order) and the two fixup lines gl_Position.y = gl_Position.y * posFixup.y; + gl_Position.xy += posFixup.zw * gl_Position.ww; immediately before SPIRV-Cross's kept depth-convention line. MonoGame's GL runtime sets the uniform per draw (+1 backbuffer / -1 render target, half-pixel offset in .zw when UseHalfPixelOffset) and skips programs without it. SPIRV-Cross's FlipVertexY is off — the old baked -gl_Position.y only matched the render-target case and rendered backbuffer draws (the normal game case) vertically inverted. FixupDepthConvention stays on. |
The VS rewrite also returns the vertex-attribute table (each vs_v{k} →
VertexElementUsage+semantic-index) which the pipeline writes into the .mgfx shader record
so MonoGame and KNI bind each attribute to the right vertex element (each VertexElement binds
to the attribute whose record carries its usage and index). The table is
ShadowDusk.Core.Reflection.VertexSemanticMapper, the one the Vulkan and DirectX 12 attribute
tables use: POSITION→0, COLOR→1, TEXCOORD→2, NORMAL→3, BINORMAL→4, TANGENT→5, BLENDINDICES→6,
BLENDWEIGHT→7, DEPTH→8, FOG→9, PSIZE→10, each with its own semantic index (TEXCOORD3 is 2/3,
BLENDWEIGHT1 7/1), exactly what mgfxc 3.8.4.1 /Profile:OpenGL writes (measured per
semantic; goldens in tests/fixtures/golden/vertex-semantics/). Until 2026-10-28 the GL table
held only the first four and every other semantic was SD0210. Unlike the Vulkan / DirectX 12
reflectors, the GL path does NOT take the mapper's TextureCoordinate fallback for an unknown
semantic: mgfxc's fxc vs_3_0 refuses one (X4502), so it stays a loud SD0210. SAMPLE
(which mgfxc maps to usage 11) is not in the shared table and is refused too. Two inputs that
name one element (: POSITION0 beside : SV_POSITION, or beside : Position0) are one input
register to fxc, so mgfxc writes ONE record and both read it; the rewrite merges them the same
way (only the same semantic spelled two ways merges; PSIZE0 beside POINTSIZE0, which the shared table also maps to one element, is refused, as fxc refuses POINTSIZE; two Position/0 records left the second attribute unbound, reading zero: (255,0,0) in ours
against (16,0,0) in mgfxc in real DesktopGL). The .mgfx cbuffer for a VS-bound buffer is named vs_uniforms_vec4
(PS-bound stays ps_uniforms_vec4); attribution is from reflection, not a PS-only assumption.
The cbuffer record model
The .mgfx constant-buffer records are built per shader, from the rewriter's own
register layout (MonoGameGlslResult.Uniforms), never by cross-stage reflection-name
dedup — so the record's offsets and the GLSL's [i] indices come from one allocation
and cannot diverge. mgfxc's model, pinned by its goldens:
- A cbuffer bound by both stages → a record per stage (
vs_uniforms_vec4ANDps_uniforms_vec4), each stage's shader binding its record by index. Several records may share a name (the SkinnedEffect golden has threevs_uniforms_vec4). The old cross-stage dedup named the single recordps_uniforms_vec4while the VS GLSL readvs_uniforms_vec4[]— MonoGame never uploaded the VS array and VS uniforms silently read zero. - Multiple same-stage cbuffers → one merged record in declaration order;
identical records dedupe across shaders mgfxc-style (
ConstantBufferData.SameAs). - Array members occupy element-stride × count registers and the parameter carries
N recursive element sub-parameter records (empty name/semantic, parent shape,
zero-data leaf) — on every target, exactly MonoGame 3.8.2
Effect.ReadParameters' recursive wire format (elements first, then struct members), soParameters["X"].SetValue(array)and.Elements[i]work beyond element 0. - Pinned divergence: mgfxc's per-stage records contain only the constants fxc
kept for that stage; ShadowDusk's carry the cbuffer's full declared layout per
stage. Both are self-consistent with their own GLSL; parameters are set by name —
render-proven equivalent (
validation/CbufferModel). - Synthesized backing (issue #187): the layout→parameter join runs in BOTH
directions. A Scalar/Vector/Matrix parameter that reflection reports for a stage
but the rewriter's layout missed — DXC's
-spirvbackend can fold a uniform's only reads and drop its then-unused cbuffer from the SPIR-V entirely, while the DXIL reflection source (like fxc) keeps it — gets a register slot appended after the live ones, cbuffer membership at that offset, and a coveringuniform vec4 {vs,ps}_uniforms_vec4[N];declaration patched into the GLSL (CompilationPipeline.PatchGlUniformArrayDeclaration, inserted past the full leading prologue —#extensionlines and balanced#if…#endifheader blocks such as the#ifdef GL_ESprecision block and the TexLod#if __VERSION__ >= 300 … #endifheader, in any order). Footprint follows the RUNTIME'S write model: a Matrix is sized by Columns (MonoGame/KNI upload matrices transposed, writingColumnCount16-byte rows —float2x4⇒ 4 registers; sizing by Rows under-allocates and crashes the firstEffectPass.Apply), scalars/vectors one register, arrays once per element. Without this the parameter existed with no cbuffer record —SetValuewrote nowhere. The synthesized shape is exactly what the mgfxc golden carries for the one known member (GradientToy.fx/iResolution) and what real mgfxc's DirectX profile ships for any declared-but-unused uniform; the GL driver link-strips the unread array (a spec-sanctioned silent skip) or uploads data nothing reads — render- identical either way. Guarded byGlPhantomParameterTests(structural backing criterion + corpus-wide sweep); full record:plan/DONE/ISSUE-187-gl-phantom-parameter-compile-fidelity.md. - mgfxc bug not replicated: an array read at only SOME static indices is broken
in mgfxc+MonoGame GL itself — fxc references only the used registers, MojoShader
emits a compacted uniform array, but mgfxc's record keeps the full layout, so
MonoGame's full-buffer
glUniform4fvlands element 0's data where the shader reads element 1 (verified: that golden renders garbage in real MonoGame 3.8.2). ShadowDusk always emits the full declared layout, rendering the source semantics correctly.
Matrix free-uniforms. A mat4 member expands to the four consecutive registers it
occupies — _Globals.M → a mat4 reconstructed transposed (the registers are taken as the
matrix's ROWS), open-coded with swizzles by BuildUploadedMat4:
mat4(vec4(P[r].x, P[r+1].x, P[r+2].x, P[r+3].x), vec4(P[r].y, …), vec4(P[r].z, …), vec4(P[r].w, …)).
Why transposed (issue #70). MonoGame/KNI's EffectParameter.SetValue(Matrix) uploads
register k = column k of the authored matrix — the layout mgfxc's golden reads with
result[j] = dot(v, register[j]) for HLSL mul(v, M) (i.e. v * mat4(reg0..reg3)).
SPIRV-Cross, however, lowers mul(v, M) to GLSL M * v (operands swapped, since the
row/column-major decoration it carries — which this rewrite strips when it flattens the UBO
into the flat register array — is what would otherwise keep the result upright). A naive
mat4(reg0..reg3) (registers as columns) would therefore compute M·v, the transpose of
the intended v·M, rendering geometry garbled (issue #70's "exploded cube"). Reconstructing the
transpose cancels the operand swap — Mᵀ * v == v * M == dot(register[i], v), the golden's
per-row dot — and is correct for every mul order. transpose() is not used (it is absent in
GLSL ES 1.00 / Reach / WebGL1 and versionless desktop GLSL 1.10). The register index is the
running register total so a mat4 correctly shifts every member after it, agreeing exactly with
the .mgfx cbuffer packing (BuildConstantBufferInfoList). Unit-pinned in
MonoGameGlslRewriterTests and render-pinned by Issue70MatrixTransposeRenderTests
(non-identity asymmetric matrix vs the mgfxc golden). Applies to both stages.
Verification: the VS-driven fixture VsTransformColorTexture.fx (custom VS +
float4x4 transform + POSITION/COLOR0/TEXCOORD0 + textured/tinted PS) compiled by ShadowDusk
loads in a real MonoGame.Framework.DesktopGL Effect and renders pixel-identical to the
mgfxc OpenGL golden via a custom vertex-buffer draw path (validation/VsDriven) — in both
the RenderTarget2D mode and the backbuffer mode (the case the static Y-flip got wrong).
The same .fx for DirectX renders pixel-identical to the mgfxc DX golden in real
MonoGame.Framework.WindowsDX via both DXBC backends (validation/VsDrivenDx).
Known limitations (future work)
- Vertex semantics beyond POSITION/COLOR/TEXCOORD/NORMAL. The attribute-table map covers the
SpriteBatch-compatible set; an unmodelled semantic (e.g.
BLENDWEIGHT) fails loudly at compile time (MonoGameGlslRewriteException) rather than binding to the wrong vertex element. ExtendSemanticToVertexUsageto add more. - Geometry / hull / domain / compute stages are out of scope (MonoGame 3.8 GL Reach doesn't support them).