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 and MultiSampleCount on GraphicsDeviceManager.
  • 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 startTutorial 31: Your First 3D Triangle — the settings are configured on the manager set up there. Renderer support differs sharply: OPENGLES3 and VULKAN implement MSAA, while SDL_RENDERER accepts the flag and ignores it.

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.

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 no MSAA rather than failing. Always check GraphicsDevice.PresentationParameters.MultiSampleCount at runtime to determine the actual sample count in use.

GraphicsDeviceManager Settings

The sample count can be specified explicitly. CNA picks the highest supported count up to and including the requested value:

graphics_.setPreferMultiSamplingProperty(true);

// Request 4x MSAA. The actual count may be lower if not supported.
// Common values: 1 (off), 2, 4, 8, 16.
// Most integrated GPUs support up to 4x; discrete GPUs support up to 16x.
graphics_.setMultiSampleCountProperty(4);

These settings can also be changed at runtime by calling GraphicsDeviceManager::ApplyChanges() after modifying them, which recreates the swap chain with the new parameters. This is useful for an in-game graphics settings screen.

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

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 driver actually gave us
    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. The requested multiSampleCount is handed to the renderer at construction, and the interface documents plainly that renderers without MSAA support silently clamp it. Nothing throws. SDL_RENDERER is the common case: setPreferMultiSamplingProperty(true) is accepted and has no effect, because SDL's 2D renderer exposes no MSAA control. Use FXAA (below) there.

Ask GraphicsDevice::SupportsCapability(GraphicsCapability::MultiSampleAntiAliasing) rather than guessing — but note that most base entries fail open. DIRECTX9, DIRECTX11, DIRECTX12 and SDL_GPU supply no override at all. Reading back the achieved sample count, as the example below does, is the answer you can trust.

  • The GL family (internally EasyGL) — enables MSAA via SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1) and SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, count), then calls glEnable(GL_MULTISAMPLE) after context creation. The actual sample count is confirmed with glGetIntegerv(GL_SAMPLES, &actual).
  • The VULKAN renderer — creates the swap chain images with the requested VkSampleCountFlagBits (e.g., VK_SAMPLE_COUNT_4_BIT) and sets up a multisample resolve attachment in the render pass. Uses vkGetPhysicalDeviceProperties to query framebufferColorSampleCounts for hardware limits.

Complete MSAA Example

class MsaaGame final : public Game {
public:
    MsaaGame() : graphics_(this) {
        // Request 4x MSAA back buffer
        graphics_.setPreferMultiSamplingProperty(true);
        graphics_.setPreferredBackBufferWidthProperty(1280);
        graphics_.setPreferredBackBufferHeightProperty(720);
    }

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

        // Confirm actual MSAA level from the driver
        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_);
        }
        gd.Present();
    }

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 with render-target support, including SDL_RENDERER.
  • 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. Alpha.1 also has a renderer-qualified path for XNA/FNA D3D9 Effect Framework bytecode, but it is not a portable source-shader compiler. Here the renderer-native shader 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.
// if (!fxaaEffect_->IsEffectValid()) { /* the GLSL 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().
    spriteBatch_->Begin(SpriteSortMode::Immediate,
                         BlendState::Opaque,
                         SamplerState::LinearClamp,
                         nullptr, nullptr,
                         fxaaEffect_.get());
    spriteBatch_->Draw(*sceneRT_, Vector2::Zero, Color::White);
    spriteBatch_->End();

    gd.Present();
}

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 multiSampleCountAny renderer with render targets
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 before Present() for residual aliasing in shaded regions.