Metal - 渲染管线 & 坐标空间
啥是馒头(Metal)
渲染管线
回顾一下第一节中对渲染管线的简介:
pipeline.pngpipeline 就是渲染管线,是在渲染处理过程中顺序执行的一系列操作。这一套渲染流程在理论层面上都是统一的,所以不论是 OpenGL ES 的渲染管线还是 Metal 的渲染管线,在理解上都是相同的。pipeline 来源于生产车间的流水线作业,在渲染过程中,一个操作接一个操作进行,就如同流水线一样,这样的实现可以极大地提高渲染效率。整个渲染管线如同下图所示:
渲染管线的大致流程为:顶点数据来源 -> 顶点着色器 -> 图元装配 ->
光栅化 -> 片元着色器 -> 拿到FrameBuffer
顶点数据来源
渲染管线中要做的第一步就是获取顶点。我们知道渲染一个 3D 的画面,它必定是由 n 个模型组成,而一个模型又是由 n 个由顶点连接的网格组成。所以顶点是一切的基础,我们可以通过 3D 模型去获取顶点,也可以通过自定义的顶点数据去获取顶点。而顶点描述器 vertexDescriptor 就是用于获取顶点的属性,比如顶点坐标、纹理坐标、法向量以及颜色等。
顶点处理
在顶点处理的阶段,GPU 主要将传进来的所有顶点坐标做坐标转换,通过对顶点进行之间的转换得到顶点在渲染视图上的最终坐标。当然同时在这个阶段也可以进行顶点中光照和颜色属性的计算。
我们所写的顶点着色器代码就是在顶点处理的阶段通过 GPU 进行计算的。比如我们写一个最简单的顶点着色器代码:
struct VertexIn {
float4 position [[ attribute(0) ]];
};
vertex float4 vertex_main(const VertexIn vertexIn [[ stage_in ]]) {
return vertexIn.position;
}
以上代码申明了一个 VertexIn 的结构,其中只有 position 的成员变量,然后在 vertex_main 这个顶点着色器代码中,接收 VertexIn 结构的参数,然后将参数中的 position 以 float4 的数据类型传出。
那么顶点的数据源从哪里来呢?所有的顶点数据都是通过 model 的 mesh 拿到的,然后都被保存在 vertex buffer 中,并且是已经排序好了的。我们知道通过 vertex descriptor 可以告诉 GPU 顶点数据需要怎么读取。所以我们通过 vertex shader 的 [[ stage_in ]] 语法可以获取到当前 index 的顶点数据。
图元装配
在顶点处理阶段之后,GPU 可以拿到一组组已经经过处理的顶点数据块。那么什么是图元呢?图元表示的就是一组表示顶点位置的顶点描述。在 Metal 中支持五种图元的类型,分别是点(Point)、线段(Line)、连续的线段(Line Strip)、三角形(Triangle)、连续的三角形(Triangle Strip)。
renderEncoder.drawIndexedPrimitives(type: .triangle,
indexCount: submesh.indexCount,
indexType: submesh.indexType,
indexBuffer: submesh.indexBuffer.buffer,
indexBufferOffset: submesh.indexBuffer.offset)
如上就是图元装配过程的代码,告诉 GPU 需要以三角形的图元去装配 vertex buffer 传入的顶点。
在图元装配的过程中,如果 pipeline 设置了顶点顺序为顺时针方向为正面的话,那么当顶点顺序为逆时针方向时,就会判定为该面在背面。如果一个图元被另一个图元完全遮盖时,这个图元就会被抛弃,如果是不完全遮盖的话,那么遮盖掉的部分就会被裁减掉。
光栅化
在图元装配之后就是光栅化的过程。光栅化是指将矢量的图元像素化的过程。渲染主要分为 ray tracing 射线跟踪和 rasterization 投射两种方法。
ray tracing 主要适合用于静态远处物体的渲染,而 rasterization 更适合用于渲染动态的距离摄像头比较近的物体。ray tracing 的机制是从取样位置发射一束光线到场景,求光线和几何图形间最近的交点,再求该交点的着色。如果该交点的材质是反射性的,可以在该交点向反射方向继续追踪。rasterization 的话简单来说,就是把大量三角形画到屏幕上。当中会采用深度缓冲(depth buffer, z-buffer),来解决多个三角形重叠时的前后问题。三角形数目影响效能,但三角形在屏幕上的总面积才是主要瓶颈。
前面提到的所有在图元装配阶段传递过来的连接顶点需要在这个时候进行像素化,这个过程就叫做三角形设置(triangle setup)。在三角形设置的过程中,会去计算连续的两个顶点连接成的线段斜率,当三个顶点连接成的三条线段斜率计算出来之后,通过这三条边就确定了一个三角形。接下来的过程是扫描转换(scan conversion)的过程,扫描转换会去求顶点连接成的线段与光栅化网格之间的交点,获取到最靠近线段的一串像素点坐标,并以此像素近似替代线段在屏幕上显示。那么如果一个物体遮盖住另一个物体的情况怎么解决呢。就是用上面提到的深度缓冲的方法,通过存储的深度信息可以判断哪个像素点在哪个像素点前面,从而去渲染最前面的像素。
rasterization.jpg片元处理
光栅化之后的过程就是片元处理的阶段了。片元处理将把光栅化的结果发送给颜色渲染单元(color writing unit)着色,最终会将渲染结果传递到内存中。
由于在硬件上只存在一个图元装配单元以及一个光栅化单元,所以在这两个过程中图元的处理是同步的。当要进入片元处理阶段的时候,数据将会被分解成一个个极小的部分,然后交给shader core 去做并行处理。当 shader core 处理完数据之后,又会把结果重新组装,然后传递给内存。
片元处理阶段和顶点处理阶段一样都是可编程的阶段。我们可以通过构造一个片元处理函数来接收顶点处理函数输出的光线、纹理坐标、深度和颜色等信息。片元处理输出的结果是对处理片元的一个颜色值。最终在 framebuffer 中的像素显示的颜色是由当前像素位置下所有不同片元的颜色共同作用的结果。片元中两个不同颜色中间的颜色会由插值函数计算填充得到。
最简单的片元处理函数举例如下:
fragment float4 fragment_main() {
return float4(1, 0, 0, 1);
}
上述函数表示所有的片元颜色将被处理成红色的颜色。
在片元处理之后,GPU 还会去做一些 alpha 测试、透明通道合成、裁剪测试、模版测试、深度测试以及抗锯齿等工作。这边先不详细介绍。
FrameBuffer
经过片元处理阶段之后,所有片元被转换到像素信息,这时候分发单元(Distributer Unit)会将数据发送到颜色渲染单元(Color Writing Unit)。颜色渲染单元负责将最终像素的颜色写入到一个特殊的内存空间中。这个内存空间就叫做 FrameBuffer。外部的 View 在每帧需要渲染的时候就可以从 FrameBuffer 中获取到着色之后的像素。但是并不是说当屏幕正在显示当前帧的过程中,GPU 才把像素颜色写入到 FrameBuffer 中的。这样会造成颜色值一边写入的过程中,屏幕上一遍在显示数据,导致用户看到的并不是一帧完整的画面,而是一帧逐渐显示完全的画面。
为了解决上述问题,GPU 采用了双缓冲的技术。所谓双缓冲就是指,GPU 会先将一帧的数据保存到缓冲区中,当屏幕在显示第一个缓冲区的数据时, GPU 会将下一帧的数据提交到第二个缓冲区中。然后在屏幕要显示下一帧的数据时,会将两个缓冲区的 buffer 交换,从而显示下一帧的画面。
End
以上就是整个 GPU 渲染管线的流程,下一节主要介绍坐标空间相关的内容。
坐标空间
前言
在阅读本章之前假设你已经了解基础的线性代数相关知识,有关线代基础的东西不再一一讲解啦。
Matrix
关于矩阵的计算有分为 CPU 计算和 GPU 计算两种。但是 GPU 有对矩阵的计算做了优化工作,所以我们尽量把矩阵的计算放到 GPU 上进行。
假设一段在 CPU 上计算矩阵运算结果的代码如下:
var matrix = matrix_identity_float4x4
matrix.columns.3 = [0.3, -0.4, 0, 1]
vertices = vertices.map {
var vertex = float4($0.x, $0.y, $0.z, 1)
vertex = matrix * vertex
return [vertex.x, vertex.y, vertex.z]
}
以上就是一段简单矩阵和向量相乘的代码,那么改为在 GPU 上运算可以修改代码如下:
renderEncoder.setVertexBytes(&matrix,
length: MemoryLayout<float4x4>.stride, index: 1)
以上是通过 renderEncoder 将矩阵发送到 GPU 的代码,随后我们到 metal 文件中,将顶点处理函数的代码进行修改:
vertex VertexOut vertex_main(constant float3 *vertices [[ buffer(0) ]],
constant float4x4 &matrix [[ buffer(1) ]],
uint id [[ vertex_id ]])
{
//vertex_out.position = float4(vertices[id], 1);
vertex_out.position = matrix * float4(vertices[id], 1);
}
以上注释代码为原来的代码,我们把 position 改为 matrix 相乘之后的结果。这样每个顶点的位置都是经过一次 matrix 转换之后得到的了。
矩阵变换
平移矩阵:
translate.jpg
旋转矩阵:
rotate.jpg
缩放矩阵:
scale.jpg
关于矩阵变换的性质。矩阵之间是可以通过乘法将不同变换的矩阵关联起来最终生成一个矩阵的。但是在做矩阵的乘法的时候需要注意相乘的顺序,先进行变换的矩阵要在右边。旋转矩阵和缩放矩阵是可以交换位置的,但是平移矩阵一定要注意顺序,不满足交换律。对于任意一个线性变换的矩阵,最终都可以拆分为 TRS 三种矩阵的乘积。
关于矩阵的逆变换。T的逆矩阵是-T,即向反方向移动。R的逆矩阵是R的转置矩阵,即以对角线翻转矩阵。S的逆矩阵是1/S,即把对角线上的三个元素都变成倒数,即反向缩放。最后,TSR的逆矩阵 = R的逆×S的逆×T的逆
坐标空间
在介绍了矩阵之后,我们就可以通过矩阵的运算完成顶点在各个坐标空间中进行转换。 在整个渲染管线中,一个顶点可能会经历一下6种坐标空间之间的转换,分别是 Object space(模型坐标)、World space(世界坐标)、Camera space(相机坐标)、Clip space(裁剪坐标)、Normalized Device Coordinate space(NDC 坐标)、Screen space(屏幕坐标)。
Object Space
模型坐标也叫做物体坐标或者本地坐标,模型坐标表示的是模型中所有点相对于模型本身原点的一个坐标系。
World Space
世界坐标是指模型中每个点,相对于世界坐标系原点的一个坐标位置。
Camera Space
Camera 是位于世界坐标系中用于拍摄其他事物的物体,那么其他物体相对于 Camera 必定是有一个映射。所以,该物体在 Camera 中的位置就是这个物体在 Camera 坐标系中的坐标。
Clip Space
我们前面所做的所有数学转换,其实就是为了把一个三维的物体展示在二维的平面上。而 Clip Space 可以想象成一个装有视野中物体的一个立方体空间,如果使用的是透视投影的话,那么这个空间中的物体呈现方式就是近大远小的效果。
clipSpace.jpgNDC Space
NDC Space 中做的事情就是把 Clip Space 坐标系的结果进行归一化。也就是说会把所有的坐标都转换成 x,y 属于 [-1,1], z 属于 [0,1]的取值范围。
Screen Space
Screen Space 很好理解,就是所有顶点最终会转换成在屏幕坐标系上的一个坐标。
坐标空间之间的转换
在以上六种坐标空间的转换中,有前面四种坐标空间的转换是可以由我们去控制的。从 Object Space 到 World Space 到 Camera Space 到 Clip Space 中,我们有三个阶段可以用变换矩阵进行坐标系的转换。分别是 Model Matrix,View Matrix,Projection Matrix。
对于坐标系统,不同的图像绘制 API 拥有不同的坐标系。比如我们知道 Metal 的 NDC 坐标空间中,Z 轴的取值范围为 0 到 1。而在 OpenGL 中,Z 轴的取值范围为 -1 到 1。除此之外,在 OpenGL 中使用的是右手坐标系,而在 Metal 中使用的是左手坐标系。
在坐标转换的过程中,我们创建一个叫 Uniforms 的结构用来保存过程中所有可能会用到的数据,比如 modelMatrix、viewMatrix、projectionMatrix 等。struct 的定义可以声明在一个 swift 和 oc 的桥接头文件 Common.h 中,如下:
typedef struct {
matrix_float4x4 modelMatrix;
} Uniforms;
通过设置 modelMatrix 可以将模型从模型坐标转换到世界坐标。同样,我们再添加一个成员变量 viewMatrix 用于控制世界坐标到相机坐标上的转换。
typedef struct {
matrix_float4x4 modelMatrix;
matrix_float4x4 viewMatrix;
} Uniforms;
接下来是 projectionMatrix。我们人眼所见的视野范围大概是120度,但是当我们在看电脑时,这个视野所占大小也就70度左右。计算机的能力是有限的,它并不能看到无限远的东西,所以我们需要给它一个远平面,以及一个近平面,两个平面中间的距离是计算机可见视野范围。平面以外都是不可见的部分,会被裁减掉。通过透视矩阵的转换,可以使得在平面上产生物体近大远小的效果。
typedef struct {
matrix_float4x4 modelMatrix;
matrix_float4x4 viewMatrix;
matrix_float4x4 projectionMatrix;
} Uniforms;
透视矩阵可以通过以下封装好的方法得到,我们只需要传入参数:视野角度、近平面深度、远平面深度就可以构造返回一个透视矩阵。
init(projectionFov fov: Float, near: Float, far: Float, aspect: Float, lhs: Bool = true) {
let y = 1 / tan(fov * 0.5)
let x = y / aspect
let z = lhs ? far / (far - near) : far / (near - far)
let X = float4( x, 0, 0, 0)
let Y = float4( 0, y, 0, 0)
let Z = lhs ? float4( 0, 0, z, 1) : float4( 0, 0, z, -1)
let W = lhs ? float4( 0, 0, z * -near, 0) : float4( 0, 0, z * near, 0)
self.init()
columns = (X, Y, Z, W)
}
let aspect = Float(metalView.bounds.width) /
Float(metalView.bounds.height)
let projectionMatrix =
float4x4(projectionFov: radians(fromDegrees: 45),
near: 0.1,
far: 100,
aspect: aspect)
uniforms.projectionMatrix = projectionMatrix
最后所有的变换矩阵都需要通过顶点处理函数中在 GPU 上进行计算才能生效,所以我们需要在 metal 文件中的顶点处理函数中修改如下代码保证经过顶点处理阶段的每个顶点都经过以上变换矩阵的转换。
float4 position = uniforms.projectionMatrix * uniforms.viewMatrix
* uniforms.modelMatrix * vertexIn.position;
最后
本章节对于坐标空间的简单介绍到此结束啦,下一章主要是对纹理方面的介绍。