Tutorial 61: Occlusion Queries
EasyGL and Vulkan backends only. OcclusionQuery relies on GL_ARB_occlusion_query (core since OpenGL 1.5) or the Vulkan query pool API. It is not available on the SDL_RENDERER backend. Always check GraphicsDevice.GraphicsProfile at runtime if your application must fall back gracefully.
The OcclusionQuery Class
An OcclusionQuery wraps a GPU hardware query object. When geometry is rendered between a matching Begin() / End() pair, the GPU counts how many fragment samples pass the depth test for that geometry. CNA retrieves this count after the GPU has finished processing the relevant commands.
The classic application is lens flare visibility: draw a small proxy geometry (a sphere or a single point sprite) at the position of the sun or a bright light source and ask the GPU whether any part of it was visible. If the count is zero the light is fully behind geometry and the lens flare should be hidden; if it is non-zero the flare should be shown, scaled proportionally to the visible pixel count for partial occlusion.
Occlusion queries are also widely used for:
- Light shaft / god ray gating — only compute expensive ray-marched light shafts when the light source is at least partially visible.
- Hardware LOD selection — if a large object is fully occluded by other geometry, skip rendering its expensive high-detail mesh entirely this frame.
- Portal occlusion — draw a portal quad and skip rendering the rooms it connects to if the portal itself is hidden.
- Impostor switching — switch from a 3D mesh to a 2D billboard impostor when the object is too small on screen (combining occlusion with screen-space size estimation).
Construction simply takes a reference to the GraphicsDevice:
auto query = std::make_unique<OcclusionQuery>(getGraphicsDeviceProperty());
Begin() and End()
Surround the proxy geometry draw calls with query.Begin() and query.End():
query_->Begin();
// Draw cheap proxy geometry at the light's world position.
// A small sphere, a billboard quad, or even a single point sprite works.
drawSunProxy(gd);
query_->End();
Important rules for the Begin/End block:
- You must not nest queries — only one query may be active (between Begin and End) at a time per
GraphicsDevice. - Do not change the render target, blend state, or depth-stencil state inside the query block in unexpected ways. The depth test must be enabled (and configured correctly for your scene) for the fragment count to be meaningful.
- The proxy geometry should be cheap to render — the point is to get a visibility answer, not to draw something beautiful. Disable colour writes during the proxy draw (set
ColorWriteChannels::None) so the query does not contribute any visible pixels to the frame.
// Best practice: disable colour writes during the proxy draw
// so the query result does not affect the visible image.
ColorWriteChannels saved = gd.BlendState.ColorWriteChannels;
BlendState noColor = BlendState::Opaque;
noColor.ColorWriteChannels = ColorWriteChannels::None;
gd.setBlendState(noColor);
query_->Begin();
drawSunProxy(gd);
query_->End();
gd.setBlendState(BlendState::Opaque); // restore
IsComplete
OcclusionQuery::IsComplete returns true once the GPU has made the result available. The result is not available immediately after End() — the GPU processes commands asynchronously and may not have reached those commands yet.
In practice on most drivers and hardware, the result becomes available one to two frames after the query was issued. Never spin-wait for IsComplete in a tight loop. The correct pattern is to check IsComplete once per frame and consume the result only when it is ready:
// Per-frame check — non-blocking
if (queryPending_ && query_->IsComplete) {
lastPixelCount_ = query_->PixelCount;
queryPending_ = false;
}
If IsComplete returns false, simply keep using the result from the previous frame. For effects like lens flare, a one- or two-frame lag is completely imperceptible to the player.
PixelCount
OcclusionQuery::PixelCount returns the number of fragment samples that passed the depth test during the query. The exact interpretation depends on what proxy geometry you drew:
- 0 — the proxy was fully occluded. The light source or effect is completely hidden behind other geometry.
- Non-zero — at least some of the proxy was visible. The value can be used to scale the flare intensity proportionally:
float visibility = clamp(pixelCount / float(maxProxyPixels), 0.0f, 1.0f); - On MSAA framebuffers the count may reflect sample coverage, not pixel coverage, and may be larger than you expect. Normalise by
MultiSampleCountif needed.
The Latency Problem and the Ring-Buffer Pattern
The most critical pitfall with occlusion queries is GPU-CPU synchronisation. If you call PixelCount before the result is ready, the driver must stall the CPU until the GPU has finished — this is a pipeline bubble that can cost several milliseconds per frame and destroy performance.
The safe solution is to decouple the query submission from the result read by one or more frames. A simple boolean flag achieves this for a single query. For multiple simultaneous queries (e.g., one per light source), use a ring buffer of query objects:
// Ring buffer of N queries (N = max pipeline depth, typically 2-3)
static constexpr int QUERY_LAG = 2;
std::unique_ptr<OcclusionQuery> queries_[QUERY_LAG];
int writeIdx_ = 0; // index to issue this frame's query
int readIdx_ = 0; // index to read last available result
bool valid_[QUERY_LAG] = {};
// In LoadContent:
for (int i = 0; i < QUERY_LAG; ++i)
queries_[i] = std::make_unique<OcclusionQuery>(gd);
// In Draw:
// 1. Read from the oldest pending query (non-blocking)
if (valid_[readIdx_] && queries_[readIdx_]->IsComplete) {
lastPixelCount_ = queries_[readIdx_]->PixelCount;
valid_[readIdx_] = false;
readIdx_ = (readIdx_ + 1) % QUERY_LAG;
}
// 2. Issue a new query for this frame
queries_[writeIdx_]->Begin();
drawProxy(gd);
queries_[writeIdx_]->End();
valid_[writeIdx_] = true;
writeIdx_ = (writeIdx_ + 1) % QUERY_LAG;
Complete Example: Lens Flare Visibility
The following example renders a lens flare sprite that fades in and out smoothly based on the GPU occlusion result for a small sun proxy sphere. The flare alpha is smoothed over time to avoid jarring one-frame pop-ins caused by the query latency.
class LensFlareGame final : public Game {
std::unique_ptr<OcclusionQuery> query_;
std::unique_ptr<Texture2D> flareTex_;
std::unique_ptr<SpriteBatch> spriteBatch_;
bool queryPending_ = false;
int lastPixelCount_ = 0;
float flareAlpha_ = 0.0f;
// World-space position of the sun
Vector3 sunWorldPos_ = Vector3(500.0f, 800.0f, -2000.0f);
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
query_ = std::make_unique<OcclusionQuery>(gd);
flareTex_ = Content.Load<Texture2D>("textures/lens_flare");
spriteBatch_ = std::make_unique<SpriteBatch>(gd);
}
void Draw(const GameTime& gt) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
// --- 1. Consume last frame's query result (non-blocking) ---
if (queryPending_ && query_->IsComplete) {
lastPixelCount_ = query_->PixelCount;
queryPending_ = false;
}
// Smoothly lerp alpha toward target (0 = hidden, 1 = fully visible)
float targetAlpha = (lastPixelCount_ > 0) ? 1.0f : 0.0f;
float dt = (float)gt.getElapsedGameTime().TotalSeconds();
flareAlpha_ += (targetAlpha - flareAlpha_) * std::min(dt * 8.0f, 1.0f);
// --- 2. Draw the scene ---
drawScene(gd);
// --- 3. Issue occlusion query for sun proxy (no colour output) ---
if (!queryPending_) {
// Disable colour writes so the proxy dot does not affect the image
BlendState noWrite = BlendState::Opaque;
noWrite.ColorWriteChannels = ColorWriteChannels::None;
gd.setBlendState(noWrite);
query_->Begin();
drawSunProxySphere(gd, sunWorldPos_); // small sphere at sun
query_->End();
queryPending_ = true;
gd.setBlendState(BlendState::Opaque);
}
// --- 4. Draw lens flare sprite at sun's screen position ---
if (flareAlpha_ > 0.005f) {
Vector3 sunScreen = gd.Viewport.Project(
sunWorldPos_, camera_.Projection(), camera_.View(), Matrix::Identity);
if (sunScreen.Z > 0.0f && sunScreen.Z < 1.0f) {
Color flareColor = Color::White * flareAlpha_;
spriteBatch_->Begin(SpriteSortMode::Immediate, BlendState::Additive);
spriteBatch_->Draw(
*flareTex_,
Vector2(sunScreen.X - flareTex_->Width / 2.0f,
sunScreen.Y - flareTex_->Height / 2.0f),
flareColor);
spriteBatch_->End();
}
}
gd.Present();
}
};
EasyGL and Vulkan Implementation Notes
On the EasyGL backend, OcclusionQuery uses glGenQueries / glBeginQuery(GL_SAMPLES_PASSED, id) / glEndQuery / glGetQueryObjectiv(id, GL_QUERY_RESULT_AVAILABLE, &ready). The GL_SAMPLES_PASSED query counts individual MSAA samples; divide by GraphicsDevice.PresentationParameters.MultiSampleCount to get pixel-level counts on MSAA framebuffers.
On the Vulkan backend, CNA allocates a VkQueryPool with VK_QUERY_TYPE_OCCLUSION and submits vkCmdBeginQuery / vkCmdEndQuery into the command buffer. Results are retrieved with vkGetQueryPoolResults using VK_QUERY_RESULT_WITH_AVAILABILITY_BIT to avoid blocking the CPU.