Tutorial 65: MSAA Anti-Aliasing

CNA Tutorials  ·  Advanced Rendering

ℹ

What you’ll learn

  • What multisampling actually smooths, and what it does not.
  • Requesting it via PreferMultiSampling on GraphicsDeviceManager, and choosing a specific MultiSampleCount through the PreparingDeviceSettings event.
  • Which renderers honour the request, and what the ones that cannot do instead.
  • FXAA as a post-process alternative, and when to prefer it.

Before you start — Tutorial 31: Your First 3D Triangle — the settings are configured on the manager set up there. Renderer support differs sharply: OPENGLES3, VULKAN and METAL implement MSAA, STUB has none, and SDL_RENDERER accepts the flag and ignores it; the table under Renderer Support covers all 14 identities. MSAA itself needs no HiDef request, but the FXAA alternative needs a renderer that executes custom shaders.

Aliasing — the "staircase" jagged edge effect on geometry silhouettes — is one of the most immediately visible quality issues in 3D rendering. This tutorial covers Multi-Sample Anti-Aliasing (MSAA), CNA's primary hardware-accelerated AA mode, and the FXAA post-process alternative for renderers and devices where MSAA is unavailable or too expensive.

ⓘ

Requirements. Back-buffer MSAA needs no HiDef request: the default Reach profile is enough, and there is no profile-specific code to add. What it needs is a renderer that multisamples (see Renderer Support). The FXAA alternative additionally needs a renderer that runs custom shaders, and an off-screen scene target whose edges stay within the profile’s limit (2048 on Reach, 4096 on HiDef; see Tutorial 152).

What is MSAA?

Multi-Sample Anti-Aliasing (MSAA) works by rendering each pixel at multiple sub-pixel sample positions and averaging the results. For a 4x MSAA framebuffer, the GPU maintains four samples per pixel, each with an independent depth and stencil value. The fragment shader runs once per pixel (not once per sample), but the coverage test runs per-sample. At pixels where a triangle edge crosses, only some of the four samples fall inside the triangle; those samples get the triangle's colour and the others keep the background colour. When the frame is displayed, the four samples are averaged ("resolved"), producing a smooth anti-aliased edge.

MSAA is highly effective at eliminating geometric aliasing (jagged polygon edges) because it operates at the rasterisation level with GPU hardware support. It is less effective at texture aliasing (shimmer on distant surfaces with high-frequency detail) — that requires mipmapping and anisotropic filtering (covered in the texture tutorials). Post-process AA techniques like FXAA or TAA can complement MSAA for texture-related aliasing.

Key characteristics of MSAA compared to alternatives:

  • Geometry edges — excellent. Hardware MSAA eliminates staircase edges on triangles.
  • Texture shimmer — no improvement without mipmapping; MSAA does not help here.
  • Fragment shader cost — the fragment shader runs only once per pixel, not per sample (unlike SSAA). This makes 4x MSAA much cheaper than 4x SSAA.
  • Depth and stencil — per-sample depth and stencil are maintained, so depth testing at edges remains correct.
  • Memory cost — proportional to sample count. 4x MSAA uses 4x the framebuffer memory and 4x the depth/stencil memory.

PreferMultiSampling Flag

In CNA, MSAA is enabled by setting PreferMultiSampling on the GraphicsDeviceManager before Run() is called. This tells the renderer to request a multisampled swap chain or framebuffer from the OS/driver:

class MyGame final : public Game {
public:
    MyGame() : graphics_(this) {
        graphics_.setPreferMultiSamplingProperty(true);
        graphics_.setPreferredBackBufferWidthProperty(1280);
        graphics_.setPreferredBackBufferHeightProperty(720);
    }
    // ...
private:
    GraphicsDeviceManager graphics_;
};

The word "Prefer" in PreferMultiSampling is significant: if the hardware or driver does not support MSAA (or does not support the requested sample count), CNA will silently fall back to fewer samples, or none, rather than failing. Ask gd.SupportsCapability(CNA::GraphicsCapability::MultiSampleAntiAliasing) to learn whether the renderer can multisample at all, and read GraphicsDevice.PresentationParameters.MultiSampleCount to learn the count in use — but only trust that read-back on the renderers marked in the table below; on the others it simply echoes the request.

GraphicsDeviceManager Settings

GraphicsDeviceManager has no sample-count property of its own; PreferMultiSampling is the whole switch. When it is true the manager requests 8 samples, and when it is false it forces the count to 0. The renderer then clamps that request to what it supports (see the table under Renderer Support). To ask for a different count, set it on the PresentationParameters inside the PreparingDeviceSettings event, which runs after the manager has filled in its defaults and before the device is created:

graphics_.setPreferMultiSamplingProperty(true);

// Request 4x instead of the default 8. The renderer clamps the request to what the
// device supports, so the actual count may be lower.
// Useful values: 2, 4, 8 (1 is not a valid MSAA count and means "none").
// Most integrated GPUs support up to 4x; discrete GPUs support more on some APIs.
graphics_.PreparingDeviceSettings +=
    [](System::Object*, const PreparingDeviceSettingsEventArgs& e) {
        e.getGraphicsDeviceInformationEXT()
         .getPresentationParametersProperty()
         .setMultiSampleCountProperty(4);
    };

These settings can also be changed at runtime by calling GraphicsDeviceManager::ApplyChanges() after modifying them, which re-applies the presentation parameters to the device (an in-place reset). This is useful for an in-game graphics settings screen. An off-screen target takes its own count: RenderTarget2D(gd, w, h, false, SurfaceFormat::Color, DepthFormat::Depth24, 4) rounds the count down to a power of two (1 becomes 0), and every target in a multiple-render-target set must have the same applied sample count.

MultiSampleCount — Choosing a Level

Sample CountQualityMemory & Fill OverheadRecommended for
1 (no MSAA)Aliased1x baselineLowest-end mobile, fallback
2xNoticeable improvement~1.5xMobile, battery-saving mode
4xGood — most edges smooth~2xDefault for desktop/console
8xVery high~3xHigh-end desktop GPU
16xDiminishing returns vs 8x~4xBenchmarks, screenshots (only where the device offers it)

On most modern dedicated GPUs, 4x MSAA is the sweet spot: it eliminates the vast majority of visible aliasing at roughly double the memory bandwidth of the unsampled case, which is well within the available bandwidth on current hardware. On integrated GPUs (Intel Iris, Apple M-series GPU, Mali, Adreno), 2x or no MSAA and FXAA as a substitute is often the better choice for maintaining 60 fps.

Verify the actual count after device creation:

void Initialize() override {
    Game::Initialize();
    auto& pp = getGraphicsDeviceProperty().getPresentationParametersProperty();
    // pp.getMultiSampleCountProperty() reports what the renderer actually applied
    // on the renderers marked "reports applied count" below; elsewhere it echoes
    // the request, so also consult SupportsCapability(MultiSampleAntiAliasing).
    int actualSamples = pp.getMultiSampleCountProperty();
    // Log to console, display in a debug overlay, etc.
    (void)actualSamples;
}

Renderer Support

⚠

A renderer that cannot do MSAA clamps your request to 1 (no multisampling). The requested multiSampleCount is handed to the renderer, and renderers without MSAA support silently clamp it. Nothing throws. SDL_RENDERER is the common case: setPreferMultiSamplingProperty(true) is accepted, the request is logged, and it has no effect, because SDL’s 2D renderer exposes no MSAA control. Use FXAA (below) where a custom shader can run.

Ask GraphicsDevice::SupportsCapability(GraphicsCapability::MultiSampleAntiAliasing) rather than guessing. On OPENGLES3, OPENGL33, WEBGL2 (needs GL_MAX_SAMPLES above 1), VULKAN, DIRECTX11, SDL_GPU, WEBGPU, FNA3D and METAL the answer comes from a real probe of the device; only DIRECTX9 inherits the permissive shared default (true). Reading back the achieved sample count, as the example below does, is the answer you can trust only on the renderers that report it (last column).

Renderer identities Back-buffer MSAA How the count is chosen Reports applied count
OPENGLES3, OPENGL33, WEBGL2Yes, when GL_MAX_SAMPLES is above 1Clamped to the GL context’s maximumYes
VULKANYes, when colour and depth sample counts allow more than 1Intersection of framebufferColorSampleCounts and framebufferDepthSampleCountsYes
SDL_GPUYesClamps 8 to 4 to 2 against colour and depth supportYes
DIRECTX11Yes, when a multisample quality level existsThe request is halved until one does; the capability probes a request of 4Yes
WEBGPUYes, when the 4x probe passes4x onlyYes
SOFTWAREYes (CPU, sample-correct)0 or 4 only; other counts are rejectedYes
FNA3DYes, when FNA3D_GetMaxMultiSampleCount is above 1Depends on the FNA3D driver chosen at run timeNo (echoes the request)
DIRECTX9Inherited default (true), not probedNot verifiedNo (echoes the request)
HEADLESSDefault true, but nothing is rasterised—No
METALYes, where the device multisamples (every Metal GPU does at 4x)Rounded down to a count the device accepts (an M4: 2 or 4, not 8); also for RenderTarget2D, RenderTargetCube and MRTYes
STUBNo (one sample per pixel)Clamped to noneNo
SDL_RENDERERNo; the request is logged and ignored—No
  • The GL family (internally EasyGL) — the request travels in the platform’s GL context description (multisampleSamples), which the SDL3 platform honours through SDL_GL_MULTISAMPLEBUFFERS/SDL_GL_MULTISAMPLESAMPLES. The achieved count is confirmed with glGetIntegerv(GL_SAMPLES, &actual) and reported back.
  • The VULKAN renderer — keeps the swap chain images single-sample and renders into a separate multisampled colour and depth target (for example VK_SAMPLE_COUNT_4_BIT), which it resolves into the swap chain image. It uses vkGetPhysicalDeviceProperties to query framebufferColorSampleCounts and framebufferDepthSampleCounts for hardware limits.

Complete MSAA Example

class MsaaGame final : public Game {
public:
    MsaaGame() : graphics_(this) {
        // Prefer multisampling: the manager requests 8 samples and the renderer
        // clamps that to what it supports (use PreparingDeviceSettings for 4).
        graphics_.setPreferMultiSamplingProperty(true);
        graphics_.setPreferredBackBufferWidthProperty(1280);
        graphics_.setPreferredBackBufferHeightProperty(720);
    }

protected:
    void Initialize() override {
        Game::Initialize();

        // Confirm the MSAA level. This is the applied count on the renderers that
        // report it (see the table); elsewhere it echoes the request.
        auto& pp = getGraphicsDeviceProperty().getPresentationParametersProperty();
        actualSampleCount_ = pp.getMultiSampleCountProperty();
    }

    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        effect_ = std::make_unique<BasicEffect>(gd);
        effect_->VertexColorEnabled = true;
        effect_->setLightingEnabledProperty(false);
        buildSpinningTriangle(gd);
    }

    void Update(GameTime& gt) override {
        angle_ += (float)gt.getElapsedGameTimeProperty().getTotalSecondsProperty() * 0.8f;
    }

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

        // Rotate a coloured triangle to make aliasing visible
        Matrix world = Matrix::CreateRotationZ(angle_);
        effect_->setWorldProperty(world);
        effect_->setViewProperty(Matrix::CreateLookAt(
            Vector3(0.0f, 0.0f, 3.0f),
            Vector3::Zero,
            Vector3::Up));
        effect_->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4,
            1280.0f / 720.0f,
            0.1f, 100.0f));

        gd.SetVertexBuffer(vb_.get());
        for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
            pass.Apply();
            gd.DrawPrimitives(PrimitiveType::TriangleList, 0, triCount_);
        }
        // No gd.Present(): Game presents after Draw() returns.
    }

private:
    GraphicsDeviceManager         graphics_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> vb_;
    float angle_           = 0.0f;
    int   triCount_        = 0;
    int   actualSampleCount_ = 1;

    void buildSpinningTriangle(GraphicsDevice& gd) {
        // A large triangle whose edges cross at steep angles
        // makes aliasing very visible when MSAA is off.
        VertexPositionColor verts[3] = {
            { Vector3( 0.0f,  1.5f, 0.0f), Color::Red   },
            { Vector3( 1.3f, -0.75f, 0.0f), Color::Green },
            { Vector3(-1.3f, -0.75f, 0.0f), Color::Blue  },
        };
        vb_ = std::make_unique<VertexBuffer>(gd,
            VertexPositionColor::getVertexDeclarationStatic(), 3, BufferUsage::WriteOnly);
        vb_->SetData(verts, 3);
        triCount_ = 1;
    }
};

int main() {
    MsaaGame game;
    game.Run();
    return 0;
}

Performance Tradeoff

MSAA's performance impact comes primarily from two sources:

  • Memory bandwidth — the framebuffer and depth buffer are N times larger (N = sample count). On bandwidth-limited GPUs (integrated graphics, mobile) this can be the dominant cost.
  • Fill rate — at triangle edges where multiple samples fall inside the triangle, the GPU must write multiple samples. In the worst case (thin triangles covering many edge pixels) this approaches the cost of SSAA. In practice, for typical scene geometry, the overhead is much lower because most pixels are fully covered or fully uncovered.

Fragment shader cost is generally not multiplied by the sample count — the shader runs once per pixel, not once per sample. This is the key architectural advantage of MSAA over SSAA.

On modern desktop discrete GPUs (NVIDIA, AMD, Intel Arc), 4x MSAA at 1080p typically adds 5–15% to frame time compared to no AA, making it essentially free for GPU-limited games at 1080p. At 4K, the absolute bandwidth cost is much higher and 2x MSAA or FXAA may be preferable.

FXAA: A Post-Process Alternative

Fast Approximate Anti-Aliasing (FXAA) is a screen-space post-processing technique that detects high-contrast edges in the final colour image and blurs them slightly. It runs as a single fullscreen fragment shader pass on the resolved (non-MSAA) framebuffer. Key properties:

  • Works on any renderer that has render targets and executes a custom shader on a full-screen pass. SDL_RENDERER is not one of them: it has no programmable shader stage and throws on any custom SpriteBatch effect. The shader dialect is the renderer’s own (see Tutorial 59 for the table), and FNA3D and SOFTWARE report CustomEffects false; on METAL the pass must be written in Metal Shading Language and drawn through SpriteBatch, the only place Metal runs a custom effect.
  • No additional memory for multi-sample buffers.
  • Detects edges from colour contrast, so it can also smooth texture edges and alpha-tested geometry.
  • Introduces a small amount of blurring on fine detail (e.g., text, thin lines) as a side effect.
  • Quality is lower than 4x MSAA for hard geometric edges, but the performance cost is very low and constant.

To implement FXAA in CNA: render the scene to a RenderTarget2D, then apply the FXAA shader as a fullscreen pass to the back buffer using a SpriteBatch with a custom effect or with a fullscreen triangle + custom vertex buffer.

FXAA GLSL Shader

// Simplified FXAA — luma-based edge detection and blend
// Based on Timothy Lottes' FXAA 3.11 algorithm (simplified for clarity)
#version 300 es
precision highp float;

in vec2 v_texcoord;
uniform sampler2D u_screen;

out vec4 fragColor;

const float FXAA_REDUCE_MIN = 1.0 / 128.0;
const float FXAA_REDUCE_MUL = 1.0 / 8.0;
const float FXAA_SPAN_MAX   = 8.0;

// Convert RGB to perceptual luminance
float luma(vec3 rgb) {
    return dot(rgb, vec3(0.299, 0.587, 0.114));
}

vec3 fxaa(sampler2D tex, vec2 uv, vec2 texelSize) {
    // Sample the pixel and its four diagonal neighbours
    vec3 rgbNW = texture(tex, uv + vec2(-1.0, -1.0) * texelSize).rgb;
    vec3 rgbNE = texture(tex, uv + vec2( 1.0, -1.0) * texelSize).rgb;
    vec3 rgbSW = texture(tex, uv + vec2(-1.0,  1.0) * texelSize).rgb;
    vec3 rgbSE = texture(tex, uv + vec2( 1.0,  1.0) * texelSize).rgb;
    vec3 rgbM  = texture(tex, uv).rgb;

    float lumaNW = luma(rgbNW);
    float lumaNE = luma(rgbNE);
    float lumaSW = luma(rgbSW);
    float lumaSE = luma(rgbSE);
    float lumaM  = luma(rgbM);

    float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
    float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));

    // Detect edge direction from luma gradient
    vec2 dir = vec2(
        -((lumaNW + lumaNE) - (lumaSW + lumaSE)),
         ((lumaNW + lumaSW) - (lumaNE + lumaSE)));

    float dirReduce = max(
        (lumaNW + lumaNE + lumaSW + lumaSE) * FXAA_REDUCE_MUL * 0.25,
        FXAA_REDUCE_MIN);
    float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);

    dir = clamp(dir * rcpDirMin, vec2(-FXAA_SPAN_MAX), vec2(FXAA_SPAN_MAX))
          * texelSize;

    // Sample along the edge direction at two offsets
    vec3 rgbA = 0.5 * (
        texture(tex, uv + dir * (1.0 / 3.0 - 0.5)).rgb +
        texture(tex, uv + dir * (2.0 / 3.0 - 0.5)).rgb);
    vec3 rgbB = rgbA * 0.5 + 0.25 * (
        texture(tex, uv + dir * -0.5).rgb +
        texture(tex, uv + dir *  0.5).rgb);

    // Use the wider sample only if it does not cross outside the local range
    float lumaB = luma(rgbB);
    if (lumaB < lumaMin || lumaB > lumaMax)
        return rgbA;
    return rgbB;
}

void main() {
    vec2 texelSize = 1.0 / vec2(textureSize(u_screen, 0));
    fragColor = vec4(fxaa(u_screen, v_texcoord, texelSize), 1.0);
}

Applying FXAA in the Draw Loop

⚠

The FXAA pass in this tutorial is a ShaderEffect. This snapshot also has a renderer-qualified path for compiled XNA/FNA Effect Framework bytecode (FNA3D always, ten more identities behind default-OFF CNA_*_COMPILED_EFFECTS options), but it is not a portable source-shader compiler. Here the renderer-native shader (GLSL ES 3.00, for OPENGLES3 and WEBGL2; other renderers need their own dialect) is handed to ShaderEffect as source text. It has no Parameters collection; u_screen is bound by telling the sampler which unit to read and letting SpriteBatch supply the texture on unit 0. See Tutorial 52.

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

// In LoadContent:
// sceneRT_     = std::make_unique<RenderTarget2D>(...);
// spriteBatch_ = std::make_unique<SpriteBatch>(gd);
//
// fxaaEffect_ = std::make_unique<ShaderEffect>(
//     gd,
//     System::IO::File::ReadAllText("Content/effects/fxaa.vert.glsl"),
//     System::IO::File::ReadAllText("Content/effects/fxaa.frag.glsl"));
//
// // The constructor does not throw on a compile failure;
// // GetCompileErrorEXT() returns the compiler log.
// if (!fxaaEffect_->IsEffectValid()) { /* the shader did not compile */ }
//
// // u_screen reads texture unit 0, which is where SpriteBatch binds its texture.
// fxaaEffect_->Apply();
// fxaaEffect_->SetUniformInt("u_screen", 0);

void Draw(const GameTime&) override {
    auto& gd = getGraphicsDeviceProperty();

    // 1. Render scene to off-screen target
    gd.SetRenderTarget(sceneRT_.get());
    gd.Clear(Color::CornflowerBlue);
    drawScene(gd);

    // 2. Apply FXAA pass to the back buffer
    gd.SetRenderTarget(nullptr);
    gd.Clear(Color::Black);

    // Nothing to rebind per frame: SpriteBatch supplies the source texture on
    // unit 0, and the sampler's unit was set once in LoadContent().
    // The sampler, depth-stencil and rasterizer parameters are pointers.
    spriteBatch_->Begin(SpriteSortMode::Immediate,
                         BlendState::Opaque,
                         &SamplerState::LinearClamp,
                         nullptr, nullptr,
                         fxaaEffect_.get());
    spriteBatch_->Draw(*sceneRT_, Vector2::Zero, Color::White);
    spriteBatch_->End();
    // No gd.Present(): Game presents after Draw() returns.
}

Choosing Between MSAA and FXAA

CriterionMSAA 4xFXAA
Geometric edge qualityExcellentGood
Texture/alpha edge qualityNoneGood
Fine detail preservationPerfectSlightly blurry
Memory overhead4x framebuffer1x (one RenderTarget2D)
Shader overheadMinimalOne fullscreen pass
Renderer requirementA renderer that honours multiSampleCount (see the table above)A renderer with render targets that executes a custom shader (not SDL_RENDERER)
Deferred rendering compatibleComplexYes (post-lighting)

In practice, many games ship with both: MSAA for the forward-rendered geometry pass, and FXAA or TAA applied as a final post-process to handle remaining aliasing from shading. CNA supports this combination — enable MSAA on the back buffer for the main pass, then optionally add an FXAA pass at the end of Draw (the Game presents afterwards) for residual aliasing in shaded regions.