UntoldEngine now has two distinct optimization workflows:
| Workflow | Use for |
|---|---|
| Entity-level LOD + manual batching | Always-resident props, structures, authored gameplay objects |
| Manifest-driven tile streaming + automatic batching | Large worlds, terrain, cities, remote streamed scenes |
For normal entities that should stay resident, combine LODComponent with StaticBatchComponent:
private func setupLODWithBatching() {
var loadedCount = 0
let totalTrees = 20
for i in 0 ..< totalTrees {
let tree = createEntity()
setEntityName(entityId: tree, name: "Tree_\(i)")
setEntityLodComponent(entityId: tree)
let x = Float(i % 5) * 10.0
let z = Float(i / 5) * 10.0
addLODLevels(entityId: tree, levels: [
(0, "tree_LOD0", "untold", 50.0, 0.0),
(1, "tree_LOD1", "untold", 100.0, 0.0),
(2, "tree_LOD2", "untold", 200.0, 0.0),
]) { success in
if success {
translateTo(entityId: tree, position: simd_float3(x, 0, z))
setEntityStaticBatchComponent(entityId: tree)
}
loadedCount += 1
if loadedCount == totalTrees {
enableBatching(true)
generateBatches()
}
}
}
}This is still the correct pattern when all meshes are present up front and stay resident.
For large worlds, do not build a manual LOD + enableStreaming(...) stack on standalone entities. Use the tiled-scene pipeline:
let sceneRoot = createEntity()
setEntityName(entityId: sceneRoot, name: "city")
setEntityStreamScene(entityId: sceneRoot, manifest: "city", withExtension: "json") { success in
setSceneReady(success)
}In this workflow:
- full-tile streaming is driven by manifest radii
- per-tile
lod_levelssupply intermediate representations hlod_levelscover the far field- OCC mesh stubs are created internally for large tiles
- static batching is updated automatically as tiles, LODs, and OCC meshes become resident
You do not call generateBatches() per tile. The runtime hands new resident tile geometry directly to BatchingSystem.
- the asset should remain in memory
- you are placing a bounded number of authored objects
- you want direct programmatic control over LOD levels per entity
- the scene is large enough that full residency is wasteful
- you need prefetch, HLOD, per-tile LOD, and eviction
- you want local or remote manifest-driven world streaming
The engine automatically applies a platform-appropriate batching preset at startup. You do not need to call anything for sensible defaults.
- macOS →
.macOSBalanced(standard per-tick budgets, 160K vertex per-cell guard) - visionOS →
.visionOSBalanced(relaxed per-cell guards to batch dense architecture, conservative apply rate to protect frame pacing)
let tuning = BatchingSystem.shared.getRuntimeBatchingTuning()
print(tuning.maxRuntimeCellVertices) // e.g. 1_200_000 on visionOS
print(tuning.cellSize) // world-space cell side lengthIf your scene has unusually dense geometry or strict frame-time requirements, start from the active preset and override only the fields you need:
var tuning = BatchingSystem.shared.getRuntimeBatchingTuning()
tuning.quiescenceFramesBeforeBatchBuild = 4 // wait longer before rebuilding during streaming churn
tuning.maxArtifactAppliesPerTick = 1 // spread swap-ins to avoid frame spikes
BatchingSystem.shared.applyRuntimeBatchingTuning(tuning)Call this after the engine has started (e.g., in your scene's init block), since the engine applies the platform preset during its own init. Your override will replace it.
Changing
cellSizeinvalidates all existing batches and triggers a full rebuild. Change it before entities are loaded whenever possible.
For a full description of every tuning parameter, see Batching System Architecture.
- Keep dynamic or animated entities out of static batching.
- Use
.untoldfor static runtime geometry whenever possible. - Keep entity-level LOD for authored objects; keep tile LOD/HLOD in the manifest.
- Treat
StreamingComponentas internal to the tiled streaming architecture.