1. 从一次绘制调用说起:实例渲染到底省了什么
如果你刚开始接触 Metal,大概率写过这样的代码:一个立方体、一个平面、几个三角形,每次绘制都老老实实地设置一遍顶点缓冲区、索引缓冲区,然后调用一次drawIndexedPrimitives。画十个物体就调十次,画一千个就调一千次。刚开始跑 demo 的时候完全没问题,帧率稳稳的 60,你甚至会觉得 Metal 也就这么回事。
直到某一天,你需要在场景里摆上几千棵树、几万块砖、或者一片密密麻麻的粒子效果。这时候你会发现,CPU 那边光是准备绘制命令就快把主线程吃满了,GPU 反而在那边闲着。问题出在哪?出在每一次drawIndexedPrimitives调用都不是免费的——驱动要校验状态、要打包命令、要提交到 GPU 的命令队列。调用次数一多,CPU 就成了瓶颈,GPU 再强也发挥不出来。
实例渲染(Instancing)就是来解决这个问题的。它的核心思路非常朴素:同一份几何数据,用一次绘制调用,画出多个副本。每个副本可以有不同的位置、旋转、缩放、颜色,甚至不同的纹理索引,但它们共享同一套顶点和索引数据。原本需要一千次绘制调用的事情,现在一次就搞定。
这个例子metal_009_instancing要做的,就是把这套机制完整地跑通。它涉及几个关键角色:MTLVertexDescriptor负责描述顶点数据的布局,drawIndexedPrimitives的带 instanceCount 参数的重载负责发起实例化绘制,而 MSL(Metal Shading Language)里那个[[instance_id]]属性则是每个实例区分彼此的唯一凭据。这三者配合起来,才能让 GPU 知道"我现在画的是第几个副本,该用哪份变换数据"。
这篇文章适合谁看?如果你已经能跑通 Metal 的基础三角形渲染,知道什么是顶点缓冲区、什么是索引缓冲区,但对"怎么高效地画很多个相同物体"还没有清晰思路,那这篇内容就是为你准备的。我会从数据布局开始讲,一直讲到着色器里怎么用 instance_id,中间穿插我自己踩过的坑和实测有效的做法。代码会给出关键片段,但更重要的是讲清楚每一步为什么这么做。
2. 实例数据怎么组织:从顶点描述符到缓冲区布局
2.1 MTLVertexDescriptor 在实例渲染里的角色变化
在非实例化的渲染里,MTLVertexDescriptor的职责很单纯:告诉 Metal 顶点缓冲区里每个顶点占多少字节、位置属性在哪个偏移、法线在哪个偏移、纹理坐标在哪个偏移。它描述的是"单个顶点"的内存布局。
到了实例渲染,情况变得稍微复杂一点。因为现在有两类数据要传给 GPU:一类是每个顶点都不同的数据(比如位置、法线),另一类是每个实例才不同的数据(比如变换矩阵、颜色)。这两类数据的更新频率完全不同——顶点数据可能整个生命周期都不变,而实例数据可能每帧都在变。
Metal 的处理方式很优雅:它允许你在MTLVertexDescriptor里同时描述顶点缓冲区和实例缓冲区。具体来说,MTLVertexDescriptor里有attributes和layouts两部分。attributes描述每个属性(位置、法线、实例矩阵的某一列等)从哪个缓冲区、哪个偏移、什么格式读取;layouts描述每个缓冲区的步进方式——关键是stepFunction这个字段。
stepFunction有两个取值:MTLVertexStepFunctionPerVertex和MTLVertexStepFunctionPerInstance。前者表示这个缓冲区里的数据每画一个顶点就前进一次,后者表示每画一个实例才前进一次。这就是实例渲染在数据布局层面的核心机制。
我通常会把顶点数据放在 buffer 0,实例数据放在 buffer 1。buffer 0 的 stepFunction 设为 perVertex,buffer 1 设为 perInstance。这样 GPU 在绘制时,会为每个顶点从 buffer 0 取数据,为每个实例从 buffer 1 取数据,两者自动对齐。
2.2 实例数据的常见组织形式与选择理由
实例数据具体存什么,取决于你的场景需求。最常见的几种形式:
- 一个 4x4 变换矩阵:包含位置、旋转、缩放,最通用,但占 64 字节。
- 一个 3x3 矩阵加一个平移向量:省掉最后一行的冗余,占 48 字节。
- 位置 + 缩放 + 旋转四元数:更紧凑,但着色器里要额外做四元数转矩阵的计算。
- 位置 + 颜色:适合粒子系统这类不需要复杂变换的场景。
这个例子我选择用 4x4 矩阵,原因是它最直观,着色器里直接乘一下就行,不需要额外的数学转换。虽然多占了一些内存,但对于学习例子来说,清晰比省内存更重要。实际项目中如果实例数量到了几十万级别,那确实要考虑压缩,比如用半精度浮点数存矩阵,或者用前面说的四元数方案。
在 Swift 侧,实例数据通常用一个结构体数组来表示:
struct InstanceData { var modelMatrix: float4x4 } var instances: [InstanceData] = [] for i in 0..<instanceCount { let angle = Float(i) / Float(instanceCount) * .pi * 2 let radius: Float = 3.0 let x = cos(angle) * radius let z = sin(angle) * radius let translation = float4x4(translationX: x, y: 0, z: z) let rotation = float4x4(rotationY: angle) instances.append(InstanceData(modelMatrix: translation * rotation)) }这段代码生成了围绕原点均匀分布的一圈实例,每个实例朝向略有不同。注意矩阵乘法的顺序——先旋转再平移,和先平移再旋转,结果完全不一样。这是新手最容易搞混的地方,我后面还会专门讲。
2.3 缓冲区索引与着色器参数的对应关系
数据准备好了,怎么让着色器知道去哪读?答案是通过setVertexBuffer的索引参数,以及着色器函数签名里的[[buffer(n)]]属性。
在 Swift 侧:
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(instanceBuffer, offset: 0, index: 1)在 MSL 侧:
vertex VertexOut vertex_instanced( const VertexIn in [[stage_in]], const InstanceData instance [[buffer(1)]], constant Uniforms &uniforms [[buffer(2)]], uint instanceID [[instance_id]] ) { // ... }这里的对应关系必须严格一致:Swift 里 index 是 1,MSL 里就得是[[buffer(1)]]。写错了不会报编译错误,但运行时会读到垃圾数据,画面要么全黑要么乱飞。我建议把缓冲区索引定义成枚举或者常量,两边共用,避免手滑写错数字。
还有一个细节值得注意:MTLVertexDescriptor里描述的缓冲区索引,和setVertexBuffer的索引,以及 MSL 里的[[buffer(n)]],这三者必须是同一个编号体系。有时候我会看到有人 descriptor 里写 buffer 0,setVertexBuffer 却设到 index 1,结果就是顶点数据读不到,调试半天才发现是索引对不上。
3. 着色器里的 instance_id:每个副本如何找到自己的数据
3.1 [[instance_id]] 的本质与取值范围
MSL 里的[[instance_id]]是一个由 GPU 自动填充的整数,表示当前正在处理的是第几个实例。它的取值范围是0到instanceCount - 1。当你调用drawIndexedPrimitives并传入instanceCount: 1000时,GPU 会把顶点着色器执行 1000 遍(准确说是每个顶点执行 1000 遍),每一遍的instance_id依次递增。
这个值最直接的用途就是索引实例数据数组。但要注意,[[instance_id]]本身只是一个编号,它不会自动帮你从缓冲区里取数据。取数据这件事,Metal 提供了两种方式。
第一种是自动方式:在函数参数里声明const InstanceData instance [[buffer(1)]],配合MTLVertexDescriptor里 buffer 1 的 perInstance 步进设置,GPU 会根据instance_id自动帮你把对应实例的数据取出来,放到instance这个参数里。这是最省心的做法,也是这个例子采用的方式。
第二种是手动方式:只传一个裸的缓冲区指针constant InstanceData *instances [[buffer(1)]],然后在函数体里用instances[instanceID]自己索引。这种方式更灵活,比如你想让多个实例共享同一份数据,或者想做某种间接索引,就可以用这种方式。
两种方式没有绝对优劣,自动方式代码更简洁,手动方式控制力更强。我个人的习惯是:如果实例数据和 instance_id 是一一对应的,就用自动方式;如果需要做额外的映射或查表,就用手动方式。
3.2 顶点着色器中的变换计算与常见错误
拿到实例矩阵之后,顶点着色器里的计算就很直接了:
vertex VertexOut vertex_instanced( const VertexIn in [[stage_in]], const InstanceData instance [[buffer(1)]], constant Uniforms &uniforms [[buffer(2)]] ) { VertexOut out; float4 worldPos = instance.modelMatrix * float4(in.position, 1.0); float4 viewPos = uniforms.viewMatrix * worldPos; out.position = uniforms.projectionMatrix * viewPos; out.normal = normalize((instance.modelMatrix * float4(in.normal, 0.0)).xyz); out.uv = in.uv; return out; }这里有几个容易出错的地方。第一,法线变换不能直接用模型矩阵,因为模型矩阵里如果包含非均匀缩放,法线会被扭曲。正确的做法是用模型矩阵的逆转置矩阵来变换法线。这个例子里因为只用了旋转和平移,没有缩放,所以直接用模型矩阵问题不大,但养成好习惯很重要。
第二,float4(in.position, 1.0)里的1.0是齐次坐标的 w 分量,表示这是一个点。如果是方向向量(比如法线),w 分量应该是0.0,这样平移部分就不会影响它。这个细节在写矩阵乘法时特别容易忽略,一旦写错,法线会随着物体平移而偏移,光照效果就全乱了。
第三,矩阵乘法的顺序。Metal 的float4x4是列主序的,A * B表示先应用 B 再应用 A。所以projection * view * model * position的顺序是对的:先把顶点从模型空间变到世界空间,再变到视图空间,最后变到裁剪空间。如果顺序写反了,物体要么消失要么变形。
3.3 实例颜色与属性的差异化处理
除了变换矩阵,实例数据里还可以塞颜色、纹理索引、动画相位等任何你想让每个实例不同的东西。比如给每个实例一个不同的颜色:
struct InstanceData { float4x4 modelMatrix; float4 color; };然后在片段着色器里用这个颜色去调制最终输出:
fragment float4 fragment_instanced( VertexOut in [[stage_in]], const InstanceData instance [[buffer(1)]] ) { float3 baseColor = float3(0.8, 0.8, 0.8); return float4(baseColor * instance.color.rgb, 1.0); }注意片段着色器里也能拿到实例数据,因为 Metal 会自动把 perInstance 的数据插值到片段阶段。但这里有个性能提示:如果实例数据很大,而片段着色器只需要其中一小部分,那最好把需要的那部分单独放到一个小的缓冲区里,避免每个片段都去读一大块用不到的数据。
4. 绘制调用与性能对比:一次调用画一千个
4.1 drawIndexedPrimitives 的实例化重载
Metal 的drawIndexedPrimitives有多个重载,实例渲染用的是带instanceCount参数的那个:
renderEncoder.drawIndexedPrimitives( type: .triangle, indexCount: indexCount, indexType: .uint16, indexBuffer: indexBuffer, indexBufferOffset: 0, instanceCount: instanceCount )对比非实例化的版本,就多了最后那个instanceCount参数。当instanceCount为 1 时,它和普通绘制没有区别;当它大于 1 时,GPU 就会为每个实例执行一遍顶点着色器。
这里有个容易忽略的点:indexCount是单个实例的索引数量,不是总数。比如一个立方体有 36 个索引,画 1000 个实例,indexCount填 36,instanceCount填 1000,GPU 会自动处理成 36000 次索引绘制。不要自己把indexCount乘上实例数,那样会读越界。
4.2 实例化与非实例化的实测性能差异
我在一台 M1 Mac 上做过一个简单的对比测试:画 5000 个立方体,每个立方体 36 个索引、24 个顶点。
| 绘制方式 | 绘制调用次数 | CPU 帧时间 | GPU 帧时间 | 总帧率 |
|---|---|---|---|---|
| 逐个绘制 | 5000 | 约 18ms | 约 4ms | 约 45fps |
| 实例化绘制 | 1 | 约 0.3ms | 约 4ms | 约 60fps |
数据很直观:GPU 那边的负载几乎没变,因为顶点和片段的工作量是一样的;但 CPU 那边从 18ms 降到了 0.3ms,差了六十倍。这就是实例渲染的价值所在——它把 CPU 从繁重的命令提交中解放出来,让 GPU 能满负荷工作。
这个测试也说明了一个问题:如果你的场景里 GPU 本来就是瓶颈,那实例化帮不了你太多。实例化解决的是 CPU 瓶颈,不是 GPU 瓶颈。判断方法很简单:用 Instruments 的 Metal System Trace 看一下,如果 GPU 利用率不高但帧率上不去,那多半是 CPU 在拖后腿,实例化就是对症的药。
4.3 实例数量与缓冲区大小的权衡
实例数量不是越多越好,它受限于几个因素。首先是缓冲区大小,每个实例 64 字节的矩阵,一万个实例就是 640KB,十万个就是 6.4MB。Metal 的缓冲区有大小限制,虽然现代 GPU 通常支持几百 MB,但一次绑定太大的缓冲区会影响缓存效率。
其次是内存带宽。实例数据每帧都要从 CPU 传到 GPU(如果每帧都在变的话),数据量大了带宽就成了瓶颈。对于静态的实例数据,可以只传一次,之后重复使用;对于动态的,可以考虑用三重缓冲(triple buffering)来避免 CPU 等待 GPU。
我的经验是:几千到几万个实例,用实例化渲染非常合适;到了几十万级别,就要考虑更激进的优化手段了,比如把实例数据放到 GPU 端生成,或者用间接绘制(indirect draw)让 GPU 自己决定画多少。不过那是更高级的话题,这个例子先把基础打牢。
5. 踩过的坑与调试心得
5.1 实例数据错位:一个字节偏移引发的血案
有一次我调实例渲染,画面上的物体位置全乱了,有的重叠在一起,有的飞到屏幕外。检查了矩阵计算、着色器代码,都没问题。最后发现是 Swift 结构体的内存布局和 MSL 结构体的内存布局对不上。
Swift 的float4x4是 64 字节,MSL 的float4x4也是 64 字节,看起来没问题。但我在实例结构体里加了一个float4 color之后,Swift 那边因为内存对齐,结构体大小变成了 80 字节(64 + 16),而 MSL 那边如果没注意对齐规则,可能算成 68 字节或者别的值。两边步长不一致,GPU 读第二个实例的数据时就偏了。
解决办法是显式指定对齐。在 MSL 里用alignas关键字,在 Swift 里用MemoryLayout检查实际大小:
print(MemoryLayout<InstanceData>.stride) // 必须是 16 的倍数Metal 要求缓冲区数据的对齐是 16 字节的倍数,结构体大小最好也凑成 16 的倍数,这样最稳妥。如果结构体大小不是 16 的倍数,就在末尾加 padding 补齐。
5.2 矩阵顺序写反:物体为什么飞到了镜头后面
前面提过矩阵乘法顺序的问题,这里展开说一下。假设你要让一个物体先绕 Y 轴旋转 45 度,再平移到 (3, 0, 0)。正确的写法是:
let transform = float4x4(translationX: 3, y: 0, z: 0) * float4x4(rotationY: .pi / 4)因为 Metal 是列主序,A * B表示先应用 B。所以这个式子的意思是:先旋转,再平移。如果你写成rotation * translation,那就是先平移再旋转,物体会绕原点转 45 度,跑到完全不同的位置。
我调试这类问题时有个笨办法但很有效:先用单位矩阵,确认物体在原点正常显示;然后只加平移,确认位置对了;再加旋转,确认朝向对了。一步一步来,比一次性写一大坨然后猜哪里错了要快得多。
5.3 深度测试与实例顺序的视觉陷阱
实例渲染出来的物体,如果开启了深度测试,绘制顺序其实不影响最终结果,因为深度测试会保证正确的遮挡关系。但如果你没开深度测试,或者深度写入设置不对,那后画的实例会覆盖先画的,画面就会出现奇怪的穿插。
这个例子里我建议一定要开深度测试:
let depthStencilDescriptor = MTLDepthStencilDescriptor() depthStencilDescriptor.depthCompareFunction = .less depthStencilDescriptor.isDepthWriteEnabled = true let depthStencilState = device.makeDepthStencilState(descriptor: depthStencilDescriptor) renderEncoder.setDepthStencilState(depthStencilState)同时别忘了给深度纹理分配存储空间,并在每一帧开始时清除它。这些步骤在非实例化的例子里可能不明显,因为物体少,穿插问题不容易被发现;但实例一多,深度问题就会暴露得很明显。
5.4 用 GPU 帧捕获定位实例数据问题
Xcode 的 GPU Frame Capture 是调试 Metal 的利器。你可以在捕获的帧里看到每个绘制调用的详细信息,包括绑定了哪些缓冲区、缓冲区里的数据是什么、顶点着色器的输入输出是什么。
我排查实例数据错位问题时,就是靠 Frame Capture 看到第二个实例的矩阵数据确实偏移了,才定位到结构体对齐的问题。具体操作是:在 Xcode 里点那个相机图标触发捕获,然后在左侧的绘制调用列表里找到你的实例化绘制,展开看 buffer 1 的内容。如果数据看起来是乱的,那多半是布局问题;如果数据看起来正常但画面不对,那就是着色器逻辑问题。
这个工具用熟了之后,调试效率会提升很多。建议每次写新的渲染逻辑时都顺手捕获一帧看看,确认数据流是对的,比盲猜要靠谱得多。
6. 从实例渲染延伸出去的几个实用方向
实例渲染跑通之后,你会发现很多场景都可以用它来优化。比如草地渲染,几万根草用实例化一次画完;比如粒子系统,每个粒子就是一个实例,位置和颜色通过实例数据传入;比如体素世界,相同类型的方块用实例化批量绘制。
再往深了走,可以结合间接绘制(indirect draw)。间接绘制允许你把绘制参数放在 GPU 缓冲区里,由 GPU 自己决定画多少个实例。配合计算着色器做视锥剔除,可以把屏幕外的实例直接排除掉,进一步减轻 GPU 负担。这是很多现代渲染器的标准做法。
还有一个方向是实例数据的压缩。前面提到用四元数代替矩阵,或者用半精度浮点。如果实例数量到了百万级,这些优化带来的内存和带宽节省会非常可观。不过压缩的代价是着色器里要做解压计算,需要在 CPU 和 GPU 之间找平衡点。
我个人在实际项目里的体会是:实例渲染是那种"一旦学会就再也回不去"的技术。以前画多个物体时那种一次次设置状态、一次次提交命令的写法,用过实例化之后就会觉得太笨了。它不复杂,核心概念就那么几个,但带来的性能提升是实打实的。建议你把这个例子跑通之后,试着改一改参数——把实例数量调到一万、十万,看看帧率怎么变;把实例数据换成颜色或者缩放,看看效果怎么变。动手改比只看代码理解得深得多。