diff --git a/Sources/UntoldEngine/Systems/ScenePickingGPUSystem.swift b/Sources/UntoldEngine/Systems/ScenePickingGPUSystem.swift index cec4975f..168ddc47 100644 --- a/Sources/UntoldEngine/Systems/ScenePickingGPUSystem.swift +++ b/Sources/UntoldEngine/Systems/ScenePickingGPUSystem.swift @@ -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 diff --git a/Sources/UntoldEngine/Systems/ScenePickingSystem.swift b/Sources/UntoldEngine/Systems/ScenePickingSystem.swift index 06d21d50..66e1a403 100644 --- a/Sources/UntoldEngine/Systems/ScenePickingSystem.swift +++ b/Sources/UntoldEngine/Systems/ScenePickingSystem.swift @@ -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 } diff --git a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift index 7e225152..23b5d983 100644 --- a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift +++ b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift @@ -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 { @@ -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 { @@ -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) } } @@ -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)) @@ -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 { @@ -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 } diff --git a/Tests/UntoldEngineTests/ScenePickingSystemTests.swift b/Tests/UntoldEngineTests/ScenePickingSystemTests.swift index 1f0b8322..32680dfe 100644 --- a/Tests/UntoldEngineTests/ScenePickingSystemTests.swift +++ b/Tests/UntoldEngineTests/ScenePickingSystemTests.swift @@ -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) diff --git a/docs/API/UsingSceneChannels.md b/docs/API/UsingSceneChannels.md index 2af48107..6d77ac1e 100644 --- a/docs/API/UsingSceneChannels.md +++ b/docs/API/UsingSceneChannels.md @@ -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 diff --git a/docs/Architecture/sceneChannels.md b/docs/Architecture/sceneChannels.md index 14831ec1..cdb39f01 100644 --- a/docs/Architecture/sceneChannels.md +++ b/docs/Architecture/sceneChannels.md @@ -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: