Day 36 - 🔬 Deep dive - LOD Pipelines - The Math Behind the Decisions

General / 19 June 2026

One measurement decides when a mesh swaps to a cheaper version. Get it wrong and assets pop or waste budget. Here's how to size an object from every angle and turn that into a draw distance.

Welcome back to Part 2 of our discussion on level of detail meshes. This one builds directly on the first post, so it's a little shorter. In Part 1 we calculated the switch distance from a single number: the diagonal of the object. That works for roughly cubic assets, but it falls apart the moment something is thin or flat, because a player in a game sees the mesh from every direction, not just along its longest line.


Why one angle isn't enough

The diagonal is dominated by the largest dimensions. For a flat asset, a wall panel, a sign, a leaf, the diagonal makes the object look bigger than it ever appears side-on, and the LOD lingers at full detail longer than it should. Measure only the thin face and you get the opposite problem: the asset drops to a cheaper mesh while it's still filling a chunk of the screen.

The fix is to stop trusting one number. I measure the object from a few representative directions, full frontal, full side, full top, plus the 3D diagonal, and feed an aggregate of those into the distance calculation. Thin and flat objects get a fairer reading because the orientation the artist happens to place them in no longer dominates the result.


Measuring from every side

Each face measurement is just a 2D diagonal of two dimensions; the overall diagonal is the 3D version. Pull the width, height and depth from the asset's bounding box, and wrap each one in Abs() so a bounding-box corner with negative coordinates can't flip a sign on you (thanks to the reader who flagged that on Part 1).

Correction to Parts 1 and 2: the earlier posts computed these with a cube root and reported the diagonal as 0.387 m. That was my mistake. A diagonal is the square root of the sum of squares, no matter how many dimensions you combine, that's just the Pythagorean theorem extended to 3D. Cube-rooting squared metres doesn't even return a length (the units come out as m^â…”). The numbers below are the corrected versions.

pseudo code
diagonal = √(0.075² + 0.157² + 0.167²) = 0.241 m
front    = √(0.075² + 0.157²)          = 0.174 m
side     = √(0.157² + 0.167²)          = 0.229 m
top      = √(0.075² + 0.167²)          = 0.183 m
mean = (0.241 + 0.174 + 0.229 + 0.183) / 4 = 0.207 m


Mean or max?

Taking the mean is the obvious first move, and it's what I shipped originally. But in hindsight I'm not sure it's the right aggregate. Averaging pulls the input toward the smaller faces, so for an object whose dimensions vary a lot, the mean sits well below the diagonal and the LOD fades out sooner than it should.

If the spread between the smallest and largest measurement is wide, the maximum is probably the safer choice: it keeps the higher-detail mesh on screen until the object is genuinely small from every angle, which is usually what you want visually. The mean is the more aggressive, more memory-friendly option. Neither is universally correct, it's a quality-versus-cost dial, and the right setting depends on how forgiving your asset class is about popping. I'd expose it rather than hard-code it.


From size to distance

Once you have a size, the distance is trigonometry. The function below answers a single question: how far from the camera does an object of this size occupy a given percentage of the screen?

CalculateDistance(inSize, inScreenSpacePercentage=100, inFovDegrees=45.0, inVerticalResolution=1080):
   ratio = inVerticalResolution / 1080 
   # distance at which the asset fills the screen top to bottom
   distance_to_object     = (inSize * 0.5) / tan(radians(inFovDegrees * 0.5))
   distance_by_screensize = distance_to_object * 100 / inScreenSpacePercentage
   max_distance           = distance_by_screensize * ratio
   return max_distance


Walking through it: distance_to_object is the distance at which the asset exactly fills the vertical field of view, half the object's height over the tangent of half the FOV, the standard "fit a height into an angle" relation. The inScreenSpacePercentage term then scales that out; if you only want the asset to occupy 50% of screen height before switching, it needs to sit twice as far away, hence 100 / percentage. Finally the resolution ratio nudges the distance for higher-density displays, on the assumption your screen-space target was tuned at 1080p.

One implementation gotcha worth calling out, because it bit me: tan() in most math libraries expects radians, not degrees. Feed it 45 * 0.5 directly and every distance comes out roughly 35% wrong. Convert the FOV first, that's the radians() call above.

Plug the mean (0.207 m) into a 90% screen target at a 45° FOV and you get a switch distance of about 0.28 m, the point where this small asset stops being rendered at L0. From there the rest of the chain follows a rule of thumb from Part 1: each LOD step aims for a 50% triangle reduction, so doubling the distance per step mostly holds (L1 around 0.56 m, and so on). It breaks down once the poly count is already low enough that further reduction wrecks the silhouette or the normals, at which point the math stops being the limiting factor and your eyes take over.


Why bother doing this by hand

Simplygon or Unreal will generate LODs for you, so why work it out yourself? Because knowing the math is what lets you debug the tool when its output looks wrong, tune the thresholds to a specific project instead of accepting generic defaults, and stay unblocked when licensing or budget takes the automated option off the table. The tool is faster; understanding why it picks a distance is what makes you useful when it doesn't.

Are there flaws here? Almost certainly, the resolution scaling in particular is a simplification, and the mean-versus-max question is still open. If you've solved either more cleanly, I'd genuinely like to hear it.


© 2026 Stefan Groenewoud - All views are my own, not those of my employer.