Tutorial 63: Stencil Buffer Effects
What you’ll learn
- The stencil fields of
DepthStencilState, and theStencilFunction/StencilOperationenums. - 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, WEBGL2 | Yes | Needs the Depth24Stencil8 request above |
VULKAN | Yes, when the chosen depth format has stencil (D24S8, D32S8, D16S8 or S8) | Reports the depth format it chose |
SDL_GPU | Yes, when the back buffer has stencil | Depth/stencil normalised to a supported native format |
WEBGPU, FNA3D | Yes, from the renderer’s depth/stencil answer | |
DIRECTX9, DIRECTX11, METAL, SOFTWARE, HEADLESS | Yes | HEADLESS 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, STUB | No | 2D 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:
| Field | Type | Description |
|---|---|---|
StencilEnable | bool | Master switch. Must be true to use stencil at all. |
StencilFunction | CompareFunction | How the stencil test compares the buffer value to ReferenceStencil. |
StencilPass | StencilOperation | Operation on the stencil buffer when both stencil and depth tests pass. |
StencilFail | StencilOperation | Operation when the stencil test fails (depth test not evaluated). |
StencilDepthBufferFail | StencilOperation | Operation when stencil passes but depth test fails. |
ReferenceStencil | int | Reference value compared against the buffer. Range 0–255. |
StencilMask | int | AND mask applied to both reference and buffer before comparison. |
StencilWriteMask | int | AND mask controlling which bits are written to the stencil buffer. |
DepthBufferEnable | bool | Enable depth testing. |
DepthBufferWriteEnable | bool | Enable 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).
| Value | Passes when |
|---|---|
Always | Always passes (used when writing to stencil without testing it) |
Never | Never passes |
Equal | buffer == reference |
NotEqual | buffer != reference |
Less | buffer < reference |
LessEqual | buffer <= reference |
Greater | buffer > reference |
GreaterEqual | buffer >= reference |
StencilOperation Enum
StencilOperation controls what happens to the stencil buffer value for each pixel, independently for three outcomes (pass, stencil-fail, depth-fail):
| Value | Effect on buffer |
|---|---|
Keep | Do not change the existing value. |
Zero | Set buffer to 0. |
Replace | Set buffer to ReferenceStencil. |
IncrementSaturation | Increment, clamped at 255. |
DecrementSaturation | Decrement, clamped at 0. |
Invert | Bitwise NOT of the current value. |
Increment | Increment with wrap-around (255 + 1 = 0). |
Decrement | Decrement with wrap-around (0 - 1 = 255). |
Writing to the Stencil Buffer
The typical pattern for writing a stencil mask is:
- Set
StencilEnable = true,StencilFunction = Always(always write, never test),StencilPass = Replace,ReferenceStencil = 1. - Disable colour writes so the mask geometry does not appear in the image (
BlendStatewithColorWriteChannels::None). - 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.
- Clear the stencil buffer to 0 (
gd.Clear(ClearOptions::Stencil, Color::Black, 1.0f, 0), which throwsInvalidOperationExceptionif the device has no stencil plane, or simply rely onClear(Color)at the start of the frame). - Draw the portal quad with
StencilPass = Replace,ReferenceStencil = 1, and colour writes disabled. Pixels inside the portal opening now have stencil=1. - 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. - Render the current scene normally (with stencil test disabled or with a
NotEqualmask 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:
- Render the scene without shadows.
- Disable colour and depth writes. Enable stencil write only.
- For front-facing shadow volume surfaces:
StencilDepthBufferFail = Increment. - For back-facing shadow volume surfaces:
StencilDepthBufferFail = Decrement. - After both passes, pixels with stencil > 0 are inside a shadow volume and should be darkened.
- Re-render the scene with
StencilFunction = Equal,ReferenceStencil = 0(lit pixels) and then withStencilFunction = 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.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- State objects: identity, binding and what reaches the renderer — BlendState, DepthStencilState, RasterizerState and SamplerState in CNA: shared identity, XNA's freeze-on-bind rule, what each renderer hook receives, profile checks and per-family support.