Tutorial 61: Occlusion Queries

CNA Tutorials  ·  Advanced Rendering

ℹ

What you’ll learn

  • Wrapping a draw call in OcclusionQuery::Begin() / End() and reading PixelCount.
  • Why IsComplete matters, 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
OPENGL33YesA real sample tally where the driver accepts GL_SAMPLES_PASSED (desktop drivers do)
OPENGLES3, WEBGL2YesA flag: 0 or 1, from GL_ANY_SAMPLES_PASSED, unless the driver accepts the exact query
VULKANYesA real tally only when the device has the precise-occlusion feature
WEBGPUYesExact on a Vulkan adapter; a 0-or-1 flag on a Metal adapter (wgpu-native counts visibility as a boolean there)
DIRECTX9, DIRECTX11, FNA3DYes (FNA3D: not with its Metal driver)A real tally
METALYes (one query open at a time; a second Begin is refused by name)A real tally (Metal’s counting visibility mode, summed across encoders)
SOFTWAREYesExact CPU count
HEADLESSAccepted (validated and traced)Nothing is rasterised, so there is no meaningful count
SDL_GPU, STUBNo (the constructor throws)—
SDL_RENDERERNo (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() throws InvalidOperationException if that same query object is already between Begin and End, and also if its previous result was never observed through IsComplete (“IsComplete must be queried before beginning this occlusion query again”). End() without a Begin() 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 OPENGLES3 and WEBGL2 the count is only a flag: 0 or 1, because their only core occlusion target is the boolean GL_ANY_SAMPLES_PASSED. A coverage ratio computed from it would be 1/area, not a fraction. Ask query.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).