MENU

图形渲染流程概述

November 29, 2020 • iOS,Metal阅读设置

Metal 代码的最终运行结构就是将图像渲染在显示设备上,但是 Metal 到底是如何将数据源的数据转换成你可以看到的东西的呢?

我们思考 3D 图形如何工作的方式和计算机思考它的方式是截然不同的。我们可以随手拿起画笔,在一张纸上画一个线条图形,但计算机不是这样做的。作为程序员,我们需要以计算机可以理解的方式来进行思考和编程。所以接下来会简单介绍 3D 图形编程中的一些概念,以及计算机图像学中的渲染流程。

GPU 的表示

GPU 是所有图形编程的核心,所以我们先要明白 Metal 框架是如果抽象表示 GPU 设备的。

GPU 在 Metal 中由符合 MTLDevice 协议的对象表示。MTLDevice 负责创建和管理用于处理数据并将其呈现到屏幕上的各种持久和临时对象。框架只提供了一个方法调用来创建默认 GPU 设备,这确保了 GPU 读写数据时的安全性。

MTLDevice 提供接口供开发人员创建命令队列。这些队列中保存着指令缓冲区,而缓冲区又包含为 GPU 附加指令的编码器。读起来有点绕,下面我们对此进行详细描述。

队列中的指令缓存区,其实存储的是 GPU 渲染所需的 GPU 指令,这些指令包括了用于设置状态的指令(控制如何进行绘制)和绘制调用(GPU执行实际的渲染工作指令)。指令都是在指令缓冲区中编码,每个指令都按其入队的顺序执行。一旦所有指令都入队,就会提交指令缓冲区并提交到指令队列。当缓冲区的指令执行完之后,缓冲区就会被释放。但是指令队列则会被重用而非销毁,因为队列的成本较高。

初步了解 Metal 中 GPU 的表示后,接下来看一下 Metal 是如何工作。

渲染状态配置

我们可以将 Metal 所做的工作概念化为在一种渲染管道中链接在一起的一系列变换操作,这些步骤相互衔接将最终数据发送给 GPU 用于最终图形渲染(如图2.1所示):

  1. 原始数据准备
  2. 顶点处理
  3. 基本体装配
  4. 碎片明暗处理
  5. 栅格输出

Image

苹果创建 Metal 的动机之一就是将耗时的 CPU 工作转移到渲染过程的开始,而非在渲染过程中进行,而这其中一项优化就是减少不必要的渲染时状态验证过程。

Metal 使用 MTLRenderPipelineState 协议,来管理渲染状态配置。渲染管线状态对象包含可以在渲染命令编码器上快速设置的状态集合,这样可以避免多次调用状态设置方法,此外还可以确保设置的状态在内部是一致的,因为它在创建管线时进行了验证。而 MTLRenderPipelineDescriptor 中又具备允许配置如何创建呈现管道状态对象的属性。

MTLRenderPipelineDescriptor 中设置预验证状态,并且该状态可以在对象创建时就进行了检查,而不是等到真正调用绘制时才进行。

可以预先验证的管线状态包括以下设置:

  • 指定着色器函数
  • 附加颜色、深度和模具数据
  • 光栅和可见性状态
  • 镶嵌状态

接下来就可以就是将 MTLRenderPipelineState 传递给命令编码器。因为 Metal 知道呈现状态是有效的,所以它不需要在每次绘制调用时不断引用它,从而消除了不必要的费时操作。

为 GPU 准备数据

大多数 3D 应用都是通过 Blender 或 Maya 等建模程序,而这些程序的格式化文件就理所当然成为 Metal 的原始数据源。这些文件中包含了模型的顶点的位置信息和颜色。

我们可以通过几种不同的方式来使用这些建模程序的模型资源数据。如果 Apple 的Model/IO 框架支持该文件格式,则可以使用该框架导入模型。如果不支持的话,可以编写自己的文件解析器。粗暴点甚至可以手动将所有顶点数据输入到应用程序中。

应用程序现在需要一种方法来理解这些值数组是它将发送到 GPU 以生成屏幕图像的数据,因此需要将命令编码到命令缓冲区。通过以下四种方法就可以对命令缓冲区的数据进行编码:

  • MTLRenderCommandEncoder
  • MTLComputeCommandEncoder
  • MTLBlitCommandEncoder
  • MTLParallelRenderCommandEncoder

我们根据对应 GPU 执行的工作类型选择所需的命令编码器类型。这里我们先以图形绘制为例选择 MTLRenderCommandEncoding,其他编码器后面有机会再说。

MTLRenderCommandEncoding 需要为顶点着色器和片段着色器准备数据源。这可以以缓冲区的形式来完成。要以GPU可以理解的方式组织这些顶点数组,创建使用这些数组作为数据源的顶点和片段缓冲区,缓冲区还需要分配足够的内存来保存顶点值的数组。

顶点着色器

为了能够最终在屏幕上渲染出图形,我们需要两个不同的着色器:点着色器和片段着色器,两个着色器的功能如下图所示。

Image

其中顶点着色器负责计算并确定每个顶点的位置,从而决定哪些顶点是可见且需要进行绘制。 GPU 会将前面准备好的顶点数据不断的发送到着色器程序中,而着色器程序则按序不断将这些数据代表的顶点绘制在屏幕中。

Metal 中的顶点着色器程序称为 Metal Shading Language。MSL 是一个基于 C++ 14 的语言子集,它复用了 C++ 中的类似 Int 和 Float 这样的标准数据类型并加入了一些专用的内置数据类型和函数,例如向量和矩阵计算中常用的点乘计算操作。

裁剪

对于 GPU 来说,显示屏幕的可视区域是有限的,我们只需要对可视区域内的图形机进行渲染,而屏幕外的图形只需要创建等移入屏幕内后再进行渲染。这样我们就能减少一些非必要的计算量,提高图形的渲染效率。

所以 Metal 的一个优化就是进行裁切,确定哪些图形在视图内,哪些被遮挡了。当图形完全不在可视区域内是就剔除该图形,部分可见时则进行裁剪掉被遮挡的部分,随后我们将进行裁剪后的数据交给后续管线程序。

Image

基础图形组装

完成顶点绘制和可视区域裁剪后,我们需要将得到的数据进行图形组装。将这部分数据描述为 Metal 支持的三种基本图形单元:

  • 点:由一个顶点组成。
  • 线条:由两个顶点组成。
  • 三角形:由三个顶点组成。

组装好的基本图形单元会被输入到 GPU 中进行绘制,并最终被转化为待进一步处理的单纯像素点现实在屏幕上。接下来就轮到像素栅格化了。

栅格化

完成了基本图形单元的绘制后,我们需要建模灯光如何散布在场景周围以及灯光如何与场景中的对象交互有关。传统上,将此数据呈现到屏幕上有两种方式:光线跟踪和光栅化。这些方法都有相似的方法,但它们从相反的方向处理问题。

光线追踪把一个场景的渲染任务拆分成了从摄像机出发的若干条光线对场景的影响,这些光线彼此不知道对方,但却知道整个场景的信息。每条光线会和场景并行地求交,根据交点位置获取表面的材质、纹理等信息,并结合光源信息计算光照。

栅格化采取了相反的方法。就像使用的老式投影仪一样,光栅化将图元投影到屏幕上,而不是从后面扫描对象。然后,它循环遍历像素并检查像素中是否存在对象。如果是,则用对象的颜色填充像素。

片段着色

片段着色器负责确定每个像素的颜色,也就是我们常说的 RGBA 。此外,如果要对对象执行纹理贴图,片段着色器也会管理该任务。

假设你有一个统一绿色的网球。如果将灯光照射到网球上,灯光会引入原始绿色的渐变阴影。为了准确建模灯光的效果,碎片着色器计算像素与光源的距离、灯光的强度以及曲面的光泽。这些计算使程序能够对灯光表面相互作用进行建模。

片段着色器返回每个像素的红色、绿色、蓝色和Alpha值的数据表示形式,并将其沿管道传递。片段着色器是将图像数据发送到帧缓冲区进行输出之前修改图像数据的最后一站。

栅格输出

在图像可以输出到屏幕之前,它首先通过帧缓冲区被缓存起来。因为无论优化渲染调用的速度到多快,GPU 绘制数据以进行光栅化仍然需要一定的时间。如果不缓存部分数据帧而直接绘制到屏幕上则可能会出现闪烁,体验会很差,这显然是不理想的效果。

通过合成图像并将其存储在帧缓冲区中,而不是尝试直接绘制到屏幕。我们能保证在任何给定时间点至少有两帧缓存图像。一个正在地显示在屏幕上,而另一个则即将会绘制到屏幕上,如下图所示。

Image

总结

我们人类将图形具像化的方式纯粹是通过视觉感受,然而计算机必须采用数字巨像表示图像。为了让计算机理解图像应该是什么样子,我们必须以计算机可以理解的方式格式化信息。

所以我们采用的方法:先 3D 图像被分解成三角形,然后将三角形分解成一组顶点描述。这样我们就将一个复杂的 3D 图像分解成了一个个顶点定位和对应三角形的绘制。总结就是,第一步:我们将图形顶点将写入顶点缓冲区并馈送到顶点着色器,第二步:计算机确定该三角形是否出现在屏幕上,并裁剪掉不会展示的区域,第三步:片段着色器对这些图形进行细化,确定其光影和颜色效果,最后描述每个像素的数据被发送到帧缓冲器并进行绘制。