Tutorial 79: Debugging Rendering Issues

CNA — C++ XNA 4.0 reimplementation

What you’ll learn

  • Reading the usual symptoms: black screen, missing geometry, inverted normals.
  • Wireframe rendering via RasterizerState and FillMode::WireFrame.
  • Validating what is actually in a vertex buffer.
  • Capturing a frame in RenderDoc and reading SDL3 debug output.

Before you startTutorial 31: Your First 3D Triangle (something to debug) and Tutorial 72: Choosing a Renderer (several techniques are renderer-specific). Wireframe rendering needs a renderer that reports GraphicsCapability::WireFrame; OPENGLES3 and VULKAN both do.

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.getIsCompleteProperty()) {
    intcs pixelCount = query.getPixelCountProperty();
    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:

  1. Download RenderDoc from https://renderdoc.org.
  2. Launch your CNA application through RenderDoc (File → Launch Application).
  3. Press F12 in-game to capture a frame.
  4. Inspect draw calls, textures, and shader outputs in the RenderDoc UI.

RenderDoc works with both the OPENGLES3 (OpenGL ES 3.0) and VULKAN renderers.

Wireframe with RasterizerState.CullNone + FillMode::WireFrame

Wireframe mode reveals hidden geometry and overdraw. It needs a renderer that supports GraphicsCapability::WireFrameOPENGLES3 and VULKAN both do, while SDL_RENDERER ignores fill mode. Query the capability rather than testing the renderer name, but note the query fails open on renderers that supply no override. 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;   // needs GraphicsCapability::WireFrame
    rs.DepthBias = 0.0f;

    if (wireframe_) {
        gd.setRasterizerStateProperty(rs);
    } else {
        gd.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);
    }

    // ... draw scene ...

    // Reset to default solid
    gd.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);

    // Draw a debug overlay in solid mode regardless
    DrawDebugOverlay(gd);
    gd.Present();
}

void Update(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.