Stencil testing

The depth buffer we just looked at has a companion. In the format every lesson creates, DXGI_FORMAT_D24_UNORM_S8_UINT, each pixel holds 24 bits of depth and, beside them, 8 more bits: the stencil buffer. Those 8 bits give every pixel a small integer, 0 to 255, which shaders never read or write. Instead, draws can be told to write values into it, and other draws can be told to keep or drop their pixels depending on what is there. That second part is the stencil test, and it runs in the output merger together with the depth test; as we will see, what a draw writes into the stencil can depend on the outcome of both.

Here is the idea in LearnOpenGL's picture. The stencil buffer is cleared to 0, a frame of 1s is written into it, and then a scene is drawn that only keeps the pixels where the stencil holds 1:

A colour buffer, a stencil buffer holding a rectangular frame of 1s, and the result after the stencil test: only the frame shows (LearnOpenGL's diagram)

Whatever the effect, the recipe usually has the same shape:

  1. Draw some objects with stencil writes on, so their shape is recorded in the stencil buffer.
  2. Turn stencil writes off.
  3. Draw other objects with a stencil test that keeps or drops their pixels according to what step 1 left behind.

The stencil buffer is cleared with the depth buffer. Every lesson's clear already includes it, with 0 as the value:

ID3D11DeviceContext_ClearDepthStencilView(context, depth_dsv, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0, 0);

The 1.0 is the depth to clear to, and the 0 the stencil value.

Stencil functions

OpenGL configures stencil testing with three calls: glEnable(GL_STENCIL_TEST), glStencilFunc for the test and glStencilOp for the writes, plus glStencilMask for which bits may change. In Direct3D all of it, together with the depth settings from the previous chapter, is one immutable object: the same depth-stencil state. The lesson builds its states with one helper:

// Pass the stencil test when (reference `func` stored); on passing, write the
// reference if `write`.
create_stencil_state: proc(device: *ID3D11Device, func: D3D11_COMPARISON_FUNC, write: b32, depth: b32) -> *ID3D11DepthStencilState = {
    desc: D3D11_DEPTH_STENCIL_DESC = {
        .DepthEnable = depth,
        .DepthWriteMask = D3D11_DEPTH_WRITE_MASK.D3D11_DEPTH_WRITE_MASK_ALL,
        .DepthFunc = D3D11_COMPARISON_FUNC.D3D11_COMPARISON_LESS,
        .StencilEnable = 1,
        .StencilReadMask = D3D11_DEFAULT_STENCIL_READ_MASK,
        .StencilWriteMask = write ? D3D11_DEFAULT_STENCIL_WRITE_MASK : 0,
        .FrontFace = {
            .StencilFunc = func,
            .StencilPassOp = D3D11_STENCIL_OP.D3D11_STENCIL_OP_REPLACE,
            .StencilFailOp = D3D11_STENCIL_OP.D3D11_STENCIL_OP_KEEP,
            .StencilDepthFailOp = D3D11_STENCIL_OP.D3D11_STENCIL_OP_KEEP,
        },
    };
    desc.BackFace = desc.FrontFace;
    state: *ID3D11DepthStencilState = null;
    ID3D11Device_CreateDepthStencilState(device, desc&, state&);
    return state;
}

Piece by piece:

Two things are different from OpenGL. The reference value is not in the state at all. It is given each time the state is bound, as the last argument of OMSetDepthStencilState, so one state object serves for any reference. And Direct3D has a separate set of operations for front-facing and back-facing triangles, FrontFace and BackFace (OpenGL has the same in glStencilOpSeparate). We draw with culling off, so both kinds of triangle reach the test, and the helper simply copies one into the other.

The operations a stencil write can perform are these:

OperationWhat is written
D3D11_STENCIL_OP_KEEPnothing: the stored value stays
D3D11_STENCIL_OP_ZERO0
D3D11_STENCIL_OP_REPLACEthe reference value
D3D11_STENCIL_OP_INCR_SATthe stored value plus 1, stopping at 255
D3D11_STENCIL_OP_DECR_SATthe stored value minus 1, stopping at 0
D3D11_STENCIL_OP_INVERTthe stored value with every bit flipped
D3D11_STENCIL_OP_INCRthe stored value plus 1, wrapping from 255 to 0
D3D11_STENCIL_OP_DECRthe stored value minus 1, wrapping from 0 to 255
The names of the counting operations are swapped between the two APIs. OpenGL's GL_INCR stops at the maximum and GL_INCR_WRAP wraps; in Direct3D, INCR_SAT stops and plain INCR wraps. Porting GL_INCR as D3D11_STENCIL_OP_INCR gives code that works until a pixel is counted 256 times.

So with a state from create_stencil_state, a pixel that passes both tests writes the reference value into the stencil (if writes are on), and a pixel that fails either test leaves the stencil as it was.

Object outlining

Descriptions of stencil state only really make sense once you see them used, so let's build something with it: an outline around objects, the kind an editor draws around a selected object or a strategy game around selected units.

A scene in which the selected objects are surrounded by a coloured border (LearnOpenGL's diagram)

The trick is to draw each object twice. The first time, normally, and while doing so we write 1 into the stencil wherever the object lands. The second time, a little larger and in one flat colour, but only where the stencil is not 1. Everywhere the bigger copy covers the original, it is rejected; what is left is the rim that sticks out past the original's silhouette. In steps:

  1. Draw the floor, which gets no outline, without touching the stencil.
  2. Draw the cubes with a stencil test that always passes and a write of 1.
  3. Scale the cubes up slightly and draw them with a flat-colour pixel shader, a stencil test that passes only where the stencil is not 1, no stencil writes and no depth test.

The flat colour comes from a second shader file, single_color.hlsl, whose vertex shader is the usual one and whose pixel shader is as short as they come:

float4 ps_main(vs_out p) : SV_Target
{
    return float4(0.04, 0.28, 0.26, 1.0);
}
s: shader = shader_create(device, filesystem_path("src/4.advanced_d3d11/2.stencil_testing/stencil_testing.hlsl"), vertex_layout);
single_color: shader = shader_create(device, filesystem_path("src/4.advanced_d3d11/2.stencil_testing/single_color.hlsl"), vertex_layout);

Each step's configuration is one depth-stencil state, created once at startup:

plain: *ID3D11DepthStencilState = create_stencil_state(device, D3D11_COMPARISON_FUNC.D3D11_COMPARISON_ALWAYS, 0, 1);
mark: *ID3D11DepthStencilState = create_stencil_state(device, D3D11_COMPARISON_FUNC.D3D11_COMPARISON_ALWAYS, 1, 1);
outside: *ID3D11DepthStencilState = create_stencil_state(device, D3D11_COMPARISON_FUNC.D3D11_COMPARISON_NOT_EQUAL, 0, 0);

The render loop then reads almost like the list of steps:

// the floor: no stencil writes
shader_use(context, s&);
ID3D11DeviceContext_OMSetDepthStencilState(context, plain, 0);
bind_texture(context, 0, floor_texture);
draw_vertices(context, transforms_buffer, t&, plane_vb, 6, m4_identity());

// 1st pass: the cubes, writing 1 to the stencil where they land
ID3D11DeviceContext_OMSetDepthStencilState(context, mark, 1);
bind_texture(context, 0, cube_texture);
for (i: u32 = 0; i < 2; i += 1) { draw_vertices(context, transforms_buffer, t&, cube_vb, 36, m4_translate(positions[i])); }

// 2nd pass: slightly larger cubes, only where the stencil is not 1
ID3D11DeviceContext_OMSetDepthStencilState(context, outside, 1);
shader_use(context, single_color&);
for (i: u32 = 0; i < 2; i += 1) { draw_vertices(context, transforms_buffer, t&, cube_vb, 36, m4_mul(m4_translate(positions[i]), m4_scale(v3(1.1, 1.1, 1.1)))); }
ID3D11DeviceContext_OMSetDepthStencilState(context, null, 0);

The reference value 1 appears twice, once when binding mark, where it is the value written, and once when binding outside, where it is the value compared against. The outline cubes are scaled by 1.1 about their own centres: m4_translate(position) * m4_scale(1.1) scales first and then moves, as in Transformations. At the end the state is reset to null, the default, so that nothing drawn later is affected by accident.

Why switch the depth test off for the outline? With it on, the outline would lose its bottom edge, which lies below the floor (the scaled cubes reach down to y = -0.55), and it would vanish wherever anything nearer covers it. With it off, the outline shows through everything, which is the look an editor wants for a selection: you can find the object even when it is behind a wall. (A disabled depth test also writes no depth, so the outline does not hide anything drawn after it.)

The two marble cubes on the metal floor, each with a dark teal outline around it

The outline is not the same width all round: scaling about the centre pushes each face out by a fixed distance in the world, and perspective makes that look wider where the cube is nearer the camera. A scale is not a true constant-width outline, but for convex shapes like a cube it is convincing.

Because both cubes write into the same stencil before either outline is drawn, the outlines merge where the cubes overlap on screen: one border around the pair, not a border on top of each cube. For a group of selected units that is usually what you want. For separate borders you would clear the stencil, or use a different reference value, per object. The full program is here, and its shaders here and here.

Outlines are only one use. A stencil can limit drawing to the inside of a mirror or a portal, so a second view of the scene appears exactly there. Counting with INCR and DECR as shadow geometry is drawn is the basis of stencil shadow volumes. And the stencil is a cheap way to mark which pixels need a costly effect, so a later full-screen pass can skip the others.

Additional resources

Exercises

  1. Turn the depth test back on for the outline pass (the last argument of create_stencil_state for outside) and fly around the cubes. Where does the outline disappear?
  2. Give each cube its own border: write reference 1 for the first cube and 2 for the second, and draw each outline with the matching reference.
  3. Draw the floor so that it only shows through a window: before drawing it, write 1 into the stencil with a small square (without drawing its colour, using a blend state whose RenderTargetWriteMask is 0), then draw the floor with EQUAL and reference 1.
Credit: this chapter follows LearnOpenGL's Stencil testing by Joey de Vries, rewritten for Direct3D 11 and rin. LearnOpenGL's text and code are licensed CC BY-NC 4.0, and so is this page; the wording, code and screenshots here are new, and diagrams marked as LearnOpenGL's are its own.