Tutorial 75: Level of Detail (LOD)

CNA Tutorials  ·  Performance

What is LOD?

Level of Detail (LOD) is the technique of substituting a simpler representation of an object when it is far from the camera. A tree 500 metres away occupies perhaps 4 pixels on screen — rendering it with 50,000 triangles is a waste. Switching to a 50-triangle proxy at that distance has no visible quality loss and cuts the vertex throughput for that object by 1000×.

LOD is a geometry bandwidth optimisation. It reduces the work the vertex shader must do without changing the number of pixels drawn (that is a fillrate concern addressed by frustum culling and occlusion queries).

LODModel class: 3 mesh variants

// LODModel.hpp
#pragma once
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/IndexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include <array>
#include <memory>
#include <string>

struct LODVariant {
    std::unique_ptr<VertexBuffer> vb;
    std::unique_ptr<IndexBuffer>  ib;
    int  primitiveCount = 0;
    float maxDistance   = 0.0f;  // Switch away from this LOD at this distance
};

class LODModel {
public:
    static constexpr int LOD_COUNT = 3;

    // Load three mesh files and their transition distances
    LODModel(GraphicsDevice& gd,
             const std::string& lod0Path,  // high   — up to  50 m
             const std::string& lod1Path,  // medium — up to 150 m
             const std::string& lod2Path,  // low    — up to 400 m
             float d0 = 50.0f,
             float d1 = 150.0f,
             float d2 = 400.0f);

    // Returns the index (0, 1, or 2) of the variant to render given
    // distance from camera, with hysteresis to reduce popping.
    int SelectLOD(float distance) const;

    // Draw using a pre-selected LOD variant
    void Draw(GraphicsDevice& gd, BasicEffect& fx, int lodIndex) const;

    // Hysteresis state — per-instance in the scene object
    mutable int currentLOD = 0;

private:
    std::array<LODVariant, LOD_COUNT> variants_;
};

Distance-based LOD selection

// LODModel.cpp
int LODModel::SelectLOD(float distance) const {
    // Find the target LOD from distance thresholds
    int target = LOD_COUNT - 1;
    for (int i = 0; i < LOD_COUNT; ++i) {
        if (distance < variants_[i].maxDistance) {
            target = i;
            break;
        }
    }
    return target;
}

void LODModel::Draw(GraphicsDevice& gd, BasicEffect& fx, int lodIndex) const {
    const auto& v = variants_[lodIndex];
    if (!v.vb || !v.ib) return;

    gd.setVertexBuffer(*v.vb);
    gd.setIndexBuffer(*v.ib);
    for (auto& pass : fx.getCurrentTechnique().Passes) {
        pass.Apply();
        gd.DrawIndexedPrimitives(
            PrimitiveType::TriangleList,
            0, 0, v.vb->getVertexCount(),
            0, v.primitiveCount);
    }
}

Hysteresis to prevent popping

Without hysteresis, an object right at a threshold distance bounces between LOD0 and LOD1 every frame as the camera moves, causing visible popping. Add a hysteresis band — the threshold to switch away from a LOD is slightly larger than the threshold to switch to it:

// Per-instance hysteresis in the update loop
void UpdateLOD(LODModel& model, float distanceToCamera) {
    const float HYSTERESIS = 5.0f;  // metres of dead band
    int target = model.SelectLOD(distanceToCamera);

    // Only upgrade LOD (more detail) immediately
    if (target < model.currentLOD) {
        model.currentLOD = target;
    }
    // Downgrade LOD (less detail) only if we exceed threshold + hysteresis
    else if (target > model.currentLOD) {
        // Re-check with hysteresis offset
        int hysteresisTarget = model.SelectLOD(distanceToCamera - HYSTERESIS);
        if (hysteresisTarget > model.currentLOD)
            model.currentLOD = target;
    }
}

LOD bias

A global LOD bias lets the player or platform config shift all thresholds up or down uniformly. A bias of 0.5 halves all distances (use higher LODs closer to the camera — better quality, more cost). A bias of 2.0 doubles them (use lower LODs, better performance, lower quality):

float lodBias = 1.0f;  // configurable per platform

int SelectLODWithBias(const LODModel& model,
                      float distance,
                      float bias) {
    return model.SelectLOD(distance / bias);
}

Impostor sprites for distant objects

Beyond the last 3D LOD threshold, replace the object entirely with a billboard sprite (an alpha-tested quad that always faces the camera). The sprite is a pre-rendered image of the object from several angles or a single averaged view. At distances where the object is only a few pixels tall, the difference is invisible.

void DrawImpostor(SpriteBatch& sb,
                  const Texture2D& impostorTex,
                  const Vector3& worldPos,
                  const Matrix& view,
                  const Matrix& proj,
                  float screenWidth, float screenHeight,
                  float size) {
    // Project world position to screen space
    Vector4 clipPos = Vector4::Transform(
        Vector4(worldPos, 1.0f), view * proj);
    if (clipPos.W <= 0.0f) return;  // behind camera

    float sx = (clipPos.X / clipPos.W * 0.5f + 0.5f) * screenWidth;
    float sy = (1.0f - (clipPos.Y / clipPos.W * 0.5f + 0.5f)) * screenHeight;
    float scale = size / clipPos.W;

    sb.Draw(impostorTex,
            Vector2(sx - scale * 0.5f, sy - scale * 0.5f),
            nullptr,
            Color::White,
            0.0f,
            Vector2::Zero,
            Vector2(scale / impostorTex.getWidth(),
                    scale / impostorTex.getHeight()),
            SpriteEffects::None, 0.5f);
}

OcclusionQuery-based LOD

CNA exposes OcclusionQuery (wrapping GL_SAMPLES_PASSED / Vulkan timestamp query). You can use it to skip objects that are occluded by large foreground objects. The query result is available one or two frames later:

// Create query in LoadContent
occlusionQuery_ = std::make_unique<OcclusionQuery>(gd);

// In Draw — begin the query before the draw call
occlusionQuery_->Begin();
DrawBoundingBox(gd, obj.worldAABB);  // draw a proxy, not the real mesh
occlusionQuery_->End();

// Next frame — check result (non-blocking if one frame old)
if (occlusionQuery_->IsComplete()) {
    bool visible = (occlusionQuery_->PixelCount > 0);
    if (!visible) return;  // occluded — skip draw
}

OcclusionQuery adds its own GPU cost and complexity. It is most useful for large interior scenes with many potential occluders (rooms within a building). For outdoor scenes with few occluders, frustum culling alone is usually sufficient.