Chapter 17 Shaders: Four Answers to XNA’s .fx
There is no single CNA answer to XNA effects. The current tree contains four different shader-delivery strategies: CNA-owned implementations of the stock semantics, recompilation of Microsoft’s stock HLSL sources on D3D9, execution of compiled XNA/FNA Effect Framework bytecode through qualified renderer runtimes, and the renderer-specific ShaderEffect route. This chapter separates those strategies and, especially, formats that share a “compiled shader” description but are not interchangeable.
17.1 XNA’s compiled-effect format
A normal XNA game does not ship .fx shader source text. The XNA content pipeline compiles .fx effect files to a proprietary compiled bytecode format at build time, and the compiled bytecode — not the HLSL source — is what ships inside the game’s .xnb content files and gets loaded at runtime via new Effect(GraphicsDevice, byte[]). On the real Xbox 360/Windows XNA runtime, that bytecode is interpreted directly; FNA instead ships MojoShader, a from-scratch bytecode parser and cross-compiler that turns the same compiled Direct3D 9 shader bytecode into GLSL (or other target shading languages) at load time, which is exactly what lets FNA run historical XNA effect content without its original source.
17.2 The public compiled-effect boundary
Two public entry points now provide one bounded route. The Effect byte-buffer constructor validates an XNA/FNA Direct3D 9 Effect Framework container, asks the active renderer for GraphicsCapability::CompiledEffects, creates its compiled runtime, and builds the reflected object graph. The general XNB EffectReader reads a length-prefixed payload and calls that same constructor. It wraps constructor/backend failures in ContentLoadException; it is not a permanent refusal reader at this tag.
The route reflects parameters, arrays, structures, annotations, techniques, passes, shader objects, sampler/texture bindings, and pass state. EffectPass::Apply() synchronizes mutable values, selects the technique/pass, applies native effect state, and makes the effect current for SpriteBatch or ordinary 3D draws on an implementing renderer. Clone() duplicates the native runtime, values and current-technique choice while keeping resources tied to the same graphics device.
Admission is deliberately narrow. The buffer must be an Effect Framework container (including the XNA wrapper form), at most 64 MiB, with bounded reflection/object graphs. A file containing .fx source, HLSL text, raw DXBC, or MonoGame’s MGFX container is a different input and is refused. The feature also needs both a parser and a renderer runtime; metadata parsing alone never promotes the capability.
Do not attach a current sample count to that gap without retesting the samples. The pinned cna-samples catalog contains 153 source-archive entries split among completed ports, tracked placeholders, and deliberate exclusions (Chapter 77); its dated missing.md files are case records, not fresh proof that every old blocker remains. For a concrete port, inventory each general EffectReader asset and direct bytecode constructor call instead.
17.3 Answer one: reimplement the stock semantics
Most CNA renderers do not consume XNA bytecode. The five public stock effects translate their properties into GpuDrawParams; each renderer then chooses CNA-authored shader variants for combinations such as fog, vertex color, alpha test, and lighting. This is broad practical coverage, but it is a semantic reimplementation rather than execution of Microsoft’s compiled programs. It also explains why adding a new arbitrary parameter to an effect is not merely a map insertion: the aggregate, every relevant renderer, and the shader variants must agree on it.
17.4 Answer two: recompile Microsoft’s sources on D3D9
The Direct3D 9 renderer takes a different evidentiary route. It vendors the six Microsoft/FNA stock .fx sources — the five public effects plus the internal sprite effect — and their four .fxh includes, discovers entry points from the source’s own compile vs_2_0/ps_2_0 statements, and compiles them with d3dcompiler_47. Its comparison tool strips only bytecode comment blocks such as CTAB and the compiler creator string: 61 of 66 resulting shader instruction streams match the Microsoft-shipped programs exactly. The five misses are all pixel-lighting vertex variants; the recorded investigation attributes them to the compiler-47 versus XNA-era compiler-43 difference after testing the available optimization flags. This is unusually strong stock effect evidence, but it still does not compile a game’s arbitrary .fx content.
17.5 Answer three: execute compiled Effect Framework bytecode
FNA3D is the always-enabled implementation in this release. It is also the provenance route for the repository’s six stock fixtures. The repository commits six FNA-derived XNA 4.0 .fxb files: Sprite, Basic, AlphaTest, DualTexture, EnvironmentMap, and Skinned effects, 106 KB in total. The build embeds those exact blobs; renderer initialization calls FNA3D_CreateEffect, which runs them through the pinned MojoShader dependency and exposes their parameter metadata. Draw dispatch selects the original program’s shader variant and applies its named parameters.
FNA3D exposes no source-string compiler, CNA’s FNA3D CreateEffectRenderer() returns null, and the renderer reports GraphicsCapability::CustomEffects == false. The committed blobs are runtime artifacts rather than in-repository build products because producing them requires the old effect toolchain. Nevertheless, the public constructor is no longer closed to those six files: other structurally valid game-authored Effect Framework binaries enter the same FNA3D runtime and reflection path.
Three other families have opt-in implementations, all disabled by default because they add the MojoShader dependency:
-
•
CNA_SDL_GPU_COMPILED_EFFECTS uses MojoShader’s SDL_GPU adapter and SPIR-V;
-
•
CNA_EASYGL_COMPILED_EFFECTS uses its OpenGL adapter for the selected EasyGL profile; and
-
•
CNA_VULKAN_COMPILED_EFFECTS uses CNA’s Vulkan runtime over MojoShader’s portable SPIR-V profile.
With an option off, the same renderer reports CompiledEffects false and construction fails explicitly. The tag’s shader-route document lists FNA3D, SDL_GPU and EasyGL in its table but describes Vulkan only later in prose; the Vulkan CMake option, runtime, tests and capability are the current source authority.
17.6 Answer four: bypass .fx with ShaderEffect
CNA does not leave custom shading entirely unaddressed, but “cross-renderer” needs a precise definition here. ShaderEffect (CNAEXT) presents one constructor shape and one IEffectRenderer contract to every renderer. A renderer factory override proves only that an effect object is returned. It does not prove that arbitrary source is compiled, that every setter has meaning, or that a 3D draw consumes the resulting program. Reading the live implementations and their registered tests gives this narrower matrix:
| Renderer | Input and path that actually consumes it |
|---|---|
| EasyGL | GLSL source is compiled and linked by the active GL driver. SpriteBatch and the general 3D draw paths consume it. Uniform names and array setters are real; 2D, cube, and volume-texture binds each have a dedicated pixel test. |
| SDL GPU | GLSL source is compiled at runtime to SPIR-V through libshaderc, then consumed by SpriteBatch’s fixed 32-byte vertex contract and 128-byte uniform block. This includes a tested two-output MRT shader. |
| Vulkan | The two std::string arguments carry raw, precompiled SPIR-V bytes — not GLSL source. SpriteBatch consumes them through a fixed 32-byte vertex contract and 128-byte push-constant layout, verified by Vulkan_ShaderEffect_SpirV. |
| D3D9 | HLSL is compiled at runtime to the active SM2/SM3 profile and consumed by SpriteBatch, with a real color-inversion pixel test. |
| D3D11/12 | HLSL is compiled at runtime as vs_5_0/ps_5_0 and consumed by SpriteBatch’s fixed vertex and 128-byte constant-buffer contracts. Scalar/vector setter names are ignored; the sprite texture and sampler still arrive through t0/s0. |
| BGFX | No input is accepted through this API: CompileProgram() always returns false. The object reports that BGFX requires precompiled binary shaders, but ShaderEffect exposes no route that loads those binaries. |
| SDL renderer | There is no effect renderer, so construction leaves IsEffectValid() false. Passing the non-null object to SpriteBatch::Begin() throws std::runtime_error; merely constructing it does not throw. |
WebGPU and FREEDIRECT/free-direct likewise inherit the null CreateEffectRenderer() default rather than compiling a public custom program; their SpriteBatch implementations reject a non-null custom effect. Canvas rejects it for the same no-programmable-stage reason. The Software and Headless overrides serve different testing purposes, not real shader execution: Software accepts any non-empty pair and marks it valid while continuing to render through its fixed CPU shading path, whereas Headless records compilation, bind, uniform, and 2D-texture activity without producing shader pixels. These are useful contracts, but calling either one custom-shader support would be misleading.
None of these ShaderEffect paths is the compiled Effect Framework route. A game using that API must provide renderer-specific source for EasyGL, SDL GPU, and Direct3D, or precompile the exact SPIR-V contract Vulkan expects. Even among the working implementations, the public setter names and supported draw shapes are not portable promises. The API is thus a real, pixel-verified alternative on a bounded set of paths, not a write-once shader format for all CNA renderers.
GpuDrawParams (Chapter 15) does carry a customEffectRenderer pointer. ShaderEffect populates it through its FillGpuDrawParams() override. At present, however, only EasyGL’s general 3D draw dispatch reads that field. Vulkan and SDL GPU retain similarly named custom-effect pointers inside their SpriteBatch-specific snapshots; the Direct3D custom paths are also SpriteBatch facilities. The default-no-op setters on IEffectRenderer (Chapter 19) are another deliberate capability boundary: a public SetUniformXxx() call may be structurally accepted without changing GPU state unless that concrete renderer overrides it.
17.6.1 Object lifetime, submission, and cloning are deliberately narrow
The constructor takes its two source arguments by reference but immediately copies both into the effect. It is therefore safe to construct from temporary or subsequently modified strings; the program is compiled from the construction-time copies, not from caller-owned character storage. If the device has a renderer, construction asks it once for an IEffectRenderer. A returned but invalid renderer does not make the common constructor throw: it writes that renderer’s GetCompileError() text to standard error and leaves IsEffectValid() false. A null renderer and a failed compile are intentionally collapsed to the same public boolean, and there is no public ShaderEffect accessor for the diagnostic. Renderers may of course fail before returning from their own factory, so this is not a promise that every renderer-specific setup failure becomes a boolean.
Treat that validity result as a precondition, not a delayed exception. Effect::Apply() does reject an already disposed effect, but its common code calls OnApply() and then makes the effect current. ShaderEffect::OnApply() merely writes a debug message when its renderer is absent or invalid; it does not bind a fallback program. Calling Apply() on an invalid custom effect can therefore still replace the device’s current effect pointer. There is no automatic retry or source recompilation in this object; construct a new effect after correcting source or changing the renderer circumstances.
The uniform and texture calls are submission calls, not an XNA-style parameter collection. ShaderEffect keeps no uniform dictionary, no sampler table, and no texture ownership: each SetUniformXxx() forwards its name and scalar or caller buffer immediately to the renderer if one exists, while each SetTexture() immediately forwards the address of the texture’s renderer. It performs no common-layer check of a uniform name, sampler-unit range, array count, or matrix/array pointer. EasyGL uploads/binds at that call; some other renderers copy into a fixed staging block; inherited interface defaults can do nothing; and Headless records a raw texture-renderer pointer for tracing. Thus a successful call is not a portable guarantee of a binding, and it never gives the effect ownership of the texture. Keep every texture alive by its normal owner and set it again whenever the target renderer/path requires rebinding. The standalone setters also do not inspect IsDisposed; unlike Apply(), they continue to forward while the renderer object remains alive, so disposal must be treated as terminal by the caller.
Clone() is equally source-only. It returns an owning raw Effect pointer whose concrete object is constructed afresh as new ShaderEffect(*device_, vertSrc_, fragSrc_): it recompiles an independent renderer program and restores the three IEffectMatrices values to identity. It does not copy submitted uniforms, texture binds, name/tag metadata, or a compiled-program handle; its base effect begins with its own empty parameter collection and one Default/P0 technique. Use an owning smart pointer at the call site and reapply all desired state to the clone. The existing ShaderEffectTests test establishes distinct object identity, copied source strings, and equal validity on the default test device. It does not compile against a renderer or cover matrix/state transfer, texture lifetime, invalid-apply behavior, or a renderer reset.
17.6.2 A minimal GLSL ShaderEffect adapted from a test
Adapted from easygl_shadereffect_texturecube_test.cpp, a minimal cube-map-sampling fragment shader and its construction:
Check IsEffectValid() before drawing. Unlike a stock effect selected from CNA’s shipped programs or FNA3D’s retained stock bytecode, ShaderEffect compiles game-supplied source at runtime. A syntax error or unsupported GLSL feature therefore produces an invalid effect that must be reported before submission.
17.6.3 The D3D9 HLSL counterpart
ShaderEffect’s constructor takes one pair of source strings for every renderer; there is no HLSL-specific overload. On a Direct3D renderer the same call shape compiles HLSL instead of GLSL. The following color-inversion example is adapted from the pinned D3D9 test and uses its SM2/SM3 Macros.fxh texture macros, which the vendored stock SpriteEffect.fx also uses:
vpSize is worth pausing on, because it is doing real work rather than being decorative: a custom vertex shader supplied this way has no other way to map SpriteBatch’s pixel-space Position input into normalized device coordinates, so this uniform (set automatically by the renderer, not by game code) is what makes the sprite land at the correct screen location at all — the same real test this example is adapted from separately verifies that sampling outside the sprite’s destination rectangle stays the clear color, proving this NDC math is genuinely correct rather than happening to paint something plausible-looking over the whole screen. One small, harmless documentation inaccuracy worth knowing if you go reading ShaderEffect’s own header next: its class-level doc comment describes it as a “GLSL-source-based effect.” That is accurate for EasyGL and SDL GPU, but this exact worked example proves the same class compiles HLSL on Direct3D, while Vulkan interprets the strings as raw SPIR-V bytes. The comment is stale cross-renderer documentation, not a constraint enforced by the public constructor.
17.6.4 Beyond SpriteBatch: a 3D draw call
Both worked examples so far apply a ShaderEffect through SpriteBatch. The wider path described in this subsection is specifically EasyGL support; no other renderer’s general 3D draw dispatch currently reads GpuDrawParams::customEffectRenderer. For a long stretch of this project’s history that was the only place a ShaderEffect could actually take effect: applying one before a raw GraphicsDevice::DrawIndexedPrimitives() call — the call shape ModelMesh::Draw() and a hand-rolled RawMesh both use — was silently ignored. Because ShaderEffect did not yet implement IEffectMatrices (Chapter 15) and its FillGpuDrawParams() override still ran the Effect base class’s no-op, GpuDrawParams stayed at its default, zero value on every 3D draw, and EasyGL’s DrawIndexedPrimitivesEx() fell back to selecting one of its own built-in, stride-keyed shaders regardless of whatever custom effect the caller had just applied — exactly the same silent-fallback failure shape Chapter 12’s ExtractMatrices() section already documents for stock effects, one layer further out.
This was closed by three coordinated changes, verified directly against ShaderEffect.hpp / .cpp and EasyGLRenderer.cpp: (1) GpuDrawParams gained a customEffectRenderer field; (2) ShaderEffect now implements IEffectMatrices — real World / View / Projection properties, extracted by the same GraphicsDevice::ExtractMatrices() every stock effect already goes through — and overrides FillGpuDrawParams() to populate customEffectRenderer; (3) EasyGL’s DrawPrimitivesEx() / DrawIndexedPrimitivesEx() check that field first and, when it is set, bind the custom compiled program and its World / View / Projection uniforms directly, bypassing the built-in stride-selected shaders entirely.
A worked example, adapted from easygl_shadereffect_3d_test.cpp, makes the wiring concrete. The shader is a minimal N-dot-L diffuse lighting pass over a single textured quad, using the same GLSL attribute-location convention (location = 0/1/2 for Position / Normal / TexCoord) as VertexPositionNormalTexture’s existing 32-byte stride:
The real test behind this example is deliberately built to rule out a false positive: it draws the same quad twice, once with World = Identity (facing the light, world normal (0,0,1), expected color close to the full (200,100,50) diffuse value) and once with World = CreateRotationY(180°). A ° rotation was deliberately not chosen for the second case — it would turn the quad edge-on to the camera, so “renders nothing” and “renders black” would look identical in a single-pixel readback. ° keeps the exact same on-screen footprint (the quad is X-symmetric, so mirroring X does not change its silhouette) while flipping the world-space normal to (0,0,-1), which the shader’s own max(dot(...), 0.0) clamps to a genuinely lit black. Only a World matrix that is actually reaching the vertex shader and actually affecting world-space lighting can produce that second, specific result rather than merely happening to draw something.
17.6.5 A second, narrower gap this one uncovered, then closed: arbitrary layouts
The first version of the 3D draw path above recognized only the five byte-strides EasyGL’s then-existing ApplyLayout() switch knew about (16/20/24/32/52). Later PBR and vertex-color work expanded the fixed family to 48/56/68 too, but a stride is still not a description of its internal fields. Arbitrary layouts were deliberately scoped out of the first task, since several real ported sample shaders (NormalMapping.fx’s Position+Normal+Binormal+Tangent+TexCoord layout, among others) need element arrangements that match none of those fixed interpretations. A truly custom VertexDeclaration — arbitrary element count, arbitrary per-element offset, any VertexElementFormat — reads back correctly through the same 3D ShaderEffect draw path via a second, separately-tracked fix: VertexBuffer::SetDataRaw() now pushes the buffer’s own VertexDeclaration down to the renderer through a new IVertexBufferRenderer::SetVertexDeclaration() call (default no-op, so Vulkan/BGFX/SDL_Renderer needed zero code changes for this to stay correct), and ApplyLayout() binds generically from that declaration when one is present — GLSL attribute location equals the element’s own index within the declaration — falling straight through to the original stride-keyed switch, unchanged, whenever no declaration was supplied. This is a purely additive capability, not a replacement of the fixed-stride path the first worked example already exercises.
The real test behind this (easygl_shadereffect_custom_vertex_layout_test.cpp) is worth citing for its own verification technique, not just its result: it declares a 5-element, 48-byte layout (Position / Normal / Tangent / TextureCoordinate / Color). CNA now also has a fixed 48-byte PBR layout, but that one contains a four-float tangent and no trailing color; sharing a total stride does not make the records equivalent. The test diagnostically encodes three of the four non-position attributes directly into the output pixel (FragColor = vec4(Normal.x, Tangent.y, TexCoord.x, Color.r)) and draws the same quad twice with two independently-chosen sets of attribute values. Distinct, correctly-offset readback values across both draws is what proves each attribute is being read from its own correct byte offset — not aliased to a neighbor, and not a value that happens to be correct by coincidence.
17.7 The honest summary
If your porting target relies only on SpriteBatch and the five public stock effects from Chapter 15, it can use CNA’s renderer-owned implementations. If it loads compiled XNA/FNA Effect Framework bytecode, the public constructor and XNB reader work on FNA3D and on opt-in SDL_GPU, EasyGL and Vulkan configurations. They do not compile .fx/HLSL source and do not accept DXBC or MGFX. A separate ShaderEffect migration still depends on its renderer-specific contract: EasyGL takes GLSL and alone extends the effect to general 3D draws; SDL GPU takes GLSL through shaderc; Vulkan takes precompiled SPIR-V; D3D9/11/12 take HLSL for SpriteBatch; BGFX has no valid public path; and the non-programmable or not-yet-wired renderers reject, simulate, or only record the object. The accurate statement is therefore neither “compiled effects are unsupported” nor “all effect formats work everywhere.” Stock semantics, D3D9 stock-source recompilation, renderer-qualified Effect Framework execution with reflection/state/cloning, and renderer-specific custom programs are separate, implemented contracts.