Tutorial 39: Depth Buffer and Z-Fighting
What you’ll learn
- How depth testing decides what is visible.
- The
DepthFormatoptions and the stockDepthStencilStatepresets. - Turning depth off to draw UI on top of a 3D scene, and restoring it afterwards.
- What causes z-fighting, sorting transparent geometry back to front, and how to draw a sky behind everything.
Before you start — Tutorial 31: Your First 3D Triangle (3D geometry to occlude) and Tutorial 22: Blend Modes and Alpha Compositing (transparency is what forces manual sorting). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default, and STUB reports no 3D capability at all.
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:
- Compute the fragment's depth value d.
- Read the stored depth for that pixel.
- If d passes the depth comparison against the stored value — with the default
DepthStencilStatethat is d ≤ stored, because XNA’s defaultDepthBufferFunctionisLessEqual— write the fragment's colour to the colour buffer and update the depth buffer with d. - 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
In a Game the graphics device is created for you by GraphicsDeviceManager, so the depth format is a preference you set on the manager, before Run() (or followed by ApplyChanges() once the device exists). The manager’s default is Depth24:
// In your Game's constructor (graphics_ is the GraphicsDeviceManager):
graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::Depth24); // 24-bit depth (the default)
graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::Depth24Stencil8); // 24-bit depth + 8-bit stencil
graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::Depth16); // 16-bit, lower precision
graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::None); // no depth buffer at all (2D only)
// After the device exists (for example from a settings menu):
// graphics_.ApplyChanges();
If you build a PresentationParameters by hand, note that it has no public field: use pp.setDepthStencilFormatProperty(DepthFormat::Depth24), and that its own default is DepthFormat::None. Render targets choose their own depth format in the RenderTarget2D constructor. Also remember that what a renderer applies can differ from what you asked for: some renderers always create a back-buffer depth buffer, and SDL_GPU normalises the request to a format its device supports.
Depth24 is the standard choice. Depth24Stencil8 adds a stencil channel used for effects like outlines, shadow volumes, and portals; you must request it explicitly, because the manager’s default Depth24 has no stencil bits. Avoid Depth16 for 3D scenes — the reduced precision increases z-fighting.
DepthStencilState.Default
DepthStencilState::Default enables both depth testing and depth writing, with CompareFunction::LessEqual (XNA’s default). 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.setDepthStencilStateProperty(DepthStencilState::Default);
There are exactly three built-in presets:
| State | Depth test | Depth write | Use case |
|---|---|---|---|
Default | On (LessEqual) | On | Opaque 3D geometry |
DepthRead | On (LessEqual) | Off | Transparent objects, particles, decals |
None | Off | Off | 2D UI / HUD over 3D, and a sky drawn first |
There is no DepthStencilState::Read; DepthRead is the name of the “test on, write off” preset. Build your own DepthStencilState object when you need a different comparison. A state object becomes immutable once it has been set on a device, so finish configuring it first.
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.setDepthStencilStateProperty(DepthStencilState::Default);
Draw3DScene();
// Draw 2D UI on top — disable depth entirely
gd.setDepthStencilStateProperty(DepthStencilState::None);
spriteBatch->Begin();
DrawHUD();
spriteBatch->End();
// Restore for the next 3D pass
gd.setDepthStencilStateProperty(DepthStencilState::Default);
The explicit None above is belt and braces: SpriteBatch::Begin applies its own depth-stencil state anyway (None unless you pass one). What is not optional is the restore line at the end. SpriteBatch does not put back the depth-stencil, blend, sampler-0 or rasterizer state it changed, so a 3D pass that follows a HUD would otherwise draw with depth testing off.
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 — Separate the surfaces: Push the foreground surface slightly toward the viewer along the surface normal. Translating along an arbitrary axis is not “toward the camera”:
// A decal lying in its local XZ plane, so its local +Y is the surface normal.
// Lift it 1 mm along that normal (towards a viewer on the normal side) before the base world matrix.
Matrix decalWorld = Matrix::CreateTranslation(0, 0.001f, 0) * baseWorld;
XNA also gives you RasterizerState::setDepthBiasProperty and setSlopeScaleDepthBiasProperty (negative values pull a surface toward the camera), and CNA passes both to the renderer. The magnitude that works depends on the renderer and depth format (CNA’s own Vulkan test needs very large negative values, because Vulkan scales the constant factor by the format’s resolution) and this page has not verified every renderer, so treat the bias as a per-renderer tuning knob and prefer real geometric separation.
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, request DepthFormat::Depth24 (already the manager’s default).
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.setDepthBufferEnableProperty(true);
depthReadOnly.setDepthBufferWriteEnableProperty(false);
depthReadOnly.setDepthBufferFunctionProperty(CompareFunction::LessEqual); // same as the DepthRead preset
// 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.setDepthStencilStateProperty(depthReadOnly);
gd.setBlendStateProperty(BlendState::AlphaBlend);
for (auto& obj : transparentObjects)
DrawTransparentObject(obj);
Full Example: Transparent Quads + Skybox Depth Trick (Sky First)
The demo draws a dark-blue sky quad first with depth testing and depth writing off, then an opaque floor with the default state, then three coloured transparent quads sorted back-to-front with DepthRead-style state (test on, write off). Drawing the sky first is the simplest correct way to put something behind everything: it needs no special depth comparison, and everything drawn later simply covers it.
A caution about the tempting alternative. Drawing the sky last with CompareFunction::Always is wrong: Always passes every fragment, so the sky would paint over all the geometry already drawn. The correct “last” variant (which saves overdraw) uses CompareFunction::LessEqual with depth writes off and forces the sky to the far plane (z = w in a custom vertex shader), so it passes only where the cleared depth value of 1.0 is still untouched. That needs a shader you write yourself, which is why the stock-effect demo below draws the sky first.
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/IndexBuffer.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_.setPreferredBackBufferWidthProperty(800);
graphics_.setPreferredBackBufferHeightProperty(600);
graphics_.setPreferredDepthStencilFormatProperty(DepthFormat::Depth24); // the default
}
protected:
void LoadContent() override {
effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
effect_->VertexColorEnabled = 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();
// DSS for transparent objects: test (LessEqual), no write.
// This is exactly the DepthStencilState::DepthRead preset, built by hand to show the fields.
transparentDSS_.setDepthBufferEnableProperty(true);
transparentDSS_.setDepthBufferWriteEnableProperty(false);
transparentDSS_.setDepthBufferFunctionProperty(CompareFunction::LessEqual);
}
void Update(GameTime& gt) override {
if (Keyboard::GetState().IsKeyDown(Keys::Escape)) Exit();
time_ += static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
}
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. Sky FIRST: no depth test, no depth write, so it only paints colour ---
// Everything drawn afterwards covers it wherever geometry exists, and the
// depth buffer still holds the cleared far value under the sky.
gd.setDepthStencilStateProperty(DepthStencilState::None);
gd.setBlendStateProperty(BlendState::Opaque);
DrawSky(view, proj);
// --- 2. Opaque geometry with the default DSS (test + write) ---
gd.setDepthStencilStateProperty(DepthStencilState::Default);
DrawFloor(view, proj);
// --- 3. Sort and draw transparent quads back-to-front ---
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.setDepthStencilStateProperty(transparentDSS_);
gd.setBlendStateProperty(BlendState::AlphaBlend);
for (auto& q : quads_) {
Matrix world = Matrix::CreateTranslation(q.position);
DrawColoredQuad(q.color, world, view, proj);
}
// No gd.Present(): Game presents in EndDraw, after Draw() returns.
}
private:
void BuildQuadBuffer() {
// Unit quad centred at origin in XY plane, wound clockwise as seen from +Z
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::getVertexDeclarationStatic(), 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_->setWorldProperty(world);
effect_->setViewProperty(view);
effect_->setProjectionProperty(proj);
// Tint the white quad with the colour via DiffuseColor. ToVector3() drops the
// alpha, so it has to be passed separately; BasicEffect premultiplies it,
// which is what BlendState::AlphaBlend (One, InverseSourceAlpha) expects.
effect_->setDiffuseColorProperty(c.ToVector3());
effect_->setAlphaProperty(c.getAProperty() / 255.0f);
gd.SetVertexBuffer(quadVB_.get());
gd.setIndicesProperty(quadIB_.get());
for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
pass.Apply();
gd.DrawIndexedPrimitives(PrimitiveType::TriangleList, 0, 0, 4, 0, 2);
}
}
void DrawFloor(Matrix view, Matrix proj) {
// Lay the quad flat (-90 degrees about X turns its +Z face to face up), widen it,
// and drop it below the camera so the camera sees the front side.
Matrix world = Matrix::CreateScale(6.0f) *
Matrix::CreateRotationX(-MathHelper::PiOver2) *
Matrix::CreateTranslation(0, -0.6f, -2);
DrawColoredQuad(Color::Gray, world, view, proj);
}
void DrawSky(Matrix view, Matrix proj) {
// A large quad behind everything — represents the sky
Matrix world = Matrix::CreateScale(10) *
Matrix::CreateTranslation(0, 0, -5);
DrawColoredQuad(Color(25, 51, 128, 255), world, view, proj); // dark blue sky
}
GraphicsDeviceManager graphics_;
std::unique_ptr<BasicEffect> effect_;
std::unique_ptr<VertexBuffer> quadVB_;
std::unique_ptr<IndexBuffer> quadIB_;
std::vector<TransparentQuad> quads_;
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(theGraphicsDeviceManagerdefault) for 3D scenes — avoidDepth16. RequestDepth24Stencil8if you need stencil. - Draw opaque geometry with
DepthStencilState::Default(test + write,LessEqual). - Draw transparent geometry with
DepthStencilState::DepthRead(test on, write off), sorted back-to-front; set its alpha withsetAlphaProperty, becauseColor::ToVector3()drops it. - Draw UI with
DepthStencilState::Noneso it always appears on top, and restore your 3D states afterwards:SpriteBatchdoes not. - Fix z-fighting by separating the surfaces along their normal, by increasing the near plane distance, or (renderer-dependent) with
RasterizerStatedepth bias. - Put a sky behind everything by drawing it first with
DepthStencilState::None; drawing it last withCompareFunction::Alwayswould overwrite the whole scene.