Tutorial 108: FNA3D: Running XNA's Real Compiled Stock Effects
What you’ll learn
- Why
FNA3Dis the only CNA renderer that runs XNA's own compiled shader programs. - How it picks SDL_GPU, Direct3D 11 or OpenGL at runtime, and how to pin that choice.
- The trade-off that buys it:
ShaderEffectdoes not work here at all, and instancing is off on purpose. - How to read its XNA oracle corpus result without mistaking a measurement for a failure.
Before you start — Tutorial 32: BasicEffect and 3D Lighting, because the stock effects are the whole point of this renderer and the only shaders it can run.
Every other CNA renderer reimplements XNA's stock effects. FNA3D does not: it loads the actual compiled shader binaries Microsoft shipped with XNA Game Studio and executes them. If you have ever wanted to know what your scene looks like under XNA's real BasicEffect rather than somebody's careful reconstruction of it, this is the renderer that answers.
It gets there by building on FNA3D, the C graphics library FNA itself renders through. FNA3D's API design is XNA 4.0 — FNA3D_DrawIndexedPrimitives, FNA3D_SetBlendState, FNA3D_VerifySampler, FNA3D_SetRenderTargets — so it maps onto CNA's renderer interface almost one to one. That closeness is why the renderer exists, and the one place FNA3D constrains its consumer is what the second half of this tutorial is about.
The route a draw call takes
CNA game code
-> Microsoft::Xna::Framework::Graphics (GraphicsDevice / SpriteBatch / BasicEffect / ...)
-> Fna3dRenderer (CNA's IGraphicsRenderer implementation)
-> FNA3D (chooses its driver at runtime)
-> SDL_GPU | Direct3D 11 | OpenGL
Note the last two rows. FNA3D belongs to CNA's portable-RHI family alongside LLGL, DILIGENT, SOKOL and BGFX — it is not one native API, and which API you end up on is decided while the program is running.
Building it
cmake -S . -B build-fna3d \
-DCNA_GRAPHICS_RENDERER=FNA3D \
-DCMAKE_BUILD_TYPE=Debug \
-DCNA_BUILD_TESTS=ON
cmake --build build-fna3d -j3
FNA3D is fetched at configure time at a pinned revision (release tag 26.08) and built as a static archive. The fetch recurses into its MojoShader submodule, because FNA3D's own CMake compiles MojoShader's translation units directly.
| Item | Detail |
|---|---|
| Upstream | FNA-XNA/FNA3D, zlib licence, pinned to release tag 26.08. |
| Vendored submodule | MojoShader (icculus/mojoshader), built from FNA3D's own CMake. |
| Dependencies | SDL 3.2.0 or newer, and nothing else — exactly the SDL3 CNA already vendors. No second SDL build, no system SDL requirement. |
| Offline builds | -DFETCHCONTENT_SOURCE_DIR_FNA3D=/path/to/FNA3D. The checkout must already have its MojoShader submodule initialised. |
The version handshake is checked rather than assumed: FNA3D_LinkedVersion() is compared against the compiled-in version at device creation, so a mismatched shared FNA3D is reported instead of silently trusted.
Runtime driver selection
FNA3D tries its drivers in the order SDL_GPU, Direct3D 11, OpenGL and reports the SDL window flags the chosen one needs. Selection happens before the window exists, because preparing the window attributes also primes the GL attributes the window's visual is chosen from — CNA therefore asks FNA3D for its flags while assembling the SDL window, the same shape LLGL, DILIGENT and BGFX already use.
You can pin the driver with an SDL hint:
# Force the OpenGL driver
FNA3D_FORCE_DRIVER=OpenGL ./my_game
# Force SDL_GPU. The aliases Vulkan, D3D12 and Metal also map onto SDL_GPU renderers.
FNA3D_FORCE_DRIVER=SDL_GPU ./my_game
Pin the driver in any test that asserts on pixels. CNA's own FNA3D tests set FNA3D_FORCE_DRIVER=OpenGL for exactly this reason: a machine with a partially functional Vulkan stack would otherwise silently change which driver the assertions were written against. Three genuinely driver-dependent behaviours have already been found here (sub-rectangle readback origin, volume readback, compressed readback), so treat a pass on one driver as evidence about that driver only.
XNA's real compiled shaders
FNA3D_CreateEffect() is the only shader entry point in the whole library. It takes a compiled Direct3D 9 Effect Framework binary and runs it through MojoShader. Nothing anywhere in FNA3D compiles a GLSL or HLSL source string, and the drivers refuse to draw without a bound MojoShader program.
A renderer built on FNA3D therefore cannot author its own shaders. It must supply .fxb blobs — and the blobs CNA supplies are the XNA 4.0 stock effects, the same artefacts FNA itself ships, loaded through the same call. Using them is not a shortcut around writing shaders; it is how FNA3D is designed to be consumed, and it is what makes this the one CNA renderer that executes XNA's own shader programs rather than a reimplementation of them.
| Blob | Techniques × passes | Shader variants |
|---|---|---|
SpriteEffect.fxb | 1 × 1 | 1 |
BasicEffect.fxb | 1 × 1 | 32, selected by ShaderIndex |
AlphaTestEffect.fxb | 1 × 1 | 8 |
DualTextureEffect.fxb | 1 × 1 | 4 |
EnvironmentMapEffect.fxb | 1 × 1 | 16 |
SkinnedEffect.fxb | 1 × 1 | 18 |
Each blob carries exactly one technique with one pass; the variant is chosen by an integer ShaderIndex parameter plus the effect's own index arrays. That is XNA's own dispatch mechanism, and CNA computes the index with the same arithmetic XNA's OnApply() does. Which means the blobs are a behavioural contract rather than build input — the arithmetic is only correct against these exact artefacts, so they are committed into the CNA tree, pinned to a specific FNA revision, rather than fetched.
Provenance matters here, and it is documented rather than assumed. The blobs come from FNA's StockEffects/FXB/ directory; the original sources are Microsoft's XNA Game Studio stock effect .fx files, released under the Microsoft Permissive Licence, and .fxb is their fxc-compiled form. CNA is itself Ms-PL, so they are redistributed under the licence they arrived with, with the attribution repeated in the repository's third-party notices.
They cannot be rebuilt from source in this repository. Compiling .fx to .fxb needs fxc from the DirectX SDK, which exists on no platform CNA builds on. The HLSL sources remain readable upstream if you want to know what the shader actually does.
This is also worth placing against CNA's wider shader story: compiled .fx bytecode is unsupported across CNA generally — Effect(device, bytecode) throws and the XNB effect reader refuses before reading a byte. FNA3D does not change that. It ships six specific known blobs and dispatches into them; it does not give you a general bytecode loader for your own compiled effects.
The trade-off: no custom shaders at all
ShaderEffect does not work on FNA3D. GraphicsCapability::CustomEffects is an authored false, and the renderer's effect factory returns null. There is no workaround, because there is no source-string compiler anywhere in FNA3D to route to.
This is the direct, unavoidable consequence of the previous section, and it is the deciding factor when choosing this renderer. Claiming otherwise would be a promise broken at the first custom effect, so the renderer says no up front instead.
Two behaviours follow, and they fail differently:
- Constructing a
ShaderEffectdoes not throw. The factory returns null, so the effect is built but invalid —IsEffectValid()answersfalse. Nothing was compiled. - Handing a custom effect to
SpriteBatch::Begin()is silently ignored. The batch draws through the stockSpriteEffectas if you had passed nothing.
So query the capability rather than waiting for a symptom:
#include "CNA/GraphicsCapability.hpp"
using CNA::GraphicsCapability;
// A `false` here is authored, not inherited -- believe it and take the other path.
if (device.SupportsCapability(GraphicsCapability::CustomEffects))
{
ShaderEffect grade(device, kVertexSrc, kFragmentSrc);
if (grade.IsEffectValid())
{
spriteBatch.Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend,
nullptr, nullptr, nullptr, &grade);
// ...
spriteBatch.End();
return;
}
}
// FNA3D lands here: stock path only.
spriteBatch.Begin();
// ...
spriteBatch.End();
Tutorial 101 explains why a false from SupportsCapability() is worth far more than a true; FNA3D is one of the renderers whose negatives are individually authored with a recorded reason.
Instancing is false, and that is a shader limit too
FNA3D instances natively. Its own instanced draw is real, and its per-stream bindings carry an instance frequency. Yet GraphicsCapability::Instancing reports false and DrawInstancedPrimitives refuses.
The reason is the same one as above, viewed from a different angle: none of XNA's compiled stock effects declares a per-instance vertex input. A per-instance stream could be bound perfectly and would still never reach a shader that reads it. Reporting true would promise instancing that renders every instance from record zero — visually, one object where you expected a thousand, with no error. The refusal spells out the same reason.
Multi-stream vertex input, by contrast, is a real true: FNA3D's vertex-binding call takes an array of per-stream declarations, each with its own stride and vertex offset, so it is the library's native shape rather than an emulation. If you came here from Tutorial 60, that is the distinction to carry away — several per-vertex streams are fine, a per-instance stream is not.
The full capability contract
| Capability | Reported | Backed by |
|---|---|---|
ThreeD | true | The full draw routes. |
DepthStencilBuffer / StencilBuffer | true | Back-buffer and per-target depth/stencil renderbuffers. |
MultiSampleAntiAliasing | device-dependent | A live query of the driver's maximum sample count. |
MultipleRenderTargets | true | The native call takes the whole ordered set. |
AnisotropicFiltering | true | Native sampler state. |
WireFrame | true | Native wireframe fill mode. |
OcclusionQuery | true | Native queries and pixel counts. |
Texture3D | true | Native volume textures. |
AdditiveBlending | true | Native blend factors. |
MultiStreamVertexInput | true | Native per-stream bindings with independent strides and offsets. |
Instancing | false | Authored no — the stock effects declare no per-instance input. |
CustomEffects | false | Authored no — FNA3D compiles no shader source. |
Every enum is numerically XNA's, and that is proved
FNA3D's header states that its enumerations should match XNA 4.0, and CNA's enums are ports of the same specification, so every conversion between them is the numeric identity. Rather than assume that, CNA guards each converter with static_asserts pinning the endpoints — and, where the enum is small enough to matter, every single value.
// Illustrative shape: a future divergence on either side becomes a compile error,
// not a silently wrong blend mode or vertex format.
static_assert(static_cast<int>(Blend::One) == FNA3D_BLEND_ONE);
static_assert(static_cast<int>(Blend::Zero) == FNA3D_BLEND_ZERO);
A runtime range check sits behind the asserts and rejects an out-of-contract ordinal from the shared layer by name, instead of casting it into an undefined enumerator. Combined, that means a state ordinal either maps exactly or fails loudly — never quietly renders with the wrong state.
The XNA oracle corpus, read correctly
CNA keeps a 39-scene oracle corpus: scene descriptions plus reference PNGs captured from the real XNA 4.0 runtime. Renderers are diffed against those images at tolerance zero. FNA3D runs the whole corpus and matches 10 of the 39 scenes exactly.
That number is a measurement, not a grade. The reference images were captured on a different rasteriser, so the corpus carries a host-wide divergence that is not renderer-specific. CNA's GL renderer family scores 10 of 39 on the same machine, and OPENGLES1 scores 11. A renderer at 10 is at the established baseline — the useful signal is the per-scene comparison, not the headline count.
Three things are worth taking from the FNA3D run:
- Every one of the 39 scenes rendered. None failed. This corpus is the only thing in the repository that drives
DualTextureEffect,EnvironmentMapEffect,SkinnedEffect, theBasicEffectlighting variants, fog and all eight alpha-test comparison functions through this renderer at all. - It is better than the GL baseline in places. The three sprite sort-mode scenes are exact under
FNA3Dand off by one sub-LSB rounding step under the GL family — which is precisely what lifts its exact count. - It found a real defect that nothing else could. Two skinned scenes landed far outside the expected band. Measuring the rendered geometry rather than guessing located it: the quad was 26 pixels left of where XNA put it, and the scene's bone translation worked out to almost exactly that distance. The bone matrix packing was writing rows where the shader expected columns, dropping the translation row entirely and silently turning every translating bone into an identity bone. A wrongly transposed identity is still an identity, and a bone palette is overwhelmingly identities — so single-bone scenes and the unit tests all looked fine. Only a bone carrying a translation reveals it.
The honest limits are recorded alongside the result: the run used a software rasteriser rather than vendor hardware, and it exercised FNA3D's OpenGL driver only. Whether SDL_GPU and Direct3D 11 — which run the same MojoShader-translated stock effects through different rasterisers — hit the same numbers is an open external question. See Verification for how CNA frames this kind of evidence.
Other boundaries worth knowing
| Area | Behaviour |
|---|---|
| Render-target array slices | Rejected: CNA exposes no texture arrays, and FNA3D's binding has no slice field. |
| An unknown vertex stride with no declaration | Throws, naming the stride. FNA3D binds real per-stream declarations, and this renderer will not guess a layout. |
| Block-compressed readback | Refused on the OpenGL and Direct3D 11 drivers — both refuse it upstream — and reported as “read nothing” rather than an untouched buffer passed off as a successful read. Which case applies is measured once per device with a small probe, not guessed from the driver name. |
Compressed Texture3D / TextureCube | Refused by name. Volume and cube transfers are RGBA8 in CNA's renderer contract. |
| Driver format limits | Queried once at device creation and enforced: a texture in a format the driver has no storage for is refused by name, and so is a bind past the driver's real sampler-slot count. |
| Sub-rectangle back-buffer readback | Handled for you. FNA3D's own sub-rectangle origin differs between drivers, so CNA reads the whole back buffer — which every driver agrees is top-row-first — and crops on the CPU. |
On the friendlier side: the presentation modes, MSAA render targets with resolve-on-unbind, mip generation, PreserveContents, MRT sets, both index widths, SetDataOptions forwarded verbatim, hardware occlusion queries and named resources in a RenderDoc capture are all real. SetDataOptions::NoOverwrite is gated on the driver actually having that fast path and downgraded where it does not.
When to choose FNA3D
- Yes — when you want XNA's genuine stock-effect output as a reference, or when you are porting a title whose look was tuned against real XNA.
- Yes — when you want one build that can fall back across SDL_GPU, Direct3D 11 and OpenGL without recompiling.
- No — if your game has any custom shader. That is a hard stop, not a degradation.
- No — if you draw crowds with hardware instancing.
If the stock effects are enough and shaders are not part of your plan, this is the closest CNA gets to running XNA's own graphics code. If they are not, Tutorial 72 covers the alternatives.