WWDC Quick Look 💓 By SwiftGGTeam
Share visionOS experiences with nearby people

Share visionOS experiences with nearby people

观看原视频

Highlight

visionOS 26 引入了 Nearby Window Sharing:每个窗口右侧新增一个分享按钮,点击后选择附近的人,窗口就会以相同位置和大小出现在所有人的空间里。窗口栏变绿表示正在共享。


核心内容

两个人都戴着 Vision Pro 坐在同一间客厅里,想一起看一部电影或者下一盘棋,过去只能各自打开自己设备里的 app,画面位置错开、声音不同步,连指着屏幕讨论都做不到。远程的 SharePlay 只把对方变成虚拟 Persona,物理上同处一室的体验反而被忽略了。

visionOS 26 用 Nearby Window Sharing 解决这件事。每个窗口栏右侧多了一个分享按钮,点开后从列表里选择附近的人,窗口就会以完全相同的位置和大小出现在所有人的空间里,窗口栏变绿表示已经共享(01:02)。任何人都可以拖动窗口、改变大小、贴到墙面,操作对所有人同步可见。手伸过窗口时内容自动淡出,保证手势可见。按下数字表冠重新居中,窗口又会回到适合所有人的位置。已经接入 SharePlay 的 app 零改动就能支持,不接 SharePlay 的 app 也能以”只读模式”被共享。FaceTime 远程参与者会以 spatial Persona 自动排列在附近参与者旁边,体型类窗口共享时还会去填补 volume 周围的空缺位置。


详细内容

要把 nearby sharing 做到位,session 给出了一条主线:先把 GroupActivity 暴露到 Share 菜单,再根据参与者位置增强体验,最后用 ARKit 把内容钉到物理空间。

第一步:把 activity 暴露到 Share 菜单06:21)。volumetric 窗口的 Share 菜单只有在 app 暴露了 activity 之后才可用。SwiftUI 的写法是塞一个隐藏的 ShareLink

import SwiftUI
import GroupActivities

struct BoardGameActivity: GroupActivity, Transferable {
    var metadata: GroupActivityMetadata = {
        var metadata = GroupActivityMetadata()
        metadata.title = "Play Together"
        return metadata
    }()
}

struct BoardGameApp: App {
    var body: some Scene {
        WindowGroup {
            BoardGameView()
            ShareLink(item: BoardGameActivity(), preview: SharePreview("Play Together"))
                .hidden()
        }
        .windowStyle(.volumetric)
    }
}

关键点:

  • BoardGameActivity 同时遵循 GroupActivityTransferable,后者让它能被 ShareLink 识别。
  • metadata.title 是 Share 菜单里显示给用户的活动名称。
  • ShareLink(item:) 接收 activity 实例,加 .hidden() 是为了不影响 UI——它只用来把 activity 注册到 Share 菜单。
  • .windowStyle(.volumetric) 声明这是一个体型类窗口,分享时其他人也以 volume 形态看到。

第二步:监听 session 并加入07:14)。从 Share 菜单激活 activity 会自动创建 GroupSession,效果等同手动调用 activate()

func observeSessions() async {
    for await session in BoardGameActivity.sessions() {
        guard let systemCoordinator = await session.systemCoordinator else { continue }
        systemCoordinator.configuration.supportsGroupImmersiveSpace = true
        session.join()
    }
}

关键点:

  • BoardGameActivity.sessions() 是一个 async sequence,每次 activity 被激活都会推送一个新 session。
  • systemCoordinator 提供 SharePlay 的系统级配置入口。
  • supportsGroupImmersiveSpace = true 表示 immersive space 在所有人面前位置一致,否则每人原地启动。
  • session.join() 是真正进入 SharePlay 的调用,缺了这一行 session 就只是被观察到,不会启动。

第三步:识别附近参与者09:59)。一旦有 FaceTime 远程参与者加入,需要区分本地和远程:

func observeParticipants(session: GroupSession<BoardGameActivity>) async {
    for await activeParticipants in session.$activeParticipants.values {
        let nearbyParticipants = activeParticipants.filter {
            $0.isNearbyWithLocalParticipant && $0 != session.localParticipant
        }
    }
}

关键点:

  • session.$activeParticipants.values 把参与者集合转成 async sequence,参与者增减都会推送。
  • isNearbyWithLocalParticipant 是 visionOS 26 新增的属性,判断对方是否和本地用户在同一物理空间。
  • 排除 localParticipant 自己,避免把自己也算进”附近的人”。
  • 拿到这个列表后可以做”附近 vs 远程”的对抗游戏分组。

第四步:读取参与者位置11:42)。Immersive space 里参与者可能站在场景任何位置,不能假设都在原点:

func observeLocalParticipantState(session: GroupSession<BoardGameActivity>) async {
    guard let systemCoordinator = await session.systemCoordinator else { return }

    for await localParticipantState in systemCoordinator.localParticipantStates {
        let localParticipantPose = localParticipantState.pose
        // Place presented content relative to the local participant pose
    }
}

关键点:

  • localParticipantStates 是 system coordinator 上的 async sequence,状态变化时推送。
  • pose 属性给出参与者在 ImmersiveSpace 场景里的位姿,含位置和朝向。
  • pose 不实时跟踪,只在分享开始、按数字表冠重新居中等关键事件后更新一次。
  • 拿到 pose 后可以在每人面前放置记分板、UI 控件,而不是把所有内容堆在场景原点。

第五步:多窗口选主15:54)。app 里有多个 WindowGroup 时,.groupActivityAssociation(.primary("InstructionalVideo")) modifier 指定哪个窗口是 SharePlay 的主窗口。

第六步:共享 World Anchor20:24)。ARKit 在 visionOS 26 给 WorldAnchor 加了一个新初始化参数:

import ARKit

class SharedAnchorController {
    func createAnchor(at transform: simd_float4x4, provider: WorldTrackingProvider) async throws {
        let anchor = WorldAnchor(originFromAnchorTransform: transform,
                                 sharedWithNearbyParticipants: true)
        try await provider.addAnchor(anchor)
    }

    func observeWorldTracking(provider: WorldTrackingProvider) async {
       for await update in provider.anchorUpdates {
            switch update.event {
            case .added, .updated, .removed:
                let anchorIdentifier = update.anchor.id
            }
        }
    }
}

关键点:

  • sharedWithNearbyParticipants: true 把锚点共享给附近参与者,所有人看到的物理位置一致。
  • 调用前应先用 provider.worldAnchorSharingAvailability 观察可用性,不可用时不要尝试共享。
  • update.anchor.id 在所有设备上一致,可以直接当作内容同步标识符。
  • 共享锚点 SharePlay 结束后不会持久化,与普通 world anchor 不同——这点容易踩坑。

媒体播放方面,visionOS 26 的 AVPlayer 自动通过 AVPlaybackCoordinator 同步附近参与者的音视频,避免一台设备的声音先到、另一台后到形成回声。


核心启发

  • 做什么:把已有 SharePlay app 拿到同一房间的两台 Vision Pro 上跑一遍,确认 nearby sharing 直接可用。

    • 为什么值得做:Apple 明确说零改动可用,但实际效果(位置同步、Persona 排列、AVPlayer 同步)需要真机验证才能发现细节问题。
    • 怎么开始:找一台 visionOS 26 设备,邀请同事戴第二台,用现有 ShareLink 启动 GroupSession,观察窗口栏变绿和位置一致性。
  • 做什么:把纯 Immersive Space 体验改造成 “非沉浸窗口 + 沉浸场景” 双模式。

    • 为什么值得做:Immersive Space 没有窗口栏,用户找不到 Share 按钮,只能依赖 app 自己提供入口。提供一个非沉浸的入门窗口可以让 Share 菜单始终可触达。
    • 怎么开始:先做一个普通 WindowGroup 作为大厅页,用 ShareLink(item: YourActivity()) 暴露 activity;所有人加入后再 transition 到 ImmersiveSpace,并设置 supportsGroupImmersiveSpace = true
  • 做什么:用 ParticipantState.pose 在每个附近参与者面前放置 UI。

    • 为什么值得做:默认假设大家围在窗口正前方往往不成立——分享开始时人可能在房间任意位置,记分板放原点等于对着空气。
    • 怎么开始:监听 systemCoordinator.localParticipantStates,在 pose 更新时把记分板、工具栏等内容钉在每人 pose 前方 0.5 米处,并在数字表冠重新居中事件后刷新位置。
  • 做什么:把多人协作的物理布局(棋盘、桌面工具、3D 模型)锚定到 Shared World Anchor。

    • 为什么值得做:让虚拟内容真正”贴”在房间的桌面上,而不是浮在每人各自的相对坐标系里,是同处一室体验最有说服力的部分。
    • 怎么开始:先 observe worldAnchorSharingAvailability;可用时再用 WorldAnchor(originFromAnchorTransform:sharedWithNearbyParticipants: true) 创建锚点;用 update.anchor.id 作为同步标识,session 结束后记得重新创建(不持久化)。

关联 Session

评论

GitHub Issues · utterances