WWDC2024 上,苹果为 RealityKit 推出了 LowLevelMesh
功能,具体好处有:
- 可以自定义顶点结构体,想传什么就传什么
- 可以方便对顶点进行原地修改,CPU 与 GPU(Compute Shader)都可以
本文通过两种方式,创建自定义的 MeshResource
,并使用计算着色器来模拟布料被风吹动效果,带大家了解新推出的 LowLevelMesh
的强大功能。
RealityKit 中的 MeshResource
RealityKit 中 3D 模型的表示方法就是 MeshResource
类型,它包含了一系列内置的基本几何体生成 API,及创建自定义几何的 API。
内置几何类型与 API
原本内置的 Box 、Plane 、Sphere 和 Text 等基本几何体生成功能一直不太够用,于是今年新增了 extruding(挤压拉伸)式生成 API,同时今年还可以直接从包含 MeshAnchor.Geometry
的 Anchor 中生成几何体,不用像以前一样绕道生成: MeshAnchor.Geometry
(ARKit 识别的现实物体几何形状) -> ShapeResource
(物理碰撞用的形状) -> MeshResource
(RealityKit 显示用的网格几何体):
swift
// 新的拉伸式创建方式,可以根据 `SwiftUI.Path` 和 `AttributedString` 拉伸挤压出几何
convenience init(
extruding path: Path,
extrusionOptions: MeshResource.ShapeExtrusionOptions = ShapeExtrusionOptions()
) throws
convenience init(
extruding string: AttributedString,
textOptions: MeshResource.GenerateTextOptions = GenerateTextOptions(),
extrusionOptions: MeshResource.ShapeExtrusionOptions = ShapeExtrusionOptions()
) throws
// 直接从 Anchor 中创建,无需再绕道 ShapeResource
convenience init(from planeAnchor: PlaneAnchor) async throws
convenience init(from meshAnchor: MeshAnchor) async throws
通过 MeshDescriptor
创建自定义几何体
这是创建自定义几何体最简单的方法,也是最易理解的方法,对程序员比较友好。只需要把 顶点位置
、顶点索引
和 贴图坐标
这些数组传进去 MeshDescriptor
就可以了。因为传入的参数,经过了 RealityKit 的处理,所以我们可以省略一些参数,比如形状简单的法线可以自动生成。
不过缺点也有,RealityKit 处理的过程会将传入的多边形重新三角化为三角形,这样不仅运行效率低,还造成了最终生成的几何体和传入参数不一致,后续几乎无法修改。
swift
var vertices: [SIMD3<Float>] = [] //顶点位置
var indices: [UInt32] = [] //顶点索引
var uvs: [SIMD2<Float>] = [] //贴图坐标
......
......
var descr = MeshDescriptor()
descr.primitives = .triangles(indices)
descr.positions = MeshBuffer(vertices)
descr.textureCoordinates = MeshBuffers.TextureCoordinates(uvs)
return try .generate(from: [descr])
通过 MeshResource.Contents
创建自定义几何体
MeshResource.Contents
的结构与 USD 等 3D 格式文件的格式高度相似,又因为它不会对传入参数进行三角化,所以和最终显示在屏幕上几何体也一致,因此就可以后续通过代码进行修改。
它的内部结构包含:models
和 instances
。前者是几何体的结构定义,而后者则是实例化的对象,特别像 类 和 对象 的区别。
swift
var vertices: [SIMD3<Float>] = [] //顶点位置
var normals: [SIMD3<Float>] = [] //法线
var indices: [UInt32] = [] //顶点索引
var uvs: [SIMD2<Float>] = [] //贴图坐标
......
......
var part = MeshResource.Part(id: "MeshPart", materialIndex: 0)
part.triangleIndices = .init(indices)
part.textureCoordinates = .init(uvs)
part.normals = .init(normals)
part.positions = .init(vertices)
let model = MeshResource.Model(id: "MeshModel", parts: [part])
let instance = MeshResource.Instance(id: "MeshModel-0", model: "MeshModel")
var contents = MeshResource.Contents()
contents.instances = .init([instance])
contents.models = .init([model])
return try MeshResource.generate(from: contents)
通过 LowLevelMesh
创建自定义几何体
今年新增了 LowLevelMesh
来创建几何体,它的好处一是可以自定义顶点的数据结构,二是通过 CPU 指针和 GPU 的计算着色器进行高效修改。
缺点就是,它的定义也复杂很多,你需要掌握更多的内存操作知识。
swift
var vertices: [SIMD3<Float>] = [] //顶点位置
var normals: [SIMD3<Float>] = [] //法线
var indices: [UInt32] = [] //顶点索引
var uvs: [SIMD2<Float>] = [] //贴图坐标
......
......
//描述内存布局
var desc = LowLevelMesh.Descriptor()
desc.vertexAttributes = [......]
desc.vertexLayouts = [......]
desc.vertexCapacity = vertices.count
desc.indexType = .uint32
desc.indexCapacity = indices.count
let mesh = try? LowLevelMesh(descriptor: desc)
mesh?.withUnsafeMutableBytes(bufferIndex: 0, { rawBufferPointer in
//用 Swift 指针写入顶点、法线和 uv 数据
......
})
mesh?.withUnsafeMutableIndices { rawIndices in
//用 Swift 指针写入顶点索引
......
}
// 设置 BoundingBox 和拓扑格式
let meshBounds = BoundingBox(min: [0, -0.1, 0], max: [Float(width), 0.1, Float(height)])
mesh?.parts.replaceAll([
LowLevelMesh.Part(
indexCount: indices.count,
topology: .triangle,
materialIndex: 0,
bounds: meshBounds
)
])
return try MeshResource(from: mesh!)
三种方法对比
从使用方便的角度讲:
MeshDescriptor
> MeshResource.Contents
>> LowLevelMesh
而从 CPU 单次生成效率角度讲:
LowLevelMesh
> MeshResource.Contents
>> MeshDescriptor
而从 GPU 连续计算与修改的角度讲:
LowLevelMesh
>> MeshResource.Contents
简单总结:
MeshDescriptor
:为方便程序员使用而生。MeshResource.Contents
:为方便对接 USD 文件,及高效提交给 GPU 渲染而生。LowLevelMesh
:为自由内存布局,及 Compute Shader 而生。
Compute Shader 与模拟布料
为什么 LowLevelMesh
与计算着色器 Compute Shader 关系这么密切?难道没有它就无法使用 Compute Shader 了吗?那倒也不是,Compute Shader 擅长处理并行计算问题,比如模拟布料,风吹动旗子的效果几乎就是为 Compute Shader 而生的。
在本文对应的 Demo:RealityComputeShader_Flag 中,V1 版就是通过 MeshResource.Contents
与 Compute Shader
联合使用,来不断刷新旗子几何体的形状来实现的,可以看到最大问题就是:Compute Shader 计算完成后,数据需要回到 CPU,CPU 读取数据并重新构建一个 MeshResource.Contents
,因为是新的几何体,所以只能将数据发送回 GPU 进行渲染。
而 Demo 中的 V2 版,则是 LowLevelMesh
与 Compute Shader
联合使用,来不断刷新旗子几何体的形状来实现的,可以看到最大好处就是:Compute Shader 计算完成后,数据无需回到 CPU,CPU 只需要知道计算完成,然后直接调用 GPU 进行渲染即可,因为数据一直都在 GPU 上。
性能对比
经过测试,V1 版本在模拟器上 CPU 占用一直在 25% 左右,而 V2 版 CPU 占用仅有 14% 左右。考虑到模拟器不够精确,Compute Shader 代码也没有进行过多优化,相信实机效果会更好。
不过,让人好奇的是,Vision Pro 真机使用的 M2 处理器,GPU 为了提供 3D 渲染已经竭尽全力了,在大型沉浸式场景中还能有多少余力来供大家跑 Compute Shader 代码?
LowLevelMesh 实际使用的问题
经过使用发现,像苹果在 WWDC Build a spatial drawing app with RealityKit 中讲的那样,自定义一个结构体来组织内存是最方便的使用方式,可以方便后续拼接更多数据,也方便在 Shader 中进行数据操作。
但是那样就需要修改 Compute Shader,造成两者不一致,于是我就想挑战一下,使用默认的布局来创建一个几何体。
其中遇到的最大困难有两个:
- 初始化时,需要使用 Swift 指针来向 LowLevelMesh 填充数据;
- 每次计算完成后,需要使用 Blit 命令,将数据复制到指定位置的内存位置上;
我们先来演示第一个,在 CPU 上初始化,使用 Swift 指针来向 mesh 填充数据:
swift
var vertices: [SIMD3<Float>] = []
var normals: [SIMD3<Float>] = []
var uvs: [SIMD2<Float>] = []
var desc = LowLevelMesh.Descriptor()
let mesh = try? LowLevelMesh(descriptor: desc)
mesh?.withUnsafeMutableBytes(bufferIndex: 0, { rawBufferPointer in
let _ = rawBufferPointer.withMemoryRebound(to: SIMD3<Float>.self) { buffer in
let offset = buffer.update(fromContentsOf: vertices+normals)
buffer.suffix(from: offset).withMemoryRebound(to: SIMD2<Float>.self) { buffer2 in
let _ = buffer2.update(fromContentsOf: uvs)
}
}
})
下面是每次 GPU 计算完成后的操作,命令 GPU 复制数据到 lowLevelMesh 对应 Buffer 的指定位置:
swift
let blitBuffer = commandQueue.makeCommandBuffer()
let blitEncoder = blitBuffer?.makeBlitCommandEncoder()
guard let vb = mesh.lowLevelMesh?.replace(bufferIndex: 0, using: blitBuffer!) else {
return
}
// 命令 GPU 复制数据到 lowLevelMesh 对应 Buffer 的指定位置
blitEncoder?.copy(from: mesh.vb1, sourceOffset: 0, to: vb, destinationOffset: 0, size: MemoryLayout<SIMD3<Float>>.size * mesh.vertexCount)
blitEncoder?.copy(from: mesh.normalBuffer, sourceOffset: 0, to: vb, destinationOffset: MemoryLayout<SIMD3<Float>>.size * mesh.vertexCount, size: MemoryLayout<SIMD3<Float>>.size * mesh.vertexCount)
blitEncoder?.copy(from: mesh.uvBuffer, sourceOffset: 0, to: vb, destinationOffset: MemoryLayout<SIMD3<Float>>.size * mesh.vertexCount * 2, size: MemoryLayout<SIMD2<Float>>.size * mesh.vertexCount)
blitEncoder?.endEncoding()
blitBuffer?.commit()