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
12 changes: 11 additions & 1 deletion Sources/UntoldEngine/Systems/InputSystem+PSVR2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ extension InputSystem {
psvr2AnchorMonitorTask?.cancel()
psvr2AccessoryLoadTask = nil
psvr2AnchorMonitorTask = nil
psvr2ControllerChirality.removeValue(forKey: ObjectIdentifier(controller))
if psvr2SpatialControllers.isEmpty {
psvr2AccessoriesStorage = []
psvr2AccessoryTrackingProviderStorage = nil
Expand Down Expand Up @@ -150,11 +151,20 @@ extension InputSystem {
guard let self else { return }
do {
var accessories: [Accessory] = []
var chirality: [ObjectIdentifier: XRSpatialChirality] = [:]
for controller in controllers {
try await accessories.append(Accessory(device: controller))
let accessory = try await Accessory(device: controller)
accessories.append(accessory)
switch accessory.inherentChirality {
case .left: chirality[ObjectIdentifier(controller)] = .left
case .right: chirality[ObjectIdentifier(controller)] = .right
case .unspecified: break
@unknown default: break
}
}
guard !Task.isCancelled, psvr2AccessoryGeneration == generation else { return }
psvr2AccessoriesStorage = accessories
psvr2ControllerChirality = chirality
NotificationCenter.default.post(
name: .psvr2AccessoryTrackingConfigurationDidChange,
object: self
Expand Down
56 changes: 44 additions & 12 deletions Sources/UntoldEngine/Systems/InputSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ public final class InputSystem: @unchecked Sendable {
public var gameControllerState = GameControllerState()
public var currentGameController: GCExtendedGamepad?
public var psvr2SenseControllerState = PSVR2SenseControllerState()
/// Which hand each connected PSVR2 Sense wand belongs to. Both wands expose their
/// single stick under the same generic `.thumbstick`/`.thumbstickButton` physical
/// input keys, so the handler needs this to know which side of GameControllerState
/// to update. Populated from ARKit accessory chirality once it loads (see
/// InputSystem+PSVR2.swift); until then, thumbstick events for that wand are dropped.
var psvr2ControllerChirality: [ObjectIdentifier: XRSpatialChirality] = [:]
#if os(visionOS)
var psvr2SpatialControllers: [GCController] = []
// Loaded Accessory objects ([Any] to avoid @available on a stored property).
Expand Down Expand Up @@ -154,7 +160,7 @@ public final class InputSystem: @unchecked Sendable {
currentGameController = gameController
configureGameControllerHandlers(gameController)
} else if isPSVR2SpatialController(controller) {
configurePhysicalGameControllerHandlers(controller.physicalInputProfile)
configurePhysicalGameControllerHandlers(controller)
} else {
Logger.log(message: "Game Controller \(controller.vendorName ?? "unknown vendor") has no supported input profile")
return
Expand Down Expand Up @@ -224,7 +230,9 @@ public final class InputSystem: @unchecked Sendable {

/// Spatial gamepads expose their controls through the physical profile rather
/// than GCExtendedGamepad. Each PSVR2 wand contributes only its own elements.
private func configurePhysicalGameControllerHandlers(_ profile: GCPhysicalInputProfile) {
private func configurePhysicalGameControllerHandlers(_ controller: GCController) {
let profile = controller.physicalInputProfile

profile.buttons["Button A"]?.pressedChangedHandler = { [weak self] _, _, pressed in self?.gameControllerState.aPressed = pressed }
profile.buttons["Button B"]?.pressedChangedHandler = { [weak self] _, _, pressed in self?.gameControllerState.bPressed = pressed }
profile.buttons["Button X"]?.pressedChangedHandler = { [weak self] _, _, pressed in self?.gameControllerState.xPressed = pressed }
Expand All @@ -241,17 +249,41 @@ public final class InputSystem: @unchecked Sendable {
self?.gameControllerState.rightTriggerPressed = pressed
}

profile.dpads["Left Thumbstick"]?.valueChangedHandler = { [weak self] _, x, y in
self?.gameControllerState.leftThumbstickX = x
self?.gameControllerState.leftThumbstickY = y
self?.gameControllerState.leftThumbStickActive = abs(x) > 0.1 || abs(y) > 0.1
// Each wand has a single stick, exposed under the generic GCInputThumbstick /
// GCInputThumbstickButton physical input keys (added in visionOS 26) rather than
// the "Left Thumbstick"/"Right Thumbstick" keys GCExtendedGamepad uses for
// two-stick gamepads. Which GameControllerState side to update is resolved
// per-event from the wand's ARKit-derived chirality rather than the key name.
profile.dpads["Thumbstick"]?.valueChangedHandler = { [weak self, weak controller] _, x, y in
guard let self, let controller else { return }
updatePSVR2Thumbstick(x: x, y: y, for: controller)
}
profile.buttons["Thumbstick Button"]?.pressedChangedHandler = { [weak self, weak controller] _, _, pressed in
guard let self, let controller else { return }
updatePSVR2ThumbstickPressed(pressed, for: controller)
}
profile.dpads["Right Thumbstick"]?.valueChangedHandler = { [weak self] _, x, y in
self?.gameControllerState.rightThumbstickX = x
self?.gameControllerState.rightThumbstickY = y
self?.gameControllerState.rightThumbStickActive = abs(x) > 0.1 || abs(y) > 0.1
}

func updatePSVR2Thumbstick(x: Float, y: Float, for controller: GCController) {
switch psvr2ControllerChirality[ObjectIdentifier(controller)] {
case .left:
gameControllerState.leftThumbstickX = x
gameControllerState.leftThumbstickY = y
gameControllerState.leftThumbStickActive = abs(x) > 0.1 || abs(y) > 0.1
case .right:
gameControllerState.rightThumbstickX = x
gameControllerState.rightThumbstickY = y
gameControllerState.rightThumbStickActive = abs(x) > 0.1 || abs(y) > 0.1
case nil:
break
}
}

func updatePSVR2ThumbstickPressed(_ pressed: Bool, for controller: GCController) {
switch psvr2ControllerChirality[ObjectIdentifier(controller)] {
case .left: gameControllerState.leftThumbstickPressed = pressed
case .right: gameControllerState.rightThumbstickPressed = pressed
case nil: break
}
profile.buttons["Left Thumbstick Button"]?.pressedChangedHandler = { [weak self] _, _, pressed in self?.gameControllerState.leftThumbstickPressed = pressed }
profile.buttons["Right Thumbstick Button"]?.pressedChangedHandler = { [weak self] _, _, pressed in self?.gameControllerState.rightThumbstickPressed = pressed }
}
}
54 changes: 54 additions & 0 deletions Tests/UntoldEngineTests/PSVR2InputSystemTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// UntoldEngineTests
//

import GameController
import simd
@testable import UntoldEngine
import XCTest
Expand All @@ -11,6 +12,8 @@ import XCTest
final class PSVR2InputSystemTests: XCTestCase {
override func setUp() {
InputSystem.shared.psvr2SenseControllerState = PSVR2SenseControllerState()
InputSystem.shared.gameControllerState = GameControllerState()
InputSystem.shared.psvr2ControllerChirality = [:]
}

func testDefaultStateIsDisconnectedAndUntracked() {
Expand Down Expand Up @@ -75,4 +78,55 @@ final class PSVR2InputSystemTests: XCTestCase {
InputSystem.shared.psvr2SenseControllerState.isConnected = true
XCTAssertTrue(isPSVR2SenseConnected())
}

func testThumbstickRoutesToLeftOrRightByChirality() {
let leftWand = GCController()
let rightWand = GCController()
InputSystem.shared.psvr2ControllerChirality[ObjectIdentifier(leftWand)] = .left
InputSystem.shared.psvr2ControllerChirality[ObjectIdentifier(rightWand)] = .right

InputSystem.shared.updatePSVR2Thumbstick(x: 0.5, y: -0.25, for: leftWand)
InputSystem.shared.updatePSVR2Thumbstick(x: -0.6, y: 0.8, for: rightWand)

let state = getGameControllerState()
XCTAssertEqual(state.leftThumbstickX, 0.5)
XCTAssertEqual(state.leftThumbstickY, -0.25)
XCTAssertTrue(state.leftThumbStickActive)
XCTAssertEqual(state.rightThumbstickX, -0.6)
XCTAssertEqual(state.rightThumbstickY, 0.8)
XCTAssertTrue(state.rightThumbStickActive)
}

func testThumbstickBelowDeadzoneIsNotActive() {
let leftWand = GCController()
InputSystem.shared.psvr2ControllerChirality[ObjectIdentifier(leftWand)] = .left

InputSystem.shared.updatePSVR2Thumbstick(x: 0.05, y: -0.05, for: leftWand)

XCTAssertFalse(getGameControllerState().leftThumbStickActive)
}

func testThumbstickPressedRoutesByChirality() {
let rightWand = GCController()
InputSystem.shared.psvr2ControllerChirality[ObjectIdentifier(rightWand)] = .right

InputSystem.shared.updatePSVR2ThumbstickPressed(true, for: rightWand)

let state = getGameControllerState()
XCTAssertTrue(state.rightThumbstickPressed)
XCTAssertFalse(state.leftThumbstickPressed)
}

func testThumbstickEventFromUnresolvedControllerIsIgnored() {
let unresolvedWand = GCController()

InputSystem.shared.updatePSVR2Thumbstick(x: 1, y: 1, for: unresolvedWand)
InputSystem.shared.updatePSVR2ThumbstickPressed(true, for: unresolvedWand)

let state = getGameControllerState()
XCTAssertEqual(state.leftThumbstickX, 0)
XCTAssertEqual(state.rightThumbstickX, 0)
XCTAssertFalse(state.leftThumbstickPressed)
XCTAssertFalse(state.rightThumbstickPressed)
}
}
20 changes: 20 additions & 0 deletions docs/API/UsingInputSystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,26 @@ if buttons.leftTriggerValue > 0.5 {

PlayStation face-button mapping uses A = Cross, B = Circle, X = Square, and Y = Triangle.

### Read thumbsticks

```swift
let buttons = getGameControllerState()

if buttons.leftThumbStickActive {
Logger.log(message: "Left stick: \(buttons.leftThumbstickX), \(buttons.leftThumbstickY)")
}

if buttons.rightThumbStickActive {
Logger.log(message: "Right stick: \(buttons.rightThumbstickX), \(buttons.rightThumbstickY)")
}

if buttons.leftThumbstickPressed {
Logger.log(message: "Left stick clicked")
}
```

Each PSVR2 Sense wand has one physical stick. Internally the engine reads it from a single generic `GCInputThumbstick` element on the wand's `physicalInputProfile` (rather than the `GCExtendedGamepad`-style `"Left Thumbstick"`/`"Right Thumbstick"` keys a conventional two-stick gamepad exposes), then routes the value into `leftThumbstickX/Y` or `rightThumbstickX/Y` using the wand's ARKit-reported chirality — the same left/right resolution `getPSVR2SenseState()` uses for pose tracking. `leftThumbStickActive`/`rightThumbStickActive` are `true` once the stick deflects past a small deadzone (~0.1); `leftThumbstickPressed`/`rightThumbstickPressed` report the stick click.

### Read spatial poses

```swift
Expand Down
Loading