Tutorial 79: Debugging Rendering Issues
Common rendering bugs
Most CNA rendering bugs fall into a handful of categories. A black screen usually means
you forgot to call Clear before drawing or Present after. Z-fighting
(flickering overlapping surfaces) happens when two polygons share identical depth values — increase the depth
bias or separate the geometry slightly. Invisible geometry is often a wrong winding order
(front/back face flipped) or an unexpected culling state left over from a previous draw call.
Texture coordinates out of range can produce unexpected color bands; check whether your
sampler state uses clamping or wrapping. Finally, a blending mode left active from an earlier
draw call is a classic source of ghosting artifacts — always reset blend state after transparent draw passes.
OcclusionQuery for visibility
OcclusionQuery lets you ask the GPU how many pixels passed the depth test for a draw call.
This is useful for culling distant objects: draw a cheap bounding-box proxy, read the pixel count on the
next frame, and skip the expensive full draw when the count is zero.
#include "Microsoft/Xna/Framework/Graphics/OcclusionQuery.hpp"
using namespace Microsoft::Xna::Framework::Graphics;
OcclusionQuery query(getGraphicsDeviceProperty());
// In Draw():
query.Begin();
// draw bounding box proxy geometry here
query.End();
// Next frame: read result (may stall if GPU hasn't finished yet)
if (query.IsComplete()) {
intcs pixelCount = query.PixelCount;
if (pixelCount == 0) {
// object is fully occluded — skip expensive draw
}
}
RenderDoc setup
RenderDoc is a free GPU frame debugger that works with OpenGL and Vulkan. To capture a CNA frame:
- Download RenderDoc from https://renderdoc.org.
- Launch your CNA application through RenderDoc (File → Launch Application).
- Press F12 in-game to capture a frame.
- Inspect draw calls, textures, and shader outputs in the RenderDoc UI.
RenderDoc works with both the EASYGL (OpenGL ES 3.0) and VULKAN backends.
Wireframe with RasterizerState.CullNone + FillMode::WireFrame (EasyGL+Vulkan only)
Wireframe mode reveals hidden geometry and overdraw. It is only available on the EASYGL and VULKAN backends; SDL_RENDERER ignores fill mode. Toggle it at runtime to compare solid and wireframe views of the same scene.
#include "Microsoft/Xna/Framework/Graphics/RasterizerState.hpp"
// Toggle wireframe at runtime
bool wireframe_ = false;
void Draw(const GameTime& gameTime) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::CornflowerBlue);
// Build a custom rasterizer state for wireframe
RasterizerState rs;
rs.CullMode = CullMode::None;
rs.FillMode = FillMode::WireFrame; // EasyGL + Vulkan backends only
rs.DepthBias = 0.0f;
if (wireframe_) {
gd.setRasterizerState(rs);
} else {
gd.setRasterizerState(RasterizerState::CullCounterClockwise);
}
// ... draw scene ...
// Reset to default solid
gd.setRasterizerState(RasterizerState::CullCounterClockwise);
// Draw a debug overlay in solid mode regardless
DrawDebugOverlay(gd);
gd.Present();
}
void Update(const GameTime&) override {
auto ks = Keyboard::GetState();
if (ks.IsKeyDown(Keys::F1) && !prevF1_) {
wireframe_ = !wireframe_;
}
prevF1_ = ks.IsKeyDown(Keys::F1);
}
void DrawDebugOverlay(GraphicsDevice& gd) {
// Draw HUD text showing current mode
spriteBatch_->Begin();
// ... font draw ...
spriteBatch_->End();
}
Validating vertex buffers
Common mistakes include uploading fewer vertices than declared in the buffer, forgetting to call
SetData, or using the wrong VertexDeclaration. Always assert that
primitiveCount matches the actual data you have uploaded. The helper below catches
the most frequent size mismatch at debug time.
// Safe vertex buffer upload helper
template<typename TVertex>
void UploadVertices(VertexBuffer& vb, const std::vector<TVertex>& verts) {
assert(!verts.empty());
assert(static_cast<int>(verts.size()) <= vb.VertexCount);
vb.SetData(verts.data(), static_cast<int>(verts.size()));
}
GPU memory leaks
CNA uses RAII — VertexBuffer, Texture2D, RenderTarget2D,
and Effect objects release their GPU resources when destroyed. Common pitfalls: storing raw
pointers to destroyed objects, recreating resources every frame instead of caching them, and forgetting to
release an old RenderTarget2D before creating a replacement. Use std::unique_ptr
for automatic cleanup and let the destructor handle deallocation at the right time.
SDL3 debug output
Enable SDL3's internal logging to see low-level errors before your game even opens a window:
// Call before creating Game:
SDL_SetLogPriority(SDL_LOG_CATEGORY_RENDER, SDL_LOG_PRIORITY_VERBOSE);
SDL_SetLogPriority(SDL_LOG_CATEGORY_ERROR, SDL_LOG_PRIORITY_VERBOSE);
On Linux, also set the MESA_DEBUG=1 and LIBGL_DEBUG=verbose environment
variables before running to get OpenGL driver warnings printed to the terminal. These flags are
zero-cost in production because they only affect Mesa's debug path.