Skip to content

Add CoolSaber demo: PSVR2 lightsaber duel with SharePlay - #17

Open
miogds wants to merge 15 commits into
untoldengine:mainfrom
miolabs:feature/saber_demo
Open

Add CoolSaber demo: PSVR2 lightsaber duel with SharePlay#17
miogds wants to merge 15 commits into
untoldengine:mainfrom
miolabs:feature/saber_demo

Conversation

@miogds

@miogds miogds commented Jul 28, 2026

Copy link
Copy Markdown

CoolSaber — PSVR2 lightsaber duel demo

A new demo showcasing the engine's PSVR2 Sense controller support on Apple Vision Pro (visionOS 26 accessory tracking): each controller is a saber hilt — pull the trigger to ignite a glowing blade — and via SharePlay two players on a FaceTime call can duel, with blade-clash sparks, per-wand haptics, and synthesized saber audio.

Plugin package (CoolSaber/)

  • Render-extension plugin mirroring the CoolCloth architecture: one additive HDR pass at .beforePostProcess draws up to 4 blades (local + remote, left/right) and 8 clash sparks in a single draw call. Vertex-ID procedural billboards, capsule-SDF fragment glow (hot core + halo windowed to zero inside the quad), HDR values feed the engine bloom. First use of the engine's blendMode: .additive.
  • Pure, host-testable helpers: segment–segment distance (clash detection), ignition animator, clash detector with hysteresis/cooldown, and Codable SharePlay wire types. 26 tests run on macOS (swift test), including a shader-ABI stride guard and a metallib function check.

Example app (CoolSaber/Examples/CoolSaberVisionOS)

  • Input: per-wand trigger/Cross bindings read directly from each GCController (the Sense wands expose unprefixed element names — "Trigger", "Button A" — with handedness only in the vendor name, so the merged engine controller state can't drive per-hand actions). Each saber ignites independently.
  • Saber fit: per-hand tilt/lean/origin-offset panel, applied live and persisted per device (@AppStorage) so every player aligns the blades to their own grip once. Right-wand defaults verified on device (tilt 73°, lean 6°).
  • SharePlay: GroupActivity + system coordinator (group immersive space, .conversational template), unreliable 45 Hz pose stream + reliable ignite/clash events, lock-guarded mailbox between the main-actor session and the XR render thread, opponent-anchor fallback when a participant isn't spatial. Solo practice works without a session.
  • Audio: fully synthesized (no assets) — hum pitched by swing speed, ignite/retract sweeps, clash bursts — rendered by a single AVAudioSourceNode (player nodes crash with an uncatchable "player did not see an IO cycle" exception when the session comes up dead during the immersive-space transition), with retryable session activation and an IO-stall watchdog.
  • Robustness: immersive space tears down and rebuilds cleanly on close/reopen; the engine input system is warmed up at launch so the PSVR2 accessory provider exists before the first ARKit provider run (late loading forces a provider re-run that can leave world tracking paused); orbiting-spark loading cue while wand tracking warms up.

Verified on device

Blade rendering/glow, per-hand ignite, clash sparks + haptics, audio, saber fit. Not yet verified: the two-headset SharePlay duel (needs two Vision Pros on a FaceTime call).

Known engine issues found while building this (fixes tracked separately)

  • Deferred G-buffer needs 44 B/px tile storage vs the visionOS simulator's 32 B limit → engine XR renderer aborts in the simulator.
  • ARKit provider re-run reuses already-run provider instances → world tracking can stay paused when the PSVR2 accessory provider loads late.

Javier Segura added 15 commits July 25, 2026 15:27
CoolSaber is a render-extension plugin drawing HDR additive saber blades
and clash sparks from the PSVR2 Sense controller poses, plus a visionOS
example app with SharePlay duels (group immersive space), CoreHaptics
clash feedback, and synthesized saber audio.
- Window the blade halo to exactly zero inside the billboard bounds and
  tighten the glow falloff, so only the light around the core is visible,
  never the quad itself; alpha now lives almost entirely in the core so
  the halo no longer dims passthrough.
- Read each wand's trigger directly from its GCController (SaberWandInput)
  instead of the engine's merged GameControllerState, whose side-prefixed
  element-name assumption doesn't hold on real Sense wands — each saber
  now ignites independently. Hand inference by vendor name or element
  prefixes, shared with haptics; wand element names are logged on connect.
On visionOS the audio engine can report started before IO cycles begin;
AVAudioPlayerNode.play() then throws the uncatchable 'player did not see
an IO cycle' NSException and kills the app at immersive-space open. The
source-node callback only renders when IO actually runs, so the failure
mode is gone by construction: hum voices are resampled per frame (rate
replaces the time-pitch unit), one-shots are mixed in the same callback,
and engine start retries while session activation lags.
The Sense wand's trigger reaches GameController with a Button A alias,
so the engine's merged aPressed fired on every trigger pull and the
toggle-both fallback overrode per-hand ignite. Remove that fallback;
SaberWandInput now also binds each wand's A/Cross alias to its own hand,
dedupes alias elements by identity, and debounces per controller so one
physical press yields exactly one toggle.
Blade axis was the controller's forward (-Z), reading as an arm
extension. Now mostly +Y with a slight forward tilt (~73 deg from
forward), starting just above the controller ring.
Once the immersive space closed (crown press, SharePlay transition),
reopening it hit the xr-already-set guard and produced a dead space that
never rendered — 'Enter the Duel Arena' then appeared to do nothing.
When the render loop exits on layer invalidation, the game and engine
now tear down (shutdownUntoldEngineXR) so the next open rebuilds
cleanly. Entering the arena also lights each saber the first time its
wand is tracked — mixed immersion shows no change at all otherwise —
and the open-space result is logged.
Blade axis leans ~29 deg forward now. The control window shows a live
status line (arena open state, last openImmersiveSpace result, PSVR2
connection and per-wand tracking) to pinpoint where 'Enter the Duel
Arena' stalls on device. Audio session activation now retries with the
engine, and the plist declares the window as the preferred default
scene role like the engine template.
Device log showed the arena opens and renders fine from the button; the
'nothing happens' was silence — AVAudioSession activated during the
scene transition comes up dead ('Session lookup failed'), the engine
reports running with no IO, and sound only appeared once SharePlay
forced a live session into the process. Session activation errors are
now retryable instead of swallowed, the first attempt waits half a
second for the transition to settle, and a watchdog restarts the engine
whenever it claims to run but the render callback stalls. Haptic
engines no longer start eagerly at attach (same transition caused a
CHHapticEngine startup timeout); they start on demand per pulse.
Device log showed the failing entry path: the PSVR2 accessory provider
loads slowly and arrives after the immersive space's first ARKit
provider run, forcing a re-run that can leave world tracking paused —
no wand tracking, no blades. Touching the input system at app launch
starts wand discovery and accessory loading long before the space
opens, so the first provider run already includes the accessory
provider (the ordering that empirically works; the SharePlay flow only
'fixed' this by adding time). If tracking still delivers nothing after
2 s in the arena, blades ignite at the resting pose so the arena is
never silently empty — frozen blades read as 'tracking dead' at a
glance. Blade tilt increased to ~37 deg forward.
Iterating the blade angle by rebuild was going in circles; the tilt
(0 = out of the fist, 90 = along the controller) is now a slider
applied live to both blades, defaulting to 60 deg per the user's
reference sketch. Once the right angle is found on device it gets
baked in as the default.

Also lands the arena loading cue that was in the working tree: while
wands are connected but not yet tracked, a pair of sparks orbits ahead
of the user (replacing the frozen-blades fallback, which read as
broken), and the control window shows a progress row until the first
wand tracks and the blades auto-ignite.
…sisted

Tilt confirmed at 68 deg on device and baked as the default. The blade
origin was still off from other viewpoints, so the control window now
has a Saber Fit panel — tilt plus side/height/forward offsets in
controller space — applied live to both blades and stored per device
via AppStorage, so each duel participant tunes the fit of their own
controllers once and keeps it. Reset button restores defaults.
Tilt only rotates the blade in the up/forward plane, so a sideways
misalignment with the handle could be masked from one viewpoint with
offsets but never fixed. Lean swings the tilted direction left/right
around the controller's up axis, completing the direction's degrees of
freedom. Defaults updated to the values tuned on device (tilt 83,
side -1.5 cm, height -0.5 cm, forward 5.5 cm).
The fit sliders pushed the duel buttons below the window edge. The fit
panel is now a DisclosureGroup (collapsed by default), the window body
scrolls, and the default height grew to 640.
The right wand's tuned fit didn't hold for the left one, so fit values
(tilt/lean/offsets) are now stored and applied per hand: the fit panel
gains a Left/Right wand picker, each hand persists its own settings,
and the left defaults to the right's verified values with the sideways
components (lean, side offset) mirrored.
@miogds

miogds commented Jul 28, 2026

Copy link
Copy Markdown
Author

this is the first version so you can test the controller and the fix in the engine. Still I need to keep working on the share play session. (multiplayer)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant