Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# Changelog
## v0.12.15 - 2026-05-26
### 🐞 Fixes
- [Patch] Add scene channels for selectable and context geometry visibility (2c9217d…)
### 📚 Docs
- [Docs] Updated documentation (cbade44…)
## v0.12.14 - 2026-05-22
### 🐞 Fixes
- [Patch] Fix transparency bug (6a51e40…)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Clone the repository and launch the demo:
```bash
git clone https://github.com/untoldengine/UntoldEngine.git
cd UntoldEngine
git checkout v0.12.14
git checkout v0.12.15
swift run untolddemo
```

Expand Down
2 changes: 1 addition & 1 deletion Sources/DemoGame/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private enum Constants {
static let appVersion = "0.12.14"
static let appVersion = "0.12.15"
static let defaultWindowSize = NSSize(width: 1920, height: 1080)
static let minimumWindowSize = NSSize(width: 640, height: 480)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/DemoGame/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

private enum Constants {
static let orbitTargetOffset: Float = 25.0
static let cameraMoveSpeed: Float = 0.5
static let cameraMoveSpeed: Float = 1.0
static let cameraInputDeltaTime: Float = 0.1
static let streamingPriority: Int = 10
static let citySceneID = "city"
Expand Down
2 changes: 1 addition & 1 deletion Sources/Sandbox/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private enum Constants {
static let appVersion = "0.12.14"
static let appVersion = "0.12.15"
static let windowSize = NSSize(width: 1600, height: 900)
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public class PickInteractionComponent: Component {
public required init() {}
}

public class EntitySceneChannelsComponent: Component {
public var channels: SceneChannel = []
public var usesDefaultChannels: Bool = false

public required init() {}
}

public class GaussianComponent: Component {
var splatData: MTLBuffer?
var gaussianSortedIndices: MTLBuffer?
Expand Down
11 changes: 8 additions & 3 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,15 +271,17 @@ public enum RenderPasses {
private static func collectVisibleBatchGroupsDirect() -> [BatchGroup] {
let groups = BatchingSystem.shared.batchGroups
guard !groups.isEmpty else { return [] }
let channelVisibleGroups = groups.filter { areSceneChannelsVisible($0.sceneChannels) }
guard !channelVisibleGroups.isEmpty else { return [] }

guard let frustum = currentFrameFrustum else {
// Frustum not yet available — derive from visible entities (safe fallback).
let ids = collectVisibleBatchIds(from: visibleEntityIds)
return groups.filter { ids.contains($0.id) }
return channelVisibleGroups.filter { ids.contains($0.id) }
}

// Phase 1: frustum cull — drop groups whose AABB is outside the view frustum.
let frustumPassed = groups.filter {
let frustumPassed = channelVisibleGroups.filter {
isAABBInFrustum(frustum, min: $0.boundingBox.min, max: $0.boundingBox.max)
}

Expand Down Expand Up @@ -367,6 +369,7 @@ public enum RenderPasses {

for entityId in entities {
if shouldSkipShadowEntity(entityId) { continue }
if shouldHideSceneEntity(entityId: entityId) { continue }
if BatchingSystem.shared.isEnabled() {
// Batch-eligible entities (StaticBatchComponent present) are always drawn
// via shadowCasterBatchGroups — whether or not the batch rebuild has landed
Expand Down Expand Up @@ -404,7 +407,7 @@ public enum RenderPasses {
}

private static func shadowCasterBatchGroups(for cascadeIdx: Int) -> [BatchGroup] {
let groups = BatchingSystem.shared.batchGroups
let groups = BatchingSystem.shared.batchGroups.filter { areSceneChannelsVisible($0.sceneChannels) }
guard !groups.isEmpty, let frustum = shadowFrustum(for: cascadeIdx) else { return [] }

return groups.filter {
Expand Down Expand Up @@ -994,6 +997,7 @@ public enum RenderPasses {
for entityId in visibleEntityIds {
// Skip entities that are pending destroy
if scene.mask(for: entityId) == nil { continue }
if shouldHideSceneEntity(entityId: entityId) { continue }

// Skip batched entities if batching is enabled
if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) {
Expand Down Expand Up @@ -2435,6 +2439,7 @@ public enum RenderPasses {

for entityId in visibleEntityIds {
if scene.mask(for: entityId) == nil { continue }
if shouldHideSceneEntity(entityId: entityId) { continue }

if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) {
continue
Expand Down
2 changes: 1 addition & 1 deletion Sources/UntoldEngine/Renderer/UntoldEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {

CameraSystem.shared.activeCamera = gameCamera

Logger.log(message: "Untold Engine Starting. Version 0.12.14")
Logger.log(message: "Untold Engine Starting. Version 0.12.15")
}

public func initSizeableResources() {
Expand Down
18 changes: 15 additions & 3 deletions Sources/UntoldEngine/Systems/BatchingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ private struct BatchBuildKey: Hashable {
let cellId: BatchCellID
let materialHash: String
let lodIndex: Int
let sceneChannelsRawValue: UInt64

var materialLODHash: String {
"\(materialHash)_LOD\(lodIndex)"
Expand All @@ -60,6 +61,7 @@ private struct BatchCandidate {
let worldTransform: WorldTransformComponent
let lodIndex: Int
let cellId: BatchCellID
let sceneChannels: SceneChannel
}

private struct BatchCellLifecycleRecord {
Expand Down Expand Up @@ -422,6 +424,7 @@ public struct BatchGroup {
var entityIds: [EntityID] // Original entities in this batch
var meshIndices: [(entityId: EntityID, meshIndex: Int)] // Track source meshes
var boundingBox: (min: simd_float3, max: simd_float3)
var sceneChannels: SceneChannel

/// Incremented each time `updateBatchMaterialInPlace` patches this group's textures.
/// Used to detect whether a batch artifact is stale relative to the live streaming state.
Expand Down Expand Up @@ -1413,7 +1416,8 @@ public class BatchingSystem: @unchecked Sendable {
let key = BatchBuildKey(
cellId: candidate.cellId,
materialHash: matHash,
lodIndex: candidate.lodIndex
lodIndex: candidate.lodIndex,
sceneChannelsRawValue: candidate.sceneChannels.rawValue
)
groupCounts[key, default: 0] += 1
groupVertices[key, default: 0] += vertexCount
Expand Down Expand Up @@ -1504,6 +1508,9 @@ public class BatchingSystem: @unchecked Sendable {
// Skip entities with empty meshes (not yet loaded by streaming)
if renderComponent.mesh.isEmpty { return nil }

// Identity-preserved streamed objects must stay individually renderable/selectable.
if shouldPreserveSceneEntityIdentity(entityId: entityId) { return nil }

// Skip entities with animations
if scene.get(component: SkeletonComponent.self, for: entityId) != nil { return nil }
if scene.get(component: AnimationComponent.self, for: entityId) != nil { return nil }
Expand Down Expand Up @@ -1531,7 +1538,8 @@ public class BatchingSystem: @unchecked Sendable {
renderComponent: renderComponent,
worldTransform: worldTransform,
lodIndex: lodIndex,
cellId: cellId
cellId: cellId,
sceneChannels: getEntitySceneChannels(entityId: entityId)
)
}

Expand All @@ -1550,7 +1558,8 @@ public class BatchingSystem: @unchecked Sendable {
let batchKey = BatchBuildKey(
cellId: candidate.cellId,
materialHash: matHash,
lodIndex: candidate.lodIndex
lodIndex: candidate.lodIndex,
sceneChannelsRawValue: candidate.sceneChannels.rawValue
)
let finalTransform = simd_mul(candidate.worldTransform.space, mesh.localSpace)

Expand Down Expand Up @@ -1907,6 +1916,7 @@ public class BatchingSystem: @unchecked Sendable {
var allIndices: [UInt32] = []
var entityIds: [EntityID] = []
var meshIndices: [(EntityID, Int)] = []
var sceneChannels: SceneChannel = []

var minBounds = simd_float3(Float.infinity, Float.infinity, Float.infinity)
var maxBounds = simd_float3(-Float.infinity, -Float.infinity, -Float.infinity)
Expand Down Expand Up @@ -1936,6 +1946,7 @@ public class BatchingSystem: @unchecked Sendable {

entityIds.append(item.entityId)
meshIndices.append((item.entityId, item.meshIndex))
sceneChannels.formUnion(getEntitySceneChannels(entityId: item.entityId))
}

guard !allPositions.isEmpty, !allIndices.isEmpty else {
Expand Down Expand Up @@ -2022,6 +2033,7 @@ public class BatchingSystem: @unchecked Sendable {
entityIds: entityIds,
meshIndices: meshIndices,
boundingBox: (min: minBounds, max: maxBounds),
sceneChannels: sceneChannels,
isLODBatch: isLODBatch
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,9 @@ extension GeometryStreamingSystem {
// The completion already holds the world-mutation gate.
setEntityStaticBatchComponentUngated(entityId: capturedMeshEntityId)

let tileRenderIds = self.collectRenderDescendantIds(capturedMeshEntityId)
let selectableRenderIds = tileRenderIds.filter { hasEntitySceneChannel(entityId: $0, channel: .selectableGeometry) }

// For fullLoad tiles (occCount == 0) the RenderComponent is
// already present on capturedMeshEntityId and its children —
// they bypass the OCC upload path that normally queues the
Expand All @@ -573,7 +576,6 @@ extension GeometryStreamingSystem {
// Also enqueue into the texture streaming burst queue so
// freshly loaded tile geometry gets its first texture upgrade
// before the regular visible-entity pass.
let tileRenderIds = self.collectRenderDescendantIds(capturedMeshEntityId)
if !tileRenderIds.isEmpty {
BatchingSystem.shared.notifyTileEntitiesResident(tileRenderIds)
TextureStreamingSystem.shared.notifyEntitiesReady(tileRenderIds)
Expand All @@ -582,7 +584,16 @@ extension GeometryStreamingSystem {

let budgetStats = MemoryBudgetManager.shared.getStats()
let geomPct = Int((budgetStats.geometryUtilization * 100).rounded())
Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)")
let selectableNames = selectableRenderIds
.map { getEntityName(entityId: $0) }
.filter { !$0.isEmpty }
.sorted()
.prefix(8)
.joined(separator: ", ")
let selectableSuffix = selectableRenderIds.isEmpty
? ""
: " selectable=[\(selectableNames)]"
Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload, render=\(tileRenderIds.count), selectable=\(selectableRenderIds.count)). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)\(selectableSuffix)")
} else {
// Destroy the pre-created child entity on failure so it
// doesn't leak as an empty, invisible stub.
Expand Down
25 changes: 24 additions & 1 deletion Sources/UntoldEngine/Systems/RegistrationSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ private func registerComponentCleanupHandlers() {
removeEntityPickInteraction(entityId: entityId)
}

ComponentRegistry.register(componentType: EntitySceneChannelsComponent.self, handlerId: "sceneChannels", priority: 30) { entityId in
removeEntitySceneChannels(entityId: entityId)
}

ComponentRegistry.register(componentType: LocalTransformComponent.self, handlerId: "transforms", priority: 90) { entityId in
removeEntityTransforms(entityId: entityId)
}
Expand Down Expand Up @@ -639,6 +643,7 @@ private func registerUntoldProgressiveStubEntity(
sc.streamingRadius = Float.greatestFiniteMagnitude
sc.unloadRadius = Float.greatestFiniteMagnitude
}
setDefaultEntitySceneChannels(entityId: childEntityId, channels: defaultSceneChannels(forName: uniqueAssetName))

return childEntityId
}
Expand Down Expand Up @@ -2168,6 +2173,9 @@ func registerRenderComponent(entityId: EntityID, meshes: [Mesh], url: URL, asset
renderComponent.assetName = assetName
renderComponent.assetURL = url
entityMeshMap[entityId] = resolvedMeshes
let entityName = getEntityName(entityId: entityId)
let channelSourceName = entityName.isEmpty ? assetName : entityName
setDefaultEntitySceneChannels(entityId: entityId, channels: defaultSceneChannels(forName: channelSourceName))

let boundingBox = Mesh.computeMeshBoundingBox(for: resolvedMeshes)

Expand Down Expand Up @@ -2213,6 +2221,15 @@ public func setEntityName(entityId: EntityID, name: String) {
list.append(entityId)
}
reverseEntityNameMap[name] = list

let hasRenderableSceneComponent = scene.get(component: RenderComponent.self, for: entityId) != nil ||
scene.get(component: StreamingComponent.self, for: entityId) != nil
if let component = scene.get(component: EntitySceneChannelsComponent.self, for: entityId),
component.usesDefaultChannels,
hasRenderableSceneComponent
{
component.channels = defaultSceneChannels(forName: name)
}
}

public func getEntityName(entityId: EntityID) -> String {
Expand Down Expand Up @@ -2501,7 +2518,7 @@ private func setEntityStaticBatchComponentRecursive(entityId: EntityID) {
// becomes GPU-resident, so it must be tagged before the RenderComponent arrives.
let hasRender = scene.get(component: RenderComponent.self, for: entityId) != nil
let hasStreaming = scene.get(component: StreamingComponent.self, for: entityId) != nil
if hasRender || hasStreaming {
if hasRender || hasStreaming, !shouldPreserveSceneEntityIdentity(entityId: entityId) {
if !hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) {
registerComponent(entityId: entityId, componentType: StaticBatchComponent.self)
} else {
Expand Down Expand Up @@ -2640,6 +2657,12 @@ func removeEntityPickInteraction(entityId: EntityID) {
}
}

func removeEntitySceneChannels(entityId: EntityID) {
if scene.get(component: EntitySceneChannelsComponent.self, for: entityId) != nil {
scene.remove(component: EntitySceneChannelsComponent.self, from: entityId)
}
}

// MARK: - Granular LOD Management Functions

/// Set up LOD component for an entity
Expand Down
Loading
Loading