阅读视图

发现新文章,点击刷新页面。

上帝视角看 GPU 学习笔记

上帝视角看GPU(1):图形流水线基础_哔哩哔哩_bilibili

《上帝视角看 GPU》龚大教程学习笔记。

一、图形流水线基础

首先思考一张图片是如何显示在屏幕上呢?

首先需要了解一个概念帧缓存(Frame Buffer),这是内存中的一块区域,这块区域上的内容和显示器上显示的像素是一一对应的。

将帧缓存上的内容输出到屏幕上,需要通过显卡,显卡上有个输出端口连接到显示器上。

那这是最简单的需求,如果此时有一个需求:需要将图像的亮度提升 2 倍呢?也就是将每个像素的 RGB 分量都乘 2。

当然可以通过 CPU 来进行计算,但这样必然要占用大量 CPU 资源,更好的做法是加入一个 处理器(PU),这个处理器可以在每个像素上执行同样的操作;

为了适应更加灵活多变的需求,比如将图像上半部分亮度提升 2 倍,下半部分亮度提升 4 倍。我们可以在 PU 上挂上一个 **程序 **,这个程序是单入单出的,输入是像素坐标,输出的是像素 RGB 颜色,也就是 片元着色器 Pixel Shader。

好了,上边就是一个最基本的针对图像的流水线。

顺便一说,在任天堂的红白机上,就有这么一个处理器,叫做 PPU(Picture Processing Unit)

那么对于显示一个三维模型呢?我们来看看一个基础的完整的图形渲染管线:下图是一个最基础的图形渲染管线,其中绿色的部分是可编程的阶段,包括顶点着色器、片元着色器,红色的部分是固定流水线单元(Fixed-pipeline Unit),为了效率由硬件直接实现。

阶段 1:Input Assembler

Input Assembler 输入装配器,是图形流水线的第一个**固定阶段**(不可编程,但可配置)。它直接与应用程序(CPU端)提交的数据打交道。

💡PS:Input Assembler 是 Direct3D 中的明确标识的管线阶段,用于组装从 Vertex Buffers 和 Index Buffers 中的顶点数据,形成图元。

但在 OpenGL 中没有对应的阶段,这一“组装”的工作需要手动处理,通过glVertexAttribPointer 系列函数来指定顶点属性的格式和布局。

阶段 2: Vertex Shader

顶点着色器(Vertex Shader)是 图形渲染管线中第一个可编程的 部分,

图形渲染管线的第一个可编程部分是顶点着色器(Vertex Shader),它把一个单独的顶点 Vertex 作为输入,经过 shader 处理后输出,顶点着色器主要的目的是对 3D 坐标进行 MVP 变换,变换为屏幕坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。

阶段 3:Primitive Assembler

图元装配(Primitive Assembly)阶段也是一个固定阶段,用于将顶点着色器(或几何着色器)输出的所有顶点作为输入(如果是GL_TRIANGLE,那么就是一个三角形),并将所有的点装配成指定图元的形状。
![](https://cdn.nlark.com/yuque/0/2025/png/23057337/1757510502620-f817c78f-cdcd-44ec-958c-d9dfbbb9718b.png)

阶段 4:Rasterizer

图元装配阶段的输出会被传入光栅化阶段(Rasterization Stage),光栅化其实就是找出三角形所覆盖区域对应的屏幕上的像素,从而将这些像素提供给片元着色器。

光栅化阶段也是一个固定流水线单元,是一个算法固定的操作,由硬件直接处理,不可编程。

在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出屏幕范围以外的所有像素,用来提升执行效率。

💡 注意光栅化本质上是将顶点信息插值到图源覆盖的每个像素上。

这也可以解释从顶点着色器传值给片元着色器的值,会经过插值。这其实就是光栅化阶段后,片元着色器接收到的已经不是某个顶点的原始输出数据了,而是经过光栅化器插值后的、针对当前这个特定片元的值,这些变量在片元着色器中声明为 in 变量。

阶段 5: Pixel Shader

片段着色器 Pixel Shader 的主要目的是计算一个像素的最终颜色,这也是所有高级效果产生的地方。跟顶点着色器 Vertex Shader 一样,Pixel Shader 也是一个单入单出的结构,
#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)

void main()
{
    FragColor = vertexColor;
}

通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

阶段 6:Output Merger

在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,我们叫做**Output Merger**(或者Alpha测试和混合)阶段,这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值并对物体进行混合(Blend)。

所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

Output Merger 也是固定流水线单元;

总结

+ 整个图形流水线要经过几个阶段,其中 Vertex Shader 和 Pixel Shader 是我们可以进行编程的阶段。 + 我们也必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。 + Vertex Shader 和 Pixel Shader 编写就像是在编写一个回调函数,他们会对每一个顶点、每一个像素上执行。

二、逻辑上的模块划分

上一部分我们介绍了最基本的图形流水线,随着时代的发展,新的需求逐渐出现,现代的 GPU 不仅仅用于图形渲染,还可以进行通用计算、用于神经网络训练的 TensorCore 、用于视频编解码等多个领域,这些不同的领域在 GPU 上都是独立的模块,具有独立的流水线。我们来看看现在的 GPU 流水线是什么样的,以及他是如何发展来的。

1、 图形

Geometry Shader

Vertex Shader 和 Pixel Shader 都是单入单出的结构,只能处理单个顶点和像素。但如果我们要处理的是一个图元(三角形),就没法处理了。

这个需求催生了几何着色器 Geometry Shader 的出现, Geometry Shader 是渲染管线中一个可选的可编程阶段,它位于顶点着色器之后、图元装配和光栅化之前。几何着色器的输入是一个完整的图元及其所有顶点数据(例如,一个三角形需要输入3个顶点),而它的输出是零个、一个或多个全新的图元

换句话说,它是单入多出的结构。

注:Geometry Shader 看起来非常灵活,但实际使用时往往会发现性能很差,这是因为如由于灵活,硬件无法做各种假设来提升性能,只能实现的非常保守。

tessellation

image.png tessellation 的出现是因为对三角形细分需求的逐步增加,由于使用 Geometry shader 性能较差,GPU 流水线在 Vertex shader 后增加了专门的tessellation 功能,他是由三个部分组成 Hull Shader、tessellation、Domain Shader。

2、 计算

GPGPU 通用 GPU

由于 GPU 在图形渲染领域强大的计算能力,逐渐出现了使用 GPU 进行其他领域更加通用的并行计算的想法。

最早的想法是渲染一个覆盖屏幕的大三角形,在 Pixel Shader 里做通用的并行计算,相当于每一个像素 Pixel 是一个线程, 这种方式虽然很“hack”,但也产出了很多的成果,这个方向就是** GPU 通用计算(GPGPU)**。

这种方式虽然解决了一些问题,但也存在学习成本高(开发人员需要学习整个图形流水线)、存在性能浪费(还是需要通过整个图形流水线包括顶点着色器)。

这个需求进一步催生了有硬件支持的 GPGPU,不需要再通过图形流水线中那些固定的阶段,同时支持“多入多出”,独立与图形流水线单独存在,应用 GPU 上的计算单元进行通用计算的 shader 叫做 compute shader

3、 光线追踪

随着游戏对画面真实感的要求越来越高,基础图形流水线采用光栅化渲染方式,对于实现高质量画面往往要采用很多的 Hack,光线跟踪这一古老的技术逐渐引起了人们注意。

由于光线跟踪与光栅化渲染方式有着完全不一样的流程,长期以来研究人员一直在研究如何利用先有的 GPU 硬件实现光线跟踪,这样的需求随着 GPU 在硬件层面提供光线跟踪支持得到了实质的发展。

三、部署到硬件

前边介绍了 GPU 在逻辑模块上的划分,本节我们来看下具体对应到硬件上,GPU 是如何设计的。

unified shader

最开始的 GPU 设计上,Vertex Shader 和 Pixel Shader 有对应的处理单元进行处理,比如 2003 年的 GeForce FX 5600,有 2 个 Vertex Shader 单元和 4 个 Pixel Shader 单元,这意味着当顶点和像素的工作量是 1:2 的时候,他们才能发挥出最高效率。对于如果有一堆很小的三角形挤到一起(顶点多像素少)或很大的三角形覆盖(顶点少像素多)的情形都不能发挥出很高的效率。

最初这样设计是因为人们通常只用 Vertex Shader 处理坐标数据,需要有较高的精度处理能力;而 Pixel Shader 只处理纹理,只需低精度运算器,但需要采样器。

而随着需求的发展,Vertex Shader 和 Pixel Shader 的能力界限逐渐变的模糊。大规模地形渲染的需求,使得 Vertex Shader 得能读取纹理,而 Pixel Shader 进行通用计算的需求,也使得 Pixel Shader 得能处理高精度数据,最终就是两种处理单元统一了起来,叫做 <font style="color:rgb(15, 17, 21);">unified shader</font>,这样 GPU 也因为一致性而变得简单。

在 GPU 工作时,由调度器根据工作任务进行动态分配,决定哪些<font style="color:rgb(15, 17, 21);"> unified shader</font>用于处理 Pixel Shader,哪些处理 Vertex Shader。

最终的结果就是虽然图形流水线中有那么多的 shader,但在硬件层面他们的执行单元都是一样的。

四、完整的软件栈

理想的软件分层体系

理想的关于 GPU 软件分层体系:
  • API 应用程序接口层:为应用程序提供统一的编程接口(如 OpenGL、Vulkan、DirectX、CUDA 等),开发者使用 API 编写图形或计算任务,无需直接处理底层硬件细节;
  • OS 操作系统层;
  • DDI 设备驱动程序接口:是操作系统与 GPU 驱动程序之间的标准接口。由操作系统定义和实现;
  • Driver 驱动程序:将 API 调用翻译成 GPU 能理解的指令,管理 GPU 资源(如显存、命令队列),由 GPU 硬件厂商(如NVIDIA、AMD)实现,并且必须严格遵循DDI的规范;
  • GPU:GPU 接收由驱动程序提交的命令和数据,进行并行处理

但现实情况下会不一样,操作系统就包括了用户态和内核态。

Direct 3D

第一个例子是微软的 Direct 3D(D3D),这个 API 不跨平台(windows),但跨厂商(如NVIDIA、AMD)。

它将驱动拆分为了(用户态UMD + 内核态KMD),并引入引入核心调度器(DXGK),将厂商实现驱动程序由作文题变为填空题,减少了驱动程序开发工作量。

Direct 3D 采用自顶向下的模式,由微软定义 API,厂商来进行实现,不方便进行扩展。在 GPU 拥有新功能的硬件支持后,只有等待 Direct 3D 发布了新版本才能支持。

另外 D3D 的 每个版本的 API 是不兼容的,这意味着每出一版 D3D,程序都得大改才能用上。

OpenGL

OpenGL 是跨平台且跨厂商的,由 Khronos (开源组织)发布。

在不同的操作系统上,OpenGL

在 Windows 上,Windows 只提供了一个框架可安装用户驱动 ICD,让硬件厂商来实现 OpenGL runtime 的 UMD。

在 Linux 上,有两种方式,一种是完全由厂商来实现 UMD 和 KMD;另一种是基于 Mesa 框架。

OpenGL 的 API 设计是向下兼容的,之前的代码往往新版本也能用。

原教程视频中还有关于图形流水线中不可编程单元(重点光栅化)、光线跟踪流水线等部分内容,讲的也非常好。因为我本职工作涉及 WebGL 内容,所以对于暂时没有对这块内容不太有耐心写下来。大家由想了解的推荐看原视频。
❌