Metal - 4.深入剖析顶点函数(Vertex Function)
在掌握了 3D 模型结构和渲染管线的整体流程后,我们将深入研究 Metal 渲染管线中两个可编程阶段的第一个:顶点阶段,特别是 顶点函数(The Vertex Function)。顶点函数是赋予模型生命力的起点,负责计算每个顶点在 3D 世界中的最终位置。
一、 着色器函数概述(Shader Functions)
Metal 中有三种主要的着色器函数类型:
- 顶点函数(Vertex function):计算模型中每个顶点的位置。
- 片段函数(Fragment function):计算屏幕上每个片段(Fragment)的颜色。
- 核函数(Kernel function):用于通用的并行计算,例如图像处理。
本章重点关注顶点函数,它的核心任务是接收模型原始位置的顶点数据,然后将其重新定位到 3D 场景中的正确位置。
顶点描述符的角色
在处理顶点数据时,我们通常会使用到顶点描述符:
- MDLVertexDescriptor (Model I/O):用于 Model I/O 框架读取模型文件(如
.obj
),并根据描述符创建包含所需属性(如位置、法线、纹理坐标)的数据缓冲区。 - MTLVertexDescriptor (Metal):用于创建 管线状态对象(PSO) 时,告知 GPU 如何读取顶点数据格式。
二、 渲染一个四边形(Rendering a Quad)
我们将通过手动创建网格来理解顶点函数如何接收数据。一个四边形(Quad)通常由两个三角形组成,即六个顶点。
1. 创建顶点缓冲区
为了避免使用 Model I/O,可以直接在 Swift 代码中定义原始顶点数组,例如一个四边形的六个顶点。然后,通过 MTLDevice
的 makeBuffer(bytes:length:options:)
方法,将这些顶点数据初始化到一个 MTLBuffer
中,准备发送给 GPU。
var vertices: [Float] = [-1, 1, 0, // triangle 11, -1, 0,-1, -1, 0,-1, 1, 0, // triangle 21, 1, 0,1, -1, 0]vertexBuffer = device.makeBuffer(bytes: &vertices,length: MemoryLayout<Float>.stride * vertices.count,options: []) else {fatalError("Unable to create quad vertex buffer")}// drawrenderEncoder.setVertexBuffer(vertexBuffer,offset: 0,index: 0)
假设 shader 是这么写的:
vertex float4 vertex_main(constant float3 *vertices [[buffer(0)]],uint vertexID [[vertex_id]]) {float4 position = float4(vertices[vertexID], 1);return position;
}
会渲染出这种扭曲的图形:
为什么呢?
vertices
数组是一个由 18 个 Float
(每个 4 字节)组成的紧密排列的内存块。总大小是 18 * 4 = 72 字节。
索引 | 值 (X) | 值 (Y) | 值 (Z) | 字节偏移 |
---|---|---|---|---|
0 | -1 | 1 | 0 | 0 - 11 字节 |
1 | 1 | -1 | 0 | 12 - 23 字节 |
2 | -1 | -1 | 0 | 24 - 35 字节 |
3 | -1 | 1 | 0 | 36 - 47 字节 |
4 | 1 | 1 | 0 | 48 - 59 字节 |
5 | 1 | -1 | 0 | 60 - 71 字节 |
GPU 端如何读取数据(错误的方式)
GPU 在执行渲染任务时,并不是像 CPU 那样一个一个地处理数据。它需要一次性拿到所有数据,然后通过并行的方式处理。GPU 会为每个顶点调用 vertex_main
函数,并使用 vertexID
来索引数据。但是,它错误地把你的数据看成是 float3
数组。
我们之前提到,在 MSL 中,float3
被**填充(padded)**到了 16 字节。这意味着,当 GPU 读取 vertices[vertexID]
时,它不是简单地从 12 字节的倍数开始读,而是从 16 字节的倍数开始读。
让我们模拟 GPU 的每一次读取操作:
第一次调用:vertexID = 0
- GPU 计算要读取的内存地址:
vertices
数组的起始地址 +0 * 16
字节。 - 它会读取从 0 字节开始的 12 字节数据,也就是
[-1, 1, 0]
。 - 结果:第一个顶点是
(-1, 1, 0)
。这个是正确的。
第二次调用:vertexID = 1
- GPU 计算要读取的内存地址:
vertices
数组的起始地址 +1 * 16
字节。 - 它会读取从 16 字节开始的 12 字节数据。
- 问题出现了! CPU 的数据在 12 到 23 字节范围是
[1, -1, 0]
,但 GPU 从 16 字节开始读取,它会读到[-1, 0, -1]
(从 CPU 数据的1, -1, 0, -1, 1, 0
中间取) - 结果:第二个顶点变成了
(-1, 0, -1)
。这是错误的!
第三次调用:vertexID = 2
- GPU 计算要读取的内存地址:
vertices
数组的起始地址 +2 * 16
字节 = 32 字节。 - 它会读取从 32 字节开始的 12 字节数据。
- CPU 的数据在 24 到 35 字节范围是
[-1, -1, 0]
,而 32 到 43 字节是[...-1, 1, 0, -1]
- 结果:第三个顶点是
(...,-1, 1, 0)
。又错了!
如果 GPU 继续这么读下去,它一定会读到数组外面去,导致未定义的行为(undefined behavior),也就是我们看到的渲染错误。
在这个例子中,vertices
数组总共只有 72 字节。
- 当
vertexID
为 0 时,GPU 读取到 11 字节。 - 当
vertexID
为 1 时,GPU 读取到 27 字节。 - 当
vertexID
为 2 时,GPU 读取到 43 字节。 - 当
vertexID
为 3 时,GPU 读取到 59 字节。 - 当
vertexID
为 4 时,GPU 读取到 75 字节。这个就超出 72 字节了!
因此,当 GPU 处理第四、第五、第六个顶点时,它正在读取不属于 vertices
数组的内存数据。这些数据可能是内存中的其他垃圾数据,也可能是另一个无关的缓冲区数据。这会导致渲染结果完全无法预测,但通常会表现为一种扭曲、破碎的形状,就像图片中看到的那样。
总结:GPU 并没有“智能”地知道你传给它的数据实际上是 float
数组。它完全是根据你给出的 float3 *
参数来盲目地以 16 字节的步长进行读取。这种 CPU 和 GPU 之间的数据布局不匹配,是导致所有错误渲染问题的根本原因。
那如何解决呢?
将 vertex_main 函数的参数从 float3 改为 packed_float3。
2. 编写顶点函数签名
在 Metal Shading Language (MSL) 中,顶点函数接收数据并通过限定符进行定位:
vertex float4 vertex_main(constant packed_float3 *vertices [[buffer(0)]],uint vertexID [[vertex_id]]) {// ...
}
[[buffer(0)]]
:这是 属性限定符(attribute qualifier),告诉 GPU 顶点数据来自 Metal 参数表(buffer argument table)中索引为 0 的缓冲区。[[vertex_id]]
:这个属性限定符提供了当前正在处理的顶点的索引 ID。程序可以使用这个 ID 索引到vertices
数组中获取当前的顶点位置。- 内存对齐与效率:如果不使用顶点描述符,需要特别注意内存对齐问题。在 Metal 中,
float3
类型通常会填充到 16 字节,占用与float4
相同的内存空间。如果 Swift 端使用的是原始Float
数组,则每个顶点实际只占用 12 字节。若在着色器中使用float3
,将导致 GPU 读取内存错位,造成渲染错误。解决方法是:在着色器中使用packed_float3
(仅占用 12 字节),或者在 Swift 端使用simd_float3
(占用 16 字节)。使用 12 字节/顶点的方法(即packed_float3
)稍微更高效。
三、 计算顶点位置(Calculating Positions)
顶点函数是进行位置更新的核心场所。
1. 发送小型数据
如果只需发送少量数据(例如,小于 4KB)给 GPU,可以使用 setVertexBytes(_:length:index:)
方法,而无需创建完整的 MTLBuffer
。这对于每帧更新的计时器 (timer) 数据非常方便。我们通常将索引 1 到 10 保留给顶点属性,将其他数据(如计时器)分配到更高的索引(例如索引 11)。
2. 简单位置转换
在顶点函数中,可以通过接收 timer
值并简单地将其添加到 yyy 轴分量上,实现四边形在屏幕上的上下平移动画。
renderEncoder.setVertexBytes(¤tTime,length: MemoryLayout<Float>.stride,index: 11)
vertex float4 vertex_main(constant packed_float3 *vertices [[buffer(0)]],constant simd_float3 *colors [[buffer(1)]],constant float &timer [[buffer(11)]],uint vertexID [[vertex_id]]) {float4 position = float4(vertices[vertexID], 1);position.y += timer;return position;
}
四、 更高效的渲染:索引绘制(Indexed Rendering)
当我们渲染一个四边形时,虽然用了六个顶点(两个三角形),但实际上只有四个唯一的顶点位置。在处理数千或数百万个顶点的网格时,减少重复数据至关重要。
1. 索引缓冲区
索引绘制(Indexed rendering)通过以下方式减少数据量:
vertices
:只存储四个独特的顶点位置。indices
:存储一个UInt16
或UInt32
类型的索引数组,该数组定义了构成每个三角形的顶点顺序。- 效率:对于四边形示例,使用索引绘制将内存占用从 72 字节减少到 60 字节。
2. 绘制指令的改变
使用索引后,绘制调用改为 renderEncoder.drawPrimitives(type:vertexStart:vertexCount:)
,其中的 vertexCount
应该等于索引数组的长度。
在顶点函数中,vertexID
现在是索引缓冲区中的索引。我们需要使用这个索引值,去查找顶点缓冲区中的实际顶点位置:
// renderEncoder.setVertexBuffer(
// quad.indexBuffer,
// offset: 0,
// index: 1)// constant ushort *indices [[buffer(1)]]
ushort index = indices[vertexID];
float4 position = float4(vertices[index], 1);
五、 顶点描述符的应用(Vertex Descriptors)
假设你已经在 renderEncoder 中通过 setVertexBuffer 将颜色数据绑定到了 index: 1,那么你在着色器(shader)中必须使用 constant simd_float3 *colors [[buffer(1)]] 这样的方式来访问颜色数据。[[buffer(1)]] 告诉着色器,colors 这个参数不是一个通过顶点属性([[attribute(X)]])传递的单个顶点数据,而是一个指向整个缓冲区 1 的指针
这是因为,如果你没有设置 MTLVertexDescriptor 中的 attributes 和 layouts,Metal 渲染管线就不知道如何自动将缓冲区数据解析并映射到着色器函数的输入参数上。
1. 描述符配置
MTLVertexDescriptor
描述了缓冲区中每个属性的格式、偏移量和步幅(Stride)。
- 步幅 (Stride):是从一个顶点的数据块跳到下一个顶点的数据块所需的总字节数。
配置完成后,通过 pipelineDescriptor.vertexDescriptor = MTLVertexDescriptor.defaultLayout
将其应用到管线状态描述符上。
2. 绘制指令的最终形式
使用顶点描述符后,绘制调用应使用 renderEncoder.drawIndexedPrimitives
,它会隐式地将索引缓冲区传递给 GPU:
renderEncoder.drawIndexedPrimitives(type: .triangle,indexCount: quad.indices.count,indexType: .uint16, // 必须匹配索引数组类型indexBuffer: quad.indexBuffer,indexBufferOffset: 0)
3. 着色器的简化
通过顶点描述符,顶点函数可以大大简化,因为布局工作已在 Swift/CPU 端完成:
// 接收一个结构体,并使用 [[stage_in]] 属性,而非多个单独的 buffer
vertex float4 vertex_main(float4 position [[attribute(0)]] [[stage_in]],constant float &timer [[buffer(11)]]) {return position; // 简洁明了
}
[[stage_in]]
:表示输入数据来自管线的前一阶段(即顶点获取阶段)。[[attribute(0)]]
:对应于MTLVertexDescriptor
中索引为 0 的属性。
即使原始数据是 3 个浮点数,GPU 也可以将其转换为着色器函数中定义的 float4
类型,并自动添加 w=1.0w=1.0w=1.0 的分量。
4. Attributes 和 Layouts 的区别与联系
attributes
和 layouts
是理解如何将数据从 CPU 传递到 GPU 的关键,简单来说:
attributes
描述了单个顶点的一个属性。它定义了这个属性的数据格式(.float3
)、偏移量(offset
)以及它存在于哪个缓冲区(bufferIndex
)。layouts
描述了整个数据缓冲区的布局。它定义了每个顶点数据的步长(stride
),即从一个顶点数据开始,需要跳过多少字节才能到达下一个顶点数据的起始位置。
它们的关系是:attributes
描述了如何从缓冲区中解析出单个顶点的数据,而 layouts
则告诉 Metal 如何遍历整个缓冲区来找到每个顶点。
我们用代码来具体解释每个索引的作用:
属性(Attributes)
vertexDescriptor.attributes[0]
和 vertexDescriptor.attributes[1]
对应的是顶点着色器(Vertex Shader)中的输入参数。Metal 会根据这里的设置,将缓冲区中的数据正确地传递给你的着色器。
如果你有一个着色器函数,比如:
vertex float4 vertex_main(float3 position [[attribute(0)]],float3 color [[attribute(1)]]) {// ...
}
attributes[0]
对应着着色器中的[[attribute(0)]]
。Metal 会根据attributes[0]
的设置,从缓冲区中读取数据来填充着色器中的position
参数。attributes[1]
对应着着色器中的[[attribute(1)]]
。它会读取数据来填充color
参数。
布局(Layouts)
vertexDescriptor.layouts[0]
和 vertexDescriptor.layouts[1]
对应的是缓冲区索引。
layouts[0]
描述了缓冲区索引 0 的数据布局。layouts[1]
描述了缓冲区索引 1 的数据布局。
attributes
和 layouts
的关系案例分析
我们用一个更具体的例子来理解它们如何协同工作。
假设你有两个缓冲区:
- 缓冲区 0:包含了所有顶点的位置(position)数据。
- 缓冲区 1:包含了所有顶点的颜色(color)数据。
你的着色器需要同时获取位置和颜色。你的 MTLVertexDescriptor
应该这样设置:
let vertexDescriptor = MTLVertexDescriptor()// 设置位置数据属性
vertexDescriptor.attributes[0].format = .float3 // 格式是 float3
vertexDescriptor.attributes[0].offset = 0 // 在缓冲区中,数据从0偏移开始
vertexDescriptor.attributes[0].bufferIndex = 0 // 数据来自缓冲区 0// 设置颜色数据属性
vertexDescriptor.attributes[1].format = .float3 // 格式是 float3
vertexDescriptor.attributes[1].offset = 0 // 在缓冲区中,数据从0偏移开始
vertexDescriptor.attributes[1].bufferIndex = 1 // 数据来自缓冲区 1// 设置缓冲区 0 的布局
// 缓冲区 0 只包含位置数据,每个顶点的位置数据占12字节(float3)
vertexDescriptor.layouts[0].stride = MemoryLayout<Float>.stride * 3 // 12 字节// 设置缓冲区 1 的布局
// 缓冲区 1 只包含颜色数据,每个顶点的颜色数据占12字节(float3)
vertexDescriptor.layouts[1].stride = MemoryLayout<Float>.stride * 3 // 12 字节
这个例子展示了 attributes
和 layouts
的一对多关系:
attributes
指定了每个输入参数来自哪个缓冲区。layouts
指定了每个缓冲区的布局。
有没有只设置 attributes[0]
,但设置 layouts[0]
和 layouts[1]
的情况?
答案是:有,而且很常见。
这通常发生在多个 attributes
共用同一个缓冲区,并且这些 attributes
都依赖同一个缓冲区布局的情况下。
例如,一个缓冲区可能同时包含位置和法线(normals)数据。
// 假设缓冲区 0 中存储了 位置(x,y,z)和法线(nx,ny,nz)数据,并且它们是交错排列的。
// 一个顶点的数据块是 [位置 x,y,z, 法线 nx,ny,nz]let vertexDescriptor = MTLVertexDescriptor()// 属性 0:位置数据
vertexDescriptor.attributes[0].format = .float3
vertexDescriptor.attributes[0].offset = 0 // 偏移量为0
vertexDescriptor.attributes[0].bufferIndex = 0 // 来自缓冲区 0// 属性 1:法线数据
vertexDescriptor.attributes[1].format = .float3
vertexDescriptor.attributes[1].offset = MemoryLayout<Float>.stride * 3 // 偏移量是 12 字节
vertexDescriptor.attributes[1].bufferIndex = 0 // 也来自缓冲区 0// 布局 0:缓冲区 0 的布局
// 因为位置和法线是交错的,每个顶点的数据总大小是 (3+3) * 4 = 24 字节
vertexDescriptor.layouts[0].stride = MemoryLayout<Float>.stride * 6 // 24 字节// layouts[1] 在这里是未使用的,因为只有一个缓冲区被使用。
反过来:
只设置
attributes[0]
,但是设置layouts[0]
和layouts[1]
这种情况在逻辑上是不合理的,Metal 可能会忽略 layouts[1]
。因为如果你只设置了 attributes[0]
并让它指向 bufferIndex = 0
,Metal 根本不会去读 bufferIndex = 1
,也就没必要去关心它的布局了。
简而言之,attributes
和 layouts
必须协同工作。attributes
决定了使用哪个缓冲区(通过 bufferIndex
),而 layouts
则定义了该缓冲区的步长。它们是 Metal 渲染管线中,CPU 和 GPU 之间数据传输的“蓝图”。
六、 传递多个顶点属性和渲染点(Multiple Attributes and Points)
1. 传递多个属性(例如颜色)
如果添加颜色等新属性,必须更新顶点描述符以包含新的属性和布局。在着色器端,我们使用一个统一的结构体 VertexIn
来接收所有属性:
struct VertexIn {float4 position [[attribute(0)]]; // 匹配描述符属性 0float4 color [[attribute(1)]]; // 匹配描述符属性 1
};
2. 顶点函数输出
为了将数据传递给渲染管线的下一阶段(片段函数),需要定义一个输出结构体 VertexOut
:
[[position]]
:必须标记VertexOut
中哪个属性是最终的顶点位置。- 数据插值:
VertexOut
中除了位置以外的其他属性(如颜色)在经过光栅化器时,会根据三角形的表面自动插值,然后作为片段函数的输入。
3. 渲染点(Rendering Points)
渲染模式可以从 .triangle
更改为 .point
。
- 点大小:如果渲染点,必须在
VertexOut
中添加[[point_size]]
属性,否则 GPU 可能会使用不确定的点大小,导致闪烁。
七、 Metal 顶点函数与 OpenGL 着色器概念对比
在 Metal 的顶点函数和 OpenGL 的顶点着色器(Vertex Shader)中,虽然目标都是转换顶点位置,但在 API 设计和状态管理上存在显著差异。
概念 | Metal (Chapter 4 重点) | OpenGL (来源资料) |
---|---|---|
语言 | Metal Shading Language (MSL)。 | OpenGL Shading Language (GLSL),C 语言风格。 |
着色器编译 | 通常在项目编译时预编译,提高运行时效率。 | 需要在运行时使用函数 (glCreateShader , glCompileShader ) 动态编译和链接。 |
数据描述 | 使用 MTLVertexDescriptor 对象集中定义顶点属性的布局(格式、偏移量、步幅)。 | 通过调用 glVertexAttribPointer 为每个属性单独设置指针(大小、类型、步幅、偏移量)。 |
属性绑定 | 通过着色器中的结构体和 [[attribute(n)]] 限定符进行隐式绑定。 | 必须在 GLSL 中使用 layout (location = n) 显式设置位置,并在 CPU 端调用 glEnableVertexAttribArray(location) 激活属性。 |
状态管理 | 顶点布局信息固定在 MTLRenderPipelineState (PSO) 中,以低开销的方式管理状态。 | 顶点配置状态(VBO、EBO 绑定和属性指针)被封装在 VAO (Vertex Array Object ) 中,VAO 存储了这些状态配置。 |
数据传输 | 少量数据可以通过 setVertexBytes 直接发送给 GPU。 | 少量全局数据通过 Uniforms 传输。 |
顶点数据存储 | 存储在 MTLBuffer 中。 | 存储在 VBO (Vertex Buffer Object ) 中。 |
索引绘制 | 使用 renderEncoder.drawIndexedPrimitives 。 | 需要先绑定 EBO (Element Buffer Object ),然后调用 glDrawElements 。 |
坐标输出 | 顶点函数输出 [[position]] 到裁剪空间,Metal 完成透视除法。NDC ZZZ 轴范围 000 到 111。 | 必须将结果写入内置变量 gl_Position 。顶点坐标应在裁剪空间,NDC 范围在所有三个轴上均为 −1.0-1.0−1.0 到 1.01.01.0。 |
点大小控制 | 通过在 VertexOut 中设置 [[point_size]] 属性来控制点的大小。 | OpenGL 也支持点图元类型,如 GL_POINTS 。 |