Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fea7a26
[Patch] Added tile preview overlay for blender
untoldengine Jun 4, 2026
e921d1f
[Patch] Add Blender tiled-scene metadata and LOD preview tools
untoldengine Jun 5, 2026
107f574
[Patch] Fix lod/hlod distances
untoldengine Jun 8, 2026
2a48368
[Patch] Improved lod overlay for uniform grid
untoldengine Jun 9, 2026
d07f69d
[Patch] Fix LOD/HLOD gen for spanning tiles and add force_local override
untoldengine Jun 10, 2026
ede24bc
[Patch] Use manifest cell_bounds for tile debug overlay
untoldengine Jun 10, 2026
2a12271
[Demo] Updated demo hud
untoldengine Jun 10, 2026
6a9d797
[Patch] Updated the default values in the overlay
untoldengine Jun 10, 2026
bf1b2ce
[Bugfix] HLOD/LOD takes precedence over unload_radius
untoldengine Jun 11, 2026
5db5b74
[Patch] Tile Floor Fill visibility toggle
untoldengine Jun 11, 2026
69feadc
[Patch] Added progress bar to tile exporter
untoldengine Jun 11, 2026
ea9ae2e
[Patch] Improve tiled LOD0 handoff diagnostics and fallback coverage
untoldengine Jun 11, 2026
12c62a3
[Patch] Fix tiled LOD fallback retention during visibility handoff
untoldengine Jun 11, 2026
3303bee
[Patch] Add tile streaming log category coverage
untoldengine Jun 12, 2026
b93a964
[Patch] Added Lod cross fade dithering
untoldengine Jun 12, 2026
f46b221
[Patch] Fixed popping after cross fade implementation
untoldengine Jun 12, 2026
7d7ec52
[Patch] Update Engine API documentation and settings
untoldengine Jun 12, 2026
9a9bcab
[Demo] Updated demo with new API
untoldengine Jun 12, 2026
37e47f7
[Chores] formatted files
untoldengine Jun 12, 2026
89f0224
[Patch] Updated additional APIs
untoldengine Jun 12, 2026
b6b797a
[Docs] Updated add-on script documentation
untoldengine Jun 12, 2026
58216d2
[Demo] Updated demo
untoldengine Jun 12, 2026
2273b6e
[Patch] Improved API
untoldengine Jun 12, 2026
e17b741
[Patch] Engine HLOD replacement fix + test
untoldengine Jun 13, 2026
bebc98d
[Patch] Add tile representation render diagnostics
untoldengine Jun 13, 2026
87cfbd2
[Patch]Collapse underfilled quadtree tile-tier groups
untoldengine Jun 13, 2026
fc42874
[Docs] Updated docs with example
untoldengine Jun 13, 2026
71947a7
[Chores] Formatted files
untoldengine Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/CShaderTypes/ShaderTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ typedef struct{
float alphaCutoff;
float passthroughAlpha; // mixed passthrough color alpha; depth remains opaque
int alphaMode; // 0=opaque, 1=mask, 2=blend
simd_float4 lodDither; // x=threshold, y=mode: 0 off, 1 keep below, 2 keep at/above
bool interactWithLight;
}MaterialParametersUniform;

Expand Down
7 changes: 4 additions & 3 deletions Sources/DemoGame/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,15 +186,16 @@
demoState.onRenderDebugViewChanged = { [weak self] mode in
self?.gameScene.setRenderDebugView(mode)
}
demoState.onSpatialDebugChanged = { [weak self] enabled, occupiedOnly, colorMode in
demoState.onSpatialDebugChanged = { [weak self] enabled, octreeCellsEnabled, occupiedOnly, colorMode in
self?.gameScene.setSpatialDebug(
enabled: enabled,
octreeCellsEnabled: octreeCellsEnabled,
occupiedOnly: occupiedOnly,
colorMode: colorMode
)
}
demoState.onTileBoundsChanged = { enabled in
setTileBoundsDebug(enabled: enabled)
demoState.onTileBoundsChanged = { [weak self] enabled in
self?.gameScene.setTileBoundsDebug(enabled)
}
demoState.onMouseOverControlPanelChanged = { [weak self] isOver in
self?.gameScene.suppressCameraInput = isOver
Expand Down
14 changes: 10 additions & 4 deletions Sources/DemoGame/DemoHUD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,15 @@
Toggle("Spatial Debug", isOn: $state.spatialDebugEnabled)
.toggleStyle(.checkbox)
if state.spatialDebugEnabled {
Toggle("Occupied Only", isOn: $state.spatialOccupiedOnly)
Toggle("Octree Cells", isOn: $state.octreeCellsEnabled)
.toggleStyle(.checkbox)
.padding(.leading, 12)
if state.octreeCellsEnabled {
Toggle("Occupied Only", isOn: $state.spatialOccupiedOnly)
.toggleStyle(.checkbox)
.padding(.leading, 24)
}
Toggle("Tile Bounds", isOn: $state.tileBoundsEnabled)
.toggleStyle(.checkbox)
.padding(.leading, 12)
Picker("Mode", selection: $state.spatialColorMode) {
Expand All @@ -361,9 +369,7 @@
}
.pickerStyle(.segmented)
.frame(minWidth: 180)
Toggle("Tile Bounds", isOn: $state.tileBoundsEnabled)
.toggleStyle(.checkbox)
.padding(.leading, 12)
.padding(.top, 2)
}

Divider()
Expand Down
33 changes: 27 additions & 6 deletions Sources/DemoGame/DemoState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,40 @@
}

var spatialDebugEnabled: Bool = false {
didSet { onSpatialDebugChanged?(spatialDebugEnabled, spatialOccupiedOnly, spatialColorMode) }
didSet {
onSpatialDebugChanged?(spatialDebugEnabled, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode)
// Tile Bounds is a child of Spatial Debug; propagate the master gate.
onTileBoundsChanged?(spatialDebugEnabled ? tileBoundsEnabled : false)
}
}

/// Whether octree leaf cells are shown within the Spatial Debug overlay.
var octreeCellsEnabled: Bool = true {
didSet {
if spatialDebugEnabled {
onSpatialDebugChanged?(true, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode)
}
}
}

var spatialColorMode: SpatialDebugLeafColorMode = .plain {
didSet { if spatialDebugEnabled { onSpatialDebugChanged?(true, spatialOccupiedOnly, spatialColorMode) } }
didSet {
if spatialDebugEnabled {
onSpatialDebugChanged?(true, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode)
}
}
}

var spatialOccupiedOnly: Bool = true {
didSet { if spatialDebugEnabled { onSpatialDebugChanged?(true, spatialOccupiedOnly, spatialColorMode) } }
didSet {
if spatialDebugEnabled {
onSpatialDebugChanged?(true, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode)
}
}
}

var tileBoundsEnabled: Bool = false {
didSet { onTileBoundsChanged?(tileBoundsEnabled) }
didSet { if spatialDebugEnabled { onTileBoundsChanged?(tileBoundsEnabled) } }
}

// MARK: - Stats
Expand All @@ -216,7 +237,7 @@
var onAntiAliasingChanged: ((AntiAliasingMode) -> Void)?
var onTextureStreamingTierDebugChanged: ((Bool) -> Void)?
var onRenderDebugViewChanged: ((RenderDebugViewMode) -> Void)?
var onSpatialDebugChanged: ((Bool, Bool, SpatialDebugLeafColorMode) -> Void)?
var onSpatialDebugChanged: ((Bool, Bool, Bool, SpatialDebugLeafColorMode) -> Void)?
var onTileBoundsChanged: ((Bool) -> Void)?
var onMouseOverControlPanelChanged: ((Bool) -> Void)?

Expand All @@ -233,7 +254,7 @@
ssaoBias = Double(preset.ssaoBias)
ssaoIntensity = Double(preset.ssaoIntensity)
isApplyingPostFXPreset = false
PostFX.apply(preset)
setPostFX(.preset(preset))
}

private func notifyColorGradingChanged(force: Bool = false) {
Expand Down
88 changes: 49 additions & 39 deletions Sources/DemoGame/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
/// - Entity lifecycle: `createEntity`, `setEntityName`, `destroyAllEntities`
/// - Camera/input: `createGameCamera`, `findGameCamera`, `moveCameraWithInput`, `orbitCameraAround`
/// - Asset loading: `setEntityMeshAsync` (always-resident), `setEntityStreamScene` (streamable scene)
/// - Performance features: `setEntityStaticBatchComponent`, `enableBatching`, `generateBatches`, `enableStreaming`
/// - Debug overlays: `setLODLevelDebug`, `setTextureStreamingTierDebug`, `setOctreeLeafBoundsDebug`
/// - Performance features: `setEntityStaticBatchComponent`, `setBatching`, `generateBatches`, `setGeometryStreaming`
/// - Debug overlays: `setSpatialDebug`
final class GameScene: @unchecked Sendable {
private enum LoadedContent {
case none
Expand Down Expand Up @@ -55,7 +55,7 @@
init() {
InputSystem.shared.registerKeyboardEvents()
InputSystem.shared.registerMouseEvents()
bypassPostProcessing = false
setRendering(.postProcessing(.enabled))
setupDefaultSceneObjects()
}
}
Expand All @@ -71,12 +71,10 @@
let light = createEntity()
setEntityName(entityId: light, name: "Directional Light")
createDirLight(entityId: light)
setCamera(.active(gameCamera))

CameraSystem.shared.activeCamera = gameCamera

applyIBL = true
renderEnvironment = false

setRendering(.environment(.ibl(true)))
setRendering(.environment(.visible(false)))
// setEngineStatsLogging(enabled: true, profile: .verbose, intervalSeconds: 1.0)
}
}
Expand All @@ -98,10 +96,10 @@
loadedEntity = success ? entity : nil
loadedContent = success ? .mesh(entity) : .none
let camera = findGameCamera()
CameraSystem.shared.activeCamera = camera
setCamera(.active(camera))
cameraBehavior = .flyOrbit
setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset)
renderEnvironment = false
setRendering(.environment(.visible(false)))
completion(success)
}
}
Expand All @@ -116,7 +114,7 @@
}

clearSceneBatches()
GeometryStreamingSystem.shared.enabled = true
setGeometryStreaming(.enabled(true))

let sceneRoot = createEntity()
setEntityName(entityId: sceneRoot, name: sceneID)
Expand All @@ -128,15 +126,15 @@
loadedContent = .tiledScene(sceneRoot)
cameraBehavior = Self.cameraBehavior(for: sceneID)
Self.applyCameraEye(for: sceneID)
renderEnvironment = Self.shouldRenderEnvironment(for: sceneID)
setRendering(.environment(.visible(Self.shouldRenderEnvironment(for: sceneID))))
}
completion(success)
}
}

private func prepareForMeshLoad(completion: @escaping () -> Void) {
clearSceneBatches()
GeometryStreamingSystem.shared.enabled = false
setGeometryStreaming(.enabled(false))

switch loadedContent {
case let .mesh(entity):
Expand All @@ -158,7 +156,7 @@
private static func applyCameraEye(for sceneID: String) {
let camera = findGameCamera()
let eye: simd_float3
let target: simd_float3
var target: simd_float3

switch cameraBehavior(for: sceneID) {
case .originOrbit:
Expand All @@ -170,7 +168,7 @@
}

cameraLookAt(entityId: camera, eye: eye, target: target, up: cameraUpDefault)
CameraSystem.shared.activeCamera = camera
setCamera(.active(camera))
if cameraBehavior(for: sceneID) == .originOrbit {
setOriginOrbitTarget(entityId: camera)
} else {
Expand Down Expand Up @@ -221,76 +219,88 @@
guard let entity = loadedEntity else { return }
if enabled {
setEntityStaticBatchComponent(entityId: entity)
enableBatching(true)
UntoldEngine.setBatching(.enabled(true))
generateBatches()
} else {
enableBatching(false)
UntoldEngine.setBatching(.enabled(false))
}
}

/// Enables or disables the geometry streaming system.
/// Streaming radii are declared in the scene manifest; this is a runtime on/off toggle only.
func setStreaming(_ enabled: Bool, streamingRadius _: Float, unloadRadius _: Float) {
GeometryStreamingSystem.shared.enabled = enabled
setGeometryStreaming(.enabled(enabled))
}
}

// MARK: - Debug Views

extension GameScene {
func setColorGrading(enabled: Bool, exposure: Float, brightness: Float, contrast: Float, saturation: Float) {
PostFX.enableColorGrading(enabled)
ColorGradingParams.shared.exposure = exposure
ColorGradingParams.shared.brightness = brightness
ColorGradingParams.shared.contrast = contrast
ColorGradingParams.shared.saturation = saturation
setPostFX(.colorGrading(.enabled(enabled)))
setPostFX(.colorGrading(.exposure(exposure)))
setPostFX(.colorGrading(.brightness(brightness)))
setPostFX(.colorGrading(.contrast(contrast)))
setPostFX(.colorGrading(.saturation(saturation)))
}

func setSSAO(enabled: Bool, radius: Float, bias: Float, intensity: Float) {
SSAO.setEnabled(enabled)
SSAO.setRadius(radius)
SSAO.setBias(bias)
SSAO.setIntensity(intensity)
setPostFX(.ssao(.enabled(enabled)))
setPostFX(.ssao(.radius(radius)))
setPostFX(.ssao(.bias(bias)))
setPostFX(.ssao(.intensity(intensity)))
}

/// Selects the active anti-aliasing pass used by the render graph.
func setAntiAliasing(_ mode: AntiAliasingMode) {
antiAliasingMode = mode
setRendering(.antiAliasing(mode))
}

/// Toggles the per-entity LOD level colour overlay.
func setLodDebug(_ enabled: Bool) {
setLODLevelDebug(enabled: enabled)
UntoldEngine.setSpatialDebug(.lodLevels(enabled))
}

/// Toggles the texture streaming tier colour overlay.
func setStreamingTierDebug(_ enabled: Bool) {
setTextureStreamingTierDebug(enabled: enabled)
UntoldEngine.setSpatialDebug(.textureStreamingTiers(enabled))
}

/// Toggles streamed tile bounds in the Spatial Debug overlay.
func setTileBoundsDebug(_ enabled: Bool) {
UntoldEngine.setSpatialDebug(.tileBounds(enabled: enabled))
}

/// Selects the renderer debug output.
func setRenderDebugView(_ mode: RenderDebugViewMode) {
if mode == .ssaoBlurred, SSAO.isEnabled() == false {
SSAO.setEnabled(true)
setPostFX(.ssao(.enabled(true)))
}
renderDebugViewMode = mode
setRendering(.debugView(mode))
}

/// Draws (or hides) the octree leaf-node bounds debug overlay.
func setSpatialDebug(
enabled: Bool,
octreeCellsEnabled: Bool,
occupiedOnly: Bool,
colorMode: SpatialDebugLeafColorMode
) {
if enabled {
setOctreeLeafBoundsDebug(
enabled: true,
maxLeafNodeCount: 0,
occupiedOnly: occupiedOnly,
colorMode: colorMode
)
// Always apply the color mode and occupiedOnly so tile bounds (which read
// these settings directly from SpatialDebugVisualization) stay in sync even
// when octree leaf cells are toggled off.
if octreeCellsEnabled {
UntoldEngine.setSpatialDebug(.octreeLeafBounds(.enabled(
maxLeafNodeCount: 0,
occupiedOnly: occupiedOnly,
colorMode: colorMode
)))
} else {
UntoldEngine.setSpatialDebug(.octreeLeafBounds(.disabled))
}
} else {
disableSpatialDebugVisualization()
UntoldEngine.setSpatialDebug(.disabled)
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,22 @@ public class TileLODTagComponent: Component {
}
}

/// Per-render-descendant dither state used while tile LOD/HLOD/full-tile
/// representations hand off visibility.
public class TileRepresentationFadeComponent: Component {
/// 0...1 cross-fade progress.
public var progress: Float = 0
/// Matches MaterialParametersUniform.lodDither.y.
/// 1 = incoming representation, 2 = outgoing representation.
public var mode: Float = 0

public required init() {}
public init(progress: Float, mode: Float) {
self.progress = progress
self.mode = mode
}
}

// MARK: Static Batching Component

public class StaticBatchComponent: Component {
Expand Down Expand Up @@ -659,6 +675,18 @@ public class TileComponent: Component {
/// Tracks representation churn for diagnostics and dwell logic.
public var lastLoadedLODIndex: Int?

/// Partition-cell AABB from the manifest (key: "cell_bounds").
/// Tighter than the mesh content AABB for spanning tiles whose geometry
/// overflows the cell boundary. Used by the debug tile-bounds visualizer
/// so the drawn box matches the Blender overlay partition cells.
/// nil for manifests that predate the cell_bounds field and for the shared bucket.
public var cellBounds: AABB?

/// True when this entity represents the shared-bucket tile (the monolithic asset
/// holding world-spanning objects). The shared bucket is not a partition cell so
/// it is excluded from the "Tile Bounds" debug overlay.
public var isSharedBucket: Bool = false

public required init() {}
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ private func expandedEngineStatsString(_ snapshot: EngineStatsSnapshot) -> Strin
Render: draws \(snapshot.render.drawCallsTotal) (opaque \(snapshot.render.drawCallsOpaque), transparent \(snapshot.render.drawCallsTransparent), shadow \(snapshot.render.drawCallsShadow), batched \(snapshot.render.drawCallsBatched)) | triangles \(snapshot.render.trianglesTotal) | visible \(snapshot.render.visibleInstances)
Culling: frustum \(snapshot.culling.frustumPassed)/\(snapshot.culling.frustumTested) failed \(snapshot.culling.frustumFailed) | occlusion \(snapshot.culling.occlusionPassed)/\(snapshot.culling.occlusionTested) failed \(snapshot.culling.occlusionFailed) | usedHZB \(snapshot.culling.usedHZB) validHZB \(snapshot.culling.hzbIsValid)
Streaming: loaded \(snapshot.streaming.loadedStreamingEntities) loading \(snapshot.streaming.loadingStreamingEntities) unloaded \(snapshot.streaming.unloadedStreamingEntities) | active \(snapshot.streaming.activeLoads) | nearby \(snapshot.streaming.nearbyEntitiesQueried) candidates \(snapshot.streaming.loadCandidates) slots \(snapshot.streaming.availableLoadSlots) | backlog \(snapshot.streaming.pendingLoadBacklog) | pendingUploads \(snapshot.streaming.pendingUploadCount) | gateMs \(formatMs(snapshot.streaming.blockedByGateMs))
Streaming: tick=\(snapshot.streaming.updateTriggered) workMs \(formatMs(snapshot.streaming.updateWorkMs)) | evictions \(snapshot.streaming.evictionsPerformed) | avgLoadMs \(formatMs(snapshot.streaming.averageAsyncLoadMs)) | applyMs \(formatMs(snapshot.streaming.lastApplyLoadedMeshMs)) | tileSwapWarn \(snapshot.streaming.tileSwapWarnings) | hierGateSkip \(snapshot.streaming.tilesSkippedByHierarchyGate)
Streaming: tick=\(snapshot.streaming.updateTriggered) workMs \(formatMs(snapshot.streaming.updateWorkMs)) | evictions \(snapshot.streaming.evictionsPerformed) | avgLoadMs \(formatMs(snapshot.streaming.averageAsyncLoadMs)) | applyMs \(formatMs(snapshot.streaming.lastApplyLoadedMeshMs)) | tileSwapWarn \(snapshot.streaming.tileSwapWarnings) | repGap \(snapshot.streaming.tileRepresentationGapWarnings) | lod0VisWarn \(snapshot.streaming.lod0VisibilityWarnings) covered \(snapshot.streaming.lod0VisibilityWarningsWithFallback) open \(snapshot.streaming.lod0VisibilityWarningsNoFallback) | hierGateSkip \(snapshot.streaming.tilesSkippedByHierarchyGate)
TileReps: resident full/lod/hlod \(snapshot.streaming.residentFullTileRepresentations)/\(snapshot.streaming.residentLODRepresentations)/\(snapshot.streaming.residentHLODRepresentations) | visible full/lod/hlod \(snapshot.streaming.visibleFullTileRepresentations)/\(snapshot.streaming.visibleLODRepresentations)/\(snapshot.streaming.visibleHLODRepresentations) | overlap visible full+lod/full+hlod/lod+hlod \(snapshot.streaming.fullAndLODVisibleOverlapTiles)/\(snapshot.streaming.fullAndHLODVisibleOverlapTiles)/\(snapshot.streaming.lodAndHLODVisibleOverlapTiles) residentFull+fallback \(snapshot.streaming.fullAndFallbackResidentOverlapTiles) | fades \(snapshot.streaming.activeTileRepresentationFades) waiting \(snapshot.streaming.waitingTileRepresentationFades)
TileRenderCost: visible full/lod/hlod \(snapshot.render.tileFullVisibleInstances)/\(snapshot.render.tileLODVisibleInstances)/\(snapshot.render.tileHLODVisibleInstances) | draws full/lod/hlod \(snapshot.render.tileFullDrawsEstimate)/\(snapshot.render.tileLODDrawsEstimate)/\(snapshot.render.tileHLODDrawsEstimate) | tris full/lod/hlod \(snapshot.render.tileFullTrianglesEstimate)/\(snapshot.render.tileLODTrianglesEstimate)/\(snapshot.render.tileHLODTrianglesEstimate)
Batching: groups \(snapshot.batching.batchGroupCount) | batchedMeshes \(snapshot.batching.batchedMeshCount) | dirty \(snapshot.batching.dirtyCellsBeforePrune)→\(snapshot.batching.dirtyCellsAfterPrune) | defWork \(snapshot.batching.deferredByWorkBudget) skipComplex \(snapshot.batching.skippedByComplexityGuard) | dispatched \(snapshot.batching.dispatchedBuilds)→\(snapshot.batching.lastRebuildOutputBatchCount) groups | rebuilds/s \(snapshot.batching.rebuildsThisSecond) | rebuildMs \(formatMs(snapshot.batching.lastRebuildCostMs))
Memory: mesh \(meshMB)/\(meshBudgetMB)mb | tex \(texMB)/\(texBudgetMB)mb | total \(memPct) | entities \(snapshot.memory.trackedEntityCount)\(pressure)
"""
Expand Down
Loading
Loading