Tutorial 39: Depth Buffer and Z-Fighting

3D Rendering  ·  Intermediate

The depth buffer is the invisible safety net that makes 3D rendering work correctly — it ensures that closer objects always obscure farther ones without you having to sort geometry by hand. This tutorial explains how it works, how to control it with DepthStencilState, and how to deal with its two most common failure modes: z-fighting and transparent objects.

Always clear the depth buffer at the start of each frame: gd.Clear(ClearOptions::DepthBuffer | ClearOptions::Target, color, 1.0f, 0). Skipping the depth clear causes ghost geometry from the previous frame to block current-frame pixels.

How the Depth Buffer Works

The depth buffer is a full-resolution 2D buffer that stores one floating-point value per screen pixel. The value represents normalised depth in clip space: 0.0 at the near plane, 1.0 at the far plane.

For every fragment the GPU rasterises:

  1. Compute the fragment's depth value d.
  2. Read the stored depth for that pixel.
  3. If d < stored (fragment is closer), write the fragment's colour to the colour buffer and update the depth buffer with d.
  4. Otherwise, discard the fragment.

This means draw order does not matter for opaque geometry — the GPU automatically keeps only the closest fragment for each pixel.

DepthFormat Enum

The depth buffer format is set in PresentationParameters before the graphics device is created:

PresentationParameters pp;
pp.DepthStencilFormat = DepthFormat::Depth24;        // 24-bit depth (recommended)
pp.DepthStencilFormat = DepthFormat::Depth24Stencil8; // 24-bit depth + 8-bit stencil
pp.DepthStencilFormat = DepthFormat::Depth16;         // 16-bit, lower precision
pp.DepthStencilFormat = DepthFormat::None;            // no depth buffer at all (2D only)

Depth24 is the standard choice. Depth24Stencil8 adds a stencil channel used for effects like outlines, shadow volumes, and portals. Avoid Depth16 for 3D scenes — the reduced precision increases z-fighting.

DepthStencilState.Default

DepthStencilState::Default enables both depth testing and depth writing. It is the correct state for rendering opaque 3D geometry:

// Applied automatically on GraphicsDevice reset, but set it explicitly
// after any state change to be safe
gd.setDepthStencilState(DepthStencilState::Default);

Common built-in states:

StateDepth testDepth writeUse case
DefaultOn (Less)OnOpaque 3D geometry
NoneOffOff2D UI / HUD over 3D
ReadOn (Less)OffTransparent objects, particles
DepthReadOnOffAlias for Read in some XNA versions

Disabling Depth Test for UI

2D UI elements (health bars, menus, subtitles) should always draw on top of the 3D world regardless of the depth values their polygons happen to produce. Use DepthStencilState::None before your UI pass:

// Draw 3D world
gd.setDepthStencilState(DepthStencilState::Default);
Draw3DScene();

// Draw 2D UI on top — disable depth entirely
gd.setDepthStencilState(DepthStencilState::None);
spriteBatch->Begin();
DrawHUD();
spriteBatch->End();

// Restore for next frame's 3D pass
gd.setDepthStencilState(DepthStencilState::Default);

Z-Fighting Causes and Fixes

Z-fighting is the flickering you see when two coplanar (or nearly coplanar) surfaces compete for the same depth value. Common scenarios: a decal placed exactly on a wall, a road drawn exactly on top of terrain.

Root cause: depth values are stored at finite precision. Two surfaces at nearly identical depth round to the same integer, so which one "wins" flips frame to frame depending on floating-point evaluation order.

Fix 1 — Small translation bias: Push the foreground surface slightly toward the camera:

// Decal world matrix — offset 1mm toward camera
Matrix decalWorld = Matrix::CreateTranslation(0, 0.001f, 0) * baseWorld;

Fix 2 — Increase the near plane: Depth precision is non-linear and concentrates near the camera. Bringing the near plane from 0.01 to 0.1 dramatically improves precision at typical play distances:

Matrix proj = Matrix::CreatePerspectiveFieldOfView(
    MathHelper::PiOver4, aspect,
    0.1f,    // near — don't go lower than you need
    1000.0f  // far
);

Fix 3 — Use Depth24 not Depth16: 16-bit depth gives only 65536 distinct values across the entire depth range. 24-bit gives 16 million. For any 3D scene, always request DepthFormat::Depth24.

Sorting Transparent Objects

Transparent objects (alpha-blended geometry) cannot use the depth buffer to composite correctly because blending requires the background colour to already be in the colour buffer when the fragment is written. If you write depth for transparent fragments, closer transparent objects will silently block farther ones that should be visible through them.

The solution is the painter's algorithm: sort transparent objects back-to-front by distance to the camera, then draw them in that order with depth testing on (so they respect opaque geometry) but depth writing off (so they don't block each other):

// Custom DepthStencilState: test depth but don't write it
DepthStencilState depthReadOnly;
depthReadOnly.DepthBufferEnable      = true;
depthReadOnly.DepthBufferWriteEnable = false;
depthReadOnly.DepthBufferFunction    = CompareFunction::Less;

// Sort transparent objects back-to-front
std::sort(transparentObjects.begin(), transparentObjects.end(),
    [&cameraPos](const TransparentObject& a, const TransparentObject& b) {
        float da = Vector3::DistanceSquared(a.position, cameraPos);
        float db = Vector3::DistanceSquared(b.position, cameraPos);
        return da > db;  // farther objects first
    });

gd.setDepthStencilState(depthReadOnly);
gd.setBlendState(BlendState::AlphaBlend);
for (auto& obj : transparentObjects)
    DrawTransparentObject(obj);

Full Example: Transparent Quads + Skybox Depth Trick

The demo renders three coloured transparent quads sorted back-to-front, then a skybox drawn last using a special DepthStencilState that passes all depth tests but writes nothing — filling only pixels not yet drawn by any opaque or transparent geometry:

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "Microsoft/Xna/Framework/Graphics/BlendState.hpp"
#include "Microsoft/Xna/Framework/Graphics/DepthStencilState.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include <algorithm>
#include <vector>

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

struct TransparentQuad {
    Vector3 position;
    Color   color;
};

class DepthDemoGame final : public Game {
public:
    DepthDemoGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
        effect_->setVertexColorEnabled(true);

        // Three transparent quads at different depths
        quads_ = {
            { { 0,  0, -1}, Color(255,   0,   0, 160) },  // red, closest
            { { 0,  0, -2}, Color(  0, 255,   0, 160) },  // green, middle
            { { 0,  0, -3}, Color(  0,   0, 255, 160) },  // blue, farthest
        };

        BuildQuadBuffer();

        // Custom DSS: depth test always passes, no writes — for skybox
        skyboxDSS_.DepthBufferEnable      = true;
        skyboxDSS_.DepthBufferWriteEnable = false;
        skyboxDSS_.DepthBufferFunction    = CompareFunction::Always;

        // DSS for transparent objects: test (less), no write
        transparentDSS_.DepthBufferEnable      = true;
        transparentDSS_.DepthBufferWriteEnable = false;
        transparentDSS_.DepthBufferFunction    = CompareFunction::Less;
    }

    void Update(GameTime& gt) override {
        if (Keyboard::GetState().IsKeyDown(Keys::Escape)) Exit();
        time_ += static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();

        // Always clear both colour and depth at the start of the frame
        gd.Clear(ClearOptions::Target | ClearOptions::DepthBuffer,
                 Color(10, 10, 20, 255), 1.0f, 0);

        Vector3 camPos = {0, 0, 2};
        Matrix view = Matrix::CreateLookAt(camPos, Vector3::Zero, Vector3::Up);
        Matrix proj = Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f);

        // --- 1. Draw opaque geometry (if any) with default DSS ---
        gd.setDepthStencilState(DepthStencilState::Default);
        gd.setBlendState(BlendState::Opaque);
        DrawFloor(view, proj);

        // --- 2. Sort and draw transparent quads ---
        std::sort(quads_.begin(), quads_.end(),
            [&camPos](const TransparentQuad& a, const TransparentQuad& b) {
                return Vector3::DistanceSquared(a.position, camPos) >
                       Vector3::DistanceSquared(b.position, camPos);
            });

        gd.setDepthStencilState(transparentDSS_);
        gd.setBlendState(BlendState::AlphaBlend);

        for (auto& q : quads_) {
            Matrix world = Matrix::CreateTranslation(q.position);
            DrawColoredQuad(q.color, world, view, proj);
        }

        // --- 3. Draw skybox LAST with always-pass depth, no write ---
        // The skybox fills pixels not touched by any earlier draw call.
        gd.setDepthStencilState(skyboxDSS_);
        gd.setBlendState(BlendState::Opaque);
        DrawSkybox(view, proj);

        gd.Present();
    }

private:
    void BuildQuadBuffer() {
        // Unit quad centred at origin in XY plane
        VertexPositionColor verts[4] = {
            { {-0.4f,  0.4f, 0}, Color::White },
            { { 0.4f,  0.4f, 0}, Color::White },
            { { 0.4f, -0.4f, 0}, Color::White },
            { {-0.4f, -0.4f, 0}, Color::White },
        };
        uint16_t idx[6] = {0,1,2, 0,2,3};

        auto& gd = getGraphicsDeviceProperty();
        quadVB_ = std::make_unique<VertexBuffer>(gd,
            VertexPositionColor::VertexDeclaration, 4, BufferUsage::WriteOnly);
        quadVB_->SetData(verts, 4);
        quadIB_ = std::make_unique<IndexBuffer>(gd,
            IndexElementSize::SixteenBits, 6, BufferUsage::WriteOnly);
        quadIB_->SetData(idx, 6);
    }

    void DrawColoredQuad(Color c, Matrix world, Matrix view, Matrix proj) {
        auto& gd = getGraphicsDeviceProperty();
        effect_->setWorld(world);
        effect_->setView(view);
        effect_->setProjection(proj);
        // Tint the white quad with the desired colour via DiffuseColor
        effect_->setDiffuseColor(c.ToVector3());
        gd.setVertexBuffer(*quadVB_);
        gd.setIndexBuffer(*quadIB_);
        for (auto& pass : effect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawIndexedPrimitives(PrimitiveType::TriangleList, 0, 0, 4, 0, 2);
        }
    }

    void DrawFloor(Matrix view, Matrix proj) {
        Matrix world = Matrix::CreateRotationX(MathHelper::PiOver2) *
                       Matrix::CreateTranslation(0, -0.6f, -2);
        effect_->setDiffuseColor({0.4f, 0.4f, 0.4f});
        DrawColoredQuad(Color::Gray, world, view, proj);
    }

    void DrawSkybox(Matrix view, Matrix proj) {
        // A large quad behind everything — represents the sky
        Matrix world = Matrix::CreateScale(10) *
                       Matrix::CreateTranslation(0, 0, -5);
        effect_->setDiffuseColor({0.1f, 0.2f, 0.5f});  // dark blue sky
        DrawColoredQuad(Color::White, world, view, proj);
    }

    GraphicsDeviceManager graphics_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> quadVB_;
    std::unique_ptr<IndexBuffer>  quadIB_;
    std::vector<TransparentQuad>  quads_;
    DepthStencilState skyboxDSS_;
    DepthStencilState transparentDSS_;
    float time_ = 0.0f;
};

int main() { DepthDemoGame g; g.Run(); }

Key Points

  • Clear both colour and depth buffers at the start of every frame.
  • Use DepthFormat::Depth24 for all 3D scenes — never Depth16.
  • Draw opaque geometry with DepthStencilState::Default (test + write).
  • Draw transparent geometry with depth test on, depth write off, sorted back-to-front.
  • Draw UI with DepthStencilState::None so it always appears on top.
  • Fix z-fighting with a small translation bias on the foreground surface, or by increasing the near plane distance.
  • The skybox trick: render last with CompareFunction::Always and no depth write, so it fills only undrawn pixels.