Tutorial 116: CNAEXT Post-Process Effects: ASCII, CRT, Depth and Colour Matrix

CNA Tutorials  ·  CNA Extensions

ℹ

What you’ll learn

  • The four CNAEXT post-process effects, and why they attach to CNA at three different layers.
  • Why AsciiPostProcessEffect is not an Effect — and why that is what makes it work everywhere.
  • How CRTEffect and DepthEffect differ from it, and which renderers actually execute them.
  • Why ColorMatrixEffect runs on the CPU sprite path only, and why other renderers refuse to fake it.

Before you start — Tutorial 24: Post-Processing with Render Targets covers the render-to-texture-then-redraw pattern every effect on this page is built on, and Tutorial 115: CNAEXT explains what “CNAEXT” means and why three of these four effects are missing from a stock build.

CNA ships four post-process effects that have no XNA precedent at all. Three of them, together with DebugDraw and the shader-package types, make up the opt-in CNAEXT extension module (Tutorial 115); the fourth, ColorMatrixEffect, is always compiled. It is tempting to treat the four as instances of one pattern — construct, configure, bind, done. They are not. Each one attaches to CNA at a different layer, and that single fact decides which renderers execute it, what it costs per frame, and how you have to structure your Draw().

EffectWhat it isBuild flagExecutes on
AsciiPostProcessEffectStandalone class — not an EffectCNA_CNAEXTAny renderer that can read pixels back
CRTEffectShaderEffect subclass built from a multi-language shader packageCNA_CNAEXTRenderers that take one of its shader variants: EasyGL family, VULKAN, WEBGPU, SDL_GPU (libshaderc builds); the HLSL variant for DIRECTX11 is unverified
DepthEffectShaderEffect subclass built from a multi-language shader packageCNA_CNAEXTThe same set as CRTEffect
ColorMatrixEffectEffect subclass with no shader at allnone — always builtThe shared CPU SpriteBatch path only (SOFTWARE)

First: three of them are not in your build

⚠

CNA_CNAEXT defaults to OFF. Every header under modules/graphics-ext/include/CNA/Graphics/ is wrapped in #ifdef CNA_CNAEXT, so on a stock build they expand to nothing and your CNA::Graphics::CRTEffect reference simply fails to resolve.

cmake -S . -B build -DCNA_CNAEXT=ON
cmake --build build -j$(nproc)

The repository’s own cnaext preset (cmake --preset cnaext) does the same for OPENGLES3 in Debug with tests on. The option is off by default and no CI workflow builds it; the CNAEXT extensions reference has the status and evidence.

ColorMatrixEffect is the exception: it lives with the XNA graphics types, not in the extension module, and is compiled into every build. It is still tagged with CNA's CNAEXT marker macro, so it will turn into a compile error under CNA_STRICT_XNA_API — see Tutorial 115.

AsciiPostProcessEffect: the one that is not an Effect

This is the most interesting of the four, because of where it came from. ASCII used to be a selectable renderer — CNA_GRAPHICS_RENDERER=ASCII. That identity was deliberately deleted, and the same quantization code was reborn as a post-process effect.

The reasoning is worth understanding, because it explains the shape of the API. The old ASCII renderer was never a real graphics implementation: it was a thin decorator around the SDL renderer that let the game draw normally into a private offscreen target, then read that frame back at present time, quantized it into a glyph grid, and drew the grid to the window instead. It had no texture, vertex or present pipeline of its own. Keeping it as a renderer identity meant the glyph look cost you a compile-time commitment, dragged the wrapped renderer's 2D-only limitation along with it (every 3D draw threw), and could never be applied to part of a frame or composed with anything else.

As an effect, the identical logic applies to the finished output of any renderer and any scene, 2D or 3D, because it only ever touches public API: it reads finished pixels through Texture2D::GetData() and redraws them through SpriteBatch. It never downcasts to a renderer type. That is why it is not an Effect subclass — an Effect is a GPU shader program that SpriteBatch and GraphicsDevice bind and apply, and this class performs its own multi-step CPU-plus-GPU pass instead. You cannot bind it as a shader effect. You call it.

#include "CNA/Graphics/AsciiPostProcessEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/RenderTarget2D.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using CNA::Graphics::AsciiPostProcessEffect;
using CNA::Graphics::AsciiQuantizeMode;

// --- once, in LoadContent() ---
sceneTarget_ = std::make_unique<RenderTarget2D>(device, 640, 480);
ascii_       = std::make_unique<AsciiPostProcessEffect>(device);
ascii_->setQuantizeMode(AsciiQuantizeMode::Color);   // or ::BlackWhite
ascii_->setCellSize(8, 8);                           // source pixels per glyph cell

// --- every frame, in Draw() ---
device.SetRenderTarget(sceneTarget_.get());
device.Clear(Color::CornflowerBlue);
DrawMyWholeScene();                 // 2D sprites, 3D geometry, anything
device.SetRenderTarget(nullptr);    // back to the real backbuffer

ascii_->Draw(*sceneTarget_);        // stretched to fill the current viewport

The second overload takes an explicit destination, so you can quantize one panel of the screen and leave the rest alone — something the old renderer could not express at all:

ascii_->Draw(*sceneTarget_, Rectangle(16, 16, 320, 240));
MemberNotes
setCellSize(int w, int h)Source pixels averaged into one cell. Defaults to 8×8. Throws std::invalid_argument on a non-positive size.
getCellSize(int& w, int& h)Reads it back. Independent of the atlas' own fixed 8×8 glyph size — each cell is stretched to its share of the destination.
setQuantizeMode(AsciiQuantizeMode)BlackWhite: glyph by luminance rank, fixed white foreground, no background fill. Color: same glyph choice, plus the cell's averaged colour as foreground and that colour at quarter brightness as background.
Draw(Texture2D&)Fills the current viewport of whatever render target is bound.
Draw(Texture2D&, const Rectangle&)Fills an explicit destination rectangle instead.
GetLastGridDimensions(int& cols, int& rows)Column/row count as of the last Draw(). Exposed for tests — it proves the cell size changed the output, not just the accessor.

The cost, stated plainly

⚠

Every Draw() performs a real GPU-to-CPU readback. The current implementation reads the source through Texture2D::GetData(), quantizes on the CPU, and re-uploads the result as textured quads. Budget for it the way you would budget for any per-frame readback.

This is not an oversight and it is not temporary sloppiness. A shader-only GPU path is architecturally possible on shader-capable renderers, and it is deliberately deferred, because it would stop working on exactly the 2D-only and non-shader renderers this effect exists to keep supporting uniformly. The portable path is the feature.

Two consequences follow directly. First, an effect that never draws costs nothing — do not construct and Draw() it on frames where the glyph look is off. Second, on HEADLESS, which has no back buffer to read, the readback is rejected with System::NotSupportedException; STUB draws nothing at all. That is a documented capability boundary, not a defect; CNA's own test for this effect catches the exception and reports a skip. See Tutorial 125. The effect needs no shaders, so CNA registers its examples for every renderer identity.

CRTEffect and DepthEffect: real ShaderEffects

These two are the opposite design. Both derive from ShaderEffect and carry a built-in shader package (GLSL ES, desktop GLSL, SPIR-V and WGSL variants of the same program), so they bind exactly like any custom shader you write yourself — and they inherit that path's renderer restrictions wholesale.

⚠

These run only where the renderer takes one of their shader variants. CRTEffect and DepthEffect build their ShaderEffect from a ShaderPackageEXT with variants in GLSL ES, desktop GLSL, Vulkan SPIR-V, WGSL and HLSL, and a variant runs only where GraphicsDevice::SupportsShaderLanguageEXT() accepts its language. That is: the EasyGL family (desktop GLSL for OPENGL33; GLSL ES for OPENGLES3 and WEBGL2), VULKAN and SDL_GPU (SPIR-V; SDL_GPU only in builds where CustomEffects is true, i.e. with libshaderc) and WEBGPU (WGSL). DIRECTX11 accepts HLSL once it has a device, so the HLSL variant is selected there, but no recorded run shows the effects on Direct3D.

Elsewhere the effect is constructed but its shader does not run. DIRECTX9 compiles HLSL with D3DCompile but does not declare HLSL support for packages, so the package falls back to its GLSL ES text, which fails to compile; the error is logged, and GetCompileErrorEXT() returns it. SOFTWARE reports CustomEffects false (the source is accepted but never executed); METAL accepts only MSL, which the packages do not carry; FNA3D reports false; HEADLESS records the call and renders nothing; STUB does nothing; and the 2D-only SDL_RENDERER has no 3D pipeline. Ask, do not guess: device.SupportsShaderLanguageEXT(CNA::ShaderLanguageEXT::SpirV, CNA::ShaderStageEXT::Fragment), or device.ExecutesShaderEffectSourceEXT() for the source-executing renderers.

If your game must look the same on a 2D-only renderer, these two are not the tool — AsciiPostProcessEffect or ColorMatrixEffect is. Check the renderer reference before you build a visual identity on either of them.

DepthEffect: colour-depth reduction

DepthEffect is a pure per-pixel colour transform, so it binds like any other SpriteBatch effect and needs no special frame structure:

#include "CNA/Graphics/DepthEffect.hpp"

using CNA::Graphics::DepthEffect;
using CNA::Graphics::DepthEffectMode;
using CNA::Graphics::DitherMode;

depth_ = std::make_unique<DepthEffect>(device);
depth_->setMode(DepthEffectMode::Palette16);      // classic 16-colour EGA/CGA
depth_->setDitherMode(DitherMode::Bayer8x8);      // break up the banding

SamplerState pointClamp = SamplerState::PointClamp;
spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend,
                    &pointClamp, nullptr, nullptr, depth_.get());
spriteBatch_->Draw(*sceneTarget_, Rectangle(0, 0, 640, 480), Color::White);
spriteBatch_->End();

Seven modes are available, and they split into two genuinely different mechanisms. Color16Bit, Color8Bit, Grayscale4Bit, Grayscale2Bit and Grayscale1Bit round each channel independently. Palette256 (the 216-colour web-safe palette) and Palette16 (EGA/CGA) do a real nearest-colour search against a fixed, non-uniform colour table — which is why they look period-correct in a way per-channel rounding never does. Those two lazily build lookup textures on first use.

Dithering offers None, Bayer4x4 and Bayer8x8. Error-diffusion dithering (Floyd–Steinberg, Atkinson) is deliberately absent: it is inherently sequential, so it does not fit a single-pass fragment shader. Ordered Bayer dithering is what real-time engines use instead, and it helps most on the modes that band worst — Color8Bit, and especially Grayscale1Bit, which degenerates into a hard threshold without it.

CRTEffect: and the trap that comes with it

⚠

CRTEffect needs a single full-screen source. Bind it to an ordinary multi-sprite batch and every sprite gets its own little curved, vignetted screen warped around its own rectangle.

The mechanism is easy to see once stated: curvature and vignette measure position from the drawn quad's own texture coordinate, which is only a meaningful proxy for “where on screen am I” when the entire frame is one quad. (Scanlines and the RGB mask index by real screen pixels and do not have this problem, but the single-pass requirement is documented for the whole effect.)

So: render the scene into a render target with no effect bound, then redraw that one composited texture full-screen through CRTEffect. No FlipVertically is needed: a RenderTarget2D handed to SpriteBatch samples in the same top-left orientation as an ordinary Texture2D, on every renderer (CNA’s own CRT demo draws it with a plain destination rectangle). Adding a flip here would turn the frame upside down.

#include "CNA/Graphics/CRTEffect.hpp"

using CNA::Graphics::CRTEffect;
using CNA::Graphics::CRTMaskType;

// Pass 1: the scene, into an offscreen target, no CRTEffect bound.
device.SetRenderTarget(sceneTarget_.get());
device.Clear(Color::Black);
DrawMyWholeScene();
device.SetRenderTarget(nullptr);

// Pass 2: one full-screen quad through CRTEffect.
crt_->setScanlineIntensity(0.30f);
crt_->setCurvature(0.08f);
crt_->setVignetteIntensity(0.25f);
crt_->setMaskIntensity(0.35f);
crt_->setMaskType(CRTMaskType::ApertureGrille);   // or ::ShadowMask, or ::None

SamplerState pointClamp = SamplerState::PointClamp;
spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::Opaque,
                    &pointClamp, nullptr, nullptr, crt_.get());
spriteBatch_->Draw(*sceneTarget_, Rectangle(0, 0, 640, 480), Color::White);
spriteBatch_->End();

Every parameter is clamped to [0,1] and has a moderate default, so an unconfigured CRTEffect already looks reasonable. The two effects are deliberately separate classes rather than one with options, because CRT emulation and colour-depth reduction are independent concerns and plenty of retro-look games want only one. To chain them, use the same multi-pass shape with DepthEffect as the first pass: scene → DepthEffect → render target → CRTEffect → backbuffer.

ColorMatrixEffect: honest about not running

ColorMatrixEffect is the odd one out. It is an Effect subclass, so you pass it to SpriteBatch::Begin() like any other — but it carries no shader source at all. It accepts only a fixed row-major RGBA matrix and an offset, transforming each sprite's sampled and tinted colour before ordinary BlendState processing:

out[row] = dot(matrix[row], inRGBA) + offset[row], with every output component clamped to [0,1]. The default is the identity transform.

#include "Microsoft/Xna/Framework/Graphics/ColorMatrixEffect.hpp"

ColorMatrixEffect tint(device);

// The shortcut: Rec. 709 grayscale, alpha preserved.
tint.SetGrayscale();

// Or a hand-written transform. Row-major, 16 floats.
tint.SetColorMatrix({
    0.60f, 0.30f, 0.10f, 0.0f,
    0.20f, 0.70f, 0.10f, 0.0f,
    0.10f, 0.20f, 0.70f, 0.0f,
    0.00f, 0.00f, 0.00f, 1.0f});
tint.SetColorOffset(Vector4(0.05f, 0.02f, 0.0f, 0.0f));

spriteBatch.Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend,
                  nullptr, nullptr, nullptr, &tint);
// ... draws ...
spriteBatch.End();

tint.Reset();   // back to identity and a zero offset

Both setters reject non-finite values, so a stray NaN fails loudly rather than blanking the screen.

The design decision worth taking away is what happens on renderers that do not implement it. The effect's state is copied into the shared CPU SpriteBatch draw description behind a single flag, and the contract in CNA's own renderer interface is explicit: other renderers deliberately leave that flag false rather than pretending to execute it.

That is why SOFTWARE — whose CPU sprite path applies no other custom effect — implements this one specifically: it is small and fixed enough that a CPU rasterizer can apply it exactly, with no shader source to parse or silently ignore. If you need a colour transform on the CPU renderer, this is the mechanism. If you need one on a GPU renderer, write a ShaderEffect (Tutorial 52).

Choosing between them

If you want…UseAnd accept
A glyph-grid look on any renderer, including 2D-only onesAsciiPostProcessEffectA GPU-to-CPU readback per Draw(); CNA_CNAEXT=ON
Scanlines, curvature, vignette, sub-pixel maskCRTEffectOnly renderers that take one of its shader variants (see above); one full-screen source; CNA_CNAEXT=ON
Retro colour depth or a fixed historical paletteDepthEffectOnly renderers that take one of its shader variants; CNA_CNAEXT=ON
A fixed colour transform on the CPU rendererColorMatrixEffectThe CPU SpriteBatch path only — a no-op elsewhere, by design
Bloom, blur or another full-screen pass of your ownYour own render-target chain and ShaderEffect (Tutorial 24, Tutorial 66)Shader source per renderer language
Renderer-native custom shaderYour own ShaderEffectLanguage or binary expected by the active renderer
XNA/FNA compiled EffectEffect / XNB EffectReaderD3D9 Effect Framework bytecode on a renderer build with compiled effects enabled (11 of the 14 identities)

Where to go next