Tutorial 52: Writing Custom Shaders (ShaderEffect)

CNA — C++ XNA 4.0 reimplementation

What you’ll learn

  • When to use renderer-native ShaderEffect source instead of the renderer-qualified compiled Effect path.
  • A minimal working vertex/fragment pair, used with SpriteBatch and with a real 3D draw call.
  • Setting uniforms, and where the shader source should live in your project.
  • Which renderers actually execute a ShaderEffect, which throw, which silently ignore it — and which shading language each expects.

Before you startTutorial 32: BasicEffect and 3D Lighting (the stock effect this replaces) and Tutorial 38: Vertex Buffers and Index Buffers (the buffers a custom shader consumes). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the thirteen 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls.

ShaderEffect is CNAEXT. It is a CNA extension, not part of the XNA 4.0 API — its header carries the CNAEXT marker on the class and on nearly every one of its methods. Real XNA had no such type; you shipped compiled .fx bytecode instead. Code written against ShaderEffect will not port back to XNA or MonoGame unchanged.

Two different custom-effect paths

XNA's custom-shader story was HLSL compiled by the content pipeline to a D3D9 Effect Framework binary, then handed to new Effect(device, effectCode). CNA 0.1.0-alpha.1 implements that constructor and the XNB EffectReader for supported XNA/FNA Effect Framework bytecode:

// Microsoft/Xna/Framework/Graphics/Effect.hpp
Effect(GraphicsDevice& device, const std::vector<SharpRuntime::bytecs>& effectCode);

That compatibility path is not universal: FNA3D supports it, while SDL_GPU, EasyGL-family and Vulkan builds require their disabled-by-default CNA_*_COMPILED_EFFECTS option. Other renderers report it unavailable. It accepts the compiled Effect Framework binary, commonly stored as .fxb or inside XNB; it does not compile HLSL .fx source and does not accept DXBC or MGFX. This tutorial covers the other path: hand-authored, renderer-native ShaderEffect.

The constructor takes source text, not a path

This is the single most important fact on this page. ShaderEffect takes three arguments, and the two strings are the shader source code itself:

// Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp
CNAEXT ShaderEffect(GraphicsDevice& device,
                   const std::string& vertSrc,   // contents of the vertex shader (not a file path)
                   const std::string& fragSrc);  // contents of the fragment shader (not a file path)

The header comment spells out "not a file path" for both parameters. Passing "Content/shaders/tinted" will not load anything — that string is handed to the renderer as shader source, fails to compile, and leaves you with an invalid effect.

The constructor does not throw on a compile failure. This is how you find out:

CNAEXT [[nodiscard]] bool IsEffectValid() const;

Treat a false here as a hard error during development. There is nothing else that will tell you.

A minimal working shader pair

The most direct place to put source is a raw string literal. This pair matches CNA's SpriteBatch vertex layout exactly: location 0 is the position in pixel coordinates, location 1 the texture coordinate, location 2 the vertex colour. SpriteBatch itself sets the projection uniform on whatever program is bound.

#version 300 es
precision highp float;

layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord;
layout(location = 2) in vec4 aColor;

out vec2 TexCoord;
out vec4 Color;

uniform mat4 projection;

void main()
{
    gl_Position = projection * vec4(aPos, 0.0, 1.0);
    TexCoord = aTexCoord;
    Color = aColor;
}
#version 300 es
precision mediump float;

in vec2 TexCoord;
in vec4 Color;

out vec4 FragColor;

uniform sampler2D texture1;

void main()
{
    vec4 t = texture(texture1, TexCoord);
    FragColor = vec4(t.r, 0.0, 0.0, t.a);   // keep only the red channel
}

The sampler2D texture1 uniform defaults to texture unit 0, which is where SpriteBatch binds the sprite texture — so for this shader no explicit sampler binding is needed at all.

Using it with SpriteBatch

The six-argument Begin() overload takes an Effect* as its last parameter. Pass the ShaderEffect there and it replaces the built-in sprite shader for that batch:

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

static const char* kVertSrc = R"(#version 300 es
// ... vertex source as above ...
)";

static const char* kFragSrc = R"(#version 300 es
// ... fragment source as above ...
)";

void Initialize() override
{
    Game::Initialize();
    auto& device = getGraphicsDeviceProperty();

    sb_ = std::make_unique<SpriteBatch>(device);
    fx_ = std::make_unique<ShaderEffect>(device, kVertSrc, kFragSrc);

    if (!fx_->IsEffectValid()) {
        // The GLSL did not compile. Do not draw with it.
    }
}

void Draw(const GameTime&) override
{
    auto& device = getGraphicsDeviceProperty();
    device.Clear(Color(0, 255, 0, 255));

    sb_->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend,
               nullptr, nullptr, nullptr, fx_.get());
    sb_->Draw(tex_, Rectangle(64, 64, 128, 128),
              Rectangle(0, 0, 1, 1), Color::White);
    sb_->End();
}

Build the effect once and keep it alive. Constructing one per frame recompiles the shader program every frame.

Setting uniforms

There is no Parameters[...] collection on ShaderEffect. Uniforms are set by name through a family of direct methods, each taking a const char* name:

MethodUniform type
SetUniformMat4(const char* name, const float* matrix)mat4, column-major
SetUniformVec4(const char* name, float x, float y, float z, float w)vec4
SetUniformVec3(const char* name, float x, float y, float z)vec3
SetUniformVec2(const char* name, float x, float y)vec2
SetUniformFloat(const char* name, float value)float
SetUniformInt(const char* name, int value)int
SetUniformFloatArray(const char* name, const float* values, int count)float[]count is the number of scalar elements
SetUniformVec2Array(const char* name, const float* values, int count)vec2[]count is the number of vec2s, so values holds count * 2 floats

Note the difference between the last two rows: SetUniformFloatArray counts scalars, SetUniformVec2Array counts elements. Both are documented that way in the header, and it is easy to get wrong.

Textures beyond unit 0 are bound with SetTexture, which has a Texture2D overload and a TextureCube overload for shaders declaring a samplerCube:

CNAEXT void SetTexture(int unit, Texture2D& texture);
CNAEXT void SetTexture(int unit, TextureCube& texture);

Unit 0 is normally driven by the caller — SpriteBatch's own texture parameter, for instance — so these are for the extra units a custom shader samples directly, the equivalent of XNA's GraphicsDevice.Textures[unit] = tex.

Call order matters. SetUniformXxx() and SetTexture() write directly to whatever shader program is currently bound. Call Apply() first to bind this effect's program, then set the uniforms, then draw. Setting uniforms before Apply() writes them into whichever program happened to be bound.

Driving a real 3D draw call

ShaderEffect implements IEffectMatrices, the same interface every stock effect implements. World, View, and Projection are therefore set as properties, and CNA forwards them to the renderer, which binds them as uniforms of exactly those names on your compiled program — matching the naming convention the original XNA samples' own .fx sources already used.

auto* fx = dynamic_cast<ShaderEffect*>(fxBase_.get());

fx->setWorldProperty(world);
fx->setViewProperty(Matrix::CreateLookAt(Vector3(0.0f, 0.0f, 3.0f),
                                         Vector3::Zero,
                                         Vector3(0.0f, 1.0f, 0.0f)));
fx->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
    MathHelper::PiOver4, vp.getAspectRatioProperty(), 0.1f, 100.0f));

// Bind the program first, then push this effect's own uniforms.
fx->Apply();
fx->SetTexture(0, whiteTex_);
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(vb_.get());
device.setIndicesProperty(ib_.get());
device.DrawIndexedPrimitives(PrimitiveType::TriangleList, 0, 0, 4, 0, 2);

The matching vertex shader declares those three uniforms and an attribute layout that matches the vertex stride you are drawing with — here VertexPositionNormalTexture, stride 32:

#version 300 es
precision highp float;

layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;

out vec3 vWorldNormal;
out vec2 vTexCoord;

uniform mat4 World;
uniform mat4 View;
uniform mat4 Projection;

void main() {
    vec4 worldPos = World * vec4(aPosition, 1.0);
    gl_Position = Projection * View * worldPos;
    vWorldNormal = mat3(World) * aNormal;
    vTexCoord = aTexCoord;
}

Where the source actually lives

The constructor wants strings, so the text has to come from somewhere. There are three practical options.

Raw string literals, as above, keep the shader in the binary: no runtime file dependency, no way for the asset to go missing. The cost is that changing a shader means recompiling the game, and no editor will syntax-highlight GLSL inside a C++ literal.

Read the file yourself and pass the result straight in. Nothing in ShaderEffect requires ContentManager involvement:

#include "System/IO/File.hpp"

const std::string vert = System::IO::File::ReadAllText("Content/shaders/lit3d.vert.glsl");
const std::string frag = System::IO::File::ReadAllText("Content/shaders/lit3d.frag.glsl");

ShaderEffect fx(device, vert, frag);

A .cnj descriptor lets ContentManager do the reading. CNA's content loader has a real Effect reader for this. The descriptor has exactly two shader fields, vertex and fragment, each naming a file relative to the descriptor:

{
  "cnjVersion": 1,
  "type": "Effect",
  "vertex": "lit3d.vert.glsl",
  "fragment": "lit3d.frag.glsl"
}

Missing either field raises a ContentLoadException. Load it as a std::shared_ptr<Effect> — that, not ShaderEffect, is the type the reader is registered for — then downcast:

getContentProperty().setRootDirectoryProperty("Content");

std::shared_ptr<Effect> fxBase =
    getContentProperty().Load<std::shared_ptr<Effect>>("lit3d");

auto* fx = dynamic_cast<ShaderEffect*>(fxBase.get());
if (fx == nullptr || !fx->IsEffectValid()) {
    // Descriptor loaded, but the shader did not compile.
}

Renderers and the shading language

The header documents these strings as GLSL, and GLSL is what the GL family expects — but the constructor does not parse them. It forwards them to whichever renderer is compiled in, and renderers disagree in three quite different ways: some execute your shader, some refuse it loudly, and one accepts it and quietly draws without it.

BGFX and SOFTWARE accept a ShaderEffect and silently ignore the source you supplied. No exception, no warning, no shader — the draw proceeds with your effect dropped. It is the one renderer where a custom shader fails without telling you, so if you develop on the GL family and later switch to bgfx, this is what you will hit.

Renderers that execute a ShaderEffect

Your shader genuinely runs on: the five GL profile identities (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2), OPENGL2, OPENGL4, VULKAN, SDL_GPU, SOKOL, LLGL, MAGNUM, DIRECTX9, DIRECTX11 and DIRECTX12. HEADLESS records the submission without executing it, which is exactly what you want from a logic-only CI renderer.

The shading language differs

RendererWhat the source strings must contain
The GL family (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2) — internally EasyGL — plus OPENGL2 and OPENGL4GLSL, compiled by the GL driver. The reference path; CNA's shader examples target it.
VULKAN, SDL_GPUSPIR-V bytes, not GLSL text. Pre-compile with glslc or glslangValidator.
DIRECTX9, DIRECTX11, DIRECTX12HLSL, compiled at runtime.
SOFTWARE, HEADLESS, SOKOL, LLGL, MAGNUMHandled by their own effect layers — check the renderer before authoring against it.

Renderers that refuse

  • GDI, METAL, HTML_DOM, SVG_DOM throw. You find out at the call site, which is the behaviour you want.
  • FNA3D cannot compile custom shaders at all.
  • BGFX accepts and ignores, as above.
  • The thirteen 2D-only renderers have no programmable pipeline to speak of; 3D calls throw there regardless.

The practical consequence, stated plainly: a single shader source string is not portable across CNA's renderers. Shipping on more than one shading language means authoring one source variant per language and selecting it at build time with the CNA_RENDERER_<NAME> define CNA's configure emits (the five GL profiles share CNA_RENDERER_EASYGL). IsEffectValid() tells you whether the variant you picked compiled — but note it cannot help you on bgfx, where nothing was attempted.

Pre-compiling GLSL to SPIR-V for VULKAN or SDL_GPU is an ordinary offline step:

glslc -fshader-stage=vert lit3d.vert.glsl -o lit3d.vert.spv
glslc -fshader-stage=frag lit3d.frag.glsl -o lit3d.frag.spv

Read those .spv files as bytes and pass their contents as the two strings.

Cloning

Clone() on a ShaderEffect recompiles a new renderer-side program from the same source strings rather than sharing the original's compiled program. This is a deliberate deviation from the stock effects, which share GPU state implicitly because CNA caches their pipelines globally by state rather than per instance. A ShaderEffect uniquely owns its compiled program, so cloning costs a real recompile — do not do it per frame.

Summary

  • ShaderEffect is a CNAEXT extension for renderer-native shader source or binary. The separate Effect(device, effectCode) compatibility path accepts XNA/FNA Effect Framework bytecode only on capable renderer builds.
  • The constructor takes three arguments — the device, and the vertex and fragment shader source text. Not a file path.
  • It does not throw on a compile failure. Check IsEffectValid().
  • Uniforms are set with SetUniformMat4/Vec4/Vec3/Vec2/Float/Int/FloatArray/Vec2Array by name, and textures with SetTexture(unit, tex). There is no Parameters collection.
  • Call Apply() before setting uniforms — they write to the currently bound program.
  • World/View/Projection come from IEffectMatrices and are bound as uniforms of those names automatically.
  • Keep source in a raw string literal, read it yourself, or reference it from a .cnj Effect descriptor with "vertex" and "fragment" fields.
  • The expected shading language is renderer-dependent: GLSL on the GL family, SPIR-V on VULKAN and SDL_GPU, HLSL compiled at runtime on the Direct3D renderers. GDI, METAL, HTML_DOM and SVG_DOM throw, FNA3D cannot compile one at all, and BGFX accepts it and silently ignores it.