Skip to content

[Feature] Select LOD levels by the size of the entity on screen - #1322

Merged
untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:feature/lod_screen_size
Oct 8, 2026
Merged

untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:feature/lod_screen_size

Conversation

@miogds

@miogds miogds commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stage 1d of docs/proposals/LargeSceneRendering.md, the half that #1299 left open. A level of detail is chosen by the camera's distance to the entity, and a distance suits one size of object under one field of view: a chain cooked for a tree is wrong for the same tree placed at half its scale, and every chain is wrong once the camera zooms. An entity can now choose its level by how large it is on screen: each level ends where the entity covers the screenPercentage of the next one, read as the LOD pass runs from the entity's world radius and the projection in use.

The same model at the same distance, in a wide and a zoomed view, with each rule

The sheet is a test model with the chain untoldengine bake-lods writes for it, from one camera position. Top row, a 60° field of view: the model is small on screen and both rules draw its coarsest level. Bottom row, the camera zoomed to 20°: the distance has not changed, so the distance rule keeps the coarsest level, now large and visibly simplified; the screen-size rule draws the full model.

The levels of the automatic chains of a .untoldpack (#1299) are selected this way. #1299 turned each level's screen size into a switch distance when the pack loaded, from the field of view of that moment; the switch now follows the field of view and the entity's scale as they are, and a placement scaled after the load switches at its new size.

Changes

  • LODComponent.selectsByScreenSize (opt-in; the pack loader sets it on the levels of a chain) and LODComponent.screenSizeRadius: the radius, in the entity's own space, of the sphere measured on screen; 0 measures the sphere around the entity's bounding box. The parts of a pack model of several nodes carry the radius of the whole model, so they switch together.
  • LODSystem: lodScreenSizeReach(projection:) reads projection[1][1] (that is 1 / tan(fovY / 2)) once per update, nil under an orthographic projection. In stereo the eyes are rendered after the update, so the reach is the larger of the two eye projections' from the frame rendered last, and until an eye has been rendered the stored distances apply (the review's first point). For a component that selects by screen size, level i ends at worldRadius × reach / screenPercentage(i + 1); the bias and the hysteresis apply to these distances as to stored ones. A level whose successor has no screen size, and every level under an orthographic projection, ends at its stored maxDistance as before.
  • Resolution-independent on purpose: the size is a share of the viewport height, whatever its resolution. A denser display draws the same levels; setLOD(.distanceBias(...)) is the knob for a platform that wants more or less detail. (A first version scaled the size by the viewport's pixel height; a Retina window or a headset then drew several times the triangles for detail that is not seen. Unreal and Unity do not scale it either.)
  • LODLevel.screenPercentage existed, was serialized and was never read; existing tests set it to arbitrary values, which is why the selection is opt-in rather than automatic for every component with a size.
  • Pack loader: sets the flag and the radius on each level it registers; the stored switch distances are still written, for the orthographic case and for an engine without this change.
  • Documented in docs/API/UsingLODSystem.md ("Selecting by Screen Size") and docs/Architecture/lodSystem.md.

Verification

  • LODScreenSizeTests (14): the reach of a projection, the switch distance for a radius and a screen size, levels without a size, the orthographic fallback, the bias and the hysteresis on computed distances, screenSizeRadius, and that a component without the flag selects as before.
  • UntoldPackLODRenderTests (+4, 15 in all): a placement selects its levels by its size on screen and its parts carry the model's radius; a placement scaled after the load switches at its new size; halving the field of view moves the switches out and doubling the resolution does not; a view without perspective switches at the distances of the load.
  • With the flag forced off inside the LOD system, three of those tests fail: the rule is what they measure.
  • swift test --filter UntoldEngineTests: 1,640 run. The only failure is ExternalRenderExtensionPackageTests, which fails locally whenever the checkout folder is not named UntoldEngine; it passes from a checkout with that name.
  • The render suite: 1,185 pass, 69 are skipped as on develop. Its 22 reference-image comparisons ran with a stand-in for compare_psnr.py that does the same arithmetic without OpenCV and scikit-image, which this machine does not have: 14 match their reference exactly, the lowest of the others is at 68 dB against thresholds of 11 to 30.
  • Zero warnings in the strict-concurrency build (scripts/strict-concurrency-guardrails.sh with MAX_UNIQUE_CONCURRENCY_WARNINGS=0); no new SwiftFormat findings in the changed files.

Limits

The LOD chain of a pack model gives each level a screen size, and the loader
turned it into a distance for each placement as the pack loaded: right for
the scale and the field of view of that moment only. A placement scaled
afterwards, or a zoom, kept switching at the old distances.

A LOD component can now select by screen size
(LODComponent.selectsByScreenSize): each level ends at the distance where
the entity covers the screenPercentage of the next one. The size is read as
each LOD pass runs, from the sphere around the entity's bounds under its
world scale and from the projection in use. It is a share of the viewport
height whatever the resolution, so a denser display draws the same levels.
The bias and the hysteresis act on these distances as they do on
maxDistance.

The placements of a pack select this way. The parts of a model with several
nodes carry the radius of the whole model (LODComponent.screenSizeRadius)
and change level together. The distance of the load stays in maxDistance:
it serves an orthographic view, and a level without a screen size.

LODLevel.screenPercentage was stored and never read. A component selects by
it only when told to, so entities that were given distances keep them.
@miogds
miogds requested a review from untoldengine as a code owner October 8, 2026 07:40
@untoldengine

Copy link
Copy Markdown
Owner

Nice work on this one — the screen-size-based switching is a clean fix for the problem #1299 left open, and the write-up made it easy to follow the reasoning.

Two small things worth a look, neither blocking:

  • renderInfo.perspectiveSpace gets written per-eye inside renderXR, which runs after LODSystem.update() in the frame loop. So in XR, the screen-size reach may read last frame's (or the other eye's) projection rather than the current one. Probably negligible in practice since FOV barely changes frame-to-frame, but it's the same shape as some past viewSpace/effectiveViewMatrix staleness bugs we've hit, so flagging it for a conscious look rather than assuming it's fine.
  • You already called this out in "Limits," but just making sure it's tracked: scene serialization doesn't persist selectsByScreenSize/screenSizeRadius yet, so a save/reload currently falls back to the baked distances.

Nothing here should hold up the merge — great addition.

…he last frame

The LOD update runs before the eyes are rendered, so in stereo the reach it
took from renderInfo.perspectiveSpace was the second eye's of the previous
frame, and on the first pass of a headset session the window's projection.
The update now takes, in stereo, the larger of the two eye projections'
reaches from the frame rendered last, and until an eye has been rendered (the
projections are still the identity) the stored distances apply, as under an
orthographic projection. A headset's field of view does not change between
frames.
@miogds

miogds commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, both looked at.

The projection in stereo. You are right that the update reads what the previous frame left, and before any eye has been rendered it read the window's projection: the first LOD pass of a headset session chose its levels with the Mac window's field of view. Now in stereo the update takes the reach from the two eye projections of the frame rendered last, the larger of the two (so an entity is drawn at the level the eye that sees it largest asks for), and until an eye has been rendered the projections are still the identity and the stored distances apply, as they do under an orthographic projection. A headset's field of view does not change between frames, so the one-frame lag has no effect after the first pass. Two tests cover the choice and the first frame; the architecture doc says where the reach comes from in stereo.

Serialization. Not tracked as an issue yet, only as a follow-up in the plan: a scene saved by the editor reloads its placements one by one, not through the pack loader, so neither the shared builds nor the chains of multi-node models survive a save, and these two fields go with that. I opened #1326 for it, with the three things a saved scene loses in one place.

@untoldengine
untoldengine merged commit 6446393 into untoldengine:develop Oct 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants