OpenGl 是一个操作图形、图像的函数规范(Specification),可以将其视为 API 定义。至于 API 的实现由 OpenGl 库开发者自己实现。不同显卡支持的 OpenGl 库往往不一致,但是遵循的 API 定义是一致的,功能表现是一致的,所以从功能角度上感受不到差异。
可以将 OpenGl 看作是一个状态机,开发 opengl 程序时,首先需要设置更新 opengl 的状态(也就是 opengl 的上下文),这些状态上下文会告诉 opengl 此刻如何运行。
GLFW 和 GLAD 是开始学习 opengl 所需要用到的 lib。
创建一个可以供 opengl 渲染的 3D 画布窗口
因为OpenGL只是一个标准/规范,具体的实现是由驱动开发商针对特定显卡实现的。由于OpenGL驱动版本众多,它大多数函数的位置都无法在编译时确定下来,需要在运行时查询。所以任务就落在了开发者身上,开发者需要在运行时获取函数地址并将其保存在一个函数指针中供以后使用。
这么麻烦的任务就交给 GLAD 了。所以你可以将 GLAD 看作是 opengl 的 api 库,通过 GLAD 就可以调用 OpenGl 的能力了。
OpenGL 的大部分工作都是关于把 3D 坐标转变为适应你屏幕的 2D 像素,3D 坐标转换为 2D 坐标的处理过程由 OpenGl 的图形渲染管线负责的。它的主要职责:
OpenGl 管线的大致流程如下:
顶点数据 ->
顶点着色器 ->
图元装配 ->
几何着色器(可选) ->
裁剪、透视除法、视口变换 ->
光栅化与属性插值 ->
片段着色器 ->
深度/模板测试与混合 ->
帧缓冲,最终显示
图形渲染管线每个阶段都将前一阶段的输出作为输入。每一个阶段都运行着各自小程序,这些小程序就是着色器。有些着色器是可编程的,可以由开发者配置的,开发者可以使用 Glsl(OpenGl Shading Language) 对着色器进行编程。
从显存中的顶点缓冲区读取模型数据, 这些数据通常保存在 VBO(Vertex Buffer Object,顶点缓冲对象)中,通过 VAO(Vertex Array Object,顶点数组对象)描述每个属性的格式。
比如一个顶点可能包含:
位置:(x, y, z)
法线:(nx, ny, nz)
纹理坐标:(u, v)
这个阶段告诉 GPU:顶点数据存放在哪里,每个字段是什么意思。
除此之外,还可以计算并输出:
顶点着色器必须设置 gl_Position,它表示该顶点在裁剪空间中的位置。
注意裁剪空间(Clip Space)和 NDC 是两个不同的坐标空间,顶点着色器输出的 gl_Position 是齐次裁剪空间坐标 ,是一个四维齐次坐标,不要求 在 内,NDC 是 属于 的坐标空间。在管线视除法阶段中,会将顶点坐标从裁剪空间转换到 NDC。
主要任务
根据绘制方式,把多个顶点组合成图元。
例如:
glDrawArrays(GL_TRIANGLES, ...);
表示每三个顶点组成一个三角形:
顶点 0、1、2 → 三角形 1
顶点 3、4、5 → 三角形 2
常见图元类型包括:
这一阶段还会确定三角形的顶点绕序,例如:
逆时针:正面
顺时针:背面
配合背面剔除,可以直接丢弃不可见的背面三角形。
输出结果
独立顶点 → 点、线或三角形
这是一个可选阶段(实际上我也没有写过这个着色器程序)。
处理单位
每次处理一个完整图元。
例如输入为三角形时,一次可以获得三个顶点。
主要任务
它可以:
例如:
一个点 → 一个四边形
一个三角形 → 多个三角形
一个三角形 → 三条法线显示线
一个三角形 → 不输出,直接删除
几何着色器可以调用:
EmitVertex();
EndPrimitive();
输出新的顶点和图元。
输出结果
零个、一个或多个新的图元。
主要任务是裁剪掉观察范围之外的部分。
摄像机能够看到的空间通常称为视锥体。完全在视锥体外的图元会被丢弃;部分位于视锥体内的图元会被切割。
例如一个三角形图元只有一半在屏幕内,裁剪阶段不会简单地把整个三角形删除,而是可能生成新的顶点,将它裁成屏幕内的形状。
目的是为了防止屏幕外图元进入后续阶段,减少无用的片段计算,处理穿过摄像机近平面的图元。
顶点着色器输出的是裁剪空间坐标:
GPU 会将前三个分量除以 :
得到标准化设备坐标 NDC。
在 OpenGL 中,NDC 范围通常为:
x,y,z∈[−1,1]
主要任务把透视投影产生的齐次坐标转换成普通三维坐标,并形成“近处大、远处小”的透视效果。
主要任务把标准化设备坐标映射到窗口坐标。
例如将:
NDC:[-1, 1] × [-1, 1]
转换为:
窗口:[0, width] × [0, height]
如果窗口分辨率为 800×600,那么 NDC 中心点: (0,0) 会被映射到 (400,300)。
此时图元已经知道自己将出现在屏幕的哪个位置。
主要任务是把连续的几何图元转换成离散的片段。
例如一个三角形是由三个顶点定义的连续区域:
A
/ \
/ \
B-----C
光栅化器会找出:这个三角形覆盖了屏幕上的哪些像素采样位置?然后针对覆盖区域产生片段。
光栅化还会进行属性插值,光栅化阶段还会在三角形内部插值顶点着色器输出的数据。假设三个顶点颜色分别是:
A:红色
B:绿色
C:蓝色
三角形内部每个位置的颜色会根据距离进行插值,从而形成渐变。
常见被插值的数据有:
因此,片段着色器接收到的纹理坐标通常不是顶点原始值,而是光栅化器插值后的值。
所以光栅化阶段的输出结果是一系列候选片段,每个片段包含:
片段着色器每次处理一个候选片段。主要任务是计算该片段应输出的颜色或其他数据。
常见操作包括:
例如:
FragColor = texture(diffuseTexture, TexCoord);
片段不等于最终像素,片段着色器输出的只是候选结果。它后面还可能因为深度测试、模板测试等原因被丢弃。因此一个片段执行了片段着色器,不代表它最终一定显示在屏幕上。
片段着色器完成后,还要经过一系列测试。
比较当前片段与深度缓冲区中的深度。
例如同一个像素位置有两个物体:
近处物体:深度 0.2
远处物体:深度 0.8
通常保留深度较小的近处片段,丢弃远处片段。
它解决的是:哪个物体挡在前面?
模板缓冲区为每个像素保存一个整数标记。
它可以用于:
模板测试解决的是:当前像素位置是否允许被绘制?
只允许指定矩形区域内的片段通过。
常用于:
主要任务是将当前片段颜色与帧缓冲中已有颜色合并。
假设:当前片段颜色为 ,帧缓冲原有颜色为 , 当前透明度为 。
常见透明混合公式为:
例如半透明红色覆盖蓝色背景:
前景:红色,alpha = 0.5
背景:蓝色
结果:红蓝混合
需要注意:片段着色器只负责输出颜色和 Alpha;真正将其与背景颜色合并的是后面的混合阶段。透明物体通常还需要按照从远到近的顺序绘制。
主要任务是保存渲染管线最终产生的数据。
一个帧缓冲通常包含:
颜色缓冲中的内容可以:
如果渲染到默认帧缓冲,最终结果通常会通过缓冲交换显示到屏幕:
swapBuffers();
(完)