Tutorial 79: Debugging Rendering Issues
What you’ll learn
- Reading the usual symptoms: black screen, missing geometry, inverted normals.
- Wireframe rendering via
RasterizerStateandFillMode::WireFrame. - Validating what is actually in a vertex buffer.
- Capturing a frame in RenderDoc, and reading CNA’s logger and SDL3 debug output.
Before you start — Tutorial 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: OPENGL33 and VULKAN normally do, while OPENGLES3 reports it only when the driver offers a polygon-mode extension (see below).
Common rendering bugs
Most CNA rendering bugs fall into a handful of categories. A black screen usually means
you forgot to Clear or to draw anything, or that an exception in Update/Draw
ended the frame — read the message on stderr. (Game presents the frame for you after
Draw returns, so a missing Present is not the cause; a manual Present inside
Draw is redundant.) Z-fighting
(flickering overlapping surfaces) happens when two polygons share identical depth values — separate the geometry slightly,
tighten the near/far planes, or use a depth bias. Be aware that RasterizerState::DepthBias is not yet
scaled to every renderer’s depth units (CNA’s known-bugs list records it as fixed for the EasyGL identities only, with
DIRECTX11 and VULKAN still open), so a bias tuned on one renderer may do nothing on another.
Invisible geometry is often a wrong winding order
(front/back face flipped) or an unexpected culling state left over from a previous draw call: XNA treats
clockwise triangles as front-facing, so the default CullCounterClockwise state culls a mesh that was wound counter-clockwise.
Texture coordinates out of range can produce unexpected color bands; check whether your
sampler state uses clamping or wrapping. 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
(SpriteBatch never restores the blend, sampler, depth or rasterizer state it changed).
Finally, CNA defaults to the Reach graphics profile, which is enforced on every renderer: multiple render targets,
OcclusionQuery, 32-bit indices, float render targets, 3D textures and large textures or cube maps throw
unless you request GraphicsProfile::HiDef (graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef)).
If a feature that works elsewhere throws NotSupportedException at start-up, check the profile first.
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. It needs the HiDef profile and a renderer that
supports queries: OPENGLES3, WEBGL2, OPENGL33, VULKAN,
DIRECTX9, DIRECTX11, WEBGPU, METAL, SOFTWARE and FNA3D on its OpenGL or Direct3D 11 driver do; SDL_GPU, FNA3D on its SDL_GPU driver and the 2D-only
SDL_RENDERER refuse construction. On
OpenGL ES 3.0 and WebGL 2 the count is a boolean (0 or 1) unless the driver offers a precise counter, so call
isPixelCountPreciseEXT() before treating it as an area.
#include "Microsoft/Xna/Framework/Graphics/OcclusionQuery.hpp"
using namespace Microsoft::Xna::Framework::Graphics;
// In the constructor: OcclusionQuery is refused on the default Reach profile
graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
// In LoadContent(): construction throws System::NotSupportedException when the
// profile is Reach or the active renderer has no occlusion queries
try {
query_ = std::make_unique<OcclusionQuery>(getGraphicsDeviceProperty());
} catch (const System::NotSupportedException&) {
query_.reset(); // no occlusion culling on this renderer: draw everything
}
// In Draw():
query_->Begin();
// draw bounding box proxy geometry here
query_->End();
// Next frame: read result (getPixelCountProperty() throws until the query is complete)
if (query_->getIsCompleteProperty()) {
int 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 the graphics APIs it supports, including OpenGL, OpenGL ES 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.
For example, it works with the OPENGLES3 (OpenGL ES 3.0), OPENGL33 and VULKAN renderers. The CPU renderer (SOFTWARE) presents no graphics-API frame for RenderDoc to capture.
Wireframe with RasterizerState.CullNone + FillMode::WireFrame
Wireframe mode reveals hidden geometry and overdraw. It needs a renderer that supports
GraphicsCapability::WireFrame. Whether that is true depends on the renderer and sometimes on the
driver: OPENGL33 uses the desktop polygon mode, OPENGLES3 and WEBGL2 only
when the context offers a polygon-mode extension (so on many OpenGL ES drivers it is false), VULKAN depends on the
device’s non-solid fill support, and WEBGPU, SDL_GPU, DIRECTX9, DIRECTX11, METAL and
SOFTWARE report it as available, while SDL_RENDERER ignores fill mode. Ask the device rather than
testing the renderer name (getGraphicsDeviceProperty().SupportsCapability(CNA::GraphicsCapability::WireFrame)), but
note the query answers “yes” by default 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"
#include "CNA/GraphicsCapability.hpp"
// Members
RasterizerState wireframeState_;
bool wireframe_ = false;
bool canWireframe_ = false;
void LoadContent() override {
// A state object may only be edited BEFORE it is first bound to the device, so configure it
// once here and reuse it. (The presets, such as RasterizerState::CullNone, are pre-bound and
// immutable; the setters below are XNA's property accessors.)
wireframeState_.setCullModeProperty(CullMode::None);
wireframeState_.setFillModeProperty(FillMode::WireFrame); // needs GraphicsCapability::WireFrame
canWireframe_ = getGraphicsDeviceProperty().SupportsCapability(
CNA::GraphicsCapability::WireFrame);
}
void Draw(const GameTime& gameTime) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::CornflowerBlue);
if (wireframe_ && canWireframe_) {
gd.setRasterizerStateProperty(wireframeState_);
} 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);
// (no Present(): Game presents the frame after Draw() returns)
}
void Update(GameTime& gameTime) override {
Game::Update(gameTime);
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.getVertexCountProperty());
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. To see what is actually alive,
configure with -DCNA_DIAGNOSTICS=STATS: textures, render targets and vertex and index buffers then register
resource metadata you can list from a snapshot (see Tutorial 73 and the
Diagnostics reference).
CNA’s own logger
CNA reports renderer fallbacks, refused features and fatal errors through CNA::Logger, which writes
to standard error. The default minimum level is TRACE in a build without NDEBUG and INFO with it; raise or lower it with
SetMinimumLevel, redirect the output with SetSink, and log your own messages under
LogCategory::RENDER so they line up with the engine’s:
#include "CNA/Logger.hpp"
CNA::Logger::SetMinimumLevel(CNA::LogLevel::DEBUG);
CNA::Logger::Debug("terrain mesh: 4096 vertices", CNA::LogCategory::RENDER);
CNA::Logger::Warn("falling back to the unlit path", CNA::LogCategory::RENDER);
SDL3 debug output
With the default SDL3 platform (CNA_PLATFORM=SDL3) you can also 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.