Tutorial 79: Debugging Rendering Issues

CNA — C++ XNA 4.0 reimplementation

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:

  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 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.