Tutorial 75: Level of Detail (LOD)
What you’ll learn
- Holding several mesh variants per object and picking one by distance.
- Hysteresis and LOD bias to stop meshes popping at the switch distance.
- Impostor sprites for far objects, and occlusion-driven LOD.
Before you start — Tutorial 35: Loading 3D Models (multiple mesh variants of one model) and Tutorial 74: Frustum Culling (LOD selection runs alongside culling). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default, and STUB reports no 3D capability at all.
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.get());
gd.setIndicesProperty(v.ib.get());
for (auto& pass : fx.getCurrentTechniqueProperty()->getPassesProperty()) {
pass.Apply();
gd.DrawIndexedPrimitives(
PrimitiveType::TriangleList,
0, 0, v.vb->getVertexCountProperty(),
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. With the selection below, which divides the distance by the bias, a bias of 0.5 halves every switch distance: lower-detail variants are used closer to the camera (better performance, lower quality). A bias of 2.0 doubles them: higher-detail variants are used out to twice the distance (better quality, more cost):
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),
std::nullopt, // whole texture (the source rectangle is a std::optional<Rectangle>)
Color::White,
0.0f,
Vector2::Zero,
Vector2(scale / impostorTex.getWidthProperty(),
scale / impostorTex.getHeightProperty()),
SpriteEffects::None, 0.5f);
}
Two cautions about drawing the impostor through SpriteBatch. First, SpriteBatch ignores the depth buffer (its default depth-stencil state is None), so a screen-space sprite is drawn on top of whatever is already on screen, including nearer 3D objects. Draw impostors before the nearer 3D geometry, or use a world-space billboard quad with AlphaTestEffect when you need depth-correct results. Second, SpriteBatch does not restore the blend, sampler, depth-stencil and rasterizer state it changes, so put your 3D state back afterwards (Tutorial 31 shows the four calls).
OcclusionQuery-based LOD
CNA exposes OcclusionQuery, which counts the samples that pass the depth test between Begin() and End(). You can use it to skip objects that are occluded by large foreground objects. The query result is available one or two frames later. Three things are specific to CNA:
- It needs the HiDef graphics profile. The default profile is Reach, and constructing an
OcclusionQueryunder Reach throwsSystem::NotSupportedException(“OcclusionQuery is not supported by the active graphics profile and renderer.”). Request HiDef withgraphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef)in theGameconstructor, beforeInitialize()applies your preferences (see Tutorial 152). - Not every renderer has queries. They are absent on
SDL_GPU, onFNA3Dwhen it runs its SDL_GPU driver (its first choice, and the one it uses on Apple), onSTUBand on the 2D-onlySDL_RENDERER. Ask first withgd.SupportsCapability(CNA::GraphicsCapability::OcclusionQuery). - The count is exact on some renderers and only a yes/no on others. On the ES3-generation GL identities (
OPENGLES3,WEBGL2) the result is just 0 or 1 (GL_ANY_SAMPLES_PASSED); desktopOPENGL33andSOFTWAREgive a real count, andVULKANdoes when the device has the precise-occlusion feature.isPixelCountPreciseEXT()tells you which you have. The test below,> 0, is valid either way.
// In the Game constructor, before Initialize() applies the preferences
graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
// Create the query in LoadContent, only where the renderer has queries
if (gd.SupportsCapability(CNA::GraphicsCapability::OcclusionQuery))
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();
// Later frame — check the result without stalling
if (occlusionQuery_->getIsCompleteProperty()) {
bool visible = (occlusionQuery_->getPixelCountProperty() > 0);
if (!visible) return; // occluded — skip draw
}
getPixelCountProperty() throws if the query is not complete yet, so always test getIsCompleteProperty() first.
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.