Four shader routes: stock semantics, D3D9 stock sources, compiled effects and ShaderEffect
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read at the pinned snapshot; the 61-of-66 bytecode comparison is CNA's own dated record; per-renderer consumer paths were located in source and their pixel tests were not run for this page.
XNA has one answer to shading: effects authored as .fx and shipped as compiled Effect Framework bytecode. CNA has four separate answers, each with its own API, input format, renderer set and evidence: CNA-owned implementations of the stock effects, recompilation of Microsoft's stock sources on DIRECTX9, execution of compiled Effect Framework bytecode on qualified renderers, and the renderer-specific ShaderEffect. This page separates them, explains which inputs are refused and why, and gives the exact object contract of ShaderEffect — lifetime, uniform submission, cloning and the draw paths that consume it. It is for porters deciding how to bring a game's shaders across and for maintainers who must not blur the routes. Usage is taught on Shader Effects and Effects System.
PUBLIC API CARRIED AS EXECUTED BY
---------- ---------- -----------
stock effects (BasicEffect ..) GpuDrawParams fields ----------> each renderer's own stock
shaders; DIRECTX9 runs
Microsoft's recompiled .fx;
FNA3D runs FNA's .fxb
Effect(device, bytes) Effect Framework binary -------> FNA3D (always) and nine
XNB EffectReader (.fxb, XNA 4 wrapper) opt-in families behind
cna-content --format xnb --+ CNA_*_COMPILED_EFFECTS
(build time: .fx via an |
external legacy fxc) ----+
ShaderEffect (CNAEXT) renderer-native text or -------> the renderer's own compiler
bytecode (GLSL, WGSL, HLSL, or loader; SpriteBatch and,
SPIR-V words) on seven families, 3D draws
CNA::Graphics engine layer shader packages (GLSL ES, > renderers that accept one of
(CNA_CNAEXT=ON) GLSL 330, SPIR-V, WGSL) the package languages
REFUSED AT RUN TIME: .fx / HLSL effect source, raw DXBC, MonoGame MGFX.
No route converts one input format into another at run time.
GpuDrawParams draw packet; compiled Effect Framework binaries go through the byte constructor or the XNB reader (and can be produced at build time by cna-content, which compiles .fx only through an external legacy fxc); ShaderEffect hands renderer-native source or bytecode to the active renderer; engine-layer passes select a variant from a multi-language shader package. The bottom line lists the inputs no route accepts at run time. Support for one route never implies another.What XNA ships, and why it matters
A normal XNA game does not ship .fx source. The XNA content pipeline compiles each .fx at build time into Direct3D 9 Effect Framework bytecode, stores it in an .xnb, and the game loads it at run time through new Effect(GraphicsDevice, byte[]) or ContentManager.Load<Effect>. On Windows and Xbox 360 the runtime hands that bytecode to Direct3D. FNA instead ships MojoShader, a parser and cross-compiler that turns the same Direct3D 9 shader bytecode into GLSL or other target languages at load time — which is what lets FNA run historical XNA effect content without its source. The consequence for a port is that the thing to inventory is not "the game's shaders" but each asset loaded through the general EffectReader and each direct bytecode-constructor call: those are what the compiled route must accept.
Earlier CNA revisions treated compiled bytecode as a blanket gap ("CNA cannot load XNA effects"). That description is stale in both directions: compiled bytecode now loads on qualified renderers (route three), and a claim about which XNA samples are blocked belongs to the sample repository's own dated records, not to a count repeated from memory.
Route one: reimplement the stock semantics
Most renderers never see XNA bytecode for the stock effects. BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect translate their properties into GpuDrawParams, and each renderer selects CNA-authored shader variants for combinations such as fog, vertex colour, alpha test and lighting. This gives the broadest practical coverage, but it is a semantic reimplementation, not execution of Microsoft's programs, and it explains why a new effect input is never just a dictionary entry: the draw packet, every effect that fills it and every renderer that must honour it have to agree. Exact semantics and the verification history are on Stock effects: exact semantics; the draw packet is on Effect object model.
Route two: recompile Microsoft's stock sources on DIRECTX9
The DIRECTX9 renderer takes a different evidentiary route. It vendors XNA's six stock .fx sources — the five public effects plus the internal SpriteEffect — and their four includes (Common.fxh, Lighting.fxh, Macros.fxh, Structures.fxh) under shaders/xna. compile_shaders_sm2.py parses the entry-point list from the sources' own compile vs_2_0/ps_2_0 statements (a hand-typed list had already invented wrong names once), compiles every entry point with Microsoft's real d3dcompiler_47.dll under a dedicated Wine prefix with D3DCOMPILE_OPTIMIZATION_LEVEL3 only, and embeds the bytecode in the checked-in d3d9_shaders.hpp. The script is run by hand after a vendor refresh; no CMake target runs it, so an ordinary build needs no shader compiler.
compare_against_fxb.py compares that bytecode with the programs Microsoft shipped inside the stock .fxb files, after stripping only comment tokens (the constant table and the compiler's creator string, which legitimately differ between compiler versions). Its recorded result (2026-07-14, CNA's own record, not re-run here): 61 of 66 instruction streams identical. The five misses are all pixel-lighting vertex variants, attributed to the d3dcompiler_47 versus XNA-era D3DCompiler_43 difference after OPTIMIZATION_LEVEL0–3, SKIP_OPTIMIZATION and AVOID_FLOW_CONTROL were all tried. This is unusually strong stock-effect evidence, and DIRECTX9 is also the renderer CNA diffs against real XNA 4.0 output in its oracle corpus (a separate result with its own scenes and tolerance), but the bytecode comparison covers only the stock sources, never a game's arbitrary .fx.
Route three: execute compiled Effect Framework bytecode
The public byte constructor and the XNB EffectReader accept an XNA/FNA Direct3D 9 Effect Framework container on renderers that report GraphicsCapability::CompiledEffects. FNA3D does so in every build. It is also where the six FNA-derived stock binaries live: modules/renderers/fna3d/effects commits SpriteEffect, BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect .fxb files (106,700 bytes together; a CNA conformance effect sits beside them), embedded into the build by embed_effects.py and loaded at renderer start-up through FNA3D_CreateEffect, which runs them through the pinned MojoShader. They are runtime artefacts rather than in-repository build products because producing them needs the legacy effect compiler. FNA3D has no source-string compiler at all — its only shader entry point takes a compiled binary — so its CreateEffectRenderer() returns null and it reports CustomEffects false; any other structurally valid game binary enters the same runtime and reflection path.
Nine more families implement the route behind default-OFF CMake options, because each adds MojoShader translation or an interpreter to a renderer that does not otherwise need it: CNA_EASYGL_COMPILED_EFFECTS (all five GL identities), CNA_OPENGL4_COMPILED_EFFECTS, CNA_VULKAN_COMPILED_EFFECTS, CNA_SDL_GPU_COMPILED_EFFECTS, CNA_WEBGPU_COMPILED_EFFECTS, CNA_SOFTWARE_COMPILED_EFFECTS (a CPU interpreter), CNA_DIRECTX9_COMPILED_EFFECTS, CNA_DIRECTX11_COMPILED_EFFECTS and CNA_DIRECTX12_COMPILED_EFFECTS. That is 14 of the 25 identities in 10 families; a default configure reports the capability true on FNA3D only. With an option off, the renderer reports CompiledEffects false and construction throws NotSupportedException before any draw. CNA's own prose tables have lagged these options; the CMake gates, runtime factories and capability overrides are the authority. The admission rules, reflected object graph, pass application and per-renderer translation are on Compiled XNA effects.
Route four: bypass .fx with ShaderEffect
ShaderEffect (CNAEXT, ShaderEffect.hpp) presents one constructor shape — the device and two strings — and one IEffectRenderer contract to every renderer. The strings are not a portable format: each renderer decides what they mean. A renderer returning an effect object proves only that an object exists; it does not prove that arbitrary source compiles, that every setter has meaning, or that a 3D draw consumes the program. The class-level header comment still calls it a "GLSL-source-based effect"; that is accurate for the GL renderers only and is stale cross-renderer documentation, not an enforced constraint.
Which input each renderer takes, and which draws consume it
| Renderer | What the two strings must contain | Paths that execute the program |
|---|---|---|
EasyGL (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2) | GLSL for the profile, compiled and linked by the GL driver | SpriteBatch and general 3D draws; uniform names and array setters are real; 2D, cube and volume texture binds each have a pixel test |
OPENGL4 | desktop GLSL | SpriteBatch and general 3D draws |
VULKAN | raw precompiled SPIR-V bytes (the size must be a multiple of four), not GLSL | SpriteBatch (the Vulkan_ShaderEffect_SpirV test) and 3D draws through QueueCustomEffect3DDrawEXT |
SDL_GPU | SPIR-V words whenever a device exists; GLSL compiled at run time through libshaderc only in builds that found it (never Windows, Apple or Emscripten) | SpriteBatch (including a tested two-output MRT shader) and 3D draws |
WEBGPU | WGSL | SpriteBatch and 3D draws |
DIRECTX11, DIRECTX12 | HLSL with entry point main, compiled at run time as vs_5_0/ps_5_0; named setters reach constant-buffer members through shader reflection | SpriteBatch, and 3D draws when the program's vertex signature can be matched to the bound VertexDeclaration (otherwise NotSupportedException) |
DIRECTX9 | HLSL compiled for SM2, or SM3 under the HiDef profile | SpriteBatch only; no 3D draw path consumes it |
SOFTWARE, HEADLESS | anything non-empty is accepted | none: Software keeps rendering through its fixed CPU shading path; Headless records compilation, binds and uniform calls without producing shader pixels |
FNA3D, PORTABLEGL | none: no effect renderer, so the effect is invalid | none; PORTABLEGL refuses a custom-program 3D draw by name |
METAL | none: construction throws NotSupportedException | none; 3D custom-effect draws are also refused until adapted macOS evidence exists |
SDL_RENDERER, CANVAS, FREEDIRECT | none: no effect renderer, construction leaves the effect invalid | none; passing the object to SpriteBatch::Begin throws std::runtime_error ("no programmable shader stage exists on this renderer"), merely constructing it does not |
GDI, HTML_DOM, SVG_DOM, DIRECT2D, STUB | none | none (the first three throw) |
The dialect question can be asked of the live device (GetShaderDialectEXT(), SupportsShaderLanguageEXT(), ExecutesShaderEffectSourceEXT()); see Shader Effects: asking the live device. A game that migrates to ShaderEffect must therefore author a source variant per renderer family it ships, or precompile the exact SPIR-V contract Vulkan expects.
The Vulkan contract (read from VulkanEffectRenderer and VulkanRenderer.cpp; Vulkan_ShaderEffect_SpirV exercises the SpriteBatch route). A batch feeds the shader SpriteBatch’s fixed 32-byte vertex: location 0 a vec2 pixel position, location 1 a vec2 texture coordinate, location 2 a vec4 colour. Scalar uniforms travel in one 128-byte push-constant block visible to both stages: bytes 0–7 are a vec2 viewport size that the batch writes for you (the active viewport’s width and height, or the target or virtual-resolution size when no custom viewport is set), bytes 8–15 are padding, bytes 16–79 one mat4, bytes 80–95 one vec4 and bytes 96–99 one float. Uniform names are not consulted: SetUniformMat4 fills the matrix slot, SetUniformVec4, SetUniformVec3 and SetUniformVec2 fill the leading four, three or two floats of the one vector slot, and SetUniformFloat and SetUniformInt both write the one float slot (the integer converted to float), so a second setter of the same kind overwrites the first. The array setters use uniform-buffer ranges in descriptor set 1, one per element type, again chosen by type rather than name. A 3D draw with a ShaderEffect needs the vertex buffer’s own VertexDeclaration (the layout is never inferred from the stride) and throws NotSupportedException without one; a per-instance stream’s attribute locations continue after the per-vertex elements.
The SDL_GPU contract uses the same fixed-slot, name-ignoring setters but a different SpriteBatch vertex: 36 bytes, a vec3 position at location 0, a vec2 texture coordinate at location 1 and a vec4 colour at location 2. Its 128-byte uniform block is copied to both stages, with a vec4 at bytes 0–15 whose first two floats are the viewport size, then the same matrix (16–79), vector (80–95) and float (96–99) slots; where that block and the sampler live (vertex set 1, fragment sets 3 and 2) is on SDL_GPU: which GLSL the runtime route accepts. The Direct3D sprite vertex is on Custom HLSL ShaderEffect on the Direct3D renderers; the three layouts are not interchangeable.
How a ShaderEffect reaches a 3D draw
For a long stretch of CNA's history a ShaderEffect applied before GraphicsDevice::DrawIndexedPrimitives() — the call shape ModelMesh::Draw() uses — was silently ignored: the class did not implement IEffectMatrices, its FillGpuDrawParams() was the base no-op, and EasyGL fell back to one of its own stride-selected stock shaders. Three coordinated changes closed it, first on EasyGL: GpuDrawParams gained a customEffectRenderer field; ShaderEffect now implements IEffectMatrices, so GraphicsDevice extracts its World/View/Projection like any stock effect's; and the renderer's draw checks the field first, binding the custom program and its World/View/Projection uniforms (the names XNA samples' own .fx sources use) instead of a stock shader.
At this snapshot ShaderEffect::FillGpuDrawParams() sets two fields and leaves every other field at its default: customEffectRenderer (the program, possibly null) and customEffectRequested (always true). The second flag exists so that a renderer without custom programs can refuse the draw instead of mistaking a null program for an ordinary stock draw. The EasyGL, OPENGL4, VULKAN, SDL_GPU, WEBGPU, DIRECTX11 and DIRECTX12 draw routes consume the program; the per-renderer table above lists the others.
The verification of the EasyGL route is worth copying. easygl_shadereffect_3d_test.cpp draws a textured quad with a hand-written N·L diffuse shader twice: with World = identity (normal (0,0,1) facing the light, expected colour near the full (200,100,50)) and with World = Matrix::CreateRotationY(MathHelper::Pi). A 90° rotation was deliberately avoided: it would turn the quad edge-on, and "draws nothing" and "draws black" would read back identically. 180° keeps the same silhouette while flipping the normal to (0,0,-1), which the shader clamps to a genuinely lit black — a result only a World matrix that really reaches the vertex shader can produce.
auto* fx = dynamic_cast<ShaderEffect*>(fxBase.get());
fx->setWorldProperty(world);
fx->setViewProperty(Matrix::CreateLookAt(Vector3(0, 0, 3), Vector3::Zero, Vector3::Up));
fx->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, aspectRatio, 0.1f, 100.0f));
fx->Apply(); // binds the program first
fx->SetTexture(0, whiteTexture); // Texture2D&
fx->SetUniformVec3("uLightDir", 0.0f, 0.0f, 1.0f);
fx->SetUniformVec3("uDiffuseColor", 200.0f / 255.0f, 100.0f / 255.0f, 50.0f / 255.0f);
device.SetVertexBuffer(vertexBuffer.get());
device.Indices(indexBuffer.get());
device.DrawIndexedPrimitives(PrimitiveType::TriangleList, 0, 0, 4, 0, 2);
Adapted from that test; GLSL for an EasyGL profile; read-checked against the TARGET headers, not compiled.
Arbitrary vertex layouts
The first 3D route recognised only EasyGL's fixed byte strides, and a stride is not a description of the fields inside it: two 48-byte layouts can hold entirely different elements. Several ported XNA sample shaders need arrangements that match no fixed layout (the normal-mapping sample's position, normal, binormal, tangent and texture coordinate is the classic case). The general fix is that every vertex buffer now hands its VertexDeclaration to the renderer through the mandatory IVertexBufferRenderer::SetVertexDeclaration(), and the GL renderers bind a custom program's attributes generically from it: the attribute location equals the element's index in the declaration, per-vertex elements first. easygl_shadereffect_custom_vertex_layout_test.cpp proves it with a five-element, 48-byte declaration (position, normal, tangent, texture coordinate, colour) whose fragment shader writes vec4(Normal.x, Tangent.y, TexCoord.x, Color.r), drawn twice with two independently chosen attribute sets: distinct, correct read-backs in both draws show each attribute comes from its own offset rather than from a neighbour or by coincidence. The declaration rules themselves are on Vertex declarations and streams.
The ShaderEffect object contract
Construction never throws for a bad shader
The constructor (ShaderEffect.cpp) copies both source strings into the object, so constructing from temporaries or later-modified strings is safe. If the device has a renderer, it asks once for an IEffectRenderer; if the returned program is invalid it writes the compiler log to standard error and continues. A null program (a renderer without custom effects) and a failed compile both leave IsEffectValid() false; HasRenderer() distinguishes them. The reason is available from GetCompileErrorEXT() and GetShaderDiagnosticsEXT(). The no-throw design is deliberate: on several renderers CustomEffects is true while text is never compiled (Vulkan takes SPIR-V and Headless accepts and ignores the source; Software also accepts it but reports CustomEffects false, so it belongs to the accept-but-never-execute group rather than to the true group), and throwing would turn a capability boundary into a crash. A renderer may still throw from its own factory (Metal, GDI, the DOM renderers), so this is not a promise that every setup failure becomes a boolean.
Validity is a precondition, not a deferred exception
Effect::Apply() rejects a disposed effect, calls OnApply() and makes the effect current. ShaderEffect::OnApply() binds the program when it is valid and otherwise only writes a debug log message; it binds no fallback. Applying an invalid ShaderEffect therefore still replaces the device's current effect. Nothing recompiles automatically: correct the source, or change renderer, and construct a new effect. Check IsEffectValid() before drawing.
Uniforms and textures are submissions, not a parameter collection
ShaderEffect keeps no uniform dictionary, sampler table or texture ownership. Each SetUniformXxx() forwards its name and value (or the caller's buffer) immediately to the renderer program if one exists; each SetTexture(unit, texture) immediately forwards the address of the texture's renderer object. The common layer checks no uniform name, sampler-unit range, array count or pointer. What happens next is renderer-specific: the GL renderers upload or bind at the call, others copy into a staging block, the interface defaults do nothing (about fifty empty-bodied virtual methods in the renderer-contract header, the IEffectRenderer setters among them), and Headless records a raw pointer for tracing. A successful call is therefore not a portable guarantee of a binding, and it never gives the effect ownership of the texture: keep every texture alive through its normal owner and set it again whenever the target path needs rebinding. The setters do not check IsDisposed; after Dispose() has released the program they become silent no-ops. The effect has an empty Parameters collection and a single Default/P0 technique.
Clone() recompiles and copies nothing else
ShaderEffect::Clone() returns an owning raw Effect* built as new ShaderEffect(device, vertexSource, fragmentSource): an independent program is compiled from the retained source, the matrices start at identity, and only the diagnostic labels and selected-language value of the typed constructors are copied. Submitted uniforms, texture binds, name and tag metadata and the compiled-program handle are not. The header explains why the program is not shared: the effect uniquely owns its renderer program, and sharing would need a reference-counted ownership model. Unlike the stock effects' Clone(), it does not check whether the source effect has been disposed. Wrap the result in a smart pointer and re-apply all state to the clone. ShaderEffectTests.cpp checks distinct identity, copied sources and equal validity on the default test device; matrix and state transfer, texture lifetime, invalid-apply behaviour and device reset are not covered by a test.
Worked use: a GLSL cube-map shader
const char* vertSrc = R"(#version 300 es
precision highp float;
layout(location = 0) in vec3 aPosition;
void main() { gl_Position = vec4(aPosition, 1.0); }
)";
const char* fragSrc = R"(#version 300 es
precision highp float;
out vec4 FragColor;
uniform samplerCube CubeSampler;
uniform vec3 direction;
void main() { FragColor = texture(CubeSampler, direction); }
)";
ShaderEffect effect(getGraphicsDeviceProperty(), vertSrc, fragSrc);
if (!effect.IsEffectValid())
std::fprintf(stderr, "%s\n", effect.GetCompileErrorEXT().c_str()); // do not draw
effect.SetUniformInt("CubeSampler", 0);
effect.SetUniformVec3("direction", dx, dy, dz);
effect.SetTexture(0, environmentCube); // TextureCube& overload
Adapted from easygl_shadereffect_texturecube_test.cpp; GLSL ES 3.00, so it compiles only on the OPENGLES3 and WEBGL2 profiles. Read-checked; not compiled.
Worked use: the DIRECTX9 HLSL counterpart
There is no HLSL-specific overload; on a Direct3D renderer the same call shape compiles HLSL. The colour-inversion example below is adapted from directx9_spritebatch_customeffect_test.cpp.
const char* vertexSrc = R"(
float2 vpSize;
struct VSInput { float3 Position : POSITION0; float4 Color : COLOR0; float2 TexCoord : TEXCOORD0; };
struct VSOutput { float4 Position : POSITION0; float4 Color : COLOR0; float2 TexCoord : TEXCOORD0; };
VSOutput main(VSInput input) {
VSOutput output;
float2 ndc = (input.Position.xy / vpSize) * 2.0 - 1.0;
output.Position = float4(ndc.x, -ndc.y, 0.0, 1.0);
output.Color = input.Color;
output.TexCoord = input.TexCoord;
return output;
}
)";
const char* pixelSrc = R"(
texture2D Texture;
sampler TextureSampler : register(s0) = sampler_state { Texture = (Texture); };
float4 main(float4 color : COLOR0, float2 texCoord : TEXCOORD0) : COLOR0 {
float4 texColor = tex2D(TextureSampler, texCoord);
return float4(1.0 - texColor.rgb, 1.0);
}
)";
ShaderEffect invertEffect(getGraphicsDeviceProperty(), vertexSrc, pixelSrc);
SamplerState pointClamp = SamplerState::PointClamp;
spriteBatch.Begin(SpriteSortMode::Deferred, BlendState::Opaque,
&pointClamp, nullptr, nullptr, &invertEffect);
spriteBatch.Draw(texture, destinationRectangle, Color::White);
spriteBatch.End();
vpSize does real work: a custom vertex shader on this path has no other way to map SpriteBatch's pixel-space positions into normalised device coordinates, and the DIRECTX9 SpriteBatch sets it from the current viewport before drawing (D3D9SpriteBatch.cpp). The test also samples outside the destination rectangle and expects the clear colour, which proves the mapping is right rather than a full-screen paint. Read-checked; not compiled (Windows-only renderer).
Choosing a route
- A game that uses only
SpriteBatchand the five stock effects runs on CNA's renderer-owned implementations (route one) on every 3D renderer, subject to the per-renderer table on Effects System. - A game that loads compiled XNA/FNA Effect Framework bytecode needs FNA3D or one of the nine opt-in configurations (route three). Neither
.fx/HLSL source, raw DXBC nor MGFX is accepted at run time;.fxsource can be compiled at build time only throughcna-contentand an external legacy compiler (Tutorial 150). - A game rewritten to
ShaderEffectdepends on each renderer's contract (route four): GL text on the GL renderers, SPIR-V on Vulkan, WGSL on WebGPU, HLSL on Direct3D — with the 3D coverage listed above.
The accurate statement is neither "compiled effects are unsupported" nor "every effect format works everywhere": stock semantics, DIRECTX9 stock-source recompilation, renderer-qualified Effect Framework execution and renderer-specific custom programs are four separately implemented contracts with separate evidence.
Evidence and limits
Checked by reading the CNA sources, CMake gates and test registrations at snapshot 009d40f5; nothing was built or executed. The 61-of-66 comparison is CNA's own dated record in compare_against_fxb.py. Per-renderer consumer paths were located in each renderer's SpriteBatch and draw code; pixel evidence exists where a named test is cited and is CNA's own, not re-run for this page. METAL cannot be built on Linux and its refusals are read from source.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Shader Effects · Effects System: compiled XNA effects · Tutorial 52: custom shaders · Tutorial 151: one shader, several renderers
- Architecture
- Graphics architecture
- Tests and validation
- Test architecture
- Deep dives
- SDL_GPU shader intake and pipeline keys