Highlight
visionOS 上的 3D 渲染有两条路:RealityKit 和 Metal。RealityKit 适合通过 Swift 或 Unity PolySpatial 来做体积窗口(Volume)和沉浸空间(ImmersiveSpace),Game Room、LEGO Builder’s Journey 就是这种模式。但如果你的游戏已经有成熟的 Metal 管线,直接用 Metal 渲染会更实际。
核心内容
你有一个跑在 iOS 上的 Metal 游戏,想让它在 Vision Pro 上也能玩。最简单的办法是直接用 iOS SDK 编译,作为兼容应用(Compatible App)跑在 visionOS 的窗口里——触摸和手柄输入开箱即用,体验跟 iPad 上差不多。但这样你就浪费了 Vision Pro 的立体显示和空间感知能力。
Apple 给出的方案是渐进式升级:从兼容应用出发,一步一步加料。先把 iOS SDK 编译换成 visionOS SDK 编译,解决少量编译错误;然后把渲染目标从 CAMetalLayer 迁移到 RealityKit 的 LowLevelTexture;接着按需叠加 3D 边框、ImmersiveSpace 背景、立体渲染(Stereoscopy)、头部追踪(Head Tracking)和可变刷新率(VRR)。每一步都可以独立发布,不需要一次做完。
Wylde Flowers 就是这个路径的真实案例。iPad 版本作为兼容应用在 visionOS 上运行后,逐步加上了随游戏场景变化的 3D 窗口边框、蒲公英飘落的沉浸背景、以及立体深度渲染,最终变成了一个完全不同的体验。Cut The Rope 3 也用同样方式做了动态边框,Void-X 则在窗口后面加了雨、闪电和 3D 子弹。
详细内容
从 CAMetalLayer 迁移到 LowLevelTexture
iOS 上的 Metal 游戏通常渲染到 CAMetalLayer。在 visionOS 上,你可以继续这样做,但 Apple 推荐迁移到 LowLevelTexture,因为它给你更多控制权——特别是后续要加立体渲染和 VRR 的时候。
CAMetalLayer 的写法是这样的(05:44):
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)
}
}
关键点:
layerClass返回CAMetalLayer.self,让这个 UIView 直接使用 Metal 层作为 backing layerCAMetalDisplayLink是 visionOS 新增的 API,替代了 iOS 上的CADisplayLink,提供每帧渲染回调- 在回调里拿到
drawable,传给你的渲染函数即可
迁移到 LowLevelTexture 的写法(06:20):
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
关键点:
LowLevelTexture用像素格式和分辨率创建,标记为.renderTarget- 通过
TextureResource(from:)把它包装成 RealityKit 可用的纹理资源,可以赋给任意 Material replace(using:)返回一个MTLTexture,你在这个纹理上执行 Metal 绘制命令- 渲染结果直接出现在 RealityKit 场景中,不需要额外的拷贝
3D 边框 + ImmersiveSpace 背景
用 ZStack 把 Metal 渲染和 RealityKit 边框叠在一起(07:06):
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)
}
}
}
关键点:
CAMetalLayerView负责游戏的 Metal 渲染RealityView加载一个 3D 模型作为边框,.frame(depth: 0)确保边框和游戏画面在同一平面上- 边框可以通过
@State变量动态切换,比如 Cut The Rope 3 根据关卡变化边框
ImmersiveSpace 背景的写法(07:45):
@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)
}
}
关键点:
- 游戏主体在
WindowGroup里,背景在ImmersiveSpace里,两者通过共享的@State对象通信 .progressive沉浸样式让背景逐渐显现,不会完全遮挡真实环境
立体渲染(Stereoscopy)
立体渲染的核心是给左右眼各渲染一张图。你需要把游戏循环从”渲染一次”改成”渲染两次”(13:11):
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)
}
}
关键点:
- Shadow Map 只编码一次,左右眼共享
provider.viewCount在开启立体渲染时为 2,分别对应左右眼- 每只眼用独立的 viewMatrix 和 projectionMatrix,渲染到各自的 color 和 depthStencil 纹理
- 建议使用 Vertex Amplification 减少绘制调用,一次 draw call 同时输出左右眼
关于视差(Parallax)的重要提醒:负视差让物体浮在画面前面,正视差让物体沉到画面后面。如果视差超过瞳距,光线会发散,眼睛无法聚焦——这比”超越无穷远”还难受。Apple 建议把立体画面渲染在无穷远平面上(类似 Spatial Photo 的 portal 方式),确保所有内容都出现在无穷远之前。同时建议在游戏设置里加一个滑块,让玩家调节立体感强度。
头部追踪(Head Tracking)
头部追踪让你的游戏看起来像一扇通向另一个世界的窗户——当你移动头部时,画面视角跟着变。需要开一个 ImmersiveSpace 来获取 ARKit 数据(13:55):
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
关键点:
WorldTrackingProvider提供设备位置,每帧查询- 传入
CACurrentMediaTime() + presentationTime做预测,补偿渲染延迟。示例中用 33ms(90fps 下的 3 帧) - 返回的
originFromAnchorTransform是 ImmersiveSpace 坐标系下的变换
ARKit 返回的头部位置在 ImmersiveSpace 坐标系下,需要转换到窗口坐标系(14:22):
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)
关键点:
- 从 ARKit 获取头部在 ImmersiveSpace 中的位置
transformMatrix(relativeTo: .immersiveSpace)是 visionOS 2.0 新 API,获取窗口实体在 ImmersiveSpace 中的变换矩阵- 求逆后变换头部位置到窗口坐标系,传给渲染器
有了头部位置,还需要构建非对称投影矩阵,让视锥体精确穿过窗口边界(15:47):
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]
])
关键点:
- 近裁剪面对齐窗口平面,防止物体穿出窗口边缘
- 用相机到窗口四边的距离构建不对称视锥体,确保画面精确映射到窗口矩形
- 当玩家偏离正前方时,视锥体自然倾斜,产生”从窗户侧面看”的效果
可变光栅化率(VRR)
立体渲染让片元数量翻倍。VRR 可以降低周边区域的分辨率,把算力集中在视野中心。如果你已经有头部追踪,可以从头部变换构建 VRR map。如果在 Shared Space 里没有头部位置,可以用 AdaptiveResolutionComponent——把它放在游戏视口上的 2D 网格中,它会告诉你每个位置上 1 米立方体在屏幕上占多少像素。从这组数值可以提取水平和垂直方向的 VRR map,插值后传给 Metal 渲染器。渲染完成后,还需要反转 VRR map 把内容重新映射到显示分辨率。
核心启发
-
做什么:给现有 iPad 游戏加 3D 边框。为什么值得做:成本极低(一个 RealityView + 3D 模型),视觉差异化极大,Cut The Rope 3 靠动态边框就让 visionOS 版本辨识度拉满。怎么开始:用 ZStack 叠加 Metal 渲染视图和 RealityView,加载 USDZ 模型作为边框,通过 @State 在不同游戏状态间切换。
-
做什么:在游戏窗口后面加 ImmersiveSpace 背景。为什么值得做:不需要改游戏渲染代码,就能让玩家感觉”被游戏世界包围”,Void-X 用雨和 3D 子弹做到了这一点。怎么开始:在 App 的 body 里加一个 ImmersiveSpace,用 .progressive 沉浸样式,共享 @State 让背景和游戏状态联动。
-
做什么:加立体渲染和头部追踪。为什么值得做:两者叠加后,游戏窗口看起来就像一扇真正的窗户,玩家从侧面看视角会变化,这比纯 2D 渲染的沉浸感有质的飞跃。怎么开始:先把渲染目标迁移到 LowLevelTexture,然后用 DrawableProviding 的 viewCount 循环渲染左右眼,再开一个 ImmersiveSpace 通过 ARKit 获取头部位置,构建非对称投影矩阵。
-
做什么:用 VRR 抵消立体渲染的性能开销。为什么值得做:立体渲染让片元翻倍,VRR 在周边区域降分辨率可以把帧率拉回来,且玩家几乎察觉不到。怎么开始:在 Shared Space 用 AdaptiveResolutionComponent 生成 2D 网格数据,提取水平和垂直 VRR map,传给 Metal 渲染器;有头部追踪时可以从变换矩阵直接构建更精确的 map。
关联 Session
- Create custom environments for your immersive apps in visionOS — 为沉浸式应用创建自定义 3D 环境,配合本 Session 的 ImmersiveSpace 背景使用
- Create enhanced spatial computing experiences with ARKit — 深入 ARKit 的 WorldTrackingProvider,本 Session 的头部追踪依赖此 API
- Dive deep into volumes and immersive spaces — 详细讲解 Volume 和 ImmersiveSpace 的新能力,是本 Session 窗口+背景架构的延伸
- Discover RealityKit APIs for iOS, macOS and visionOS — 包含 portal-crossing API,本 Session 提到用它让物体穿出窗口边界
评论
GitHub Issues · utterances