Tutorial 63: Stencil Buffer Effects

CNA Tutorials  ·  Advanced Rendering

ℹ

What you’ll learn

  • The stencil fields of DepthStencilState, and the StencilFunction / StencilOperation enums.
  • Writing a mask into the stencil buffer and then testing against it.
  • Silhouette outlines and portal rendering as worked examples.
  • Why the default back buffer has no stencil plane, and how to ask for one.

Before you start — Tutorial 39: Depth Buffer and Z-Fighting — stencil state lives on the same DepthStencilState object. Requires a 3D-capable renderer with a stencil plane, such as OPENGLES3 or VULKAN (the table below lists all 14 identities); the 2D-only SDL_RENDERER throws on 3D calls by default, and STUB draws nothing.

The stencil buffer is an 8-bit integer channel attached alongside the depth buffer. It acts as a per-pixel mask that you write in one draw call and read back in subsequent draw calls to include or exclude pixels from rendering. Combined with CNA's DepthStencilState API it enables a wide range of effects that are impossible or expensive to achieve any other way.

⚠

A stencil buffer is a per-renderer capability. Ask GraphicsDevice::SupportsCapability(GraphicsCapability::StencilBuffer) before designing a technique around it. The shared base delegates this entry to the renderer’s depth/stencil answer (true by default); the other members of the 19-member enum answer either from a renderer switch or from a dedicated hook, so read a positive answer as the renderer’s own claim. Only DIRECTX9 has no capability override at all; DIRECTX11, SDL_GPU, VULKAN, METAL and the others answer explicitly. The 2D-only SDL_RENDERER throws on 3D calls regardless of what a capability says.

⚠

Requirements: a stencil plane on the back buffer. This tutorial does not need HiDef, but it does need a stencil plane, and the default back buffer has none: GraphicsDeviceManager’s preferred depth format is DepthFormat::Depth24. Without stencil bits the stencil test silently does nothing, and asking Clear for ClearOptions::Stencil (or depth) on a device without that plane throws InvalidOperationException. Request Depth24Stencil8 in the Game constructor, before Initialize() applies your preferences (an off-screen target asks for it through the DepthFormat argument of its RenderTarget2D constructor):

OutlineGame() : graphics_(this) {
    graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::Depth24Stencil8);
}
Renderer identities StencilBuffer Notes
OPENGLES3, OPENGL33, WEBGL2YesNeeds the Depth24Stencil8 request above
VULKANYes, when the chosen depth format has stencil (D24S8, D32S8, D16S8 or S8)Reports the depth format it chose
SDL_GPUYes, when the back buffer has stencilDepth/stencil normalised to a supported native format
WEBGPU, FNA3DYes, from the renderer’s depth/stencil answer
DIRECTX9, DIRECTX11, METAL, SOFTWARE, HEADLESSYesHEADLESS validates and traces but draws no pixels; on METAL a target has a stencil plane only when its depth format names one, as in XNA
SDL_RENDERER, STUBNo2D only (or nothing at all for STUB)

DepthStencilState Stencil Settings

DepthStencilState is a pipeline state object that controls both the depth test and stencil test. The stencil-relevant fields are:

FieldTypeDescription
StencilEnableboolMaster switch. Must be true to use stencil at all.
StencilFunctionCompareFunctionHow the stencil test compares the buffer value to ReferenceStencil.
StencilPassStencilOperationOperation on the stencil buffer when both stencil and depth tests pass.
StencilFailStencilOperationOperation when the stencil test fails (depth test not evaluated).
StencilDepthBufferFailStencilOperationOperation when stencil passes but depth test fails.
ReferenceStencilintReference value compared against the buffer. Range 0–255.
StencilMaskintAND mask applied to both reference and buffer before comparison.
StencilWriteMaskintAND mask controlling which bits are written to the stencil buffer.
DepthBufferEnableboolEnable depth testing.
DepthBufferWriteEnableboolEnable depth writes.

CNA provides several built-in presets: DepthStencilState::Default (depth on, stencil off), DepthStencilState::DepthRead (depth test without write, stencil off), and DepthStencilState::None (both off). For stencil effects you always construct a custom state. In C++ each field above is a property pair, for example setStencilEnableProperty(true) / getStencilEnableProperty(), as the code below shows. The presets are pre-bound to the device and cannot be modified; a state object you create becomes immutable too once you have set it on the device, so configure it completely first and never touch it again afterwards (changing a bound state throws InvalidOperationException).

StencilFunction (CompareFunction)

The StencilFunction field uses the same CompareFunction enum as depth testing. The comparison is: (buffer_value & StencilMask) OP (ReferenceStencil & StencilMask).

ValuePasses when
AlwaysAlways passes (used when writing to stencil without testing it)
NeverNever passes
Equalbuffer == reference
NotEqualbuffer != reference
Lessbuffer < reference
LessEqualbuffer <= reference
Greaterbuffer > reference
GreaterEqualbuffer >= reference

StencilOperation Enum

StencilOperation controls what happens to the stencil buffer value for each pixel, independently for three outcomes (pass, stencil-fail, depth-fail):

ValueEffect on buffer
KeepDo not change the existing value.
ZeroSet buffer to 0.
ReplaceSet buffer to ReferenceStencil.
IncrementSaturationIncrement, clamped at 255.
DecrementSaturationDecrement, clamped at 0.
InvertBitwise NOT of the current value.
IncrementIncrement with wrap-around (255 + 1 = 0).
DecrementDecrement with wrap-around (0 - 1 = 255).

Writing to the Stencil Buffer

The typical pattern for writing a stencil mask is:

  1. Set StencilEnable = true, StencilFunction = Always (always write, never test), StencilPass = Replace, ReferenceStencil = 1.
  2. Disable colour writes so the mask geometry does not appear in the image (BlendState with ColorWriteChannels::None).
  3. Draw the mask geometry (e.g., a sphere silhouette, a portal quad, a mirror plane). Pixels covered by this geometry will have stencil value 1 after this pass.
DepthStencilState writeStencil;
writeStencil.setStencilEnableProperty(true);
writeStencil.setStencilFunctionProperty(CompareFunction::Always);
writeStencil.setStencilPassProperty(StencilOperation::Replace);
writeStencil.setReferenceStencilProperty(1);
writeStencil.setDepthBufferEnableProperty(true);
writeStencil.setDepthBufferWriteEnableProperty(true);

BlendState noColorWrite = BlendState::Opaque;
noColorWrite.setColorWriteChannelsProperty(ColorWriteChannels::None);

gd.setDepthStencilStateProperty(writeStencil);
gd.setBlendStateProperty(noColorWrite);
drawMaskGeometry(gd);
gd.setBlendStateProperty(BlendState::Opaque);

Masking with the Stencil Buffer

Once the stencil buffer contains the mask, draw the content that should only appear inside (or outside) the mask:

// Draw content only where stencil == 1
DepthStencilState testStencil;
testStencil.setStencilEnableProperty(true);
testStencil.setStencilFunctionProperty(CompareFunction::Equal);
testStencil.setStencilPassProperty(StencilOperation::Keep);
testStencil.setReferenceStencilProperty(1);
testStencil.setDepthBufferEnableProperty(true);
testStencil.setDepthBufferWriteEnableProperty(true);

gd.setDepthStencilStateProperty(testStencil);
drawContent(gd); // only pixels where buffer == 1 survive

Object Outline / Silhouette Effect

A two-pass technique produces a coloured outline around any object. It is widely used for selection highlighting in strategy games, interactable object indicators in adventure games, and enemy highlighting in shooters.

Pass 1 — draw the object normally. Set StencilPass = Replace and ReferenceStencil = 1 so every pixel covered by the object writes stencil=1. This also draws the object's normal appearance to the colour buffer.

Pass 2 — draw the same object again but scaled up by a few percent (e.g. 1.05x). Set StencilFunction = NotEqual, ReferenceStencil = 1. Only pixels where the stencil is not 1 pass, i.e., only the thin ring of pixels that the scaled-up version covers but the original did not. Disable depth testing so the outline appears in front of all geometry.

⚠

This outline example uses ShaderEffect, but the stencil technique does not need one. A ShaderEffect takes renderer-native shader source and sets uniforms through SetUniformXxx(); it only runs where the renderer executes custom source (the GL family, VULKAN with SPIR-V, WEBGPU with WGSL, SDL_GPU in a libshaderc or SPIR-V setup, and DIRECTX11 with HLSL; FNA3D and SOFTWARE report CustomEffects false, and METAL runs custom effects only in SpriteBatch, not in this 3D outline pass). If you need only flat-shaded geometry for the outline pass, BasicEffect with setLightingEnabledProperty(false) and a solid diffuse colour works on every renderer that has a stencil plane, and is the portable choice. This snapshot’s separate compiled XNA/FNA Effect Framework path (11 of the 14 identities, behind default-OFF build options except on FNA3D) is another compatibility path. See Tutorial 52 and Tutorial 128.

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

// A minimal camera helper used by the examples in this series. It is application
// code, not a CNA type; Tutorial 34 builds a fuller FpsCamera.
struct Camera {
    Matrix view = Matrix::CreateLookAt(Vector3(0.0f, 2.0f, 6.0f), Vector3::Zero, Vector3::Up);
    Matrix projection = Matrix::CreatePerspectiveFieldOfView(
        MathHelper::ToRadians(60.0f), 16.0f / 9.0f, 0.1f, 100.0f);
    const Matrix& View() const       { return view; }
    const Matrix& Projection() const { return projection; }
};

class OutlineGame final : public Game {
    GraphicsDeviceManager graphics_;
    Camera            camera_;
    DepthStencilState writeStencil_;   // pass 1: write stencil
    DepthStencilState outlineStencil_; // pass 2: draw outline ring

    std::unique_ptr<ShaderEffect> solidEffect_;
    std::unique_ptr<ShaderEffect> outlineEffect_;

    // ShaderEffect's matrix setter takes raw column-major floats.
    static void SetMat4(ShaderEffect& fx, const char* name, const Matrix& m) {
        float cm[16];
        m.ToColumnMajor(cm);
        fx.SetUniformMat4(name, cm);
    }

public:
    OutlineGame() : graphics_(this) {
        // The default back buffer is Depth24 (no stencil): ask for a stencil plane.
        graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::Depth24Stencil8);
    }

protected:
    void LoadContent() override {
        // Configure both states completely here. Once a state has been set on the
        // device (in Draw) it is immutable, so nothing below changes them afterwards.
        // Pass 1: draw object, write stencil=1 everywhere it covers
        writeStencil_.setStencilEnableProperty(true);
        writeStencil_.setStencilFunctionProperty(CompareFunction::Always);
        writeStencil_.setStencilPassProperty(StencilOperation::Replace);
        writeStencil_.setReferenceStencilProperty(1);
        writeStencil_.setDepthBufferEnableProperty(true);
        writeStencil_.setDepthBufferWriteEnableProperty(true);

        // Pass 2: draw scaled-up object only where stencil != 1
        outlineStencil_.setStencilEnableProperty(true);
        outlineStencil_.setStencilFunctionProperty(CompareFunction::NotEqual);
        outlineStencil_.setStencilPassProperty(StencilOperation::Keep);
        outlineStencil_.setReferenceStencilProperty(1);
        outlineStencil_.setDepthBufferEnableProperty(false); // always in front
        outlineStencil_.setDepthBufferWriteEnableProperty(false);

        // Three arguments: device, vertex source, fragment source. Not file paths.
        auto& gd = getGraphicsDeviceProperty();
        solidEffect_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/effects/solid.vert.glsl"),
            System::IO::File::ReadAllText("Content/effects/solid.frag.glsl"));
        outlineEffect_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/effects/flat_color.vert.glsl"),
            System::IO::File::ReadAllText("Content/effects/flat_color.frag.glsl"));

        // Neither constructor throws on a compile failure -- check both.
        // GetCompileErrorEXT() returns the compiler log (also written to stderr).
        if (!solidEffect_->IsEffectValid() || !outlineEffect_->IsEffectValid()) {
            // The shader did not compile. Do not draw with it.
        }
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        // Clear(Color) clears colour, depth and (because the back buffer has one) stencil.
        gd.Clear(Color::DarkSlateGray);

        // --- Pass 1: draw object + write stencil ---
        gd.setDepthStencilStateProperty(writeStencil_);
        // Apply() first is the portable order for a ShaderEffect.
        solidEffect_->Apply();
        SetMat4(*solidEffect_, "u_world",      objectWorld_);
        SetMat4(*solidEffect_, "u_view",       camera_.View());
        SetMat4(*solidEffect_, "u_projection", camera_.Projection());
        drawObjectMesh(gd, *solidEffect_);

        // --- Pass 2: draw scaled-up outline where stencil != 1 ---
        gd.setDepthStencilStateProperty(outlineStencil_);
        float scale = 1.05f;
        Matrix outlineWorld = Matrix::CreateScale(scale, scale, scale) * objectWorld_;
        outlineEffect_->Apply();
        SetMat4(*outlineEffect_, "u_world",      outlineWorld);
        SetMat4(*outlineEffect_, "u_view",       camera_.View());
        SetMat4(*outlineEffect_, "u_projection", camera_.Projection());
        outlineEffect_->SetUniformVec4("u_color", 1.0f, 0.5f, 0.0f, 1.0f);
        drawObjectMesh(gd, *outlineEffect_);

        // Restore default state before drawing anything else. No gd.Present() here:
        // Game presents after Draw() returns, so a manual call would present twice.
        gd.setDepthStencilStateProperty(DepthStencilState::Default);
    }

    Matrix objectWorld_ = Matrix::CreateTranslation(0.0f, 0.0f, 0.0f);
};

Portal Rendering

The stencil buffer is ideal for portals: rectangular openings in the world that display a different scene or location.

  1. Clear the stencil buffer to 0 (gd.Clear(ClearOptions::Stencil, Color::Black, 1.0f, 0), which throws InvalidOperationException if the device has no stencil plane, or simply rely on Clear(Color) at the start of the frame).
  2. Draw the portal quad with StencilPass = Replace, ReferenceStencil = 1, and colour writes disabled. Pixels inside the portal opening now have stencil=1.
  3. Set StencilFunction = Equal, ReferenceStencil = 1. Render the "other side" scene (using a different camera that looks through the portal). Only the portal pixels receive the other-scene rendering.
  4. Render the current scene normally (with stencil test disabled or with a NotEqual mask to skip the portal opening).

This technique requires depth buffer management too: after drawing the portal destination scene, reset the depth buffer at the portal pixels to the portal quad's depth value before drawing the main scene, so main-scene geometry in front of the portal still occludes it correctly.

Shadow Volumes (Advanced)

The classic Carmack's Reverse (depth-fail) shadow volume algorithm uses stencil increment and decrement operations to count how many shadow volume surfaces surround a pixel:

  1. Render the scene without shadows.
  2. Disable colour and depth writes. Enable stencil write only.
  3. For front-facing shadow volume surfaces: StencilDepthBufferFail = Increment.
  4. For back-facing shadow volume surfaces: StencilDepthBufferFail = Decrement.
  5. After both passes, pixels with stencil > 0 are inside a shadow volume and should be darkened.
  6. Re-render the scene with StencilFunction = Equal, ReferenceStencil = 0 (lit pixels) and then with StencilFunction = Greater, ReferenceStencil = 0 (shadowed pixels) using a dark blending pass.

Shadow volumes produce pixel-perfect hard shadows and require no shadow map resolution compromises, but they are expensive when the shadow caster has complex silhouettes. Shadow mapping (Tutorial 59) is usually preferred in modern engines.