大多数浏览器和
Developer App 均支持流媒体播放。
-
将 iOS 或 iPadOS 游戏移植到 visionOS
探索如何将你的 iOS 或 iPadOS 游戏转换为别具一格的 visionOS 体验。利用 3D 框架或沉浸式背景,让你的游戏更加令人沉浸 (且乐趣无穷)。使用立体视觉或头部跟踪功能为窗口添加深度,引领玩家进一步沉浸在你构建的世界。
章节
- 0:00 - Introduction
- 1:42 - Render on visionOS
- 3:48 - Compatible to native
- 6:41 - Add a frame and a background
- 8:00 - Enhance the rendering
资源
相关视频
WWDC24
- 使用 RealityKit 构建空间绘画 App
- 在 visionOS 中使用透视功能渲染 Metal
- 在 visionOS 中探索游戏输入
- 打造引人入胜的空间照片和视频使用体验
- 探索适用于 iOS、macOS 和 visionOS 的 RealityKit API
WWDC23
-
下载
大家好 我叫 Olivier 是一名软件工程师 从事 RealityKit 和 visionOS 开发 在这个视频中 我将介绍 如何将你的 iOS 或 iPadOS 游戏 转换成更具身临其境的 沉浸式体验的 visionOS 游戏
我们以“Wylde Flowers”为例 这是 一款休闲放松的模拟农场经营游戏 这是这款游戏的 iPad 版
这是在 visionOS 上的窗口中 作为兼容 App 运行的 同一个 iPad 版本
可从 visionOS App Store 获得的 最新版本的“Wylde Flowers” 针对这个特定平台 推出了许多增强功能
例如 窗口周围 有一个 3D 框架
这个框架会根据游戏剧情而变化 例如 当玩家与花园交互时 框架会显示 3D 花园模型 和其他 UI 元素
游戏周围还有一个沉浸式背景 其中有随风飘落的蒲公英 和飞来飞去的蜂鸟
最后 游戏视图 进行了立体视觉渲染 以增强场景的纵深感
得益于这些增强功能 “Wylde Flowers”优化了 visionOS 上的游戏体验 而在这个视频中 我将介绍如何对 iOS 或 iPadOS 游戏完成相同的操作
首先谈谈 visionOS 上 可以使用的渲染技术 然后展示如何将 iOS App 转换为原生 visionOS App 我将展示如何为游戏添加 RealityKit 框架 和背景 最后 我将介绍如何 借助立体视觉、头部追踪和 VRR 来增强 Metal 渲染
首先介绍一下 visionOS 上 可以使用的渲染技术 你可以使用 RealityKit 和 Metal 在 visionOS 上进行 3D 渲染
RealityKit 是一个框架 可以直接在 Swift 中使用 也可通过 Unity PolySpatial 等技术间接使用 它可以在 visionOS 上的 同一共享空间中渲染多个 App 例如 你可以使用 RealityKit 在视体窗口中渲染内容 比如“Game Room”和 “LEGO Builder's Journey”等游戏 或者 也可以使用 RealityKit 在 ImmersiveSpace 中渲染内容 比如“Super Fruit Ninja” 和“Synth Riders”
还可以在 visionOS 直接使用 Metal 进行渲染 如果你需要自定渲染管道 或者将游戏移植到 RealityKit 对你来说不切实际 那么你可能会选择这种方式
在 visionOS 上使用 Metal 进行渲染的模式主要有两种
你可以在窗口中将游戏 作为兼容 App 来运行 在这种情况下 App 的运行方式 和在 iPad 上非常相似
iPad 版“Wylde Flowers” 就是这样 在 visionOS 上作为兼容 App 运行
在窗口中运行 App 的一大优势 是可以在共享空间中 与其他 App 一起运行 例如 你可以在玩游戏的同时 使用“Safari 浏览器” 或是向朋友发送信息
还可以使用 CompositorServices 将游戏作为完全沉浸式 App 来运行 在这种情况下 游戏中的相机 由玩家的头部控制 例如 视频“在 visionOS 中使用透视功能 渲染 Metal”中的示例 就是使用 CompositorService 渲染的 观看这个视频可以了解更多信息
将 iPad App 作为 visionOS 上的 兼容 App 来运行非常简单 而使用 CompositorService 让游戏成为沉浸式 App 可以更加生动逼真地呈现游戏内容 但可能需要进行 大量重新设计和重新开发 因为玩家可以完全控制相机 从而可能会看向场景中的任意位置
在这个视频中 我将介绍一组 介于这两种模式之间的技巧 我将展示如何 先从兼容 App 做起 然后逐步添加功能 以增强沉浸感 并利用 Vision Pro 的功能
第一步也是最简单的一步 就是让游戏 作为 visionOS 上的兼容 App 运行
我将使用 Metal Deferred Lighting 示例作为 iOS 游戏的一个例子 这个视频展示了 在 iPad 上运行的示例
如需下载这个示例的 iOS 版本 可以访问开发者网站 developer.apple.com/cn/
首先使用 iOS SDK 对 App 进行编译 然后让它在 visionOS 上 作为兼容 App 运行
在 visionOS 上 兼容 App 将在窗口中运行
请注意 触摸控件 和游戏控制器 在 visionOS 上都是可用的 因此玩家一开箱就能 在所有平台上获得统一的体验
如需了解有关游戏输入的更多信息 可以观看视频 “在 visionOS 中探索游戏输入”
兼容 App 可在 visionOS 上 顺畅运行 但接下来 我要将 App 转换为原生 App 以便开始使用 visionOS SDK 进入 App 的 Build Settings 然后选择 iOS 目标 目前 它由于 在 visionOS 上使用 iOS SDK 而被标记为 Designed for iPad
将 Apple Vision 添加为受支持的平台 以便使用 visionOS SDK 来编译 App
可能会遇到一些编译错误 但如果 App 是为 iOS 而设计的 那么大部分代码应该可以编译
例如 你可以通过几个选项 在 visionOS 上显示 Metal iOS 游戏的内容
可以渲染到 CAMetalLayer 然后轻松整合到 UI 视图中 也可以使用新的 LowLevelTexture API 直接渲染到 RealityKit 纹理
可以先渲染到 CAMetalLayer 这种方法可能更容易 但我建议换到 LowLevelTexture 以便获得最大控制权
如果想要渲染到 CAMetalLayer 则可以创建包含它的视图
还可以创建 CADisplayLink 以便每一帧都能获取回调
相应的代码是这样的 UIView 声明一个 CAMetalLayer 作为它的 layerClass 创建 CAMetalDisplayLink 获取渲染回调
最后 它会在每一帧 通过回调渲染 CAMetalLayer
你可以采用类似的方式 来使用 LowLevelTexture 可以使用给定像素格式和分辨率 创建 LowLevelTexture 然后可以从 LowLevelTexture 创建 TextureResource 并在 RealityKit 场景中的 任意位置使用 可以使用 CommandQueue 来通过 MTLTexture 绘制到 LowLevelTexture
下面介绍如何通过代码实现这一操作 代码会创建 LowLevelTexture 然后从它创建 TextureResource 可在 RealityKit 场景中的 任意位置使用
然后绘制到每一帧的纹理中
如需详细了解 LowLevelTexture 可以观看视频“使用 RealityKit 构建空间绘画 App” 现在 我们已经将游戏转换为 原生 visionOS App 接下来可以添加 特定于 visionOS 的功能 例如 我们可以 在游戏视图周围添加框架 并在 ImmersiveSpace 中添加背景 从而增强 App 的沉浸感
游戏“Cut The Rope 3” 在窗口周围有一个动态框架
这个框架是使用 RealityKit 渲染的 而游戏是使用 Metal 渲染的
你可以使用ZStack 来实现这一点 其中包含将游戏渲染到纹理的 Metal 视图 就像我刚才展示的一样 然后就可以使用 RealityView 来创建框架 RealityView 会在游戏周围 载入 3D 模型
可以使用 @State 变量 创建动态框架 例如 在“Cut The Rope 3”中 这个框架会随关卡变化而变化 还可以在游戏后面添加沉浸式背景 游戏“Void-X”就是一个很好的例子 大多数游戏剧情都发生在窗口中 但“Void-X”会在背景中 添加雨和闪电 还会让 3D 子弹 在玩家头顶上飞来飞去 以增强沉浸感
可以使用 SwiftUI 中的 ImmersiveSpace 来创建背景
也可以将 iOS 游戏 放在 WindowGroup 中
还可以通过使用 SwiftUI @State 对象来采用介于窗口 和 ImmersiveSpace 之间的共享 @State
我已经介绍了如何 在游戏周围添加元素 接下来 我将带大家了解 一些用于增强游戏的 Metal 渲染的技巧 首先 我将介绍 如何添加立体视觉 以增强游戏的纵深感 然后介绍如何添加头部追踪 让游戏看起来就像 通往另一个世界的实体窗口 最后 我将介绍 如何在游戏中添加 VRR 以提升游戏性能
你可以在游戏中添加立体视觉 以增强场景的纵深感 原理和立体电影类似
这是一个来自 Deferred Lighting 示例的场景 进行了立体视觉渲染 为了便于说明 我将以红蓝立体照片的形式 来展示立体视觉 但在 Vision Pro 上 每只眼看到的图像不一样
立体视觉的基本原理 是向每只眼展示不同的图像 在 visionOS 上 你可以 使用 RealityKit ShaderGraph 来实现这一点 更具体地说 是使用 CameraIndex 节点
还可以向每只眼睛提供不同的图像 以实现立体视觉
立体视觉的景深效果 来自于每个图像中 对象视图之间的距离 称为视差
这种视差会让你视线的会聚程度 变得更高或更低 具体取决于你与对象之间的距离 你双眼的视线 在看向无限远处时是平行的 而在看向近处的物体时会发生会聚 这是大脑用来判断距离的线索之一 立体视觉就是通过这种方式 让场景具有纵深感的 就像你眼前有一个实体微缩世界 或是一扇通往另一个世界的窗口一样
视差为负的对象会显示在图像前方 如果对象没有视察 就意味着两个图像重叠 显示在图像平面上的效果 像 2D 图像一样 视差为正的对象 会显示在图像平面后面
这只是立体视觉正面观看效果的 示意图
在实践中 如果从侧面观看 在窗口之外是看不到任何东西的 因为内容只显示在一个矩形区域内
如果想让对象超出矩形的边界 使用 RealityKit 和新推出的 传送门穿越 API 等 API 来渲染对象 观看视频“探索适用于 iOS、macOS 和 visionOS 的 RealityKit API” 可了解传送门穿越的示例
如果你不使用玩家的头部位置 则从侧面观看时 场景会呈现出投影效果 我稍后将演示 如何使用头部位置 这个示意图展示了 立体视觉的作用原理 观看者感知的对象 位于两条光线的交点处 感知的景深具体取决于 两个图像之间的视差 随着视差的变化 交点位置也会发生变化
另外还要注意 对于给定的立体图像 感知的景深具体取决于 窗口的大小和位置 即使图像没有变化 也是如此
视频“打造引人入胜的 空间照片和视频使用体验” 详细介绍了如何为 Vision Pro 创建立体内容
我建议避免的主要情况之一 是让内容渲染超出无限远 当观看者注视内容时 双眼的视线 应该要么会聚 要么平行 如果视差大于观看者 双眼之间的距离 内容就会超出无限远 光线会岔开 不会有交点 这种情况在观看者注视真实内容时 永远不会发生 并且会让观看者感到很不舒服 这个问题有一种解决方法 是在无限远的平面上显示立体图像 例如 这个图像在窗口平面上渲染 部分内容显示在图像平面后面 还有部分内容显示在图像平面前方 通过在无限远的图像平面上渲染内容 例如像空间照片一样 通过传送门来进行 视差为 0 的内容会显示在无限远处 而其他所有内容都会显示在它前方 且视差为负 这样一来 就可以保证 所有内容都显示在无限远处的前方
我还建议在游戏的设置中 添加一个滑块 供玩家用来调整立体视觉的强度 以获得舒适的观看体验 你可以通过 更改两台虚拟相机之间的距离 来实现这一点 为了生成立体影像 你需要更新游戏循环 以便为每只眼进行渲染 iOS 上 Deferred Lighting 示例的游戏循环 是这样的
这个示例会更新游戏状态和动画效果 然后进行离屏渲染 例如阴影贴图 然后渲染到屏幕上 最后 这个示例会显示渲染
要实现立体视觉 你需要为每只眼复制屏幕渲染 要实现最佳性能 可以使用 Vertex Amplification 通过相同的绘制调用 为双眼进行渲染
开发者文档网站上有一篇 介绍 Vertex Amplification 的文章
例如 我是这样调整 Deferred Lighting 示例的代码的 首先编码阴影通道 进行一次 然后 遍历每个视图 并设置适当的相机矩阵 最后 将渲染命令编码到 这个视图的颜色纹理和景深纹理
立体视觉可以增强场景的纵深感 和立体电影或空间照片类似 为了让游戏看起来像是 通往另一个世界的窗口 还可以添加头部追踪 例如 Deferred Lighting 示例 与头部追踪可以这样配合使用 相机会随着玩家头部的移动而移动
你可以通过打开 ImmersiveSpace 并使用 ARKit 来获取玩家头部的位置 然后就可以在每一帧 都通过 ARKit 获取头部位置 并传递给渲染器 以控制相机
相应的代码是这样的 示例会先导入 ARKit 然后创建 ARKitSession 和 WorldTrackingProvider 在每一帧都会查询头部变换
另外还要注意 窗口和 ImmersiveSpaces 在 visionOS 上具有自己的坐标空间 来自 ARKit 的头部变换 位于 ImmersiveSpace 的 坐标空间中 要在窗口中使用它 可以将位置转换为窗口的坐标空间
下面介绍如何通过代码实现这一操作 你可以在 ImmersiveSpace 的 坐标空间中从 ARKit 获取头部位置
然后可以使用 visionOS 2.0 提供的 这个全新 API 来获取窗口中的 实体相对于 ImmersiveSpace 的 transformMatrix
你可以对这个矩阵求逆 从而将头部位置转换到 窗口空间 最后 可以将这个相机位置 设置到渲染器中
为了获得最佳效果 请务必对头部位置进行预测 由于进行渲染需要一定时间 因此对于要显示的渲染 请使用预估时间并预测头部位置 以便让渲染尽可能 与最终头部位置相匹配 如果你给出 App 的预估渲染时间 ARKit 就会对头部进行预测 在示例中 预估 presentationTime 使用的是 33 毫秒 对应于 90 fps 下的 3 帧 为了让游戏看起来就像 透过实体窗口呈现的一样 你还需要构建一个非对称投影矩阵 如果使用固定投影矩阵 它会与窗口的形状不匹配 必须让相机视锥体穿过窗口 例如 可以使用指向窗口 左侧和右侧的矢量 来构建投影矩阵 以这种方式构建投影矩阵的一个好处 是可以使用与窗口对齐的近裁剪平面 以防止对象与窗口的边相交
相应的代码是这样的 可以从相机位置 和视口的 3D 边界着手 在给定位置 相机朝向 Z 轴 然后计算到视口每一侧的距离 并使用这些距离 来构建非对称投影矩阵 以上介绍了如何使用头部追踪 让游戏看起来就像 通往另一个世界的实体窗口 立体视觉可以增强游戏的沉浸感 但也会增加渲染的成本 因为游戏需要渲染的片段数量 是原来的两倍 你可以通过使用 Variable Rasterization Rates 在一定程度上抵消这方面的成本 以提高游戏的渲染效率 Variable Rasterization Rates 是 Metal 的一项功能 可以以可变分辨率 在整个屏幕上进行渲染
你可以使用这项功能 来降低外围的分辨率 并提高中心处的分辨率 如果使用了头部追踪 则可以用头部变换 构建 VRR 贴图 因为你可以知道 像素位于视野的中心还是外围 如果使用了共享空间 则无法访问头部位置 但仍可以使用 AdaptiveResolutionComponent 构建 VRR 贴图 并将这些组件放置在 游戏视口上方的 2D 网格中
AdaptiveResolutionComponent 能够给出在这个 3D 位置 1 立方米大致占用的屏幕空间大小 所使用的单位是像素 例如 在这里 这个值 从 1024 到 2048 像素不等
这个视频介绍了 AdaptiveResolutionComponent 组件上的值 如何随着相机推远和拉近而变化
你可以从 2D 网格中提取 水平和垂直 VRR 贴图 为了获得更平滑的结果 可以在每个 VRR 贴图中进行插值 接下来终于可以将这些贴图 传递给 Metal 渲染器了 最后 在使用 VRR 进行内容渲染后 必须通过反转 VRR 贴图 将它重新映射到显示屏 以上介绍了如何使用 Variable Rasterization Rates 来调整渲染到相机变换的分辨率 从而提升游戏的性能
利用上述各项增强功能 你可以进一步优化 visionOS 上的游戏体验 就像“Wylde Flowers”游戏 为适应 visionOS 而进行的转换那样
在这个视频中 我们了解了如何 将 iOS 游戏移植到 visionOS 上 以及如何添加框架 和 ImmersiveSpace 以增强游戏的沉浸感 如何将立体视觉和头部追踪 添加到 Metal 渲染器中 让游戏看起来就像 通往另一个世界的窗口 以及如何使用 VRR 来优化性能 我希望以上技巧 可以帮助大家在 visionOS 上 进一步优化 iOS 游戏 我也期待在我的 Vision Pro 上 畅玩各位的游戏
-
-
5:44 - Render with Metal in a UIView
// Render with Metal in a UIView. class CAMetalLayerBackedView: UIView, CAMetalDisplayLinkDelegate { var displayLink: CAMetalDisplayLink! override class var layerClass : AnyClass { return CAMetalLayer.self } func setup(device: MTLDevice) { let displayLink = CAMetalDisplayLink(metalLayer: self.layer as! CAMetalLayer) displayLink.add(to: .current, forMode: .default) self.displayLink.delegate = self } func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { let drawable = update.drawable renderFunction?(drawable) } }
-
6:20 - Render with Metal to a RealityKit LowLevelTexture
// Render Metal to a RealityKit LowLevelTexture. let lowLevelTexture = try! LowLevelTexture(descriptor: .init( pixelFormat: .rgba8Unorm, width: resolutionX, height: resolutionY, depth: 1, mipmapLevelCount: 1, textureUsage: [.renderTarget] )) let textureResource = try! TextureResource( from: lowLevelTexture ) // assign textureResource to a material let commandBuffer: MTLCommandBuffer = queue.makeCommandBuffer()! let mtlTexture: MTLTexture = texture.replace(using: commandBuffer) // Draw into the mtlTexture
-
7:06 - Metal viewport with a 3D RealityKit frame around it
// Metal viewport with a 3D RealityKit frame // around it. struct ContentView: View { @State var game = Game() var body: some View { ZStack { CAMetalLayerView { drawable in game.render(drawable) } RealityView { content in content.add(try! await Entity(named: "Frame")) }.frame(depth: 0) } } }
-
7:45 - Windowed game with an immersive background
// Windowed game with an immersive background @main struct TestApp: App { @State private var appModel = AppModel() var body: some Scene { WindowGroup { // Metal render ContentView(appModel) } ImmersiveSpace(id: "ImmersiveSpace") { // RealityKit background ImmersiveView(appModel) }.immersionStyle(selection: .constant(.progressive), in: .progressive) } }
-
13:11 - Render to multiple views for stereoscopy
// Render to multiple views for stereoscopy. override func draw(provider: DrawableProviding) { encodeShadowMapPass() for viewIndex in 0..<provider.viewCount { scene.update(viewMatrix: provider.viewMatrix(viewIndex: viewIndex), projectionMatrix: provider.projectionMatrix(viewIndex: viewIndex)) var commandBuffer = beginDrawableCommands() if let color = provider.colorTexture(viewIndex: viewIndex, for: commandBuffer), let depthStencil = provider.depthStencilTexture(viewIndex: viewIndex, for: commandBuffer) { encodePass(into: commandBuffer, color: color, depth: depth) } endFrame(commandBuffer) } }
-
13:55 - Query the head position from ARKit every frame
// Query the head position from ARKit every frame. import ARKit let arSession = ARKitSession() let worldTracking = WorldTrackingProvider() try await arSession.run([worldTracking]) // Every frame guard let deviceAnchor = worldTracking.queryDeviceAnchor( atTimestamp: CACurrentMediaTime() + presentationTime ) else { return } let transform: simd_float4x4 = deviceAnchor .originFromAnchorTransform
-
14:22 - Convert the head position from the ImmersiveSpace to a window
// Convert the head position from the ImmersiveSpace to a window. let headPositionInImmersiveSpace: SIMD3<Float> = deviceAnchor .originFromAnchorTransform .position let windowInImmersiveSpace: float4x4 = windowEntity .transformMatrix(relativeTo: .immersiveSpace) let headPositionInWindow: SIMD3<Float> = windowInImmersiveSpace .inverse .transform(headPositionInImmersiveSpace) renderer.setCameraPosition(headPositionInWindow)
-
15:05 - Query the head position from ARKit every frame
// Query the head position from ARKit every frame. import ARKit let arSession = ARKitSession() let worldTracking = WorldTrackingProvider() try await arSession.run([worldTracking]) // Every frame guard let deviceAnchor = worldTracking.queryDeviceAnchor( atTimestamp: CACurrentMediaTime() + presentationTime ) else { return } let transform: simd_float4x4 = deviceAnchor .originFromAnchorTransform
-
15:47 - Build the camera and projection matrices
// Build the camera and projection matrices. let cameraPosition: SIMD3<Float> let viewportBounds: BoundingBox // Camera facing -Z let cameraTransform = simd_float4x4(AffineTransform3D(translation: Size3D(cameraPosition))) let zNear: Float = viewportBounds.max.z - cameraPosition.z let l /* left */: Float = viewportBounds.min.x - cameraPosition.x let r /* right */: Float = viewportBounds.max.x - cameraPosition.x let b /* bottom */: Float = viewportBounds.min.y - cameraPosition.y let t /* top */: Float = viewportBounds.max.y - cameraPosition.y let cameraProjection = simd_float4x4(rows: [ [2*zNear/(r-l), 0, (r+l)/(r-l), 0], [ 0, 2*zNear/(t-b), (t+b)/(t-b), 0], [ 0, 0, 1, -zNear], [ 0, 0, 1, 0] ])
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。