Logger
UntoldEngine includes a thread-safe logger with log-level filtering, per-category toggles, and a sink API for routing log events to custom destinations (e.g. an in-editor console).
Log Levels
Log level controls the minimum severity that is emitted. Set it once at startup:
Logger.logLevel = .debug // emit everything
Logger.logLevel = .info // emit info, warnings, and errors
Logger.logLevel = .warning // emit warnings and errors only
Logger.logLevel = .error // emit errors only
Logger.logLevel = .none // suppress all output
| Level | Value | What emits |
|---|---|---|
.none |
0 | nothing |
.error |
1 | errors |
.warning |
2 | warnings + errors |
.info |
3 | info + warnings + errors |
.debug |
4 | everything |
.test |
5 | everything (used in unit tests) |
The default level is .debug.
Logging Messages
Info / general trace
Requires logLevel >= .info. Suppressed if the category is disabled.
Console output is prefixed with Log:.
Warnings
Requires logLevel >= .warning. Always emits regardless of category state.
Console output is prefixed with Warning:.
Errors
Requires logLevel >= .error. Always emits regardless of category state.
Console output is prefixed with Error:.
Debug vectors
Logger.log(vector: simd_float3(1, 2, 3), category: LogCategory.general.rawValue)
Logger.log(message: "Camera position", vector: position, category: LogCategory.xrCamera.rawValue)
Vector helpers require logLevel >= .debug and are suppressed if the category is disabled.
Note: Messages are lazily evaluated (
@autoclosure), so string interpolation cost is skipped when the log would be suppressed.
Log Categories
Categories let you silence or focus specific subsystems without changing the global log level.
| Category | Raw value | Default state |
|---|---|---|
.general |
"General" |
enabled |
.ecs |
"ECS" |
enabled |
.engineStats |
"EngineStats" |
enabled |
.integration |
"Integration" |
enabled |
.xrCamera |
"XRCamera" |
disabled |
.oocTiming |
"OOCTiming" |
disabled |
.oocStatus |
"OOCStatus" |
disabled |
.assetLoader |
"AssetLoader" |
disabled |
.tileStreaming |
"TileStreaming" |
disabled |
.streamingHeartbeat |
"StreamingHeartbeat" |
disabled |
.textureStreaming |
"TextureStreaming" |
disabled |
.textureLoading |
"TextureLoading" |
disabled |
.batching |
"Batching" |
disabled |
High-volume categories are off by default to avoid log spam during normal operation.
Enabling and Disabling Categories
// Enable a category
Logger.enable(category: .oocStatus)
// Disable a category
Logger.disable(category: .xrCamera)
// Toggle with a Bool
Logger.set(category: .assetLoader, enabled: true)
// Check current state
if Logger.isEnabled(category: .ecs) { ... }
// Reset all overrides back to defaults
Logger.resetCategoryToggles()
Typical debug session
// Turn on verbose geometry streaming traces for a debug session
Logger.enable(category: .tileStreaming)
Logger.enable(category: .streamingHeartbeat)
Logger.enable(category: .oocStatus)
Logger.enable(category: .oocTiming)
Logger.enable(category: .assetLoader)
// ... reproduce the issue ...
// Clean up after capture
Logger.disable(category: .tileStreaming)
Logger.disable(category: .streamingHeartbeat)
Logger.disable(category: .oocStatus)
Logger.disable(category: .oocTiming)
Logger.disable(category: .assetLoader)
Texture diagnostics can be enabled separately:
Static batching diagnostics
The .batching category drives BatchingSystem's material-diversity report. It tells you why batch coverage is low — too few StaticBatchComponent entities, material variety that prevents grouping, cells blocked by the complexity guard, and so on.
One-shot snapshot (most common):
Logger.enable(category: .batching)
BatchingSystem.shared.logMaterialDiagnosticsNow() // immediate scan and emit
Logger.disable(category: .batching)
Periodic auto-logging (fires at most once every 30 s while enabled):
// Call once at startup to arm it; the engine loop calls logMaterialDiagnosticsIfDue() each frame.
Logger.enable(category: .batching)
// When done:
Logger.disable(category: .batching)
Sample output:
[BatchMaterial] staticBatch=916 registered=916 resolved=916 batchable=87%
| singletons=119 groupable=797 | cellsBlocked=2
| uniqueMatLOD=80 singletonKeys=119 groupableKeys=132
[BatchMaterial] cell(0,-1,0) ents=224 uniqueKeys=49 singletons=22 groupable=27 groups=0 ratio=0.45
[BatchMaterial] cell(-1,-1,0) ents=238 uniqueKeys=42 singletons=16 groupable=26 groups=0 ratio=0.38
| Field | Meaning |
|---|---|
staticBatch |
Entities in the scene that carry StaticBatchComponent |
registered |
Entities resident in the batching system |
resolved |
Passed all eligibility checks (no animation, no transparency, etc.) |
batchable |
Percentage of resolved entities that share a material key with a peer |
singletons |
Entities that are the only one with their material in their cell — can never batch |
groupable |
Entities that can form a batch group |
cellsBlocked |
Cells rejected by the runtime complexity guard |
uniqueMatLOD |
Distinct (material × LOD) keys across the whole scene |
ratio |
Per-cell singleton fraction — close to 1.0 means high material diversity |
A low batchable percentage with a small uniqueMatLOD count points to a cell-size or complexity-guard problem. A high uniqueMatLOD count with a high ratio per cell points to material diversity in the asset.
See UsingProfiler.md → Static Batching Triage for a full diagnostic workflow.
Adding a Custom Sink
Implement LoggerSink to route events to a custom destination such as an editor console or file:
final class ConsoleSink: LoggerSink {
func didLog(_ event: LogEvent) {
print("[\(event.category)] \(event.message)")
}
}
let sink = ConsoleSink()
Logger.addSink(sink)
LogEvent exposes level, message, category, file, function, line, and timestamp.
Sinks are held weakly — the logger will not extend their lifetime.
Sink delivery and backlog replay are available on macOS (
AppKit) builds only.
Category Toggle Notes
Logger.log(...)respects bothlogLeveland category state.Logger.logWarning(...)andLogger.logError(...)respectlogLevelonly — they are never suppressed by category.- Category overrides layer on top of the built-in defaults. Call
resetCategoryToggles()to restore defaults without restarting.