第一个Metal应用:从零搭一个三角形

好,咱们开始动手了。
这一章的目标很直接——在屏幕上画一个彩色三角形。
听起来简单,但背后涉及的步骤,其实就是所有Metal渲染应用的基础骨架。

我个人习惯,学新框架先从最小的可运行demo开始。
这样你很快能建立起「代码 → 屏幕」的心理映射。
后面再往里面加复杂的东西,心里就有底了。

先搭好画板:MTKView

在Mac/iOS上做Metal渲染,最方便的方式是用MTKView。
它是苹果帮我们封装好的Metal渲染视图,自带渲染循环和像素格式管理。

你想想看,如果自己从头搭一个Metal渲染表面,要处理窗口系统、图层、像素格式同步……
很麻烦。MTKView把这些全包了。

创建方式有两种:

  • 代码创建 —— 手动alloc/init,设置frame
  • Storyboard/XIB —— 拖一个UIView上去,class改成MTKView

我个人推荐代码方式,清晰。尤其是教程,每一步都看得见。

// Swift
let metalView = MTKView()
metalView.frame = view.bounds
metalView.device = MTLCreateSystemDefaultDevice()
metalView.clearColor = MTLClearColor(red: 0.2, green: 0.2, blue: 0.3, alpha: 1.0)
metalView.delegate = self
view.addSubview(metalView)

小提示:
clearColor 是每帧渲染开始前的背景色。我一般用深蓝色调,方便调试时看渲染边界。

这里有个关键点:MTLCreateSystemDefaultDevice() 返回的是当前系统默认的GPU设备。
在真机上,它可能是A系列芯片;在模拟器上?嗯,模拟器不支持Metal的完整功能,最好用真机跑。

初始化设备:一切GPU操作的入口

Metal里的 MTLDevice 是所有GPU资源的总工厂。
纹理、缓冲区、渲染管线状态……全从它身上创建。

你只需要调用一次,然后整个App生命周期都复用同一个device对象。
我见过有人每次渲染都重新建device——性能直接崩了。千万别这么干。

guard let device = MTLCreateSystemDefaultDevice() else {
    fatalError("Metal is not supported on this device")
}
metalView.device = device

注意:
模拟器上 MTLCreateSystemDefaultDevice() 可能返回nil。
曾经有个新手直接在这行crash了,后来发现是在模拟器上跑。

拿到device后,接下来要做两件事:

  1. 创建顶点缓冲区 —— 把三角形三个顶点的坐标喂给GPU
  2. 创建渲染管线状态 —— 告诉GPU用什么着色器来画

顶点数据:三角形的骨骼

咱们画一个最简单的三角形,三个顶点。每个顶点包含位置和颜色。

Metal里的顶点数据存在 MTLBuffer 里。说白了就是一块GPU能读的内存。

let vertexData: [Float] = [
    // 位置          // 颜色
     0.0,  0.5, 0.0,  1.0, 0.0, 0.0,
    -0.5, -0.5, 0.0,  0.0, 1.0, 0.0,
     0.5, -0.5, 0.0,  0.0, 0.0, 1.0
]

let vertexBuffer = device.makeBuffer(
    bytes: vertexData,
    length: vertexData.count * MemoryLayout<Float>.stride,
    options: .storageModeShared
)

你看,每个顶点6个float:xyz + rgb。数据紧凑,GPU喜欢这种布局。

重点: 坐标范围 -1.0 到 1.0 是所谓的「裁剪空间」。
超出这个范围的顶点会被GPU裁掉,屏幕上就看不到了。

着色器:GPU的微型程序

Metal使用自家的着色器语言——MSL(Metal Shading Language)。
语法类似C++,但跑在GPU上。我们至少需要两个:

  • 顶点着色器 —— 把顶点数据从CPU传到GPU
  • 片元着色器 —— 决定每个像素的颜色

我个人习惯,把着色器写在单独的 .metal 文件里,Xcode会自动编译。

// Shaders.metal
#include <metal_stdlib>
using namespace metal;

struct VertexIn {
    float3 position [[attribute(0)]];
    float3 color    [[attribute(1)];
};

struct VertexOut {
    float4 position [[position]];
    float3 color;
};

vertex VertexOut vertex_main(
    VertexIn in [[stage_in]],
    constant float4x4 &matrix [[buffer(1)]]
) {
    VertexOut out;
    out.position = matrix * float4(in.position, 1.0);
    out.color = in.color;
    return out;
}

fragment float4 fragment_main(
    VertexOut in [[stage_in]]
) {
    return float4(in.color, 1.0);
}

这里用 [[attribute]] 绑定顶点属性顺序,跟CPU端 vertexData 布局要一一对应。

我曾经因为attribute索引写错,三角形一直不显示——后来发现是[[attribute(0)]] 和 [[attribute(1)]] 顺序反了。坑不坑?

渲染管线状态:把一切串起来

有设备、有着色器、有顶点数据,还缺一步——告诉Metal这些东西怎么配合。

MTLRenderPipelineState 就是干这事的。

let library = device.makeDefaultLibrary()!
let vertexFunc = library.makeFunction(name: "vertex_main")
let fragmentFunc = library.makeFunction(name: "fragment_main")

let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunc
pipelineDescriptor.fragmentFunction = fragmentFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
pipelineDescriptor.vertexDescriptor = vertexDescriptor // 描述属性布局

pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)

提示: vertexDescriptor 要手动设置每个attribute的offset和format。
如果偷懒不设,GPU会读到错位的数据。

最后一击:在MTKView的delegate里绘制

MTKView会在每帧调用 draw(in:) 方法。我们就在这里面提交渲染命令。

extension ViewController: MTKViewDelegate {
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // 窗口大小变化时触发,可更新投影矩阵
    }

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let descriptor = view.currentRenderPassDescriptor,
              let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
        else { return }

        encoder.setRenderPipelineState(pipelineState)
        encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)

        encoder.endEncoding()
        commandBuffer.present(view.currentDrawable!)
        commandBuffer.commit()
    }
}

流程很简单:拿命令缓冲区 → 创建渲染编码器 → 绑定管线状态和顶点缓冲区 → 画三角形 → 结束编码 → 提交。

重要:
commandQueue 要提前从device创建,commandQueue = device.makeCommandQueue()。
不要在draw循环里创建,性能会爆炸。

来张图理清全流程

这就是整个渲染管线的数据流转。看一遍,对照代码,思路就通了。

Metal三角形渲染流程 MTKView MTLDevice 顶点缓冲区 渲染管线状态 着色器库 命令队列 渲染命令编码器 drawPrimitives → 屏幕显示

你看,从MTKView开始,一路连到设备、缓冲区、着色器、管线状态,最后通过命令编码器发出绘制调用,三角形就出来了。

常见问题:为什么三角形没显示?

现象 可能原因 快速检查
全黑屏 clearColor 设置不对 / 没有调用 present 检查 view.currentDrawable 是否为 nil
三角形缺失一角 顶点坐标超出 -1...1 范围 打印顶点数据确认
颜色不对 片元着色器返回值顺序搞错了(BGRA vs RGBA) 检查 colorPixelFormat
crash: nil pipeline 着色器函数名写错了,或者vertexDescriptor配置错误 检查 makeFunction 返回值

一句话总结:
创建MTKView → 拿到device → 准备顶点数据和着色器 → 配置渲染管线 → 在draw循环里提交。三步走,缺一不可。

跑通这个demo之后,你就算正式踏入Metal的世界了。
后面24章的内容,全是基于这个骨架往上加肌肉和细节。