Skip to content

[Feature] Preview the scene on an Apple Vision Pro, drawn by the Mac - #153

Merged
untoldengine merged 4 commits into
untoldengine:developfrom
miolabs:feature/ui_redesign_visionpro
Oct 7, 2026
Merged

untoldengine merged 4 commits into
untoldengine:developfrom
miolabs:feature/ui_redesign_visionpro

Conversation

@miogds

@miogds miogds commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #131 (stage 1.7c of the editor UI redesign).

What

macOS 26 renders immersive content for an Apple Vision Pro nearby ("Mac spatial rendering"): the editor opens a RemoteImmersiveSpace and draws it with CompositorLayer, as a visionOS app would, from its own renderer through the engine's public XR API. Nothing is installed on the headset.

View > Preview on Apple Vision Pro, or the Preview pill beside the build target, asks for it; the headset's wearer accepts; the scene shows in full immersion. Esc, the menu, the pill or the label's Stop end it; so do Play, a scene load, and the headset closing the space.

How it behaves

  • The headset rides the editor's camera: it starts where the camera stood, facing its way. W A S D fly level, Q E go up and down, and the pointer turns the view as a second head on top of the wearer's own, hidden and held while the preview runs, the same with a mouse, a Magic Mouse or a trackpad. Scroll and pinch do nothing meanwhile. A card over the viewport says so.
  • The Mac's viewport mirrors the left eye. The panels edit the live scene; clicks select nothing in the viewport and no overlay is drawn; the camera submenu is off.
  • Each frame of the headset is drawn as the engine's own visionOS loop draws it: one update, the culling once, renderXR per eye, the HZB, present.

Running it

A bare executable, as swift run and Xcode start the editor, has no bundle identifier and no LaunchServices record. Apple's device picker traps without the first and refuses to connect without the second. So, in the first commit:

  • Sources/UntoldEditor/Info.plist is embedded in the executable (the manifest's linker settings, -sectcreate __TEXT __info_plist; an unsafeFlags, allowed in a root package and in Xcode's local packages). The identifier is the packaged app's, com.untoldengine.studio; the settings follow it (EditorSettingsDomain copies the old UntoldEditor domain once).
  • scripts/dev-app.sh --run (or make run-app) builds, wraps the result in Untold Engine Studio.app inside the build folder (the executable copied, the resource bundles and the exporter scripts linked, the bundle registered), and starts it with its output in the terminal. ComponentSDK and EditorEnginePackage follow its BuildProducts link. The packaged app from create_app_bundle.sh works as it is. Xcode's Run is still a bare executable: for the headset, start the editor with the script (README).

Requirements: macOS 26 on Apple silicon, a Vision Pro on visionOS 26, both on the same Apple Account with Wi‑Fi and Bluetooth on, nearby. The editor keeps macOS 14 as its minimum: the two frameworks are weak-linked, and the menu item and the pill are hidden where the Mac cannot preview.

Design notes

The plan note with the verified facts, the stages and decisions D1–D11 is docs/proposals/EditorVisionProPreview.md in the engine's proposal drafts. The ones a reviewer will meet in the code:

  • The loop lives in the editor for now, on the engine's public API only; folding it into UntoldEngineXR for macOS is a later engine PR, so that any game gets "Play on Vision Pro" from its Mac build.
  • Steering and drawing take turns under a lock (VisionProPreviewState.cameraLock): the engine keeps the camera's view matrix in the camera and renderXR sets it per eye, so a move in the middle of an eye draws part of it from the camera's own view.
  • The head's position is not written into the camera, as the engine's own loop does for streaming and LOD: the camera is the ride, read back every frame; the head stays within a room of it.
  • The editor's own per-frame input stands down while previewing: runFrame runs it on the XR path too, and its fly goes where the camera looks.
  • .full immersion only: Apple's visionOS 26 release notes say .progressive crashes the Mac app.

Tests

1234 pass. New: the placement math, the flight, the loop drawing both eyes into textures from a fake frame source with the pixels read back, the mirror under Metal's validation (MTL_DEBUG_LAYER=1 swift test), the session's states with a live fake source on a thread, the settings domain, the SDK and engine-package link, the pointer capture's lifecycle. SwiftFormat clean under the 0.60.1 rules.

Review

Harold's three points (d238e1e): a stop whose wait runs out is logged and the editor is put back when the loop's thread ends, not before; a drawable beyond the first is cleared before it is presented; the loop draws two eyes at most and says once when a frame brings more.

Tried

On an M4 Max with a Vision Pro on visionOS 26: open, look around, fly, turn, stop; five rounds of fixes from the tries are recorded in the plan note.

Not in this PR

Reconnecting after a dropped stream, the headset's frame rate in the status bar, gaze and pinch from the headset, the window and full-screen preview destinations the pill is ready for, and the engine fold-in: stages B to D of the plan note.

Javier Segura added 2 commits October 6, 2026 19:18
…velopment bundle

A bare executable, as `swift run` and Xcode start the editor, has no bundle
identifier and no LaunchServices record. The Apple Vision Pro preview's device
picker traps without the identifier and refuses to connect without the record.

- `Sources/UntoldEditor/Info.plist` is embedded in the executable
  (`-sectcreate __TEXT __info_plist`, from the manifest's linker settings): the
  identifier of the packaged app, `com.untoldengine.studio`. The launch line
  logs it.
- The settings move with the identity: `EditorSettingsDomain` copies what the
  old domain, `UntoldEditor`, holds into the new one, once.
- `scripts/dev-app.sh [--run] [debug|release]` and `make run-app` wrap the
  build in `Untold Engine Studio.app` inside the build folder: the executable
  copied, the resource bundles and the exporter scripts linked, the bundle
  registered. `ComponentSDK` and `EditorEnginePackage` follow its
  `BuildProducts` link to the modules and the `Package.resolved`.
macOS 26 lets a Mac app open a `RemoteImmersiveSpace` on a Vision Pro nearby
and draw it with `CompositorLayer`, as a visionOS app would. The editor is a
SwiftUI `App` around its AppKit delegate, window and menus, and declares that
space. View > Preview on Apple Vision Pro, or the Preview pill beside the
build target, asks for it; the headset's wearer accepts; the scene shows in
full immersion, drawn by the editor's own renderer through the engine's public
XR API, on a thread of its own, frame by frame as the headset asks.

- The headset rides the editor's camera. W A S D fly it level, Q E up and
  down, the pointer turns it as a second head, hidden and held while the
  preview runs, on top of the wearer's own head. Esc ends the preview. A card
  over the viewport says so.
- The Mac's viewport mirrors the left eye; the panels edit the live scene;
  clicks select nothing and the overlays are off. Play, a scene load and the
  headset closing the space end it.
- Steering and drawing take turns under a lock, the camera's pose is read from
  its position and rotation, and the engine's own per-frame fly and its head
  write stand down meanwhile.
- The two frameworks are weak-linked: the editor still starts on macOS 14.
- Tests on a fake frame source: the placement, the flight, the loop rendering
  into textures, the mirror under Metal's validation, the session's states.

Plan and decisions: EditorVisionProPreview.md in the engine's proposal drafts.
@untoldengine

Copy link
Copy Markdown
Owner

Really nice work on this one — the frame-source abstraction (VisionProFrameSource/VisionProFrame) makes the drawing loop testable without a headset, and the test suite backs up some genuinely subtle invariants (e.g. test_theHeadMovingAbout_doesNotMoveThePlacement, test_theCamerasPose_comesFromWhatTheKeysAndTheMouseMove_notFromLookAtAlone). The camera-lock comment explaining why it's needed (view matrix lives on the camera entity, read per-eye) is exactly the kind of thing I'd want future-me to find.

A few things worth a look before merge:

1. VisionProPreviewSession.stopLoopAndRestore — timeout fallback has no safety net
loopDone?.wait(timeout: .now() + .seconds(2)) is fire-and-forget on timeout. If the render thread is ever stuck mid-draw(frame) (e.g. blocked on a GPU command buffer), we proceed to null out mirror/pointer and swap renderer.metalView.delegate while the loop thread might still be touching those same objects. Worth at least a Logger.log on timeout so a hang is diagnosable, and maybe worth double-checking that draw(frame) can't dereference mirror after stop() sets stopped = true but before the thread actually exits its current iteration.

2. CompositorFrame.present presents all drawables, but acquireEyes only reads the first
The comment says the second target is "presented and left blank," but since we never explicitly clear/blank it, it'll present whatever garbage/stale content happens to be in that texture. Probably fine in practice but wanted to flag in case it's not actually always blank from the compositor's side.

3. passDescriptors is hardcoded to 2 (VisionProPreviewLoop)
passDescriptors[min(index, passDescriptors.count - 1)] means a 3rd eye (if that's ever a thing — foveated/multi-view configs) would silently clobber eye 1's render target assignment rather than fail loudly. Not a real risk today given Vision Pro is fixed at 2 eyes, but maybe worth a comment or an assert so it doesn't bite someone quietly later.

Nothing here feels blocking to me — #1 is the one I'd actually want addressed (or at least logged) before this ships, the other two are more "leave a trail of breadcrumbs for future us." Great PR overall, thanks for the thorough plan doc and test coverage!

… the stop, undrawn drawables are cleared, two eyes at most

From Harold's review of untoldengine#153.

- `stopLoopAndRestore` went on after its wait and re-made the renderer's stereo
  targets under a loop that might still draw into them. A timeout is logged
  now, the session stays in `ending`, and the editor is put back when the
  loop's thread ends. The wait is `loopStopTimeout`, which tests shorten.
- Every drawable the compositor hands over gets the head's anchor, and a
  drawable beyond the first is cleared before it is presented, so that it
  shows nothing rather than what its textures held.
- The loop draws the first two eyes, the engine's stereo, and says once when a
  frame brings more, instead of drawing a third eye over the second.

Tests: a loop held inside a frame outlives the stop and the editor is put back
when it is released; a three-eye frame draws two and leaves the third untouched.
@miogds

miogds commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks Harold, all three were right. Fixed in d238e1e.

  1. The timeout had no net, and it was worse than a log: after the wait the session re-made the renderer's stereo targets with leaveStereo under a loop that might still draw into them. The mirror was safe (the loop holds its own reference) but the targets were not. Now a timeout is logged, the session stays in ending, and the editor is put back when the loop's thread ends. Test: a loop held inside a frame outlives the stop; nothing is taken from it; it is put back when released.
  2. Every drawable now gets the anchor, and a drawable beyond the first is cleared to black before it is presented, so nothing stale shows.
  3. The loop draws the first two eyes, the engine's stereo, and logs once when a frame brings more. Test: a three-eye frame draws two and leaves the third untouched.

@untoldengine
untoldengine merged commit a6d4064 into untoldengine:develop Oct 7, 2026
2 checks passed
untoldengine pushed a commit that referenced this pull request Oct 8, 2026
* [Bugfix] Keep the editor's menu bar when SwiftUI installs its own

Since #153 the editor is a SwiftUI App, for the space the Apple Vision Pro
preview shows, with its menus still built by the AppKit delegate. SwiftUI
installs a menu bar of its own from its default commands; on macOS 26 it does
so after the delegate has set the editor's, and the bar then reads Edit, View,
Window and Help: no File menu, none of the editor's View items, and the app
menu named after the Info.plist. On macOS 27 the editor's menu survived, which
is why the change was not seen before the merge.

EditorMainMenuKeeper installs the editor's menu and watches the application's
main menu; whenever another menu replaces it, the editor's is put back on the
next turn of the run loop (logged once). Five tests on the real NSApplication,
one of them showing that without the keeper another menu stays.

* [Bugfix] Menu bar: move the editor's menus into the bar SwiftUI installs

Putting the editor's menu back by KVO did not hold on macOS 26: SwiftUI
keeps the bar it installed. The keeper no longer replaces that NSMenu; it
moves the editor's menus into whatever bar is installed, takes the bar's own
items out, drops the delegate that fills it in, and keeps the Window menu the
editor's. It looks again when the main menu changes and after every event
the application handles (NSApplication.didUpdate, didBecomeActive,
NSWindow.didBecomeKey), so it does not depend on the setter being observable.

Nine tests, on a bar installed before, one installed later, one refilled in
place without a setter, one that fills itself in through its delegate.

---------

Co-authored-by: Javier Segura <javier@miolabs.com>
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.

2 participants