Tutorial 116: CNAEXT Post-Process Effects: ASCII, CRT, Depth and Colour Matrix
What you’ll learn
- The four CNAEXT post-process effects, and why they attach to CNA at three different layers.
- Why
AsciiPostProcessEffectis not anEffect— and why that is what makes it work everywhere. - How
CRTEffectandDepthEffectdiffer from it, and which renderers actually execute them. - Why
ColorMatrixEffectruns 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. It is tempting to treat them as four 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().
| Effect | What it is | Build flag | Executes on |
|---|---|---|---|
AsciiPostProcessEffect | Standalone class — not an Effect | CNA_CNAEXT | Any renderer that can read pixels back |
CRTEffect | ShaderEffect subclass (GLSL) | CNA_CNAEXT | Shader-capable renderers only |
DepthEffect | ShaderEffect subclass (GLSL) | CNA_CNAEXT | Shader-capable renderers only |
ColorMatrixEffect | Effect subclass with no shader at all | none — always built | The shared CPU SpriteBatch path only |
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 -j3
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));
| Member | Notes |
|---|---|
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 the renderers with no back buffer to read at all — HEADLESS and STUB — the readback is rejected with System::NotSupportedException. That is a documented capability boundary, not a defect; CNA's own test for this effect catches that exception and reports a skip. See Tutorial 125.
CRTEffect and DepthEffect: real ShaderEffects
These two are the opposite design. Both derive from ShaderEffect and carry a built-in GLSL 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 custom ShaderEffects run. That is the five GL-profile identities, plus OPENGL2, OPENGL4, VULKAN, SDL_GPU, SOKOL, LLGL, MAGNUM, DIRECTX9, DIRECTX11 and DIRECTX12. Elsewhere the outcome is one of three: BGFX and SOFTWARE accept the effect and silently ignore the source you supplied; GDI, METAL, HTML_DOM and SVG_DOM throw; FNA3D cannot compile custom shaders at all. HEADLESS records the call and renders nothing.
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. Note the FlipVertically — render target contents are stored bottom-up, and without it the composited frame comes out 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),
Rectangle(0, 0, 640, 480), Color::White,
0.0f, Vector2::Zero, SpriteEffects::FlipVertically, 0.0f);
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. The PortableGL renderer goes further and refuses the draw with a diagnostic naming the effect.
That is why GDI — which rejects every other custom effect for SpriteBatch outright — accepts this one specifically: it is small and fixed enough that a CPU rasterizer can implement it exactly, with no shader source to parse or silently ignore. If you need a colour transform that survives on a 2D 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… | Use | And accept |
|---|---|---|
| A glyph-grid look on any renderer, including 2D-only ones | AsciiPostProcessEffect | A GPU-to-CPU readback per Draw(); CNA_CNAEXT=ON |
| Scanlines, curvature, vignette, sub-pixel mask | CRTEffect | Shader-capable renderers only; one full-screen source; CNA_CNAEXT=ON |
| Retro colour depth or a fixed historical palette | DepthEffect | Shader-capable renderers only; CNA_CNAEXT=ON |
| A fixed colour transform on a CPU 2D renderer | ColorMatrixEffect | The CPU SpriteBatch path only — a no-op elsewhere, by design |
| Anything else | Your own ShaderEffect | Hand-written GLSL — compiled .fx bytecode is unsupported |