一、从一个让人头疼的渲染问题说起

在游戏开发里,只要跟画面渲染沾边,几乎都会碰到同一件事:把场景画到一张纹理上,而不是直接画到屏幕。比如做小地图、做镜面效果、做阴影贴图,都需要先在一个“看不见的画布”上作画,画完后再拿去做二次处理。以前在OpenGL ES时代,我们管这个画布叫FBO(Frame Buffer Object)。到了苹果的Metal渲染管线,这个老概念换了身衣服,变成了两个好朋友:纹理(Texture)和渲染通路描述(Render Pass Descriptor)。如果你以前写过FBO相关代码,那么恭喜你,理解Metal这套东西会轻松很多;如果你是个纯新手,也别怕,今天咱们就把这块“画布”是怎么搭起来的,掰开揉碎讲清楚。我们不会从零教你Metal怎么画三角形,只会聚焦在“画布怎么配置”这件事上。只要你写过几行Metal代码,或者至少知道它是干嘛的,接下来的内容肯定看得懂。

二、Metal里和FBO对应的是什么

既然要聊配置,先得弄明白Metal到底把FBO变成了什么。说直白点,以前OpenGL那个FBO是一个大杂烩,把颜色缓冲、深度缓冲、模板缓冲全绑在一起。Metal呢?它把这些东西全拆开了,各管各的。但好在拆完之后,反而更灵活。

2.1 纹理就是画布

在Metal里,你想离线渲染,首先得有一块画布,这块画布就是纹理。你可以把纹理理解成一张能装像素的“纸”,只不过这张纸在显卡显存里面。平时我们看到的纹理通常是用来贴到模型表面的,但在这里,它会被专门用来承接渲染结果。我们可以把颜色、深度、模板信息分别存在不同的纹理里,就像把颜料、底稿和遮罩分开放在不同图层一样。颜色纹理就是你最终想看到的那张图,像素格式最常见的是BGRA8;深度纹理用来记录每个像素离摄像机是远还是近,这样后面的物体就不会挡住前面的物体。如果你介意,还可以搞一个模板纹理,用来做奇奇怪怪的裁剪。但在大多数游戏里,颜色加深度就已经足够了。

2.2 渲染通路描述就是配置单

光有画布还不够,你还得告诉Metal:这回我要画到哪块纹理上?要不要先清空画布?画完之后是保留还是扔掉?这些问题都写在一张“配置单”上,这就是MTLRenderPassDescriptor。它就像一张详细的施工单,Metal只需要照着单子干活就行。在旧OpenGL里,这个配置是挂在FBO上的;在Metal里,我们每次创建命令编码器(MTLRenderCommandEncoder)时,都要用这么一张配置单。说得再生活化一点:纹理是纸,渲染通路描述是规定你在纸上怎么画、画完怎么办的规则。两者缺一不可。

三、手把手配置一个带颜色和深度缓冲的FBO

下面我们用Swift和Metal,从零创建一个最常用的离屏渲染目标:既要有颜色纹理,也要有深度纹理。这样才能保证我们渲染3D场景时,远近遮挡关系是正常的。

3.1 先创建颜色纹理

颜色纹理存放的是最终画面里每个像素的颜色值。我们创建一个和屏幕尺寸一样大的纹理,格式用最常见的BGRA8。注意,这里说的是离屏纹理,不是屏幕上的画布。屏幕上的画布我们从哪来,后面会专门讲。

// 技术栈:Swift 5.7 + Metal 3
import Metal

// 假设你已经有了一个MTLDevice对象
let device: MTLDevice

// 根据给定尺寸创建颜色纹理
func makeColorTexture(size: CGSize) -> MTLTexture {
    // 纹理描述符:定义纹理的宽高、像素格式等
    let descriptor = MTLTextureDescriptor.texture2DDescriptor(
        pixelFormat: .bgra8Unorm,   // 最常见的颜色格式,适合屏幕显示
        width: Int(size.width),     // 宽度(像素)
        height: Int(size.height),   // 高度(像素)
        mipmapped: false            // 离屏渲染通常不需要mipmap
    )
    // 声明这个纹理接下来要当渲染目标,也可能被拿去采样
    descriptor.usage = [.renderTarget, .shaderRead]
    // 真正在GPU上分配内存并创建纹理
    return device.makeTexture(descriptor: descriptor)!
}

注意这里用到了MTLTextureDescriptor,它是纹理的“身份证”。左上角的mipmapped: false是因为我们离屏渲染不需要多级纹理,省点内存。如果你想把离屏结果当贴图用,那usage里一定得有.shaderRead,因为着色器需要读它。

3.2 再创建深度纹理

深度纹理存的是每个像素的深度值,用来做遮挡判断。没有它,后面的物体可能盖住前面的,非常混乱。深度纹理不需要被着色器读取,所以usage只设置.renderTarget就够了。像素格式用.depth32Float,这是最有代表性的格式,精度高,基本适配所有常见的深度测试需求。

// 技术栈:Swift 5.7 + Metal 3
// 创建深度纹理
func makeDepthTexture(size: CGSize) -> MTLTexture {
    let descriptor = MTLTextureDescriptor.texture2DDescriptor(
        pixelFormat: .depth32Float, // 32位浮点深度,足够精确
        width: Int(size.width),
        height: Int(size.height),
        mipmapped: false
    )
    descriptor.usage = .renderTarget // 深度纹理只需要作为渲染目标
    return device.makeTexture(descriptor: descriptor)!
}

3.3 组装渲染通路描述

有了两块纹理,接下来把它们“粘”到渲染通路描述上,就大功告成了。这个描述会告诉我们后续的渲染命令把结果写到哪。

// 技术栈:Swift 5.7 + Metal 3
// 创建渲染通路描述
func makeRenderPassDescriptor(colorTexture: MTLTexture,
                              depthTexture: MTLTexture) -> MTLRenderPassDescriptor {
    let descriptor = MTLRenderPassDescriptor()
    
    // 设置颜色附件:第0个附件对应着色器里的color(0)
    descriptor.colorAttachments[0].texture = colorTexture
    descriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.1, 0.2, 0.3, 1.0)
    descriptor.colorAttachments[0].loadAction = .clear   // 开始前清空画布
    descriptor.colorAttachments[0].storeAction = .store // 结束后保留结果
    
    // 设置深度附件
    descriptor.depthAttachment.texture = depthTexture
    descriptor.depthAttachment.clearDepth = 1.0          // 深度初始化为1(最远)
    descriptor.depthAttachment.loadAction = .clear
    descriptor.depthAttachment.storeAction = .dontCare   // 深度值后面用不到,就不用存了
    
    return descriptor
}

看到没有?loadAction和storeAction非常关键。如果你每帧都要重新画,那么每帧开始时用.clear把画布清干净;如果还要把结果保存下来供后面使用,那就用.store。这里深度纹理的storeAction设为.dontCare,是为了让GPU可以偷懒,有助于提升性能。反过来,如果某个场景下一帧还要用到上一帧的深度数据,那就得改成.store。

四、让FBO和视图控制器紧密配合

在实际游戏里,我们通常不是直接渲染到屏幕上,而是先渲染到离屏纹理,做完后期处理,再回到屏幕。这就涉及到两个“画布”的配合。

4.1 从CAMetalLayer拿到屏幕画布

iOS和macOS上,Metal内容显示在屏幕上,依赖的是CAMetalLayer。平时我们用MTKView会把很多细节隐藏起来,但如果你想精细控制,就会自己跟CAMetalLayer打交道。每帧开始前,我们调用nextDrawable()从图层里拿一个可绘制对象,它的.texture就是屏幕背后的那块纹理,我们同样把它挂在颜色附件上。注意,这块纹理你是不能随意创建的,只能由系统给你。它的像素格式通常跟你的CAMetalLayer设置的pixelFormat一致,一般是bgra8Unorm。

4.2 离屏渲染和屏幕渲染分开

离屏渲染时,我们用的是自己创建的颜色纹理;屏幕渲染时,我们用的是drawable的纹理。流程可以理解为:先在离屏画布上画出场景,再把离屏画布作为一张贴图,喂给屏幕画布上的一个全屏四边形。这样就能方便地加入模糊、调色、描边等效果。这个“全屏四边形”不需要多么复杂的模型,就是一个覆盖整个屏幕的矩形,顶点坐标范围在标准化设备坐标里是(-1,-1)到(1,1)。

来看一个简化版的帧循环:

// 技术栈:Swift 5.7 + Metal 3
// 每帧执行一次的主循环
func drawFrame() {
    // 1. 离屏渲染:把场景画到自制的FBO上
    let offscreenPass = makeRenderPassDescriptor(
        colorTexture: offscreenColorTexture,
        depthTexture: offscreenDepthTexture
    )
    let commandBuffer = commandQueue.makeCommandBuffer()!
    let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: offscreenPass)!
    // 这里添加你的场景绘制调用,比如画一个带方向的物体
    // renderEncoder.setVertexBuffer(...)
    // renderEncoder.drawPrimitives(...)
    renderEncoder.endEncoding()
    
    // 2. 屏幕渲染:把离屏结果画到屏幕上
    guard let drawable = metalLayer.nextDrawable() else { return }
    let screenPass = MTLRenderPassDescriptor()
    screenPass.colorAttachments[0].texture = drawable.texture
    screenPass.colorAttachments[0].loadAction = .clear
    screenPass.colorAttachments[0].storeAction = .store
    
    if let screenEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: screenPass) {
        // 绑定离屏纹理到采样器,并画一个全屏矩形
        // screenEncoder.setFragmentTexture(offscreenColorTexture, index: 0)
        // screenEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
        screenEncoder.endEncoding()
    }
    
    commandBuffer.present(drawable)
    commandBuffer.commit()
}

注意上面代码里我用注释省略了一些绑定细节,不然篇幅会太长。但整体的骨架就是这么一回事。记住,离屏pass和屏幕pass是先后两个渲染命令编码器,两者操控的纹理不同,彼此不打扰。

五、你一定会遇到的坑和解决办法

实际开发中,FBO配置这块的报错和黑屏,十有八九是下面几个原因引起的。

5.1 纹理格式不匹配

一个常见错误是颜色纹理用的是bgra8Unorm,但你在着色器里输出却用了别的格式。Metal对格式要求很严格,两者不一致,渲染结果会变成一团乱码,或者干脆报错。解决方法是保持纹理描述符的pixelFormat和渲染管线中的颜色附件格式一致。另外,CAMetalLayer的像素格式,也要和最终屏幕渲染时用的渲染管线附件格式保持一致。

5.2 尺寸变化问题

游戏窗口或视图大小一变,离屏纹理的尺寸也必须跟着变。如果你还拿着旧尺寸的纹理硬撑,画面会被拉伸或截断。所以要在视图的layoutSubviews或viewWillTransition里重新创建纹理。千万别忘了把旧纹理丢弃,否则内存会一直涨。同时,如果同时在做深度纹理,两项都得一起重建。别只换颜色纹理,忘了深度纹理,否则渲染会出错。

5.3 内存和性能注意事项

离屏渲染纹理非常吃显存,尤其开了高倍率的多重采样后,显存会翻好几倍。建议只在需要的时候创建,不需要了就置成nil。另外,loadAction和storeAction的设置直接影响GPU带宽,能设成.dontCare的地方就不要用.store。想想看,如果一帧结束后你根本不需要那些深度数据,还傻乎乎地把它们存进内存,那就是白白浪费带宽。反之,如果后处理需要用到颜色纹理,那就必须.store,否则后面采样采到一个空壳。

六、关联技术:MSAA多重采样抗锯齿

说到FBO配置,不得不提多重采样抗锯齿(MSAA)。它能有效缓解3D场景里的锯齿边缘。原理很简单:对每个像素做多次采样,最后把这几个采样点的颜色混合成一个颜色输出。效果很好,但代价是内存和带宽成倍增加。如果你在做游戏,尤其是有很多硬边的场景,MSAA几乎是必备选项。

6.1 MSAA的画布怎么创建

在Metal里,要创建一个支持MSAA的离屏画布,只需要把纹理描述符的textureType改成.type2DMultisample,设置sampleCount为4或更高。然后我们需要两个纹理:一个是多重采样纹理,用来真正存储多次采样结果;另一个是普通纹理,用来存储最终解析后的颜色。这个普通纹理也叫“解析纹理”,因为MSAA最后一步“解析”就是把多重采样数据浓缩成一个像素值。

6.2 在Metal中配置MSAA FBO

// 技术栈:Swift 5.7 + Metal 3
// 创建MSAA颜色纹理(4倍采样)
let msaaDescriptor = MTLTextureDescriptor.texture2DDescriptor(
    pixelFormat: .bgra8Unorm,
    width: 320,
    height: 240,
    mipmapped: false
)
msaaDescriptor.textureType = .type2DMultisample // 关键!
msaaDescriptor.sampleCount = 4                  // 4倍多重采样
msaaDescriptor.usage = .renderTarget
let msaaTexture = device.makeTexture(descriptor: msaaDescriptor)!

// 创建解析目标纹理(普通单采样纹理)
let resolveTexture = makeColorTexture(size: CGSize(width: 320, height: 240))

// 在渲染通路描述中设置
let msaaPass = MTLRenderPassDescriptor()
msaaPass.colorAttachments[0].texture = msaaTexture
msaaPass.colorAttachments[0].resolveTexture = resolveTexture
msaaPass.colorAttachments[0].loadAction = .clear
msaaPass.colorAttachments[0].storeAction = .multisampleResolve

看到没,关键就在storeAction设为.multisampleResolve,它告诉GPU:先把多重采样纹理里的数据“压缩”成普通纹理,然后再保存。这样我们后续拿到的就是干净的抗锯齿画面。需要注意的是,多重采样纹理本身不能直接采样,必须经过解析这一步,否则你的后期效果会惨不忍睹。另外,如果深度纹理也想做MSAA,那它也得是.type2DMultisample,并且把sampleCount设为和颜色纹理一致,否则设备会拒绝你的配置。

七、应用场景和优缺点分析

7.1 应用场景

FBO配置在游戏里随处可见。比如小地图的生成,雷达图,技能预览,角色选择界面的旋转展示,还有阴影贴图、环境贴图、后处理特效(泛光、景深、运动模糊)等等,全都依赖离屏渲染技术。只要你想对画面做“二次加工”,就得用到这套配置。还有一些更进阶的玩法,比如反射效果,先渲染出反射视角的场景,再把结果作为反射纹理贴到镜面物体上。这些都是FBO的拿手好戏。

7.2 优点

最大的优点是灵活。你可以把任何场景渲染成任意尺寸的纹理,然后像贴纸一样贴到别的地方。而且离屏渲染不会影响主屏幕的显示,方便做各种后期处理。另外,Metal的性能很出色,只要配置得当,渲染效率非常高。离屏渲染还方便做异步计算,你可以同时渲染多个小图,然后合并成一个大图。

7.3 缺点

缺点是管理成本高。你需要自己跟踪纹理的生命周期,处理尺寸变化,还得小心翼翼地和格式、sampleCount较劲。一不小心就容易内存暴涨或者画面变黑。比起直接用MTKView的默认渲染目标,离屏FBO需要多写不少代码。而且如果你的显卡能力比较弱,高分辨率的离屏纹理加高倍MSAA,可能会让帧率掉到你不想看的地步。

八、注意事项

  • 创建纹理时,记得看一眼usage。如果忘了写.renderTarget,这个纹理是无法作为渲染目标的。
  • 不要把同一块纹理同时绑定到渲染目标又用它做采样,这种情况至少在同一个pass里是不允许的。如果你非要在一帧里既渲染它又读它,那得拆成两个pass,中间加上同步指令。
  • 当你的渲染通路里用了深度纹理,记得给渲染管线状态设置对应的深度格式,否则会匹配不上。比如这里用的是.depth32Float,你的MTLRenderPipelineDescriptor里的深度附件像素格式也得是.depth32Float。
  • 在iOS上如果设备支持MTLTextureUsage,尽量明确声明,避免GPU做不必要的额外工作。
  • 如果遇到画面全黑,优先检查loadAction是不是.clear以及clearColor是否可见。有时候你把storeAction设成.dontCare,然后后面又去读它,就会读到垃圾数据。
  • 多做性能监控。用Xcode的GPU调试工具看看Metal Pipeline的占用,不要盲目追求高倍MSAA。4倍和8倍视觉差异不大,但性能差异非常明显。

九、总结

说到底,苹果平台上的Metal FBO配置并不是什么神秘魔法。它只是把传统FBO拆成了纹理和渲染通路描述这两个小工具,让我们用起来更灵活,也更贴近GPU的底层逻辑。你只要记清楚三个关键动作:创建合适格式的纹理、把纹理挂到渲染通路描述上、合理设置加载和存储动作。剩下的就是多练、多调试。希望这篇博客能帮你在Metal渲染管线的道路上少踩几个坑。下次再遇到需要离屏渲染的需求,记得先配好你的画布哦。如果看的过程中遇到了什么奇怪的问题,欢迎自己动手改一改配置,看看画面会有什么变化,实践几次就熟悉了。