Tutorial 61: Occlusion Queries
What you’ll learn
- Wrapping a draw call in
OcclusionQuery::Begin()/End()and readingPixelCount. - Why
IsCompletematters, and the ring-buffer pattern that hides query latency. - Using visibility results without stalling the CPU on the GPU.
- Which renderers support queries at all, and the HiDef profile they require.
Before you start — Tutorial 31: Your First 3D Triangle (drawing the geometry you are querying) and Tutorial 44: Bounding Volumes and Spatial Queries (visibility tests generally). Requires a 3D-capable renderer that implements occlusion queries, such as OPENGLES3, OPENGL33 or VULKAN (the table below lists all 14 identities); the 2D-only SDL_RENDERER throws on 3D calls by default, and STUB draws nothing. The implementation notes at the end cover the GL family and VULKAN.
Occlusion queries are a per-renderer capability. OcclusionQuery relies on real GPU query objects — core GL query objects on the GL family, a query pool on Vulkan, the native query of Direct3D and WebGPU, Metal’s counting visibility mode on METAL, an exact CPU count on SOFTWARE. The public OcclusionQuery constructor never hands you an unusable object: it throws NotSupportedException when the profile is Reach, when SupportsCapability(GraphicsCapability::OcclusionQuery) is false, or when the renderer’s factory returns nothing (SDL_GPU throws from the factory itself). SDL_GPU (its vendored SDL_gpu has no query API) and STUB have no occlusion queries, nor does FNA3D on its Metal driver (its default on Apple), and the 2D-only SDL_RENDERER throws on 3D calls.
Query the capability, and know its limits. Ask gd.SupportsCapability(GraphicsCapability::OcclusionQuery) before you build a technique around queries. Note that the answer does not include the profile: a true still throws under Reach. Only DIRECTX9 inherits the shared default without an override (its factory returns nothing, and the constructor then throws, if the device lacks queries); DIRECTX11, SDL_GPU, VULKAN, METAL and the others answer explicitly.
Requirements: HiDef profile. CNA’s default GraphicsProfile is Reach, and this snapshot enforces it on every renderer. Constructing an OcclusionQuery under Reach throws NotSupportedException, so every example on this page throws on default settings until you request HiDef in the Game constructor, before the device exists:
LensFlareGame() : graphics_(this) {
graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
// Or for the whole project, before the Game is constructed:
// CNA::SetProjectGraphicsProfileEXT(GraphicsProfile::HiDef); // "CNA/ProjectGraphicsProfile.hpp"
}
The full list of profile ceilings, and the errors you will see when one is exceeded, is in Tutorial 152: Reach vs HiDef.
| Renderer identities | Occlusion queries (HiDef) | What PixelCount means |
|---|---|---|
OPENGL33 | Yes | A real sample tally where the driver accepts GL_SAMPLES_PASSED (desktop drivers do) |
OPENGLES3, WEBGL2 | Yes | A flag: 0 or 1, from GL_ANY_SAMPLES_PASSED, unless the driver accepts the exact query |
VULKAN | Yes | A real tally only when the device has the precise-occlusion feature |
WEBGPU | Yes | Exact on a Vulkan adapter; a 0-or-1 flag on a Metal adapter (wgpu-native counts visibility as a boolean there) |
DIRECTX9, DIRECTX11, FNA3D | Yes (FNA3D: not with its Metal driver) | A real tally |
METAL | Yes (one query open at a time; a second Begin is refused by name) | A real tally (Metal’s counting visibility mode, summed across encoders) |
SOFTWARE | Yes | Exact CPU count |
HEADLESS | Accepted (validated and traced) | Nothing is rasterised, so there is no meaningful count |
SDL_GPU, STUB | No (the constructor throws) | — |
SDL_RENDERER | No (2D only, 3D calls throw) | — |
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. It throws NotSupportedException when the profile or the renderer cannot do queries (see Requirements above), so guard it with the capability check when you must run on renderers that lack them:
auto& gd = getGraphicsDeviceProperty();
std::unique_ptr<OcclusionQuery> query;
if (gd.SupportsCapability(CNA::GraphicsCapability::OcclusionQuery)) {
query = std::make_unique<OcclusionQuery>(gd); // still throws on Reach
}
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 or repeat a query.
Begin()throwsInvalidOperationExceptionif that same query object is already betweenBeginandEnd, and also if its previous result was never observed throughIsComplete(“IsComplete must be queried before beginning this occlusion query again”).End()without aBegin()throws as well. Whether several different query objects may be active at once on one device is up to the renderer, so keep to one at a time unless you have tested the renderer you ship. - 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.
// BlendState::Opaque is a pre-bound preset, so copy it before changing it;
// once a state object is bound to the device it can no longer be modified.
BlendState noColor = BlendState::Opaque;
noColor.setColorWriteChannelsProperty(ColorWriteChannels::None);
gd.setBlendStateProperty(noColor); // do not mutate noColor after this line
query_->Begin();
drawSunProxy(gd);
query_->End();
gd.setBlendStateProperty(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_->getIsCompleteProperty()) {
lastPixelCount_ = query_->getPixelCountProperty();
queryPending_ = false;
}
The rule is enforced: asking for PixelCount before the query is complete throws InvalidOperationException (“The occlusion query has not completed; query IsComplete before reading PixelCount.”), and a Begin() on a query whose previous result was never observed through IsComplete throws too. 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
OPENGLES3andWEBGL2the count is only a flag: 0 or 1, because their only core occlusion target is the booleanGL_ANY_SAMPLES_PASSED. A coverage ratio computed from it would be 1/area, not a fraction. Askquery.isPixelCountPreciseEXT()(a CNA extension) whether you are holding a real tally before dividing by an area; the lens-flare example below only needs “zero or not”, so it works either way. - Where the count is precise, on MSAA framebuffers it may reflect sample coverage, not pixel coverage, and be larger than you expect. Normalise by the applied sample count if you divide by an area.
The Latency Problem and the Ring-Buffer Pattern
The most critical pitfall with occlusion queries is GPU-CPU synchronisation. Reading a result in the same frame you issued the query would force the CPU to wait for the GPU — a pipeline bubble that can cost several milliseconds per frame and destroy performance. CNA does not let you fall into it silently: PixelCount throws until IsComplete has said the result is ready, so the safe pattern is also the only working one.
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 (each one is observed through IsComplete before it is begun again, so the ring below never trips the “previous result was not observed” check):
// 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_]->getIsCompleteProperty()) {
lastPixelCount_ = queries_[readIdx_]->getPixelCountProperty();
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.
// A minimal camera helper used by the examples in this series. It is application
// code, not a CNA type; Tutorial 34 builds a fuller FpsCamera.
struct Camera {
Matrix view = Matrix::CreateLookAt(Vector3::Zero, Vector3(0.2f, 0.3f, -1.0f), Vector3::Up);
Matrix projection = Matrix::CreatePerspectiveFieldOfView(
MathHelper::ToRadians(60.0f), 16.0f / 9.0f, 0.1f, 5000.0f);
const Matrix& View() const { return view; }
const Matrix& Projection() const { return projection; }
};
class LensFlareGame final : public Game {
GraphicsDeviceManager graphics_;
std::unique_ptr<OcclusionQuery> query_; // null where queries are unavailable
Texture2D flareTex_;
std::unique_ptr<SpriteBatch> spriteBatch_;
Camera camera_;
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);
public:
LensFlareGame() : graphics_(this) {
// OcclusionQuery construction throws on the default Reach profile.
graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
}
protected:
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
// HiDef is not enough on its own: SDL_GPU, STUB and FNA3D's Metal
// driver have no queries. Fall back to "always visible".
if (gd.SupportsCapability(CNA::GraphicsCapability::OcclusionQuery)) {
query_ = std::make_unique<OcclusionQuery>(gd);
} else {
lastPixelCount_ = 1;
}
flareTex_ = getContentProperty().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 (query_ && queryPending_ && query_->getIsCompleteProperty()) {
lastPixelCount_ = query_->getPixelCountProperty();
queryPending_ = false;
}
// Smoothly lerp alpha toward target (0 = hidden, 1 = fully visible)
float targetAlpha = (lastPixelCount_ > 0) ? 1.0f : 0.0f;
float dt = (float)gt.getElapsedGameTimeProperty().getTotalSecondsProperty();
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 (query_ && !queryPending_) {
// Disable colour writes so the proxy dot does not affect the image
BlendState noWrite = BlendState::Opaque;
noWrite.setColorWriteChannelsProperty(ColorWriteChannels::None);
gd.setBlendStateProperty(noWrite);
query_->Begin();
drawSunProxySphere(gd, sunWorldPos_); // small sphere at sun
query_->End();
queryPending_ = true;
gd.setBlendStateProperty(BlendState::Opaque);
}
// --- 4. Draw lens flare sprite at sun's screen position ---
if (flareAlpha_ > 0.005f) {
Vector3 sunScreen = gd.getViewportProperty().Project(
sunWorldPos_, camera_.Projection(), camera_.View(), Matrix::getIdentityProperty());
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_.getWidthProperty() / 2.0f,
sunScreen.Y - flareTex_.getHeightProperty() / 2.0f),
flareColor);
spriteBatch_->End();
}
}
// No gd.Present(): Game presents after Draw() returns.
}
};
Two details of the example matter. The flare only needs to know “was any of the proxy visible”, so lastPixelCount_ > 0 is correct even on OPENGLES3 and WEBGL2, where the count is just 0 or 1. And Draw does not call Present(): the Game presents in its own end-of-frame step, so a manual call would present twice. For a partial-visibility fade on renderers with a real tally, gate on query_->isPixelCountPreciseEXT() and divide by the pixel count you expect the proxy to cover.
Implementation Notes: the GL family and Vulkan
On the GL family (internally EasyGL), OcclusionQuery uses core query objects — glGenQueries / glBeginQuery / glEndQuery / glGetQueryObjectiv(id, GL_QUERY_RESULT_AVAILABLE, &ready) — and no ARB extension. It probes GL_SAMPLES_PASSED once per process and keeps it if the driver accepts it (desktop GL, OPENGL33); otherwise it falls back to the boolean GL_ANY_SAMPLES_PASSED (OPENGLES3, WEBGL2), and isPixelCountPreciseEXT() reports which one you got. The exact query counts individual MSAA samples, so on an MSAA framebuffer normalise by the applied sample count when you need pixel-level counts.
On the VULKAN renderer, 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_64_BIT without a wait flag, so the CPU does not block: IsComplete stays false while the call returns VK_NOT_READY. The count is exact only when the device offers the occlusionQueryPrecise feature; isPixelCountPreciseEXT() tells you. The Direct3D, WebGPU, FNA3D and SOFTWARE renderers use their own real queries (the CPU rasteriser counts exactly).
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Vulkan draw-time state, ordered clears, occlusion queries and descriptor pools — How CNA's deferred VULKAN renderer carries blend, stencil, viewport and scissor state, orders clears, counts occlusion queries and grows descriptor pools, with the defects behind each rule.