Tutorial 151: One Shader, Several Renderers: Choosing a Dialect at Run Time

CNA Tutorials  ·  CNA snapshot 009d40f5

ℹ

What you’ll learn: The four-question decision ladder for a custom ShaderEffect (CustomEffects, ExecutesShaderEffectSourceEXT(), SupportsShaderLanguageEXT(), IsEffectValid()), authoring one effect in desktop GLSL and GLSL ES, picking the variant from the live device, and a fallback that always works.

A custom ShaderEffect takes shader source text, and CNA's renderers disagree about which language that text must be in (Tutorial 52). Some execute it, some accept it and silently ignore it, and a few throw when you even construct the effect. So the honest way to ship one effect across renderers is not a compile-time #ifdef but a small decision ladder that asks the live device, one shader variant per dialect you care about, and a fallback that always works. This tutorial builds exactly that: a grayscale sprite effect in two GLSL dialects, chosen at run time, with SpriteBatch's built-in shader as the fallback.

ℹ

Before you start — Tutorial 24: Post-Processing with Render Targets (a ShaderEffect used through SpriteBatch) and Tutorial 52: Writing Custom Shaders (the constructor, IsEffectValid(), the per-renderer language table). Any renderer works for running the program; the custom shader path is taken on OPENGL33, OPENGL4, OPENGLES3 and WEBGL2, and the fallback path everywhere else.

The decision ladder

Four questions, asked in this order. Each has a public query on GraphicsDevice, and each “no” ends in the same place: draw without the custom shader.

#QuestionQuery“No” happens on
1Does this renderer accept a custom effect at all?SupportsCapability(GraphicsCapability::CustomEffects)FNA3D, METAL, PORTABLEGL, SOFTWARE, STUB, the seven 2D-only renderers, and SDL_GPU builds without libshaderc. Ask before constructing: GDI, HTML_DOM, SVG_DOM and METAL throw from the constructor.
2Does it actually run the source text?ExecutesShaderEffectSourceEXT()HEADLESS (accepts and ignores), VULKAN (takes SPIR-V words, not text), DIRECTX9 (compiles HLSL for SpriteBatch but does not declare it), and everything from row 1.
3Which dialect does it consume?SupportsShaderLanguageEXT(language, stage)The renderer runs source, but not GLSL: WEBGPU (WGSL), DIRECTX11/DIRECTX12 (HLSL), SDL_GPU (SPIR-V).
4Did the driver's compiler accept it?IsEffectValid(), then GetCompileErrorEXT()A GLSL error, or a profile mismatch such as a #version 300 es shader on OPENGLES2/WEBGL1, which are GLSL ES 1.00.

Vulkan and SDL_GPU deserve a footnote: both execute caller SPIR-V, yet GetRendererCapabilityProfileEXT() reports all six ShaderDialect* features as unsupported for them (SPIR-V has no feature entry). SupportsShaderLanguageEXT(ShaderLanguageEXT::SpirV, stage) is the truthful query there — use it, not the profile, if you add a SPIR-V variant.

Step 1: one effect, two dialects

The effect draws a sprite and blends its colour towards its luminance by a uniform Amount. It reads SpriteBatch's vertex layout — location 0 position (x, y and a layer depth), 1 texture coordinate, 2 colour — and two uniforms SpriteBatch fills for you: projection and texture1 (unit 0). Put the four sources in a header (raw string literals keep them in the binary, so there is no file to go missing):

// gray_shaders.hpp -- one effect, two GLSL dialects (SpriteBatch vertex layout: see Tutorial 52)
#pragma once

// Desktop GLSL 3.30 core: OPENGL33 and OPENGL4.
inline constexpr const char* kGrayVert330 = R"GLSL(#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTexCoord;
layout(location = 2) in vec4 aColor;

out vec2 vTexCoord;
out vec4 vColor;

uniform mat4 projection;      // set by SpriteBatch

void main()
{
    gl_Position = projection * vec4(aPos.xy, 0.0, 1.0);
    vTexCoord   = aTexCoord;
    vColor      = aColor;
}
)GLSL";

inline constexpr const char* kGrayFrag330 = R"GLSL(#version 330 core
in vec2 vTexCoord;
in vec4 vColor;
out vec4 fragColor;

uniform sampler2D texture1;   // unit 0: the sprite SpriteBatch is drawing
uniform float     Amount;     // 0 = colour, 1 = grayscale

void main()
{
    vec4  c    = texture(texture1, vTexCoord) * vColor;
    float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
    fragColor  = vec4(mix(c.rgb, vec3(luma), Amount), c.a);
}
)GLSL";

// GLSL ES 3.00: OPENGLES3 and WEBGL2.
inline constexpr const char* kGrayVert300es = R"GLSL(#version 300 es
precision highp float;

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

out vec2 vTexCoord;
out vec4 vColor;

uniform mat4 projection;

void main()
{
    gl_Position = projection * vec4(aPos.xy, 0.0, 1.0);
    vTexCoord   = aTexCoord;
    vColor      = aColor;
}
)GLSL";

inline constexpr const char* kGrayFrag300es = R"GLSL(#version 300 es
precision mediump float;

in vec2 vTexCoord;
in vec4 vColor;
out vec4 fragColor;

uniform sampler2D texture1;
uniform float     Amount;

void main()
{
    vec4  c    = texture(texture1, vTexCoord) * vColor;
    float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
    fragColor  = vec4(mix(c.rgb, vec3(luma), Amount), c.a);
}
)GLSL";

The logic is identical; only the #version line and the precision qualifiers differ. OPENGL33 and OPENGL4 compile the 330 core pair; OPENGLES3 and WEBGL2 the ES 3.00 pair. The EasyGL profiles pass your source to the driver verbatim, which is why the version line matters. The GLSL ES 1.00 profiles (OPENGLES2, WEBGL1) need a third variant with attribute/varying and no layout(location); that is not covered here, so this tutorial sends them to the fallback.

Step 2: choose a variant from the live device

A small function (ChooseShaderVariant, first in the listing below) turns the ladder into an enum. GetGraphicsRendererType() reports which renderer this device resolved to, which is what you want in a multi-renderer build (a compile-time CNA_RENDERER_* macro would name the build's default, not the running device).

Step 3: build the effect, then fall back

The full program, with the ladder, the construction, the compile check and the draw call. Note that the fallback needs no separate code path: passing a null Effect* to SpriteBatch::Begin means “use the built-in sprite shader”.

// main.cpp
#include <cmath>
#include <cstdio>
#include <memory>
#include <string>
#include <string_view>

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"
#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "CNA/GraphicsCapability.hpp"
#include "CNA/GraphicsRendererType.hpp"
#include "CNA/ShaderLanguageEXT.hpp"
#include "gray_shaders.hpp"

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

enum class ShaderVariant { None, GlslDesktop330, GlslEs300 };

// Ask the live device -- not the build -- which variant it can run.
static ShaderVariant ChooseShaderVariant(GraphicsDevice& device)
{
    using CNA::ShaderLanguageEXT;
    using CNA::ShaderStageEXT;

    // 1. Does this renderer accept a custom effect at all? Check BEFORE constructing one:
    //    GDI, HTML_DOM, SVG_DOM and METAL throw from the ShaderEffect constructor.
    if (!device.SupportsCapability(CNA::GraphicsCapability::CustomEffects))
        return ShaderVariant::None;

    // 2. Does it actually run the source text? False on SOFTWARE and HEADLESS (accept and
    //    ignore), on VULKAN (takes SPIR-V words), on DIRECTX9 and on FNA3D.
    if (!device.ExecutesShaderEffectSourceEXT())
        return ShaderVariant::None;

    // OPENGLES2 and WEBGL1 are GLSL ES 1.00: neither variant below compiles there.
    const CNA::GraphicsRendererType type = device.GetGraphicsRendererType();
    if (type == CNA::GraphicsRendererType::OpenGLES2 || type == CNA::GraphicsRendererType::WebGL1)
        return ShaderVariant::None;

    // 3. Which dialect does it consume?
    if (device.SupportsShaderLanguageEXT(ShaderLanguageEXT::GlslDesktop, ShaderStageEXT::Fragment))
        return ShaderVariant::GlslDesktop330;      // OPENGL33, OPENGL4
    if (device.SupportsShaderLanguageEXT(ShaderLanguageEXT::GlslEs, ShaderStageEXT::Fragment))
        return ShaderVariant::GlslEs300;           // OPENGLES3, WEBGL2
    return ShaderVariant::None;                    // WEBGPU, DIRECTX11/12, SDL_GPU, ...
}

class GrayGame final : public Game {
public:
    GrayGame() : graphics_(this) {}

protected:
    void LoadContent() override {
        auto& device = getGraphicsDeviceProperty();
        spriteBatch_ = std::make_unique<SpriteBatch>(device);
        logo_ = getContentProperty().Load<Texture2D>("Textures/logo");

        std::fprintf(stderr, "renderer: %s\n",
                     std::string(device.GetGraphicsRendererName()).c_str());

        switch (ChooseShaderVariant(device)) {
            case ShaderVariant::GlslDesktop330:
                effect_ = std::make_unique<ShaderEffect>(device, kGrayVert330, kGrayFrag330);
                break;
            case ShaderVariant::GlslEs300:
                effect_ = std::make_unique<ShaderEffect>(device, kGrayVert300es, kGrayFrag300es);
                break;
            case ShaderVariant::None:
                break;
        }

        // 4. Did it compile? A failed compile is not an exception.
        if (effect_ && !effect_->IsEffectValid()) {
            std::fprintf(stderr, "gray shader rejected:\n%s\n", effect_->GetCompileErrorEXT().c_str());
            effect_.reset();                       // fall back to plain SpriteBatch
        }
        std::fprintf(stderr, "gray effect: %s\n", effect_ ? "custom shader" : "SpriteBatch fallback");
    }

    void Update(GameTime& gameTime) override {
        const double t = gameTime.getTotalGameTimeProperty().getTotalSecondsProperty();
        amount_ = static_cast<float>(0.5 + 0.5 * std::sin(t));
    }

    void Draw(const GameTime&) override {
        auto& device = getGraphicsDeviceProperty();
        device.Clear(Color::CornflowerBlue);

        if (effect_) {
            effect_->Apply();                                  // bind first (portable order)
            effect_->SetUniformFloat("Amount", amount_);
        }
        // A null effect means "the built-in sprite shader": the fallback is the same call.
        spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend,
                            nullptr, nullptr, nullptr, effect_.get());
        spriteBatch_->Draw(logo_, Vector2(100.0f, 80.0f), Color::White);
        spriteBatch_->End();
        // Game presents in EndDraw().
    }

private:
    GraphicsDeviceManager          graphics_;
    std::unique_ptr<SpriteBatch>   spriteBatch_;
    std::unique_ptr<ShaderEffect>  effect_;
    Texture2D                      logo_;
    float                          amount_ = 0.0f;
};

int main() { GrayGame game; game.Run(); }

Points worth noticing in that listing:

  • Construction happens only after the ladder says yes, so the renderers that throw from the constructor are never asked to build one.
  • A failed compile is not an exception. The effect comes back invalid; GetCompileErrorEXT() gives the driver's log (or GetShaderDiagnosticsEXT() the structured form). The code logs it and drops to the fallback.
  • Apply() first, then SetUniformFloat: the portable order (the GL renderers also make the program current inside each setter).
  • No Present(): Game presents in EndDraw().

Step 4: see what you got

Log the renderer and, when something surprises you, the capability report. GetGraphicsRendererName() names the active identity; GetRendererCapabilityReportEXT() returns a human-readable dump of the 32 features and 22 limits the renderer declares (for logs — do not parse it; Tutorial 133 reads the structured form). Running the same binary on a few renderers is the quickest way to see the ladder work: HEADLESS and SOFTWARE answer “no” at step 1 or 2 and print SpriteBatch fallback (a useful GPU-free CI check that your fallback works), OPENGL33/OPENGL4 print custom shader with the 330 pair, and OPENGLES3 or a browser WEBGL2 build take the ES 3.00 pair. Build one configuration per renderer (-DCNA_GRAPHICS_RENDERER=<NAME>) or a multi-renderer build with -DCNA_GRAPHICS_RENDERERS=... and select at start-up (Tutorial 126).

Which renderer takes which path

RendererPath in this tutorialWhat it would need to run a custom shader
OPENGL33, OPENGL4custom, desktop GLSL 330—
OPENGLES3, WEBGL2custom, GLSL ES 3.00—
OPENGLES2, WEBGL1fallbacka third variant in GLSL ES 1.00
VULKAN, SDL_GPUfallbackSPIR-V words (pre-compiled with glslc); SDL_GPU also accepts GLSL text in builds with libshaderc
WEBGPUfallbacka WGSL variant
DIRECTX11, DIRECTX12fallbackan HLSL variant (string constructor; the typed constructors do not take HLSL)
DIRECTX9fallbackHLSL, compiled for SpriteBatch draws only
FNA3D, METAL, PORTABLEGL, SOFTWARE, HEADLESS, STUBfallbacknot possible with source (FNA3D can run compiled effect bytecode instead: Tutorial 128)
SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SVG_DOM, FREEDIRECT, GDIfallback (2D-only)not possible: no programmable pipeline

Adding a WGSL, HLSL or SPIR-V variant means adding a branch to the ladder and a source for the binding conventions of that renderer; the uniform and texture conventions for those renderers' custom sprite shaders are outside this tutorial, so start from the Shader Effects reference and the renderer's own examples.

Optional: a shader package (CNA_CNAEXT=ON)

If you build CNA with CNA_CNAEXT=ON (still off by default) the ladder's steps 2 and 3 can be delegated. A ShaderPackageEXT holds the variants and the stages a caller requires; selectFor(device) picks one deterministically for that device using SupportsShaderLanguageEXT, and ShaderEffect(device, package) compiles the selection. The selection carries a diagnostic when nothing fits, and constructing throws std::invalid_argument for a malformed package or CNA::ShaderCompilationExceptionEXT when the renderer refuses the chosen language:

// Needs CNA_CNAEXT=ON (the package and typed-code classes only exist then) and the CNAEXT library.
#include <cstdio>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"
#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "CNA/Graphics/ShaderCodeEXT.hpp"
#include "CNA/Graphics/ShaderPackageEXT.hpp"
#include "gray_shaders.hpp"

using namespace Microsoft::Xna::Framework::Graphics;
using CNA::ShaderLanguageEXT;
using CNA::ShaderStageEXT;
using CNA::Graphics::ShaderCodeEXT;
using CNA::Graphics::ShaderPackageEXT;

std::unique_ptr<ShaderEffect> MakeGrayEffect(GraphicsDevice& device)
{
    std::vector<ShaderCodeEXT> variants;
    variants.emplace_back(ShaderLanguageEXT::GlslDesktop, ShaderStageEXT::Vertex,
                          "main", "gray.vert (330 core)", kGrayVert330);
    variants.emplace_back(ShaderLanguageEXT::GlslDesktop, ShaderStageEXT::Fragment,
                          "main", "gray.frag (330 core)", kGrayFrag330);
    variants.emplace_back(ShaderLanguageEXT::GlslEs, ShaderStageEXT::Vertex,
                          "main", "gray.vert (300 es)", kGrayVert300es);
    variants.emplace_back(ShaderLanguageEXT::GlslEs, ShaderStageEXT::Fragment,
                          "main", "gray.frag (300 es)", kGrayFrag300es);

    ShaderPackageEXT package(std::move(variants),
                             { ShaderStageEXT::Vertex, ShaderStageEXT::Fragment });

    // Deterministic selection for THIS device, with a diagnostic when nothing fits.
    const auto selection = package.selectFor(device);
    if (!selection.isUsable()) {
        std::fprintf(stderr, "no usable variant: %s\n", selection.getDiagnostic().c_str());
        return nullptr;                         // fall back to plain SpriteBatch
    }

    // Throws std::invalid_argument / CNA::ShaderCompilationExceptionEXT if the package is
    // malformed or the renderer refuses the chosen language.
    auto effect = std::make_unique<ShaderEffect>(device, package);
    return effect;                              // effect->GetSelectedShaderLanguageEXT() names the pick
}

Two caveats. The package classes exist only under CNA_CNAEXT and their constructors live in the engine layer, so you must link the CNAEXT library. And selection is by language, not by dialect version: the GlslEs variant here is ES 3.00, so on OPENGLES2/WEBGL1 selection succeeds and the compile fails — step 4 (IsEffectValid()) still applies. For real multi-language payloads, CNA's own engine passes are generated offline with tools/shader_package/generate_shader_package.py, which turns GLSL into checked-in SPIR-V packages (with a --check reproducibility gate); the runtime never links shaderc or DXC.

Summary

  • Ask the device: CustomEffects, then ExecutesShaderEffectSourceEXT(), then SupportsShaderLanguageEXT(), then IsEffectValid().
  • Ship one source variant per dialect and pick it at run time; never assume one string is portable.
  • Construct a ShaderEffect only after the capability check, because some renderers throw from the constructor.
  • Keep a fallback that needs no custom shader: for SpriteBatch it is a null effect pointer.
  • A failed compile is an invalid effect with a log, not an exception (except for the typed CNA_CNAEXT constructors).

Next steps