第一个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后,接下来要做两件事:
- 创建顶点缓冲区 —— 把三角形三个顶点的坐标喂给GPU
- 创建渲染管线状态 —— 告诉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循环里创建,性能会爆炸。
来张图理清全流程
这就是整个渲染管线的数据流转。看一遍,对照代码,思路就通了。
你看,从MTKView开始,一路连到设备、缓冲区、着色器、管线状态,最后通过命令编码器发出绘制调用,三角形就出来了。
常见问题:为什么三角形没显示?
| 现象 | 可能原因 | 快速检查 |
|---|---|---|
| 全黑屏 | clearColor 设置不对 / 没有调用 present |
检查 view.currentDrawable 是否为 nil |
| 三角形缺失一角 | 顶点坐标超出 -1...1 范围 |
打印顶点数据确认 |
| 颜色不对 | 片元着色器返回值顺序搞错了(BGRA vs RGBA) | 检查 colorPixelFormat |
| crash: nil pipeline | 着色器函数名写错了,或者vertexDescriptor配置错误 | 检查 makeFunction 返回值 |
一句话总结:
创建MTKView → 拿到device → 准备顶点数据和着色器 → 配置渲染管线 → 在draw循环里提交。三步走,缺一不可。
跑通这个demo之后,你就算正式踏入Metal的世界了。
后面24章的内容,全是基于这个骨架往上加肌肉和细节。