Skip to content

[Performance] Automatic LOD chains for the models of a .untoldpack - #1299

Merged
untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:feature/pack_lod_chain
Oct 4, 2026
Merged

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

Conversation

@miogds

@miogds miogds commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stage 1c of docs/proposals/LargeSceneRendering.md. A scene exported from Blender has no levels of detail, and a pack can place a very dense model hundreds of times: on the BIM site of #1290 one tree of 1.85 M triangles is placed 175 times, 92.5 % of the 349 M triangles of the scene. The cook now writes simplified levels of every model that is detailed enough, and the pack loader registers them on the entities of each placement.

A model and the three levels the cook writes for it, solid and in wireframe

The sheet is a procedural test model (a ground, a boulder and a tree whose canopy is 3,200 separate cards) and the three levels the cook writes for it, drawn at the same size: 197,200 triangles, then 98,598, 32,290 and 7,348. The cook gives each level the screen size from which it is detailed enough, here 58 %, 33 % and 16 % of the height of the screen, so levels 2 and 3 are shown larger than they are in use.

BIM site pack (10,314 placements, 4,583 files) Before After
Triangles placed, at the finest level 349 M 349 M
Triangles placed, at the coarsest level 349 M 12.6 M
Triangles per frame (six viewpoints) 187–293 M 7–21 M
GPU time per frame 141–220 ms 11–22 ms
Cook of the chains 3.4 s (217 of the 4,583 models, 649 level files, 233 MB)

Release build, headless at 1920×1080 on an M-series Mac. From the same viewpoints the tree looks the same with and without the chain at 3, 7, 15 and 40 m.

Changes

Cook (UntoldMeshLODCooker, untoldengine bake-lods, and the last step of untoldengine export for a pack unless --no-lods):

  • Levels at 50 %, 15 % and 3 % of the triangles by default, for models of 2,000 triangles or more, written next to the model as <name>_LOD<n>.untold: the same entities, mesh records and materials with fewer triangles.
  • A level may deviate by the size of one of its triangles (model diameter / √triangles). Vertices that two meshes of a model share stay in place, so the materials of one surface do not come apart. Texture seams are kept on textured meshes, unless they hold a level more than 25 % above its target.
  • A level that is not at least 15 % smaller than the one before is left out. Skinned, morphing and animation-only models get no chain.
  • Vegetation: the small detached pieces of an aggregate (leaf cards, twigs) are collapsed only while they keep their outline, then thinned evenly through space, and the survivors are enlarged to give back the area removed. Edge collapse alone leaves 29 % of the tree's leaf area at 3 % of its triangles, and the canopy shows through.
  • The manifest records each chain under lodChains, with a screen size per level. A manifest without the key loads as before, and an older engine ignores it.
  • A level file is recognised by a flag in its header (UntoldFileFlags.generatedLODLevel), never by its name: real packs have models named …_LOD4.

The cook is a library of its own, UntoldEngineMeshCook, which the CLI links. The simplifier is meshoptimizer 1.3 (MIT), vendored unmodified as the C++ target CMeshOptimizer (the five source files the cook uses). The UntoldEngine module does not import it: code compiled against a built engine module without SwiftPM, as the editor compiles a project's Code Components, would otherwise need the module map of the C++ library and fail with "missing required module 'CMeshOptimizer'". An app that only loads packs does not build the simplifier.

Loader:

  • Each level is built once per pack load (the cache of [Performance] Load each .untold of a pack once #1290) and shared by the placements.
  • A level's screen size becomes a switch distance per placement, from the model's bounds, the placement's scale and the field of view.
  • The levels are drawn with the model's materials, and a material edited or streamed while one level is on screen stays when another takes its place (LODComponent.levelsShareMaterials).

LOD system:

  • The hysteresis takes at most a tenth of a level's switch distance. With the default of 5 units a level that switches closer than that could never be returned to.
  • The configuration is read once per update, not four times per entity.

Verification

  • UntoldMeshLODCookerTests (26): the levels of a model and their triangle counts, the error limit, locked and protected vertices, aggregates and their area, the level file's tables and header flag, the skip reasons, re-cooking, the manifest, a name that is taken.
  • UntoldPackLODRenderTests (11): a cooked pack registers its levels on every placement and shares their meshes, switch distances follow the placement's scale, the materials follow the entity from level to level, a pack without chains loads as before.
  • LODSystemTests (+2) for the hysteresis; BakeLODsCommandTests (6) in the CLI package.
  • swift test --filter UntoldEngineTests: 1583 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 (1208 tests) passes. Its 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.
  • The CLI's tests (18) pass. Zero warnings in the strict-concurrency build; no new SwiftFormat findings in the changed files.
  • UntoldEngine and UntoldEngineMeshCook build and link for the iOS and visionOS simulators. A file that imports UntoldEngine compiles against the built module with only the CShaderTypes module map, as the editor's component compiler does.

Limits

  • After this change the scene's frame is held by the CPU, not the GPU: 17,600 to 21,500 draws cost more to encode than their triangles cost to draw. That is the subject of the next stages of the plan.
  • The tree's last level still has 83,611 triangles (4.5 %): its branches stop simplifying at the error limit. Going lower needs an impostor or cluster LOD, not more levels.
  • A scene saved by the editor reloads its placements one by one, not through the pack loader, so neither the shared builds of [Performance] Load each .untold of a pack once #1290 nor the chains survive a save and reload yet.
  • The editor cooks through scripts/export-untold, which writes no chains; until it calls the cooker, run untoldengine bake-lods on the pack.

Summary by CodeRabbit

  • New Features
    • Added automatic mesh LOD generation for models and packs, with a bake-lods command and an option to skip LOD generation during export.
    • Pack loading now supports LOD chains, switching detail levels based on placement-specific distances while keeping materials consistent.
    • Added mesh optimization support for preparing generated LODs.
  • Bug Fixes
    • Capped LOD hysteresis to prevent it from eliminating nearby level-switch thresholds.
  • Documentation
    • Added guidance for generating, loading, and configuring LOD chains.

Javier Segura added 2 commits October 3, 2026 21:44
CMeshOptimizer holds the files of meshoptimizer 1.3 (MIT) that the asset
cook uses, unmodified: the simplifier, the index generator, the vertex
cache and vertex fetch optimizers and the allocator they share. It is
called from Swift through its C API; no C++ interoperability is needed.
A scene exported from Blender has no levels of detail, and a pack can place
a very dense model hundreds of times. The cook now writes simplified levels
of every model that is detailed enough, and the pack loader registers them
on the entities of each placement.

Cook (UntoldMeshLODCooker, `untoldengine bake-lods`, last step of
`untoldengine export` for a pack):
- levels at 50 %, 15 % and 3 % of the triangles by default, for models of
  2,000 triangles or more, written next to the model as <name>_LOD<n>.untold
- a level may deviate by the size of one of its triangles; vertices that two
  meshes of a model share stay in place; texture seams are kept on textured
  meshes unless they hold a level well above its target
- the small detached pieces of an aggregate (leaves, twigs) are thinned
  evenly and the survivors enlarged, so a canopy keeps its area
- the manifest records each chain under "lodChains", with a screen size per
  level

The cook is a library of its own, UntoldEngineMeshCook, which the CLI links.
The UntoldEngine module does not import the simplifier: code that is compiled
against a built engine module without SwiftPM, as the editor compiles the
components of a project, would otherwise need the module map of the C++
library and fail with "missing required module 'CMeshOptimizer'".

Loader:
- each level is built once per pack load and shared by the placements
- a level's screen size becomes a switch distance per placement, from the
  model's bounds, the placement's scale and the field of view
- levels are drawn with the model's materials, and the materials of an
  entity follow it from level to level

LOD system:
- the hysteresis takes at most a tenth of a level's switch distance, so a
  level that switches closer than the hysteresis can be returned to
- the configuration is read once per update, not per entity
@miogds
miogds requested a review from untoldengine as a code owner October 3, 2026 21:18
@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (1)
Generated by CodeRabbit — auto-discovered
ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5914d384-a01d-4036-87f2-69a9c845cf7f
📥 Commits

Reviewing files that changed from the base of the PR and between cb53996 and e908f29.

📒 Files selected for processing (29)
  • Package.swift
  • Sources/CMeshOptimizer/LICENSE.md
  • Sources/CMeshOptimizer/README.md
  • Sources/CMeshOptimizer/allocator.cpp
  • Sources/CMeshOptimizer/include/meshoptimizer.h
  • Sources/CMeshOptimizer/indexgenerator.cpp
  • Sources/CMeshOptimizer/simplifier.cpp
  • Sources/CMeshOptimizer/vcacheoptimizer.cpp
  • Sources/CMeshOptimizer/vfetchoptimizer.cpp
  • Sources/UntoldEngine/AssetFormat/UntoldFormat.swift
  • Sources/UntoldEngine/ECS/Components.swift
  • Sources/UntoldEngine/Systems/LODSystem.swift
  • Sources/UntoldEngine/Systems/RegistrationSystem.swift
  • Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift
  • Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift
  • Sources/UntoldEngineMeshCook/UntoldMeshLODWriter.swift
  • Sources/UntoldEngineMeshCook/UntoldPackLODCooker.swift
  • Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift
  • Tests/UntoldEngineTests/LODSystemTests.swift
  • Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift
  • Tools/UntoldEngineCLI/Package.swift
  • Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BakeLODsCommand.swift
  • Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift
  • Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift
  • Tools/UntoldEngineCLI/Tests/UntoldEngineCLITests/BakeLODsCommandTests.swift
  • docs/API/UsingLODSystem.md
  • docs/API/UsingTheExporter.md
  • docs/API/UsingUntoldEngineCLI.md
  • docs/Architecture/lodSystem.md

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The change adds mesh-cooking support for generating model LOD files and pack manifest chains. The engine loads those chains and selects levels at runtime. The CLI adds LOD baking for models and packs, and export enables pack LOD generation by default.

Changes

Automatic LOD Chains

Layer / File(s) Summary
Mesh optimizer and package integration
Package.swift, Sources/CMeshOptimizer/*
Adds the UntoldEngineMeshCook product and the vendored meshoptimizer C target. The C sources provide mesh index generation, vertex-cache optimization, and vertex-fetch optimization.
Model LOD cooking and file output
Sources/UntoldEngineMeshCook/*, Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift
Adds model LOD options, eligibility checks, mesh simplification, and generated .untold level writing. Tests cover output, simplification cases, recooking, and pack-cooking support fixtures.
Pack manifest LOD cooking
Sources/UntoldEngineMeshCook/UntoldPackLODCooker.swift, Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift
Adds concurrent cooking of distinct model paths, progress reporting, result aggregation, and lodChains manifest updates.
Runtime pack loading and LOD selection
Sources/UntoldEngine/AssetFormat/UntoldFormat.swift, Sources/UntoldEngine/ECS/Components.swift, Sources/UntoldEngine/Systems/*, Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift, Tests/UntoldEngineTests/LODSystemTests.swift, docs/API/UsingLODSystem.md, docs/Architecture/lodSystem.md
Loads compatible pack LOD meshes and assigns placement-specific switch distances. LOD selection caps finer-level hysteresis and can carry materials between shared-material levels.
CLI baking and export integration
Tools/UntoldEngineCLI/Package.swift, Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/*, Tools/UntoldEngineCLI/Tests/UntoldEngineCLITests/BakeLODsCommandTests.swift, docs/API/UsingTheExporter.md, docs/API/UsingUntoldEngineCLI.md
Adds bake-lods for model and pack inputs. Export bakes pack LOD chains unless --no-lods is set. CLI tests and documentation cover the command and export options.

Priority: ⬆️ High

Estimated code review effort: 5 (Critical) | ~120 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant CLI as bake-lods CLI
  participant UntoldMeshLODCooker
  participant UntoldMeshLODSimplifier
  participant UntoldMeshLODWriter
  participant UntoldPackLODCooker
  CLI->>UntoldMeshLODCooker: cook a model input
  UntoldMeshLODCooker->>UntoldMeshLODSimplifier: simplify mesh levels
  UntoldMeshLODCooker->>UntoldMeshLODWriter: write generated model levels
  CLI->>UntoldPackLODCooker: cook a pack input
  UntoldPackLODCooker->>UntoldMeshLODCooker: cook each distinct model path
  UntoldPackLODCooker->>UntoldPackLODCooker: update pack manifest with LOD chains
Loading

Merge Risk: ⚪ Minimal · up to e908f

This change adds automatic LOD generation for pack models, along with runtime loading and level selection for those chains. No concrete defect was established. A model that cannot be cooked falls back to its full-detail mesh, and the export still succeeds.

Security Architecture Review

Security architecture risk: 🟠 High · up to e908f

Processing an untrusted pack can direct the new cooking flow outside the pack directory, where it can create or remove generated model levels using the caller’s filesystem permissions. The new cooker also accepts malformed geometry that can escape its recoverable failure handling. Ordinary-file collision checks and runtime fallback limit some consequences, but do not enforce these boundaries.

Retained concerns

  • High · security · inferred: Manifest-controlled paths are not confined to the pack root before the new cooking and LOD-loading operations. An attacker who supplies a pack for processing can use traversal paths, or a suitable symlink layout, to reach external models and their generated-level siblings under the invoking process’s permissions. Cooking adds external writes and cleanup beyond the pre-existing runtime model-read exposure. Cleanup and overwrite protection are limited to files recognized as generated levels, rather than assets owned by this pack.
  • Medium · security · inferred: The new cooker relies on decoded geometry without enforcing the cross-buffer invariant that nonempty indices require vertices. A crafted model with zero vertices and sufficient indices can pass the inspected decoder and unpacking checks; invalid indices become zero, while the component builder subsequently indexes an empty position-remap buffer. This creates an inferred process-level failure path outside the per-model throwing-error report, compromising containment of an untrusted asset during a pack cook.
Security review details

Security Blast Radius

  • inferred — The independently attackable scope is a pack accepted for cooking or loading. External access is bounded by the process’s filesystem permissions, not the pack directory. Cooking writes deterministic generated-level siblings and removes recognized generated siblings; this is not evidence of unrestricted ordinary-file overwrite, privilege escalation or remote execution.

Security Findings and Attack Paths

  • inferred — An untrusted manifest model path reaches external model decoding, sibling cleanup and generated-file writes during cooking. Its LOD paths independently reach runtime asset reads before mesh compatibility validation. The new write/cleanup authority is the principal expansion over base behavior.
  • inferred — A malformed model can declare zero vertices and nonzero indices while satisfying the inspected size and stride checks. The cooker substitutes index zero and subsequently uses those indices against an empty remap buffer. Such invalid accesses are outside its throwing-error recovery path; process failure is inferred, not experimentally demonstrated.

Trust Boundaries and Controls

  • observed — The CLI standardizes the selected input URL, but manifest child paths are appended without corresponding containment enforcement. Generated-header collision checks and runtime geometry compatibility checks act on different properties and therefore do not enforce the pack-root boundary.

Resilience and Maintainability Implications

  • inferred — Atomic replacement protects individual files, not the asset generation as a whole. Interruption can leave the old manifest referencing removed or replaced levels. Deduplication uses textual paths, and result locks do not serialize generated-file ownership, so aliases or independent concurrent cooks can race on the same siblings. Runtime level omission limits rendering failure but does not restore the prior generation.

Hardening Proposals

  • proposed — Enforce a shared pack-relative path policy before reads, writes and cleanup, including canonical containment, symlink handling and operation-time ownership checks. Keep explicitly selected standalone-model authority separate from manifest-derived authority.
  • proposed — Validate geometry’s cross-buffer invariants before constructing Swift or native indexed buffers. Reject nonempty indices without vertices and invalid index references through recoverable validation errors rather than substituting an index that may not exist.
  • proposed — Stage generation-specific files, publish their manifest references only after successful completion, and clean up the previous generation afterward. Serialize cooks by canonical asset identity so aliases and concurrent invocations cannot share destructive cleanup authority.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.49% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 204 functions across 21 files. (6 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: automatic LOD chains for models in .untoldpack files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 25.49% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 204 functions across 21 files. (6 skipped: 6 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@untoldengine

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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