Tutorial 75: Level of Detail (LOD)
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.