Tutorial 65: MSAA Anti-Aliasing

CNA Tutorials  ·  Advanced Rendering

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 backends 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 backend to request a multisampled swap chain or framebuffer from the OS/driver:

class MyGame final : public Game {
public:
    MyGame() : graphics_(this) {
        graphics_.setPreferMultiSampling(true);
        graphics_.setPreferredBackBufferWidth(1280);
        graphics_.setPreferredBackBufferHeight(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_.setPreferMultiSampling(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_.setMultiSampleCount(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().PresentationParameters;
    // pp.MultiSampleCount reports what the driver actually gave us
    int actualSamples = pp.MultiSampleCount;
    // Log to console, display in a debug overlay, etc.
    (void)actualSamples;
}

Backend Support

SDL_RENDERER backend: setPreferMultiSampling(true) is accepted but silently ignored. SDL2's 2D renderer does not expose MSAA control. Use FXAA (see below) as a post-process alternative when targeting this backend.

  • EasyGL backend — 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).
  • Vulkan backend — 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_.setPreferMultiSampling(true);
        graphics_.setPreferredBackBufferWidth(1280);
        graphics_.setPreferredBackBufferHeight(720);
    }

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

        // Confirm actual MSAA level from the driver
        auto& pp = getGraphicsDeviceProperty().PresentationParameters;
        actualSampleCount_ = pp.MultiSampleCount;
    }

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

    void Update(const GameTime& gt) override {
        angle_ += (float)gt.getElapsedGameTime().TotalSeconds() * 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_->setWorld(world);
        effect_->setView(Matrix::CreateLookAt(
            Vector3(0.0f, 0.0f, 3.0f),
            Vector3::Zero,
            Vector3::Up));
        effect_->setProjection(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4,
            1280.0f / 720.0f,
            0.1f, 100.0f));

        gd.setVertexBuffer(*vb_);
        for (auto& pass : effect_->getCurrentTechnique().Passes) {
            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::VertexDeclaration, 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 backend, 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

// In LoadContent:
// sceneRT_ = std::make_unique<RenderTarget2D>(...);
// fxaaEffect_ = Content.Load<Effect>("effects/fxaa");
// spriteBatch_ = std::make_unique<SpriteBatch>(gd);

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);

    fxaaEffect_->Parameters["u_screen"].SetValue(sceneRT_.get());
    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
Backend requirementEasyGL or VulkanAny backend
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.