Metal - 渲染管线 & 坐标空间

啥是馒头(Metal)

渲染管线

回顾一下第一节中对渲染管线的简介:

pipeline 就是渲染管线,是在渲染处理过程中顺序执行的一系列操作。这一套渲染流程在理论层面上都是统一的,所以不论是 OpenGL ES 的渲染管线还是 Metal 的渲染管线,在理解上都是相同的。pipeline 来源于生产车间的流水线作业,在渲染过程中,一个操作接一个操作进行,就如同流水线一样,这样的实现可以极大地提高渲染效率。整个渲染管线如同下图所示:

pipeline.png

渲染管线的大致流程为:顶点数据来源 -> 顶点着色器 -> 图元装配 ->
光栅化 -> 片元着色器 -> 拿到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.jpg
NDC 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;

最后

本章节对于坐标空间的简单介绍到此结束啦,下一章主要是对纹理方面的介绍。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335