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
1 change: 1 addition & 0 deletions Sources/UntoldEngine/Systems/ScenePickingGPUSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ private func scenePickingComputeEntitySignature(_ entityId: EntityID) -> UInt64
scenePickingHashCombine(&hash, UInt64(renderComponent.mesh.count))
scenePickingHashCombine(&hash, scenePickingHasTransparentSubmesh(renderComponent) ? 1 : 0)
scenePickingHashCombine(&hash, scenePickingIsParticipationEnabled(for: entityId) ? 1 : 0)
scenePickingHashCombine(&hash, isSceneEntityPickableByChannel(entityId: entityId) ? 1 : 0)
scenePickingHashCombine(&hash, UInt64(scenePickingHitRepresentationMode(for: entityId).rawValue))

// Only hash transform for static entities. Dynamic entities changing position should not
Expand Down
2 changes: 2 additions & 0 deletions Sources/UntoldEngine/Systems/ScenePickingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -403,12 +403,14 @@ func scenePickingHitRepresentationMode(for entityId: EntityID) -> PickHitReprese
@inline(__always)
func scenePickingUsesMeshHitRepresentation(_ entityId: EntityID) -> Bool {
scenePickingIsParticipationEnabled(for: entityId)
&& isSceneEntityPickableByChannel(entityId: entityId)
&& scenePickingHitRepresentationMode(for: entityId) == .mesh
}

@inline(__always)
func scenePickingShouldIgnoreEntityDueToInteractionSettings(_ entityId: EntityID) -> Bool {
guard scenePickingIsParticipationEnabled(for: entityId) else { return true }
guard isSceneEntityPickableByChannel(entityId: entityId) else { return true }
return scenePickingHitRepresentationMode(for: entityId) == .none
}

Expand Down
57 changes: 57 additions & 0 deletions Sources/UntoldEngine/Utils/SceneContextVisibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public enum SceneChannelRenderMode: Equatable, Sendable {

public enum SceneChannelProperty: Sendable {
case renderMode(SceneChannelRenderMode)
case pickParticipation(Bool)
}

private final class SceneChannelVisibilityState: @unchecked Sendable {
Expand Down Expand Up @@ -120,6 +121,36 @@ private final class SceneChannelVisibilityState: @unchecked Sendable {
}
}

private final class SceneChannelInteractionState: @unchecked Sendable {
static let shared = SceneChannelInteractionState()

private let lock = NSLock()
private var pickDisabledChannels: SceneChannel = []

func setPickParticipation(_ channel: SceneChannel, enabled: Bool) {
lock.lock()
if enabled {
pickDisabledChannels.remove(channel)
} else {
pickDisabledChannels.insert(channel)
}
lock.unlock()
}

func isPickEnabled(for channels: SceneChannel) -> Bool {
lock.lock()
let disabled = pickDisabledChannels
lock.unlock()
return disabled.intersection(channels).isEmpty
}

func reset() {
lock.lock()
pickDisabledChannels = []
lock.unlock()
}
}

public let selectableSceneEntityNamePrefix = "NM_"

public func defaultSceneChannels(forName name: String, isRenderable: Bool = true) -> SceneChannel {
Expand Down Expand Up @@ -195,6 +226,9 @@ public func setSceneChannel(_ channel: SceneChannel, _ property: SceneChannelPro
switch property {
case let .renderMode(mode):
SceneChannelVisibilityState.shared.setRenderMode(channel, mode)
case let .pickParticipation(enabled):
SceneChannelInteractionState.shared.setPickParticipation(channel, enabled: enabled)
markScenePickingDirty(forChannel: channel)
}
}

Expand All @@ -206,6 +240,10 @@ public func getSceneChannelVisible(_ channel: SceneChannel) -> Bool {
SceneChannelVisibilityState.shared.isVisible(channel)
}

public func getSceneChannelPickParticipation(_ channel: SceneChannel) -> Bool {
SceneChannelInteractionState.shared.isPickEnabled(for: channel)
}

@available(*, deprecated, message: "Use setSceneChannel(_:, .renderMode(_:)) instead")
public func setSceneChannelRenderMode(_ channel: SceneChannel, _ mode: SceneChannelRenderMode) {
setSceneChannel(channel, .renderMode(mode))
Expand All @@ -218,6 +256,7 @@ public func setSceneChannelVisible(_ channel: SceneChannel, _ visible: Bool) {

public func resetSceneChannelVisibility() {
SceneChannelVisibilityState.shared.reset()
SceneChannelInteractionState.shared.reset()
}

public func shouldHideSceneEntity(entityId: EntityID) -> Bool {
Expand All @@ -236,6 +275,24 @@ public func areSceneChannelsVisible(_ channels: SceneChannel) -> Bool {
SceneChannelVisibilityState.shared.isVisible(channels)
}

public func areSceneChannelsPickable(_ channels: SceneChannel) -> Bool {
SceneChannelInteractionState.shared.isPickEnabled(for: channels)
}

/// Whether ray-picking is enabled for `entityId` based solely on its scene channel
/// membership, independent of any per-entity `PickInteractionComponent` override.
public func isSceneEntityPickableByChannel(entityId: EntityID) -> Bool {
areSceneChannelsPickable(getEntitySceneChannels(entityId: entityId))
}

/// Marks every visible entity belonging to `channel` as pick-dirty so the picking
/// acceleration structures are rebuilt with the updated channel pick state.
private func markScenePickingDirty(forChannel channel: SceneChannel) {
for entityId in visibleEntityIds where hasEntitySceneChannel(entityId: entityId, channel: channel) {
scenePickingMarkEntityDirty(entityId)
}
}

public func shouldRenderSceneChannelsAsWireframe(_ channels: SceneChannel) -> Bool {
getSceneChannelRenderMode(channels) == .wireframe
}
Expand Down
69 changes: 69 additions & 0 deletions Tests/UntoldEngineTests/ScenePickingSystemTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,75 @@ final class ScenePickingSystemTests: XCTestCase {
XCTAssertNil(afterDestroy)
}

// MARK: - Scene Channel Pick Participation

func testSceneChannelPickParticipationDisablesChannelButNotOthers() {
let wall = createRenderableEntity(position: simd_float3(3, 0, 0))
let pipe = createRenderableEntity(position: simd_float3(8, 0, 0))
setEntitySceneChannels(entityId: wall, channels: .contextGeometry)
setEntitySceneChannels(entityId: pipe, channels: [.selectableGeometry, .preserveIdentity])
visibleEntityIds = [wall, pipe]

setSceneChannel(.contextGeometry, .pickParticipation(false))

let result = pickEntity(
rayOrigin: simd_float3(0, 0, 0),
rayDirection: simd_float3(1, 0, 0),
options: ScenePickOptions(backend: .cpuOnly)
)

XCTAssertEqual(result?.entityId, pipe, "Context geometry should be skipped while the pipe remains pickable")
}

func testSceneChannelPickParticipationReEnable() {
let wall = createRenderableEntity(position: simd_float3(3, 0, 0))
setEntitySceneChannels(entityId: wall, channels: .contextGeometry)
visibleEntityIds = [wall]

setSceneChannel(.contextGeometry, .pickParticipation(false))
XCTAssertNil(pickEntity(
rayOrigin: simd_float3(0, 0, 0),
rayDirection: simd_float3(1, 0, 0),
options: ScenePickOptions(backend: .cpuOnly)
))

setSceneChannel(.contextGeometry, .pickParticipation(true))
let result = pickEntity(
rayOrigin: simd_float3(0, 0, 0),
rayDirection: simd_float3(1, 0, 0),
options: ScenePickOptions(backend: .cpuOnly)
)
XCTAssertEqual(result?.entityId, wall)
}

func testEntityPickParticipationStillExcludesEntityWithinPickableChannel() {
let wall = createRenderableEntity(position: simd_float3(3, 0, 0), pickParticipation: false)
let pipe = createRenderableEntity(position: simd_float3(8, 0, 0))
setEntitySceneChannels(entityId: wall, channels: .contextGeometry)
setEntitySceneChannels(entityId: pipe, channels: [.selectableGeometry, .preserveIdentity])
visibleEntityIds = [wall, pipe]

// Channel itself remains pickable, but the wall opted out individually.
let result = pickEntity(
rayOrigin: simd_float3(0, 0, 0),
rayDirection: simd_float3(1, 0, 0),
options: ScenePickOptions(backend: .cpuOnly)
)

XCTAssertEqual(result?.entityId, pipe)
}

func testGetSceneChannelPickParticipationReflectsState() {
XCTAssertTrue(getSceneChannelPickParticipation(.contextGeometry))

setSceneChannel(.contextGeometry, .pickParticipation(false))
XCTAssertFalse(getSceneChannelPickParticipation(.contextGeometry))
XCTAssertTrue(getSceneChannelPickParticipation(.selectableGeometry))

setSceneChannel(.contextGeometry, .pickParticipation(true))
XCTAssertTrue(getSceneChannelPickParticipation(.contextGeometry))
}

func testPickInteractionSettingsPersistThroughSceneSerialization() {
let entity = createEntity()
setEntityPickParticipation(entityId: entity, enabled: false)
Expand Down
25 changes: 23 additions & 2 deletions docs/API/UsingSceneChannels.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,32 @@ Outside mixed passthrough mode, ghosted channels render as normal opaque geometr

Use `.ghostGeometry` when only selected walls or structures should ghost. Regular `.contextGeometry` can stay normal, hidden, or wireframe independently. If the entity is already static-batched, changing its scene channels queues a batch rebuild so it can split from its previous context batch.

## Pick Participation

The `.pickParticipation` property controls whether ray-picking (`pickEntity`) considers an entire channel:

```swift
setSceneChannel(.contextGeometry, .pickParticipation(false))
setSceneChannel(.contextGeometry, .pickParticipation(true))
```

This is useful for large scenes where background geometry (walls, floors, merged tile geometry) should never be hit by picking rays, while interactive objects remain pickable:

```swift
// Walls, floors, and merged context geometry are skipped by pickEntity().
setSceneChannel(.contextGeometry, .pickParticipation(false))

// Pipes (.selectableGeometry / .preserveIdentity) are unaffected and remain pickable.
```

Channel pick state and per-entity pick state (`setEntityPickParticipation(entityId:enabled:)`) combine with "most restrictive wins": an entity is pickable only if both its channel allows picking and its own `PickInteractionComponent` allows picking. So `setEntityPickParticipation(entityId:enabled:false)` can still exclude individual entities from a channel that otherwise allows picking, but it cannot make an entity pickable again once its channel disables picking.

## Reading Channel State

```swift
let mode = getSceneChannelRenderMode(.contextGeometry) // SceneChannelRenderMode
let visible = getSceneChannelVisible(.contextGeometry) // Bool
let mode = getSceneChannelRenderMode(.contextGeometry) // SceneChannelRenderMode
let visible = getSceneChannelVisible(.contextGeometry) // Bool
let pickable = getSceneChannelPickParticipation(.contextGeometry) // Bool
```

## Explicit Entity Channels
Expand Down
17 changes: 17 additions & 0 deletions docs/Architecture/sceneChannels.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ For `.untold` assets, the exporter writes optional architectural edge index buff

`WireframeRenderParams` controls visual density. `distanceFadeEnabled`, `fadeStartDistance`, `fadeEndDistance`, and `minimumAlpha` reduce line opacity for distant geometry without changing the scene-channel API. The fragment shader uses `color.a` as the near opacity and fades to `color.a * minimumAlpha` between `fadeStartDistance` and `fadeEndDistance`.

## Picking

`SceneChannelInteractionState` (in `SceneContextVisibility.swift`) tracks a bitmask of channels with picking disabled via:

```swift
setSceneChannel(.contextGeometry, .pickParticipation(false))
```

Two gating functions in `ScenePickingSystem.swift` check both the per-entity `PickInteractionComponent.participatesInPicking` and `isSceneEntityPickableByChannel(entityId:)` (channel-derived), combining them with "most restrictive wins":

- `scenePickingShouldIgnoreEntityDueToInteractionSettings(_:)` — used by the CPU candidate list and the octree broad-phase.
- `scenePickingUsesMeshHitRepresentation(_:)` — used by the standalone GPU backend (`pickEntityGPU`) to build/validate the Metal acceleration structure. This path is also reached from `automatic`/`octreeGPUPreferred` whenever the octree is disabled, so both functions need the channel check for full backend coverage.

Toggling a channel's pick participation marks all currently-visible entities in that channel dirty (`scenePickingMarkEntityDirty`), forcing the GPU picking acceleration structures to rebuild on the next pick. `scenePickingComputeEntitySignature` also hashes the channel-derived pickability so cached signatures stay consistent.

Channel pick state does not affect batching or rendering — it only filters `pickEntity` candidates.

## Batching

Scene channels affect batching in two ways:
Expand Down
Loading